windraw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db764078067f634ec0cedfab09263f585de8ddf731e47296e3c01471dcaf488f
4
+ data.tar.gz: 9ebc3074ddbbf78b48b3e80689616fa002475962d1fbf73580dae387393a86e0
5
+ SHA512:
6
+ metadata.gz: e8ff7ea96f7c957ac95ec973480e82f3cf1d0b2a0ead223a4fc5b5746ba7b4ac1c16b58fad2a1bf9b5637202cdf942bdf0b5ae2cccd08ac72abb90178861e36c
7
+ data.tar.gz: fb1f3b325e7b1934ffd0d7479f7297264e907b48128f4475c81aba4c3ef9b0b0835aad35bb209400d02eb6a8669dd868805ea82874b95ba0b63a136984eb2c8c
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-05-30
10
+
11
+ ### Added
12
+ - `Windraw.surface(width, height) { |c| ... }` — create an off-screen Direct2D
13
+ surface, draw, and (with a block) auto-finish.
14
+ - `Surface#clear`, `#rectangle`/`#rect` (with `radius:` for rounded corners),
15
+ `#ellipse`, `#circle`, `#line`, `#polygon`, `#polyline`, and `#text`, all with
16
+ hex-color (`#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa`) fill/stroke keywords.
17
+ - `Surface#save(path)` — write a PNG via WIC.
18
+ - `Surface#to_png` — return the PNG bytes as a binary String.
19
+ - `Surface#close`/`#dispose` — release COM resources deterministically.
20
+ - `Windraw::Color.parse` — CSS-style hex color to straight RGBA floats.
21
+ - DirectWrite text with font family, size, bold, and italic.
22
+
23
+ [Unreleased]: https://github.com/main-path/windraw/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/main-path/windraw/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ned
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # windraw
2
+
3
+ **Headless 2D drawing for Windows — Direct2D + DirectWrite + WIC, straight to PNG.**
4
+
5
+ `windraw` is a small Cairo-style 2D canvas built entirely on the graphics stack
6
+ that already ships with Windows: **Direct2D** for vector shapes, **DirectWrite**
7
+ for text, and **WIC** for PNG encoding. It renders into an off-screen bitmap —
8
+ **no window, no message loop** — so it's perfect for generating images from
9
+ scripts, jobs, and tests.
10
+
11
+ ```ruby
12
+ require "windraw"
13
+
14
+ Windraw.surface(800, 600) do |c|
15
+ c.clear("#1e1e2e")
16
+ c.rectangle(50, 50, 200, 120, fill: "#89b4fa", stroke: "#ffffff", width: 3)
17
+ c.ellipse(400, 300, 120, 80, fill: "#a6e3a1aa")
18
+ c.circle(650, 150, 60, fill: "#f38ba8")
19
+ c.line(50, 550, 750, 450, color: "#fab387", width: 6)
20
+ c.text("Hello, Direct2D!", 60, 220, font: "Segoe UI", size: 40, color: "#cdd6f4", bold: true)
21
+ end.save("hello.png")
22
+ ```
23
+
24
+ ## Requirements
25
+
26
+ - **Windows** with a native **MSVC (mswin)** Ruby (`x64-mswin64`). On a
27
+ MinGW/UCRT Ruby this gem is not supported — its `extconf.rb` will say so.
28
+ - Visual Studio 2017+ or the Build Tools with the **Desktop development with
29
+ C++** workload (for `cl.exe` + the Windows SDK headers/libs).
30
+
31
+ > Building uses [`vcvars`](https://rubygems.org/gems/vcvars) to load the MSVC
32
+ > toolchain automatically — no "Developer Command Prompt" needed.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ gem install windraw
38
+ ```
39
+
40
+ ## API
41
+
42
+ ### `Windraw.surface(width, height) { |c| ... } → Surface`
43
+
44
+ Creates an off-screen surface (pixels; origin top-left). With a block, it yields
45
+ the surface, finishes drawing when the block returns, and returns the surface so
46
+ you can chain `#save` / `#to_png`. All drawing methods return `self`.
47
+
48
+ ### Drawing
49
+
50
+ ```ruby
51
+ c.clear("#1e1e2e") # fill the whole canvas
52
+ c.rectangle(x, y, w, h, fill:, stroke:, width: 1.0, radius: nil) # alias: c.rect; radius rounds corners
53
+ c.ellipse(cx, cy, rx, ry, fill:, stroke:, width: 1.0)
54
+ c.circle(cx, cy, radius, fill:, stroke:, width: 1.0)
55
+ c.line(x1, y1, x2, y2, color: "#000000", width: 1.0)
56
+ c.polygon([[x, y], ...], fill:, stroke:, width: 1.0) # closed
57
+ c.polyline([[x, y], ...], color: "#000000", width: 1.0) # open
58
+ c.text(str, x, y, color:, font: "Segoe UI", size: 16, bold: false, italic: false)
59
+ ```
60
+
61
+ `radius:` takes a single Numeric (`rx == ry`) or an `[rx, ry]` pair.
62
+
63
+ ### Output & lifecycle
64
+
65
+ ```ruby
66
+ surface.save("out.png") # => "out.png"
67
+ surface.to_png # => String (PNG bytes, ASCII-8BIT)
68
+ surface.size # => [width, height]
69
+ surface.close # release COM resources now (alias: dispose; GC also frees)
70
+ ```
71
+
72
+ > **Roadmap (0.2):** gradient brushes, arcs/bezier paths, and image compositing
73
+ > (load + draw existing PNGs).
74
+
75
+ `fill:` and `stroke:` take hex colors and are optional; passing both draws the
76
+ fill first, then the stroke.
77
+
78
+ ### Colors
79
+
80
+ Any CSS-style hex string: `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa` (the `#` is
81
+ optional). Alpha is supported everywhere.
82
+
83
+ ```ruby
84
+ Windraw::Color.parse("#ff8800") # => [1.0, 0.533..., 0.0, 1.0]
85
+ Windraw::Color.parse("#0af5") # => [0.0, 0.666..., 1.0, 0.333...]
86
+ ```
87
+
88
+ ### Output
89
+
90
+ ```ruby
91
+ surface.save("out.png") # => "out.png" (writes a PNG via WIC)
92
+ surface.to_png # => String (PNG bytes, ASCII-8BIT)
93
+ surface.size # => [width, height]
94
+ ```
95
+
96
+ ## How it works
97
+
98
+ A `Surface` creates a WIC bitmap and a Direct2D **WIC bitmap render target** at
99
+ 96 DPI (so 1 unit == 1 pixel). Drawing calls go straight to Direct2D /
100
+ DirectWrite; `save`/`to_png` flush the render target (`EndDraw`) and encode the
101
+ bitmap to PNG with WIC — to a file stream or an in-memory stream. COM is
102
+ initialized per surface; drawing is intended to happen on the thread that
103
+ created the surface.
104
+
105
+ ## License
106
+
107
+ [MIT](LICENSE.txt).
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the windraw C++ extension (Direct2D + DirectWrite + WIC).
4
+ #
5
+ # windraw is Windows-MSVC-only: it links the Direct2D/DirectWrite/WIC system
6
+ # import libraries and uses the MSVC C++ compiler. mkmf auto-discovers the .cpp
7
+ # source and compiles it as C++ (its CXX_EXT list includes "cpp").
8
+
9
+ require "mkmf"
10
+
11
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
12
+ abort <<~MSG
13
+ windraw requires a native Windows MSVC (mswin) Ruby — it builds against
14
+ Direct2D, DirectWrite, and WIC with cl.exe. Your Ruby is
15
+ "#{RbConfig::CONFIG['arch']}". On a MinGW/UCRT Ruby this gem is not supported.
16
+ MSG
17
+ end
18
+
19
+ # C++ exceptions are not enabled by the stock mswin CXXFLAGS; the extension uses
20
+ # std::wstring (and C++ COM patterns), so turn them on.
21
+ $CXXFLAGS << " -EHsc"
22
+
23
+ # System import libraries. On mswin, mkmf expects bare "NAME.lib" tokens in
24
+ # $libs (no -l prefix); they land on the link line after the Ruby import lib.
25
+ # d2d1 - Direct2D (D2D1CreateFactory, render target, brushes, shapes)
26
+ # dwrite - DirectWrite (text formats + DrawText)
27
+ # windowscodecs - WIC (imaging factory, bitmap, PNG encoder, WIC CLSIDs)
28
+ # ole32 - COM (CoInitializeEx / CoCreateInstance / CoUninitialize)
29
+ # uuid - additional CLSID/IID definitions
30
+ # shlwapi - SHCreateMemStream (in-memory PNG for #to_png)
31
+ $libs = [$libs, "d2d1.lib", "dwrite.lib", "windowscodecs.lib",
32
+ "ole32.lib", "uuid.lib", "shlwapi.lib"].join(" ")
33
+
34
+ # Produces lib/windraw/windraw.so, required as "windraw/windraw".
35
+ create_makefile("windraw/windraw")
@@ -0,0 +1,635 @@
1
+ /*
2
+ * windraw — headless 2D drawing for Windows via Direct2D + DirectWrite + WIC.
3
+ *
4
+ * Renders shapes and text into an off-screen WIC bitmap through a Direct2D
5
+ * render target, and encodes the result to PNG (to a file or an in-memory
6
+ * Ruby string). No window / message pump required.
7
+ *
8
+ * Build notes (verified on Ruby 3.4 x64-mswin64_140, MSVC cl 19.5):
9
+ * - <ruby.h> MUST come before <windows.h> (winsock2 ordering / macro hygiene).
10
+ * - <windows.h> #defines DrawText as DrawTextW (a GDI macro) and OUT/IN as SAL
11
+ * markers; we #undef DrawText so rt->DrawText resolves to the D2D method, and
12
+ * we never use OUT/IN as identifiers.
13
+ * - All COM calls are checked; failures are released-then-raised so an rb_raise
14
+ * longjmp never skips a Release. Numeric/string args are converted up front,
15
+ * before any COM object exists, for the same reason.
16
+ */
17
+
18
+ #include <ruby.h>
19
+ #include <ruby/encoding.h>
20
+
21
+ #define WIN32_LEAN_AND_MEAN
22
+ #include <windows.h>
23
+ #include <d2d1.h>
24
+ #include <d2d1helper.h>
25
+ #include <dwrite.h>
26
+ #include <wincodec.h>
27
+ #include <shlwapi.h>
28
+
29
+ #include <string>
30
+ #include <cstdlib> /* malloc / free for the longjmp-safe PNG buffer */
31
+ #include <climits> /* LONG_MAX */
32
+
33
+ /* NOTE on DrawText: <windows.h> defines `DrawText` as the GDI macro `DrawTextW`.
34
+ * Because that macro is active when <d2d1.h> is parsed, ID2D1RenderTarget's
35
+ * method is actually declared as `DrawTextW`, and our call site is rewritten the
36
+ * same way — so they match. Do NOT #undef DrawText here: that would desync the
37
+ * call (DrawText) from the real member (DrawTextW) -> C2039. */
38
+
39
+ template <class T>
40
+ static void SafeRelease(T** pp) {
41
+ if (*pp) { (*pp)->Release(); *pp = nullptr; }
42
+ }
43
+
44
+ typedef struct WindrawSurface {
45
+ UINT width;
46
+ UINT height;
47
+ DWORD thread_id; /* thread that ran CoInitializeEx (teardown must match) */
48
+ bool com_owned; /* did our CoInitializeEx succeed (so we must CoUninitialize)? */
49
+ bool draw_open; /* BeginDraw issued, EndDraw not yet */
50
+ bool finished; /* EndDraw done; drawing no longer allowed */
51
+ bool closed; /* COM objects released + CoUninitialize done */
52
+ ID2D1Factory* d2d;
53
+ IWICImagingFactory* wic;
54
+ IWICBitmap* bitmap;
55
+ ID2D1RenderTarget* rt;
56
+ IDWriteFactory* dwrite; /* created lazily on first text() */
57
+ } WindrawSurface;
58
+
59
+ /* ---- TypedData plumbing -------------------------------------------------- */
60
+
61
+ /* Release every COM object and balance CoInitializeEx. MUST run on the thread
62
+ * that created the surface (COM init is per-thread; the D2D factory is
63
+ * single-threaded). */
64
+ static void surface_teardown(WindrawSurface* s) {
65
+ if (s->draw_open && s->rt) { s->rt->EndDraw(); s->draw_open = false; }
66
+ SafeRelease(&s->dwrite);
67
+ SafeRelease(&s->rt);
68
+ SafeRelease(&s->bitmap);
69
+ SafeRelease(&s->wic);
70
+ SafeRelease(&s->d2d);
71
+ if (s->com_owned) { CoUninitialize(); s->com_owned = false; }
72
+ s->closed = true;
73
+ }
74
+
75
+ static void surface_free(void* p) {
76
+ WindrawSurface* s = static_cast<WindrawSurface*>(p);
77
+ if (!s) return;
78
+ if (!s->closed) {
79
+ /* COM init/uninit is per-thread and the D2D factory is single-threaded,
80
+ * so we can only tear down safely on the creating thread. GC may run
81
+ * this free on another thread; if so, skip COM teardown (a small leak)
82
+ * rather than corrupt COM state. Use #close on the creating thread for
83
+ * deterministic cleanup in multi-threaded programs. */
84
+ if (GetCurrentThreadId() == s->thread_id) surface_teardown(s);
85
+ }
86
+ xfree(s);
87
+ }
88
+
89
+ static size_t surface_memsize(const void* p) {
90
+ (void)p;
91
+ return sizeof(WindrawSurface);
92
+ }
93
+
94
+ static const rb_data_type_t surface_type = {
95
+ "Windraw::Surface",
96
+ { 0, surface_free, surface_memsize },
97
+ 0, 0, RUBY_TYPED_FREE_IMMEDIATELY
98
+ };
99
+
100
+ static VALUE surface_alloc(VALUE klass) {
101
+ WindrawSurface* s;
102
+ return TypedData_Make_Struct(klass, WindrawSurface, &surface_type, s);
103
+ }
104
+
105
+ static WindrawSurface* get_surface(VALUE self) {
106
+ WindrawSurface* s;
107
+ TypedData_Get_Struct(self, WindrawSurface, &surface_type, s);
108
+ return s;
109
+ }
110
+
111
+ /* ---- helpers ------------------------------------------------------------- */
112
+
113
+ static void raise_hr(const char* what, HRESULT hr) {
114
+ rb_raise(rb_eRuntimeError, "windraw: %s failed (hr=0x%08lX)", what,
115
+ static_cast<unsigned long>(hr));
116
+ }
117
+
118
+ static void ensure_drawable(WindrawSurface* s) {
119
+ if (!s->rt) rb_raise(rb_eRuntimeError, "windraw: surface is not initialized");
120
+ if (s->finished || !s->draw_open)
121
+ rb_raise(rb_eRuntimeError, "windraw: surface is finished; cannot draw on it");
122
+ }
123
+
124
+ /* Convert a Ruby String (any encoding) to a UTF-16 std::wstring. */
125
+ static std::wstring to_utf16(VALUE str) {
126
+ str = rb_str_export_to_enc(StringValue(str), rb_utf8_encoding());
127
+ long bytelen = RSTRING_LEN(str);
128
+ if (bytelen <= 0) return std::wstring();
129
+ const char* p = RSTRING_PTR(str);
130
+ int need = MultiByteToWideChar(CP_UTF8, 0, p, static_cast<int>(bytelen), nullptr, 0);
131
+ if (need <= 0) return std::wstring();
132
+ std::wstring w(static_cast<size_t>(need), L'\0');
133
+ MultiByteToWideChar(CP_UTF8, 0, p, static_cast<int>(bytelen), &w[0], need);
134
+ return w;
135
+ }
136
+
137
+ /* WIC-encode the surface's bitmap to a PNG written into `out`. Releases its own
138
+ * locals and returns the HRESULT; never raises (caller raises after cleanup). */
139
+ static HRESULT encode_png(WindrawSurface* s, IStream* out) {
140
+ IWICBitmapEncoder* encoder = nullptr;
141
+ IWICBitmapFrameEncode* frame = nullptr;
142
+
143
+ HRESULT hr = s->wic->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder);
144
+ if (SUCCEEDED(hr)) hr = encoder->Initialize(out, WICBitmapEncoderNoCache);
145
+ if (SUCCEEDED(hr)) hr = encoder->CreateNewFrame(&frame, nullptr);
146
+ if (SUCCEEDED(hr)) hr = frame->Initialize(nullptr);
147
+ if (SUCCEEDED(hr)) hr = frame->SetSize(s->width, s->height);
148
+ if (SUCCEEDED(hr)) {
149
+ WICPixelFormatGUID pf = GUID_WICPixelFormat32bppPBGRA;
150
+ hr = frame->SetPixelFormat(&pf);
151
+ }
152
+ if (SUCCEEDED(hr)) hr = frame->WriteSource(s->bitmap, nullptr);
153
+ if (SUCCEEDED(hr)) hr = frame->Commit();
154
+ if (SUCCEEDED(hr)) hr = encoder->Commit();
155
+
156
+ SafeRelease(&frame);
157
+ SafeRelease(&encoder);
158
+ return hr;
159
+ }
160
+
161
+ /* ---- methods ------------------------------------------------------------- */
162
+
163
+ static VALUE surface_initialize(VALUE self, VALUE vw, VALUE vh) {
164
+ WindrawSurface* s = get_surface(self);
165
+ int w = NUM2INT(vw);
166
+ int h = NUM2INT(vh);
167
+ if (w <= 0 || h <= 0) rb_raise(rb_eArgError, "width and height must be positive");
168
+ s->width = static_cast<UINT>(w);
169
+ s->height = static_cast<UINT>(h);
170
+ s->thread_id = GetCurrentThreadId();
171
+
172
+ HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
173
+ if (hr == S_OK || hr == S_FALSE) {
174
+ s->com_owned = true; /* we incremented the init count; balance it */
175
+ } else if (hr == RPC_E_CHANGED_MODE) {
176
+ s->com_owned = false; /* thread already in another apartment; usable, not ours */
177
+ } else {
178
+ raise_hr("CoInitializeEx", hr);
179
+ }
180
+
181
+ hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, __uuidof(ID2D1Factory),
182
+ reinterpret_cast<void**>(&s->d2d));
183
+ if (FAILED(hr)) raise_hr("D2D1CreateFactory", hr);
184
+
185
+ hr = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
186
+ IID_PPV_ARGS(&s->wic));
187
+ if (FAILED(hr)) raise_hr("CoCreateInstance(WICImagingFactory)", hr);
188
+
189
+ hr = s->wic->CreateBitmap(s->width, s->height, GUID_WICPixelFormat32bppPBGRA,
190
+ WICBitmapCacheOnLoad, &s->bitmap);
191
+ if (FAILED(hr)) raise_hr("CreateBitmap", hr);
192
+
193
+ /* DPI 96 => 1 DIP == 1 pixel, so all coordinates are exact pixels. */
194
+ D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties(
195
+ D2D1_RENDER_TARGET_TYPE_DEFAULT,
196
+ D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED),
197
+ 96.0f, 96.0f);
198
+ hr = s->d2d->CreateWicBitmapRenderTarget(s->bitmap, props, &s->rt);
199
+ if (FAILED(hr)) raise_hr("CreateWicBitmapRenderTarget", hr);
200
+
201
+ s->rt->BeginDraw();
202
+ s->draw_open = true;
203
+ s->rt->Clear(D2D1::ColorF(0.0f, 0.0f, 0.0f, 0.0f)); /* transparent canvas */
204
+ return self;
205
+ }
206
+
207
+ static VALUE surface_clear(VALUE self, VALUE vr, VALUE vg, VALUE vb, VALUE va) {
208
+ WindrawSurface* s = get_surface(self);
209
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
210
+ ensure_drawable(s);
211
+ s->rt->Clear(D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
212
+ static_cast<float>(b), static_cast<float>(a)));
213
+ return self;
214
+ }
215
+
216
+ static VALUE surface_fill_rect(VALUE self, VALUE vx, VALUE vy, VALUE vw, VALUE vh,
217
+ VALUE vr, VALUE vg, VALUE vb, VALUE va) {
218
+ WindrawSurface* s = get_surface(self);
219
+ double x = NUM2DBL(vx), y = NUM2DBL(vy), w = NUM2DBL(vw), h = NUM2DBL(vh);
220
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
221
+ ensure_drawable(s);
222
+
223
+ ID2D1SolidColorBrush* brush = nullptr;
224
+ HRESULT hr = s->rt->CreateSolidColorBrush(
225
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
226
+ static_cast<float>(b), static_cast<float>(a)), &brush);
227
+ if (SUCCEEDED(hr)) {
228
+ s->rt->FillRectangle(
229
+ D2D1::RectF(static_cast<float>(x), static_cast<float>(y),
230
+ static_cast<float>(x + w), static_cast<float>(y + h)), brush);
231
+ }
232
+ SafeRelease(&brush);
233
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
234
+ return self;
235
+ }
236
+
237
+ static VALUE surface_stroke_rect(VALUE self, VALUE vx, VALUE vy, VALUE vw, VALUE vh,
238
+ VALUE vr, VALUE vg, VALUE vb, VALUE va, VALUE vwidth) {
239
+ WindrawSurface* s = get_surface(self);
240
+ double x = NUM2DBL(vx), y = NUM2DBL(vy), w = NUM2DBL(vw), h = NUM2DBL(vh);
241
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
242
+ double sw = NUM2DBL(vwidth);
243
+ ensure_drawable(s);
244
+
245
+ ID2D1SolidColorBrush* brush = nullptr;
246
+ HRESULT hr = s->rt->CreateSolidColorBrush(
247
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
248
+ static_cast<float>(b), static_cast<float>(a)), &brush);
249
+ if (SUCCEEDED(hr)) {
250
+ s->rt->DrawRectangle(
251
+ D2D1::RectF(static_cast<float>(x), static_cast<float>(y),
252
+ static_cast<float>(x + w), static_cast<float>(y + h)),
253
+ brush, static_cast<float>(sw));
254
+ }
255
+ SafeRelease(&brush);
256
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
257
+ return self;
258
+ }
259
+
260
+ static VALUE surface_fill_ellipse(VALUE self, VALUE vcx, VALUE vcy, VALUE vrx, VALUE vry,
261
+ VALUE vr, VALUE vg, VALUE vb, VALUE va) {
262
+ WindrawSurface* s = get_surface(self);
263
+ double cx = NUM2DBL(vcx), cy = NUM2DBL(vcy), rx = NUM2DBL(vrx), ry = NUM2DBL(vry);
264
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
265
+ ensure_drawable(s);
266
+
267
+ ID2D1SolidColorBrush* brush = nullptr;
268
+ HRESULT hr = s->rt->CreateSolidColorBrush(
269
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
270
+ static_cast<float>(b), static_cast<float>(a)), &brush);
271
+ if (SUCCEEDED(hr)) {
272
+ s->rt->FillEllipse(
273
+ D2D1::Ellipse(D2D1::Point2F(static_cast<float>(cx), static_cast<float>(cy)),
274
+ static_cast<float>(rx), static_cast<float>(ry)), brush);
275
+ }
276
+ SafeRelease(&brush);
277
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
278
+ return self;
279
+ }
280
+
281
+ static VALUE surface_stroke_ellipse(VALUE self, VALUE vcx, VALUE vcy, VALUE vrx, VALUE vry,
282
+ VALUE vr, VALUE vg, VALUE vb, VALUE va, VALUE vwidth) {
283
+ WindrawSurface* s = get_surface(self);
284
+ double cx = NUM2DBL(vcx), cy = NUM2DBL(vcy), rx = NUM2DBL(vrx), ry = NUM2DBL(vry);
285
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
286
+ double sw = NUM2DBL(vwidth);
287
+ ensure_drawable(s);
288
+
289
+ ID2D1SolidColorBrush* brush = nullptr;
290
+ HRESULT hr = s->rt->CreateSolidColorBrush(
291
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
292
+ static_cast<float>(b), static_cast<float>(a)), &brush);
293
+ if (SUCCEEDED(hr)) {
294
+ s->rt->DrawEllipse(
295
+ D2D1::Ellipse(D2D1::Point2F(static_cast<float>(cx), static_cast<float>(cy)),
296
+ static_cast<float>(rx), static_cast<float>(ry)),
297
+ brush, static_cast<float>(sw));
298
+ }
299
+ SafeRelease(&brush);
300
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
301
+ return self;
302
+ }
303
+
304
+ static VALUE surface_line(VALUE self, VALUE vx1, VALUE vy1, VALUE vx2, VALUE vy2,
305
+ VALUE vr, VALUE vg, VALUE vb, VALUE va, VALUE vwidth) {
306
+ WindrawSurface* s = get_surface(self);
307
+ double x1 = NUM2DBL(vx1), y1 = NUM2DBL(vy1), x2 = NUM2DBL(vx2), y2 = NUM2DBL(vy2);
308
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
309
+ double sw = NUM2DBL(vwidth);
310
+ ensure_drawable(s);
311
+
312
+ ID2D1SolidColorBrush* brush = nullptr;
313
+ HRESULT hr = s->rt->CreateSolidColorBrush(
314
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
315
+ static_cast<float>(b), static_cast<float>(a)), &brush);
316
+ if (SUCCEEDED(hr)) {
317
+ s->rt->DrawLine(
318
+ D2D1::Point2F(static_cast<float>(x1), static_cast<float>(y1)),
319
+ D2D1::Point2F(static_cast<float>(x2), static_cast<float>(y2)),
320
+ brush, static_cast<float>(sw));
321
+ }
322
+ SafeRelease(&brush);
323
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
324
+ return self;
325
+ }
326
+
327
+ static VALUE surface_fill_round_rect(VALUE self, VALUE vx, VALUE vy, VALUE vw, VALUE vh,
328
+ VALUE vrx, VALUE vry,
329
+ VALUE vr, VALUE vg, VALUE vb, VALUE va) {
330
+ WindrawSurface* s = get_surface(self);
331
+ double x = NUM2DBL(vx), y = NUM2DBL(vy), w = NUM2DBL(vw), h = NUM2DBL(vh);
332
+ double rx = NUM2DBL(vrx), ry = NUM2DBL(vry);
333
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
334
+ ensure_drawable(s);
335
+
336
+ ID2D1SolidColorBrush* brush = nullptr;
337
+ HRESULT hr = s->rt->CreateSolidColorBrush(
338
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
339
+ static_cast<float>(b), static_cast<float>(a)), &brush);
340
+ if (SUCCEEDED(hr)) {
341
+ D2D1_ROUNDED_RECT rr = D2D1::RoundedRect(
342
+ D2D1::RectF(static_cast<float>(x), static_cast<float>(y),
343
+ static_cast<float>(x + w), static_cast<float>(y + h)),
344
+ static_cast<float>(rx), static_cast<float>(ry));
345
+ s->rt->FillRoundedRectangle(rr, brush);
346
+ }
347
+ SafeRelease(&brush);
348
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
349
+ return self;
350
+ }
351
+
352
+ static VALUE surface_stroke_round_rect(VALUE self, VALUE vx, VALUE vy, VALUE vw, VALUE vh,
353
+ VALUE vrx, VALUE vry,
354
+ VALUE vr, VALUE vg, VALUE vb, VALUE va, VALUE vwidth) {
355
+ WindrawSurface* s = get_surface(self);
356
+ double x = NUM2DBL(vx), y = NUM2DBL(vy), w = NUM2DBL(vw), h = NUM2DBL(vh);
357
+ double rx = NUM2DBL(vrx), ry = NUM2DBL(vry);
358
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
359
+ double sw = NUM2DBL(vwidth);
360
+ ensure_drawable(s);
361
+
362
+ ID2D1SolidColorBrush* brush = nullptr;
363
+ HRESULT hr = s->rt->CreateSolidColorBrush(
364
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
365
+ static_cast<float>(b), static_cast<float>(a)), &brush);
366
+ if (SUCCEEDED(hr)) {
367
+ D2D1_ROUNDED_RECT rr = D2D1::RoundedRect(
368
+ D2D1::RectF(static_cast<float>(x), static_cast<float>(y),
369
+ static_cast<float>(x + w), static_cast<float>(y + h)),
370
+ static_cast<float>(rx), static_cast<float>(ry));
371
+ s->rt->DrawRoundedRectangle(rr, brush, static_cast<float>(sw));
372
+ }
373
+ SafeRelease(&brush);
374
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
375
+ return self;
376
+ }
377
+
378
+ /* Build a closed/open path geometry from a flat [x0,y0,x1,y1,...] Ruby array.
379
+ * Returns the geometry (caller Releases) or raises. All point values are
380
+ * validated and extracted into a malloc'd buffer up front, so no rb_raise can
381
+ * longjmp while a COM object or the buffer is held un-freed. */
382
+ static ID2D1PathGeometry* build_polygon_geometry(WindrawSurface* s, VALUE arr,
383
+ bool closed, bool filled) {
384
+ Check_Type(arr, T_ARRAY);
385
+ long n = RARRAY_LEN(arr);
386
+ if (n < 4 || (n % 2) != 0) {
387
+ rb_raise(rb_eArgError,
388
+ "windraw: polygon/polyline needs an even, >= 2 list of x,y coordinates");
389
+ }
390
+ for (long i = 0; i < n; i++) {
391
+ VALUE e = rb_ary_entry(arr, i);
392
+ if (!RTEST(rb_obj_is_kind_of(e, rb_cNumeric)))
393
+ rb_raise(rb_eTypeError, "windraw: polygon points must be numbers");
394
+ /* Force the float conversion now: a Numeric that can't become a Float
395
+ * (e.g. a Complex with imaginary part) raises HERE, before malloc, so
396
+ * the longjmp can't skip free(pts). */
397
+ (void)NUM2DBL(e);
398
+ }
399
+
400
+ long npoints = n / 2;
401
+ D2D1_POINT_2F* pts =
402
+ static_cast<D2D1_POINT_2F*>(malloc(sizeof(D2D1_POINT_2F) * static_cast<size_t>(npoints)));
403
+ if (!pts) rb_raise(rb_eNoMemError, "windraw: out of memory building polygon");
404
+ for (long i = 0; i < npoints; i++) {
405
+ pts[i].x = static_cast<float>(NUM2DBL(rb_ary_entry(arr, 2 * i)));
406
+ pts[i].y = static_cast<float>(NUM2DBL(rb_ary_entry(arr, 2 * i + 1)));
407
+ }
408
+
409
+ ID2D1PathGeometry* geo = nullptr;
410
+ ID2D1GeometrySink* sink = nullptr;
411
+ HRESULT hr = s->d2d->CreatePathGeometry(&geo);
412
+ if (SUCCEEDED(hr)) hr = geo->Open(&sink);
413
+ if (SUCCEEDED(hr)) {
414
+ sink->BeginFigure(pts[0],
415
+ filled ? D2D1_FIGURE_BEGIN_FILLED : D2D1_FIGURE_BEGIN_HOLLOW);
416
+ if (npoints > 1) sink->AddLines(pts + 1, static_cast<UINT32>(npoints - 1));
417
+ sink->EndFigure(closed ? D2D1_FIGURE_END_CLOSED : D2D1_FIGURE_END_OPEN);
418
+ hr = sink->Close();
419
+ }
420
+ SafeRelease(&sink);
421
+ free(pts);
422
+ if (FAILED(hr)) { SafeRelease(&geo); raise_hr("path geometry", hr); }
423
+ return geo;
424
+ }
425
+
426
+ static VALUE surface_fill_polygon(VALUE self, VALUE arr,
427
+ VALUE vr, VALUE vg, VALUE vb, VALUE va) {
428
+ WindrawSurface* s = get_surface(self);
429
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
430
+ ensure_drawable(s);
431
+
432
+ ID2D1PathGeometry* geo = build_polygon_geometry(s, arr, /*closed*/ true, /*filled*/ true);
433
+ ID2D1SolidColorBrush* brush = nullptr;
434
+ HRESULT hr = s->rt->CreateSolidColorBrush(
435
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
436
+ static_cast<float>(b), static_cast<float>(a)), &brush);
437
+ if (SUCCEEDED(hr)) s->rt->FillGeometry(geo, brush);
438
+ SafeRelease(&brush);
439
+ SafeRelease(&geo);
440
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
441
+ return self;
442
+ }
443
+
444
+ static VALUE surface_stroke_polygon(VALUE self, VALUE arr,
445
+ VALUE vr, VALUE vg, VALUE vb, VALUE va,
446
+ VALUE vwidth, VALUE vclosed) {
447
+ WindrawSurface* s = get_surface(self);
448
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
449
+ double sw = NUM2DBL(vwidth);
450
+ bool closed = RTEST(vclosed);
451
+ ensure_drawable(s);
452
+
453
+ ID2D1PathGeometry* geo = build_polygon_geometry(s, arr, closed, /*filled*/ false);
454
+ ID2D1SolidColorBrush* brush = nullptr;
455
+ HRESULT hr = s->rt->CreateSolidColorBrush(
456
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
457
+ static_cast<float>(b), static_cast<float>(a)), &brush);
458
+ if (SUCCEEDED(hr)) s->rt->DrawGeometry(geo, brush, static_cast<float>(sw));
459
+ SafeRelease(&brush);
460
+ SafeRelease(&geo);
461
+ if (FAILED(hr)) raise_hr("CreateSolidColorBrush", hr);
462
+ return self;
463
+ }
464
+
465
+ static VALUE surface_text(VALUE self, VALUE vstr, VALUE vx, VALUE vy,
466
+ VALUE vr, VALUE vg, VALUE vb, VALUE va,
467
+ VALUE vfont, VALUE vsize, VALUE vbold, VALUE vitalic) {
468
+ WindrawSurface* s = get_surface(self);
469
+ double x = NUM2DBL(vx), y = NUM2DBL(vy), size = NUM2DBL(vsize);
470
+ double r = NUM2DBL(vr), g = NUM2DBL(vg), b = NUM2DBL(vb), a = NUM2DBL(va);
471
+ DWRITE_FONT_WEIGHT weight = RTEST(vbold) ? DWRITE_FONT_WEIGHT_BOLD
472
+ : DWRITE_FONT_WEIGHT_NORMAL;
473
+ DWRITE_FONT_STYLE style = RTEST(vitalic) ? DWRITE_FONT_STYLE_ITALIC
474
+ : DWRITE_FONT_STYLE_NORMAL;
475
+ /* Coerce both strings up front: if either is not String-convertible, the
476
+ * TypeError must raise before any std::wstring exists (rb_raise longjmps
477
+ * past C++ destructors, which would otherwise leak the first wstring). */
478
+ StringValue(vfont);
479
+ StringValue(vstr);
480
+ ensure_drawable(s);
481
+
482
+ /* Lazily create the DirectWrite factory (shared). */
483
+ if (!s->dwrite) {
484
+ HRESULT hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory),
485
+ reinterpret_cast<IUnknown**>(&s->dwrite));
486
+ if (FAILED(hr)) raise_hr("DWriteCreateFactory", hr);
487
+ }
488
+
489
+ HRESULT hr;
490
+ IDWriteTextFormat* format = nullptr;
491
+ ID2D1SolidColorBrush* brush = nullptr;
492
+ {
493
+ std::wstring wfont = to_utf16(vfont);
494
+ std::wstring wtext = to_utf16(vstr);
495
+ hr = s->dwrite->CreateTextFormat(wfont.c_str(), nullptr, weight, style,
496
+ DWRITE_FONT_STRETCH_NORMAL, static_cast<float>(size), L"", &format);
497
+ if (SUCCEEDED(hr)) {
498
+ hr = s->rt->CreateSolidColorBrush(
499
+ D2D1::ColorF(static_cast<float>(r), static_cast<float>(g),
500
+ static_cast<float>(b), static_cast<float>(a)), &brush);
501
+ }
502
+ if (SUCCEEDED(hr)) {
503
+ D2D1_RECT_F rect = D2D1::RectF(static_cast<float>(x), static_cast<float>(y),
504
+ static_cast<float>(s->width),
505
+ static_cast<float>(s->height));
506
+ s->rt->DrawText(wtext.c_str(), static_cast<UINT32>(wtext.length()),
507
+ format, rect, brush);
508
+ }
509
+ } /* wfont/wtext destroyed here, before any possible raise */
510
+
511
+ SafeRelease(&brush);
512
+ SafeRelease(&format);
513
+ if (FAILED(hr)) raise_hr("CreateTextFormat/DrawText", hr);
514
+ return self;
515
+ }
516
+
517
+ static void ensure_encodable(WindrawSurface* s) {
518
+ if (s->closed || !s->wic) rb_raise(rb_eRuntimeError, "windraw: surface is closed");
519
+ }
520
+
521
+ static VALUE surface_finish(VALUE self) {
522
+ WindrawSurface* s = get_surface(self);
523
+ if (s->draw_open && s->rt) {
524
+ HRESULT hr = s->rt->EndDraw();
525
+ s->draw_open = false;
526
+ s->finished = true;
527
+ if (FAILED(hr)) raise_hr("EndDraw", hr);
528
+ }
529
+ s->finished = true;
530
+ return self;
531
+ }
532
+
533
+ /* Release all COM resources now, on the calling (creating) thread. Idempotent.
534
+ * After #close the surface can no longer draw or encode. */
535
+ static VALUE surface_close(VALUE self) {
536
+ WindrawSurface* s = get_surface(self);
537
+ if (!s->closed) surface_teardown(s);
538
+ return self;
539
+ }
540
+
541
+ static VALUE surface_save(VALUE self, VALUE vpath) {
542
+ WindrawSurface* s = get_surface(self);
543
+ ensure_encodable(s);
544
+ surface_finish(self); /* flush drawing before encoding */
545
+
546
+ IWICStream* stream = nullptr;
547
+ HRESULT hr;
548
+ {
549
+ std::wstring wpath = to_utf16(vpath);
550
+ hr = s->wic->CreateStream(&stream);
551
+ if (SUCCEEDED(hr)) hr = stream->InitializeFromFilename(wpath.c_str(), GENERIC_WRITE);
552
+ } /* wpath destroyed before any raise */
553
+ if (SUCCEEDED(hr)) hr = encode_png(s, stream);
554
+ SafeRelease(&stream);
555
+ if (FAILED(hr)) raise_hr("save", hr);
556
+ return vpath;
557
+ }
558
+
559
+ static VALUE surface_to_png(VALUE self) {
560
+ WindrawSurface* s = get_surface(self);
561
+ ensure_encodable(s);
562
+ surface_finish(self);
563
+
564
+ IStream* mem = SHCreateMemStream(nullptr, 0);
565
+ if (!mem) rb_raise(rb_eRuntimeError, "windraw: SHCreateMemStream failed");
566
+
567
+ /* Drain the encoded PNG into a plain C buffer while the stream is alive,
568
+ * then Release the stream BEFORE building the Ruby string. rb_str_new can
569
+ * raise (NoMemError) via longjmp, which would otherwise skip mem->Release()
570
+ * and leak the COM stream. We also reject >LONG_MAX sizes up front (on
571
+ * LLP64 a cast to `long` would otherwise go negative). */
572
+ char* buf = nullptr;
573
+ long buflen = 0;
574
+ HRESULT hr = encode_png(s, mem);
575
+ if (SUCCEEDED(hr)) {
576
+ STATSTG st;
577
+ hr = mem->Stat(&st, STATFLAG_NONAME);
578
+ if (SUCCEEDED(hr)) {
579
+ ULONGLONG full = st.cbSize.QuadPart;
580
+ if (full > static_cast<ULONGLONG>(LONG_MAX)) {
581
+ hr = E_OUTOFMEMORY; /* PNG too large to hand to Ruby */
582
+ } else if (full > 0) {
583
+ LARGE_INTEGER zero;
584
+ zero.QuadPart = 0;
585
+ hr = mem->Seek(zero, STREAM_SEEK_SET, nullptr);
586
+ if (SUCCEEDED(hr)) {
587
+ buf = static_cast<char*>(malloc(static_cast<size_t>(full)));
588
+ if (!buf) {
589
+ hr = E_OUTOFMEMORY;
590
+ } else {
591
+ ULONG got = 0;
592
+ hr = mem->Read(buf, static_cast<ULONG>(full), &got);
593
+ if (SUCCEEDED(hr)) buflen = static_cast<long>(got);
594
+ }
595
+ }
596
+ }
597
+ }
598
+ }
599
+ mem->Release();
600
+ if (FAILED(hr)) { free(buf); raise_hr("to_png", hr); }
601
+
602
+ VALUE result = rb_str_new(buf, buflen); /* copies; buf may be null when 0 */
603
+ free(buf);
604
+ rb_enc_associate(result, rb_ascii8bit_encoding());
605
+ return result;
606
+ }
607
+
608
+ static VALUE surface_width(VALUE self) { return UINT2NUM(get_surface(self)->width); }
609
+ static VALUE surface_height(VALUE self) { return UINT2NUM(get_surface(self)->height); }
610
+
611
+ extern "C" void Init_windraw(void) {
612
+ VALUE mWindraw = rb_define_module("Windraw");
613
+ VALUE cSurface = rb_define_class_under(mWindraw, "Surface", rb_cObject);
614
+
615
+ rb_define_alloc_func(cSurface, surface_alloc);
616
+ rb_define_method(cSurface, "initialize", RUBY_METHOD_FUNC(surface_initialize), 2);
617
+ rb_define_method(cSurface, "_clear", RUBY_METHOD_FUNC(surface_clear), 4);
618
+ rb_define_method(cSurface, "_fill_rect", RUBY_METHOD_FUNC(surface_fill_rect), 8);
619
+ rb_define_method(cSurface, "_stroke_rect", RUBY_METHOD_FUNC(surface_stroke_rect), 9);
620
+ rb_define_method(cSurface, "_fill_ellipse", RUBY_METHOD_FUNC(surface_fill_ellipse), 8);
621
+ rb_define_method(cSurface, "_stroke_ellipse", RUBY_METHOD_FUNC(surface_stroke_ellipse), 9);
622
+ rb_define_method(cSurface, "_line", RUBY_METHOD_FUNC(surface_line), 9);
623
+ rb_define_method(cSurface, "_fill_round_rect", RUBY_METHOD_FUNC(surface_fill_round_rect), 10);
624
+ rb_define_method(cSurface, "_stroke_round_rect", RUBY_METHOD_FUNC(surface_stroke_round_rect), 11);
625
+ rb_define_method(cSurface, "_fill_polygon", RUBY_METHOD_FUNC(surface_fill_polygon), 5);
626
+ rb_define_method(cSurface, "_stroke_polygon", RUBY_METHOD_FUNC(surface_stroke_polygon), 7);
627
+ rb_define_method(cSurface, "_text", RUBY_METHOD_FUNC(surface_text), 11);
628
+ rb_define_method(cSurface, "finish", RUBY_METHOD_FUNC(surface_finish), 0);
629
+ rb_define_method(cSurface, "close", RUBY_METHOD_FUNC(surface_close), 0);
630
+ rb_define_method(cSurface, "save", RUBY_METHOD_FUNC(surface_save), 1);
631
+ rb_define_method(cSurface, "to_png", RUBY_METHOD_FUNC(surface_to_png), 0);
632
+ rb_define_method(cSurface, "width", RUBY_METHOD_FUNC(surface_width), 0);
633
+ rb_define_method(cSurface, "height", RUBY_METHOD_FUNC(surface_height), 0);
634
+ rb_define_alias(cSurface, "dispose", "close");
635
+ }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windraw
4
+ # Parses CSS-style hex colors into straight (non-premultiplied) RGBA floats in
5
+ # 0.0..1.0, the form Direct2D's D2D1::ColorF expects.
6
+ module Color
7
+ module_function
8
+
9
+ # Accepts "#rgb", "#rgba", "#rrggbb", "#rrggbbaa" (leading "#" optional).
10
+ # Returns [r, g, b, a] as floats in 0.0..1.0. Raises ArgumentError otherwise.
11
+ def parse(spec, default_alpha: 1.0)
12
+ unless spec.is_a?(String) || spec.respond_to?(:to_str)
13
+ raise ArgumentError, "invalid hex color: #{spec.inspect} (expected a String)"
14
+ end
15
+ s = spec.to_s.strip
16
+ s = s[1..] if s.start_with?("#")
17
+
18
+ unless s.match?(/\A\h+\z/)
19
+ raise ArgumentError, "invalid hex color: #{spec.inspect} (non-hex characters)"
20
+ end
21
+
22
+ case s.length
23
+ when 3 # rgb
24
+ r, g, b = s.chars.map { |c| (c * 2).to_i(16) }
25
+ a = (default_alpha * 255).round
26
+ when 4 # rgba
27
+ r, g, b, a = s.chars.map { |c| (c * 2).to_i(16) }
28
+ when 6 # rrggbb
29
+ r, g, b = s.scan(/../).map { |h| h.to_i(16) }
30
+ a = (default_alpha * 255).round
31
+ when 8 # rrggbbaa
32
+ r, g, b, a = s.scan(/../).map { |h| h.to_i(16) }
33
+ else
34
+ raise ArgumentError,
35
+ "invalid hex color: #{spec.inspect} (use #rgb, #rgba, #rrggbb, or #rrggbbaa)"
36
+ end
37
+
38
+ [r / 255.0, g / 255.0, b / 255.0, a / 255.0]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "windraw/color"
4
+
5
+ module Windraw
6
+ # Ergonomic, hex-color-aware drawing API layered over the native Surface
7
+ # primitives (defined in the C++ extension). Coordinates are in pixels; the
8
+ # origin (0, 0) is the top-left corner.
9
+ #
10
+ # Every drawing method returns self, so calls can be chained.
11
+ class Surface
12
+ # Fill the whole surface with a single color (default fully transparent).
13
+ def clear(color = "#00000000")
14
+ r, g, b, a = Color.parse(color)
15
+ _clear(r, g, b, a)
16
+ self
17
+ end
18
+
19
+ # Axis-aligned rectangle from (x, y) with the given width/height.
20
+ # Pass fill: and/or stroke: hex colors; with both, fill is drawn first.
21
+ # radius: rounds the corners — a single Numeric (rx == ry) or a [rx, ry] pair.
22
+ def rectangle(x, y, w, h, fill: nil, stroke: nil, width: 1.0, radius: nil)
23
+ if radius
24
+ rx, ry =
25
+ if radius.is_a?(Array)
26
+ unless radius.length == 2
27
+ raise ArgumentError, "radius: must be a Numeric or an [rx, ry] pair, got #{radius.inspect}"
28
+ end
29
+ radius
30
+ else
31
+ [radius, radius]
32
+ end
33
+ if fill
34
+ r, g, b, a = Color.parse(fill)
35
+ _fill_round_rect(x, y, w, h, rx, ry, r, g, b, a)
36
+ end
37
+ if stroke
38
+ r, g, b, a = Color.parse(stroke)
39
+ _stroke_round_rect(x, y, w, h, rx, ry, r, g, b, a, width)
40
+ end
41
+ else
42
+ if fill
43
+ r, g, b, a = Color.parse(fill)
44
+ _fill_rect(x, y, w, h, r, g, b, a)
45
+ end
46
+ if stroke
47
+ r, g, b, a = Color.parse(stroke)
48
+ _stroke_rect(x, y, w, h, r, g, b, a, width)
49
+ end
50
+ end
51
+ self
52
+ end
53
+ alias rect rectangle
54
+
55
+ # Ellipse centered at (cx, cy) with radii rx, ry.
56
+ def ellipse(cx, cy, rx, ry, fill: nil, stroke: nil, width: 1.0)
57
+ if fill
58
+ r, g, b, a = Color.parse(fill)
59
+ _fill_ellipse(cx, cy, rx, ry, r, g, b, a)
60
+ end
61
+ if stroke
62
+ r, g, b, a = Color.parse(stroke)
63
+ _stroke_ellipse(cx, cy, rx, ry, r, g, b, a, width)
64
+ end
65
+ self
66
+ end
67
+
68
+ # Circle centered at (cx, cy) — convenience for an ellipse with equal radii.
69
+ def circle(cx, cy, radius, fill: nil, stroke: nil, width: 1.0)
70
+ ellipse(cx, cy, radius, radius, fill: fill, stroke: stroke, width: width)
71
+ end
72
+
73
+ # Straight line from (x1, y1) to (x2, y2).
74
+ def line(x1, y1, x2, y2, color: "#000000", width: 1.0)
75
+ r, g, b, a = Color.parse(color)
76
+ _line(x1, y1, x2, y2, r, g, b, a, width)
77
+ self
78
+ end
79
+
80
+ # Closed polygon through the given points (an Array of [x, y] pairs).
81
+ # fill: and/or stroke: hex colors; with both, fill is drawn first.
82
+ def polygon(points, fill: nil, stroke: nil, width: 1.0)
83
+ flat = flatten_points(points)
84
+ if fill
85
+ r, g, b, a = Color.parse(fill)
86
+ _fill_polygon(flat, r, g, b, a)
87
+ end
88
+ if stroke
89
+ r, g, b, a = Color.parse(stroke)
90
+ _stroke_polygon(flat, r, g, b, a, width, true)
91
+ end
92
+ self
93
+ end
94
+
95
+ # Open connected line segments through the given points (Array of [x, y]).
96
+ def polyline(points, color: "#000000", width: 1.0)
97
+ r, g, b, a = Color.parse(color)
98
+ _stroke_polygon(flatten_points(points), r, g, b, a, width, false)
99
+ self
100
+ end
101
+
102
+ # Draw a string with its top-left at (x, y).
103
+ def text(string, x, y, color: "#000000", font: "Segoe UI", size: 16.0,
104
+ bold: false, italic: false)
105
+ r, g, b, a = Color.parse(color)
106
+ _text(string.to_s, x, y, r, g, b, a, font.to_s, size, bold, italic)
107
+ self
108
+ end
109
+
110
+ # [width, height] in pixels.
111
+ def size
112
+ [width, height]
113
+ end
114
+
115
+ def inspect
116
+ "#<Windraw::Surface #{width}x#{height}>"
117
+ end
118
+
119
+ private
120
+
121
+ # Convert an Array of [x, y] pairs into a flat [x0, y0, x1, y1, ...] Array of
122
+ # Floats. Raises ArgumentError on non-numeric coords or fewer than 2 points.
123
+ def flatten_points(points)
124
+ flat = Array(points).flat_map do |p|
125
+ unless p.is_a?(Array) && p.length == 2
126
+ raise ArgumentError, "each point must be an [x, y] pair, got #{p.inspect}"
127
+ end
128
+ [Float(p[0]), Float(p[1])]
129
+ end
130
+ if flat.length < 4
131
+ raise ArgumentError, "need at least 2 [x, y] points, got #{points.inspect}"
132
+ end
133
+ flat
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Windraw
4
+ VERSION = "0.1.0"
5
+ end
data/lib/windraw.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "windraw/version"
4
+ require "windraw/windraw" # native C++ extension: defines Windraw::Surface
5
+ require "windraw/color"
6
+ require "windraw/surface" # reopens Windraw::Surface with the ergonomic API
7
+
8
+ # windraw — headless 2D drawing for Windows using Direct2D + DirectWrite + WIC.
9
+ #
10
+ # Windraw.surface(800, 600) do |c|
11
+ # c.clear("#1e1e2e")
12
+ # c.rectangle(50, 50, 200, 120, fill: "#89b4fa")
13
+ # c.ellipse(400, 300, 80, 50, fill: "#a6e3a1")
14
+ # c.text("Hello, Direct2D!", 60, 200, font: "Segoe UI", size: 32, color: "#cdd6f4")
15
+ # end.save("out.png")
16
+ module Windraw
17
+ # Create a drawing surface of the given pixel size. With a block, yields the
18
+ # surface, finishes drawing when the block returns, and returns the surface
19
+ # (so you can chain #save / #to_png). Without a block, returns an open surface.
20
+ #
21
+ # width/height are whole pixels; a Float is truncated toward zero (NUM2INT).
22
+ def self.surface(width, height)
23
+ surface = Surface.new(width, height)
24
+ if block_given?
25
+ yield surface
26
+ surface.finish
27
+ end
28
+ surface
29
+ end
30
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: windraw
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ windraw renders shapes and text into an off-screen bitmap using the graphics
76
+ stack that already ships with Windows — Direct2D for vector drawing,
77
+ DirectWrite for text, and WIC for PNG encoding — then saves a PNG to a file
78
+ or returns the bytes as a string. A Cairo-style 2D canvas with no window or
79
+ message loop required. Windows MSVC (mswin) Ruby only.
80
+ executables: []
81
+ extensions:
82
+ - ext/windraw/extconf.rb
83
+ extra_rdoc_files: []
84
+ files:
85
+ - CHANGELOG.md
86
+ - LICENSE.txt
87
+ - README.md
88
+ - ext/windraw/extconf.rb
89
+ - ext/windraw/windraw.cpp
90
+ - lib/windraw.rb
91
+ - lib/windraw/color.rb
92
+ - lib/windraw/surface.rb
93
+ - lib/windraw/version.rb
94
+ homepage: https://github.com/main-path/windraw
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/main-path/windraw
99
+ changelog_uri: https://github.com/main-path/windraw/blob/main/CHANGELOG.md
100
+ bug_tracker_uri: https://github.com/main-path/windraw/issues
101
+ rubygems_mfa_required: 'true'
102
+ rdoc_options: []
103
+ require_paths:
104
+ - lib
105
+ required_ruby_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '3.0'
110
+ required_rubygems_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '0'
115
+ requirements: []
116
+ rubygems_version: 3.6.9
117
+ specification_version: 4
118
+ summary: Headless 2D drawing for Windows (Direct2D + DirectWrite + WIC) to PNG.
119
+ test_files: []