GHIElectronics.Endpoint.Drawing 0.5.3__py3-none-any.whl
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.
- GHIElectronics/Endpoint/Drawing/__init__.py +609 -0
- ghielectronics_endpoint_drawing-0.5.3.dist-info/METADATA +20 -0
- ghielectronics_endpoint_drawing-0.5.3.dist-info/RECORD +5 -0
- ghielectronics_endpoint_drawing-0.5.3.dist-info/WHEEL +5 -0
- ghielectronics_endpoint_drawing-0.5.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""GHIElectronics.Endpoint.Drawing, off-screen 2D drawing primitives.
|
|
2
|
+
API mirrors the .NET package character-for-character (PascalCase).
|
|
3
|
+
|
|
4
|
+
from GHIElectronics.Endpoint.Drawing import Canvas, Color, PixelFormat
|
|
5
|
+
|
|
6
|
+
canvas = Canvas.Create(240, 320, PixelFormat.Rgb565)
|
|
7
|
+
canvas.Clear(Color.Black)
|
|
8
|
+
canvas.DrawLine(10, 10, 230, 310, Color.Red, 2)
|
|
9
|
+
canvas.FillCircle(120, 160, 40, Color.Blue)
|
|
10
|
+
spi.Write(canvas.Buffer)
|
|
11
|
+
|
|
12
|
+
The Canvas drawing primitives run in pure Python against an in-memory
|
|
13
|
+
bytearray; Image / Font delegate to the stb_image + stb_truetype decoders
|
|
14
|
+
inside libendpoint. Hand `Canvas.Buffer` to any device that accepts raw
|
|
15
|
+
bytes (DisplayController.Flush, SpiDevice.Write, I2cDevice.Write, ...)."""
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
from GHIElectronics.Endpoint.Core._native import native
|
|
21
|
+
from GHIElectronics.Endpoint.Core._errors import (
|
|
22
|
+
ArgumentException, ArgumentOutOfRangeException,
|
|
23
|
+
NotSupportedException, NotImplementedException, ObjectDisposedException,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["Canvas", "Color", "Font", "Image", "PixelFormat"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# PixelFormat
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class PixelFormat:
|
|
35
|
+
"""Pixel layout of a Canvas buffer. Values mirror the .NET enum."""
|
|
36
|
+
|
|
37
|
+
Monochrome = 1 # 1 bpp, MSB-first packed (SSD1306 / SH1106 OLEDs)
|
|
38
|
+
Rgb565 = 2 # 16 bpp little-endian (on-board panel, most SPI TFTs)
|
|
39
|
+
Rgb888 = 3 # 24 bpp R-G-B byte order (parallel RGB panels @ 24 bpp)
|
|
40
|
+
Argb8888 = 4 # 32 bpp A-R-G-B byte order (off-screen compositing)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Color
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
class Color:
|
|
48
|
+
"""A 32-bit color with 8 bits each of alpha, red, green, blue. Canvas
|
|
49
|
+
maps it to its PixelFormat at draw time."""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("R", "G", "B", "A")
|
|
52
|
+
|
|
53
|
+
def __init__(self, r, g, b, a=0xFF):
|
|
54
|
+
self.R = int(r) & 0xFF
|
|
55
|
+
self.G = int(g) & 0xFF
|
|
56
|
+
self.B = int(b) & 0xFF
|
|
57
|
+
self.A = int(a) & 0xFF
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def FromArgb(argb):
|
|
61
|
+
"""Color from a 0xAARRGGBB value."""
|
|
62
|
+
argb = int(argb)
|
|
63
|
+
return Color((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF, (argb >> 24) & 0xFF)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def FromRgb(rgb):
|
|
67
|
+
"""Color from a 0xRRGGBB value, opaque."""
|
|
68
|
+
rgb = int(rgb)
|
|
69
|
+
return Color((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Common colors — names match System.Drawing / CSS where they overlap.
|
|
73
|
+
# Attached after the class body to sidestep the forward-reference problem.
|
|
74
|
+
Color.Transparent = Color(0, 0, 0, 0)
|
|
75
|
+
Color.Black = Color(0x00, 0x00, 0x00)
|
|
76
|
+
Color.White = Color(0xFF, 0xFF, 0xFF)
|
|
77
|
+
Color.Red = Color(0xFF, 0x00, 0x00)
|
|
78
|
+
Color.Green = Color(0x00, 0xFF, 0x00)
|
|
79
|
+
Color.Blue = Color(0x00, 0x00, 0xFF)
|
|
80
|
+
Color.Yellow = Color(0xFF, 0xFF, 0x00)
|
|
81
|
+
Color.Cyan = Color(0x00, 0xFF, 0xFF)
|
|
82
|
+
Color.Magenta = Color(0xFF, 0x00, 0xFF)
|
|
83
|
+
Color.Navy = Color(0x00, 0x00, 0x80)
|
|
84
|
+
Color.Maroon = Color(0x80, 0x00, 0x00)
|
|
85
|
+
Color.Olive = Color(0x80, 0x80, 0x00)
|
|
86
|
+
Color.Gray = Color(0x80, 0x80, 0x80)
|
|
87
|
+
Color.Silver = Color(0xC0, 0xC0, 0xC0)
|
|
88
|
+
Color.Orange = Color(0xFF, 0xA5, 0x00)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Image
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
class Image:
|
|
96
|
+
"""A decoded image (JPEG / PNG / BMP / GIF / TGA — detected from the
|
|
97
|
+
file's magic bytes), ready to blit into a Canvas. Decode runs once in
|
|
98
|
+
Image.Load; Canvas.DrawImage then just blits + converts."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, width, height, pixel_format, pixels):
|
|
101
|
+
self.Width = width
|
|
102
|
+
self.Height = height
|
|
103
|
+
self.PixelFormat = pixel_format
|
|
104
|
+
# Internal RGB888 pixel bytes — read by Canvas.DrawImage / Widget.
|
|
105
|
+
self._pixels = pixels
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def Load(file_bytes):
|
|
109
|
+
"""Decode an image file into an in-memory bitmap. The returned Image
|
|
110
|
+
is the format-neutral handle — pass it to Canvas.DrawImage / Widget
|
|
111
|
+
.SetBackgroundImage."""
|
|
112
|
+
if file_bytes is None:
|
|
113
|
+
raise ArgumentException("file_bytes must not be None")
|
|
114
|
+
if len(file_bytes) == 0:
|
|
115
|
+
raise ArgumentException("Image file bytes are empty.")
|
|
116
|
+
|
|
117
|
+
data = bytes(file_bytes)
|
|
118
|
+
src = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
|
|
119
|
+
out_pixels = ctypes.POINTER(ctypes.c_uint8)()
|
|
120
|
+
out_w = ctypes.c_int()
|
|
121
|
+
out_h = ctypes.c_int()
|
|
122
|
+
native.image_decode(src, len(data),
|
|
123
|
+
ctypes.byref(out_pixels), ctypes.byref(out_w), ctypes.byref(out_h))
|
|
124
|
+
try:
|
|
125
|
+
byte_count = out_w.value * out_h.value * 3
|
|
126
|
+
# Copy pixels out of native storage into a managed bytes object so
|
|
127
|
+
# the native allocation can be freed immediately.
|
|
128
|
+
managed = bytes(ctypes.string_at(out_pixels, byte_count))
|
|
129
|
+
return Image(out_w.value, out_h.value, PixelFormat.Rgb888, managed)
|
|
130
|
+
finally:
|
|
131
|
+
native.image_free(out_pixels)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Font
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
class Font:
|
|
139
|
+
"""A TrueType / OpenType font for Canvas.DrawText. Rasterizes glyphs
|
|
140
|
+
on demand through stb_truetype inside libendpoint. Separate from
|
|
141
|
+
GHIElectronics.Endpoint.UI.Font (which is LVGL's lv_tiny_ttf for
|
|
142
|
+
widget text)."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, handle):
|
|
145
|
+
self._handle = handle
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def Load(ttf_bytes):
|
|
149
|
+
"""Load a TTF / OTF font from raw file bytes. The bytes are copied
|
|
150
|
+
into native storage, so the caller's buffer can be released as soon
|
|
151
|
+
as this returns."""
|
|
152
|
+
if ttf_bytes is None:
|
|
153
|
+
raise ArgumentException("ttf_bytes must not be None")
|
|
154
|
+
if len(ttf_bytes) == 0:
|
|
155
|
+
raise ArgumentException("Font bytes are empty.")
|
|
156
|
+
|
|
157
|
+
data = bytes(ttf_bytes)
|
|
158
|
+
src = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
|
|
159
|
+
out_handle = native.out_handle()
|
|
160
|
+
native.font_init(src, len(data), native.ref(out_handle))
|
|
161
|
+
return Font(out_handle.value)
|
|
162
|
+
|
|
163
|
+
def GetMetrics(self, size_px):
|
|
164
|
+
"""Font-wide vertical metrics at the given pixel height. Returns
|
|
165
|
+
(ascent, descent, line_gap). line_height = ascent - descent + line_gap."""
|
|
166
|
+
if self._handle is None:
|
|
167
|
+
raise ObjectDisposedException("The Font is disposed")
|
|
168
|
+
a, d, g = native.out_int(), native.out_int(), native.out_int()
|
|
169
|
+
native.font_metrics(self._handle, int(size_px), native.ref(a), native.ref(d), native.ref(g))
|
|
170
|
+
return a.value, d.value, g.value
|
|
171
|
+
|
|
172
|
+
def Dispose(self):
|
|
173
|
+
if self._handle is not None:
|
|
174
|
+
native.font_free(self._handle)
|
|
175
|
+
self._handle = None
|
|
176
|
+
|
|
177
|
+
def __del__(self):
|
|
178
|
+
try: self.Dispose()
|
|
179
|
+
except Exception: pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Canvas
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _rgb565(c):
|
|
187
|
+
# Pack an sRGB Color into 5-6-5 little-endian (matches /dev/fb0 on
|
|
188
|
+
# little-endian ARM; SPI TFTs expecting MSB-first need a byte swap on write).
|
|
189
|
+
return ((c.R & 0xF8) << 8) | ((c.G & 0xFC) << 3) | (c.B >> 3)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _byte_count_for(width, height, fmt):
|
|
193
|
+
if fmt == PixelFormat.Monochrome: return ((width + 7) // 8) * height
|
|
194
|
+
if fmt == PixelFormat.Rgb565: return width * height * 2
|
|
195
|
+
if fmt == PixelFormat.Rgb888: return width * height * 3
|
|
196
|
+
if fmt == PixelFormat.Argb8888: return width * height * 4
|
|
197
|
+
raise ArgumentOutOfRangeException(f"Unsupported pixel format: {fmt}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Canvas:
|
|
201
|
+
"""A pixel buffer plus the primitives to draw into it. Pick the size
|
|
202
|
+
and PixelFormat at Create() time; when done, hand Buffer to whichever
|
|
203
|
+
device accepts bytes - DisplayController.Flush, SpiDevice.Write,
|
|
204
|
+
I2cDevice.Write, a file, anywhere.
|
|
205
|
+
|
|
206
|
+
Example::
|
|
207
|
+
|
|
208
|
+
canvas = Canvas.Create(240, 320, PixelFormat.Rgb565)
|
|
209
|
+
canvas.Clear(Color.Black)
|
|
210
|
+
canvas.DrawLine(10, 10, 230, 310, Color.Red, 2)
|
|
211
|
+
canvas.FillCircle(120, 160, 40, Color.Blue)
|
|
212
|
+
canvas.DrawText("Hello Endpoint", font, 10, 30, 20, Color.White)
|
|
213
|
+
spi.Write(canvas.Buffer)
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self, width, height, pixel_format):
|
|
217
|
+
if width <= 0 or height <= 0:
|
|
218
|
+
raise ArgumentOutOfRangeException("Width and height must be positive.")
|
|
219
|
+
self.Width = int(width)
|
|
220
|
+
self.Height = int(height)
|
|
221
|
+
self.PixelFormat = pixel_format
|
|
222
|
+
self._buffer = bytearray(_byte_count_for(self.Width, self.Height, pixel_format))
|
|
223
|
+
self._disposed = False
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def Create(width, height, pixel_format):
|
|
227
|
+
"""Allocate a new Canvas of the given size and pixel format."""
|
|
228
|
+
return Canvas(width, height, pixel_format)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def Buffer(self):
|
|
232
|
+
"""Raw pixel bytes. Length and layout match Width/Height/PixelFormat."""
|
|
233
|
+
if self._disposed:
|
|
234
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
235
|
+
return self._buffer
|
|
236
|
+
|
|
237
|
+
# ---------- draw primitives (managed) ----------------------------------
|
|
238
|
+
|
|
239
|
+
def Clear(self, color):
|
|
240
|
+
"""Fill the whole buffer with a single color."""
|
|
241
|
+
if self._disposed:
|
|
242
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
243
|
+
fmt = self.PixelFormat
|
|
244
|
+
buf = self._buffer
|
|
245
|
+
# Slice-assignment hands the fill to the C bytearray slot: ~100x faster
|
|
246
|
+
# than a per-byte Python loop on a Cortex-A7 at 800x480.
|
|
247
|
+
if fmt == PixelFormat.Monochrome:
|
|
248
|
+
fill = 0xFF if (color.R | color.G | color.B) != 0 else 0x00
|
|
249
|
+
buf[:] = bytes([fill]) * len(buf)
|
|
250
|
+
elif fmt == PixelFormat.Rgb565:
|
|
251
|
+
px = _rgb565(color)
|
|
252
|
+
buf[:] = bytes([px & 0xFF, (px >> 8) & 0xFF]) * (len(buf) // 2)
|
|
253
|
+
elif fmt == PixelFormat.Rgb888:
|
|
254
|
+
buf[:] = bytes([color.R, color.G, color.B]) * (len(buf) // 3)
|
|
255
|
+
elif fmt == PixelFormat.Argb8888:
|
|
256
|
+
buf[:] = bytes([color.A, color.R, color.G, color.B]) * (len(buf) // 4)
|
|
257
|
+
|
|
258
|
+
def SetPixel(self, x, y, color):
|
|
259
|
+
"""Set a single pixel. No-op if (x, y) is outside the canvas."""
|
|
260
|
+
if self._disposed:
|
|
261
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
262
|
+
if x < 0 or x >= self.Width or y < 0 or y >= self.Height:
|
|
263
|
+
return
|
|
264
|
+
fmt = self.PixelFormat
|
|
265
|
+
buf = self._buffer
|
|
266
|
+
if fmt == PixelFormat.Monochrome:
|
|
267
|
+
byte_index = y * ((self.Width + 7) // 8) + (x // 8)
|
|
268
|
+
bit = 7 - (x & 7)
|
|
269
|
+
if (color.R | color.G | color.B) != 0:
|
|
270
|
+
buf[byte_index] |= (1 << bit)
|
|
271
|
+
else:
|
|
272
|
+
buf[byte_index] &= (~(1 << bit)) & 0xFF
|
|
273
|
+
elif fmt == PixelFormat.Rgb565:
|
|
274
|
+
px = _rgb565(color)
|
|
275
|
+
i = (y * self.Width + x) * 2
|
|
276
|
+
buf[i] = px & 0xFF
|
|
277
|
+
buf[i + 1] = (px >> 8) & 0xFF
|
|
278
|
+
elif fmt == PixelFormat.Rgb888:
|
|
279
|
+
i = (y * self.Width + x) * 3
|
|
280
|
+
buf[i] = color.R; buf[i + 1] = color.G; buf[i + 2] = color.B
|
|
281
|
+
elif fmt == PixelFormat.Argb8888:
|
|
282
|
+
i = (y * self.Width + x) * 4
|
|
283
|
+
buf[i] = color.A; buf[i + 1] = color.R; buf[i + 2] = color.G; buf[i + 3] = color.B
|
|
284
|
+
|
|
285
|
+
def DrawLine(self, x0, y0, x1, y1, color, thickness=1):
|
|
286
|
+
"""Bresenham line, honoring thickness by stamping an r-pixel disc at each step."""
|
|
287
|
+
x0 = int(x0); y0 = int(y0); x1 = int(x1); y1 = int(y1)
|
|
288
|
+
dx = abs(x1 - x0); sx = 1 if x0 < x1 else -1
|
|
289
|
+
dy = -abs(y1 - y0); sy = 1 if y0 < y1 else -1
|
|
290
|
+
err = dx + dy
|
|
291
|
+
r = thickness // 2
|
|
292
|
+
while True:
|
|
293
|
+
self._stamp_disc(x0, y0, r, color)
|
|
294
|
+
if x0 == x1 and y0 == y1:
|
|
295
|
+
break
|
|
296
|
+
e2 = 2 * err
|
|
297
|
+
if e2 >= dy:
|
|
298
|
+
err += dy; x0 += sx
|
|
299
|
+
if e2 <= dx:
|
|
300
|
+
err += dx; y0 += sy
|
|
301
|
+
|
|
302
|
+
def DrawRectangle(self, x, y, width, height, color, thickness=1):
|
|
303
|
+
"""Stroke a rectangle outline."""
|
|
304
|
+
if thickness <= 0 or width <= 0 or height <= 0:
|
|
305
|
+
return
|
|
306
|
+
t = int(thickness)
|
|
307
|
+
x = int(x); y = int(y); width = int(width); height = int(height)
|
|
308
|
+
# Four axis-aligned bands via FillRectangle (one scanline slice per
|
|
309
|
+
# row on Rgb565): orders of magnitude faster than Bresenham DrawLine
|
|
310
|
+
# for borders. Top + bottom span the full width; left + right fill
|
|
311
|
+
# the remaining vertical strip so corners are not double-drawn.
|
|
312
|
+
self.FillRectangle(x, y, width, t, color)
|
|
313
|
+
self.FillRectangle(x, y + height - t, width, t, color)
|
|
314
|
+
inner_h = height - 2 * t
|
|
315
|
+
if inner_h > 0:
|
|
316
|
+
self.FillRectangle(x, y + t, t, inner_h, color)
|
|
317
|
+
self.FillRectangle(x + width - t, y + t, t, inner_h, color)
|
|
318
|
+
|
|
319
|
+
def FillRectangle(self, x, y, width, height, color):
|
|
320
|
+
"""Fill a rectangle."""
|
|
321
|
+
if self._disposed:
|
|
322
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
323
|
+
# Clip to canvas bounds.
|
|
324
|
+
x0 = max(0, int(x)); y0 = max(0, int(y))
|
|
325
|
+
x1 = min(self.Width, int(x) + int(width))
|
|
326
|
+
y1 = min(self.Height, int(y) + int(height))
|
|
327
|
+
if x0 >= x1 or y0 >= y1:
|
|
328
|
+
return
|
|
329
|
+
fmt = self.PixelFormat
|
|
330
|
+
buf = self._buffer
|
|
331
|
+
# Rgb565 fast path: slice-assign one scanline at a time.
|
|
332
|
+
if fmt == PixelFormat.Rgb565:
|
|
333
|
+
px = _rgb565(color)
|
|
334
|
+
row = bytes([px & 0xFF, (px >> 8) & 0xFF]) * (x1 - x0)
|
|
335
|
+
stride = self.Width * 2
|
|
336
|
+
for yy in range(y0, y1):
|
|
337
|
+
off = yy * stride + x0 * 2
|
|
338
|
+
buf[off: off + len(row)] = row
|
|
339
|
+
return
|
|
340
|
+
# Fallback: per-pixel (less common formats).
|
|
341
|
+
for yy in range(y0, y1):
|
|
342
|
+
for xx in range(x0, x1):
|
|
343
|
+
self.SetPixel(xx, yy, color)
|
|
344
|
+
|
|
345
|
+
def DrawCircle(self, cx, cy, radius, color, thickness=1):
|
|
346
|
+
"""Midpoint-algorithm circle outline."""
|
|
347
|
+
for t in range(thickness):
|
|
348
|
+
self._midpoint_circle(cx, cy, radius - t, color, fill=False)
|
|
349
|
+
|
|
350
|
+
def FillCircle(self, cx, cy, radius, color):
|
|
351
|
+
"""Filled circle."""
|
|
352
|
+
self._midpoint_circle(cx, cy, radius, color, fill=True)
|
|
353
|
+
|
|
354
|
+
def DrawArc(self, cx, cy, radius, start_deg, end_deg, color, thickness=1):
|
|
355
|
+
"""Planned — not implemented in the managed prototype; needs an
|
|
356
|
+
LVGL-backed native backend."""
|
|
357
|
+
raise NotImplementedException("DrawArc is pending the LVGL-backed native implementation.")
|
|
358
|
+
|
|
359
|
+
def DrawText(self, text, font, x, y, size_px, color):
|
|
360
|
+
"""Rasterize `text` with `font` at `size_px`, top-left at (x, y).
|
|
361
|
+
Alpha-blends against whatever is already in the canvas. The font is
|
|
362
|
+
rasterized on every call; cache into a Canvas and re-blit for high FPS."""
|
|
363
|
+
if self._disposed:
|
|
364
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
365
|
+
if text is None:
|
|
366
|
+
raise ArgumentException("text must not be None")
|
|
367
|
+
if font is None:
|
|
368
|
+
raise ArgumentException("font must not be None")
|
|
369
|
+
if self.PixelFormat not in (PixelFormat.Rgb565, PixelFormat.Monochrome):
|
|
370
|
+
raise NotSupportedException(
|
|
371
|
+
f"DrawText into a {self.PixelFormat} canvas is not implemented "
|
|
372
|
+
"(Rgb565 and Monochrome targets only).")
|
|
373
|
+
if size_px <= 0 or len(text) == 0:
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
ascent, _, _ = font.GetMetrics(int(size_px))
|
|
377
|
+
pen_x = int(x)
|
|
378
|
+
baseline_y = int(y) + ascent
|
|
379
|
+
mono = self.PixelFormat == PixelFormat.Monochrome
|
|
380
|
+
lit = (color.R | color.G | color.B) != 0
|
|
381
|
+
|
|
382
|
+
for ch in text:
|
|
383
|
+
codepoint = ord(ch)
|
|
384
|
+
bmp = ctypes.POINTER(ctypes.c_uint8)()
|
|
385
|
+
gw = ctypes.c_int(); gh = ctypes.c_int()
|
|
386
|
+
gxo = ctypes.c_int(); gyo = ctypes.c_int()
|
|
387
|
+
advance = ctypes.c_int()
|
|
388
|
+
native.font_rasterize(font._handle, codepoint, int(size_px),
|
|
389
|
+
ctypes.byref(bmp),
|
|
390
|
+
ctypes.byref(gw), ctypes.byref(gh),
|
|
391
|
+
ctypes.byref(gxo), ctypes.byref(gyo),
|
|
392
|
+
ctypes.byref(advance))
|
|
393
|
+
try:
|
|
394
|
+
if bmp and gw.value > 0 and gh.value > 0:
|
|
395
|
+
if mono:
|
|
396
|
+
self._stamp_glyph_monochrome(bmp, gw.value, gh.value,
|
|
397
|
+
pen_x + gxo.value, baseline_y + gyo.value, lit)
|
|
398
|
+
else:
|
|
399
|
+
self._alpha_blend_glyph(bmp, gw.value, gh.value,
|
|
400
|
+
pen_x + gxo.value, baseline_y + gyo.value, color)
|
|
401
|
+
finally:
|
|
402
|
+
if bmp:
|
|
403
|
+
native.image_free(bmp)
|
|
404
|
+
pen_x += advance.value
|
|
405
|
+
|
|
406
|
+
def DrawImage(self, source, x, y, width=None, height=None, pixel_format=None):
|
|
407
|
+
"""Blit an image into this canvas at (x, y).
|
|
408
|
+
|
|
409
|
+
- `source` is an Image: use its cached decoded pixels (fast path).
|
|
410
|
+
- `source` is bytes-like and width/height/pixel_format are None: treat
|
|
411
|
+
it as encoded file bytes (JPG/PNG/BMP/GIF/TGA), decode once and blit.
|
|
412
|
+
- `source` is bytes-like and width/height/pixel_format are given:
|
|
413
|
+
treat as a raw pixel buffer of that size and format."""
|
|
414
|
+
if self._disposed:
|
|
415
|
+
raise ObjectDisposedException("The Canvas is disposed")
|
|
416
|
+
if source is None:
|
|
417
|
+
raise ArgumentException("source must not be None")
|
|
418
|
+
|
|
419
|
+
if isinstance(source, Image):
|
|
420
|
+
self._blit_pixels(source._pixels, x, y, source.Width, source.Height, source.PixelFormat)
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
if width is None or height is None or pixel_format is None:
|
|
424
|
+
# Encoded file bytes: decode then blit.
|
|
425
|
+
if len(source) == 0:
|
|
426
|
+
raise ArgumentException("Image file bytes are empty.")
|
|
427
|
+
data = bytes(source)
|
|
428
|
+
src = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
|
|
429
|
+
out_pixels = ctypes.POINTER(ctypes.c_uint8)()
|
|
430
|
+
out_w = ctypes.c_int(); out_h = ctypes.c_int()
|
|
431
|
+
native.image_decode(src, len(data),
|
|
432
|
+
ctypes.byref(out_pixels), ctypes.byref(out_w), ctypes.byref(out_h))
|
|
433
|
+
try:
|
|
434
|
+
byte_count = out_w.value * out_h.value * 3
|
|
435
|
+
managed = bytes(ctypes.string_at(out_pixels, byte_count))
|
|
436
|
+
self._blit_pixels(managed, x, y, out_w.value, out_h.value, PixelFormat.Rgb888)
|
|
437
|
+
finally:
|
|
438
|
+
native.image_free(out_pixels)
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
# Raw pixel buffer path.
|
|
442
|
+
self._blit_pixels(bytes(source), x, y, int(width), int(height), pixel_format)
|
|
443
|
+
|
|
444
|
+
def Dispose(self):
|
|
445
|
+
"""Release the pixel buffer. Idempotent."""
|
|
446
|
+
self._disposed = True
|
|
447
|
+
self._buffer = bytearray()
|
|
448
|
+
|
|
449
|
+
def __del__(self):
|
|
450
|
+
try: self.Dispose()
|
|
451
|
+
except Exception: pass
|
|
452
|
+
|
|
453
|
+
# ---------- internals -------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def _stamp_disc(self, cx, cy, r, color):
|
|
456
|
+
if r <= 0:
|
|
457
|
+
self.SetPixel(cx, cy, color)
|
|
458
|
+
return
|
|
459
|
+
# Fill the disc as horizontal spans: one FillRectangle per row (which
|
|
460
|
+
# is a slice-assignment for Rgb565). Massively faster than per-pixel.
|
|
461
|
+
r2 = r * r
|
|
462
|
+
for yy in range(-r, r + 1):
|
|
463
|
+
# Half-width of the span at this row: largest xx with xx*xx+yy*yy <= r2.
|
|
464
|
+
# Closed form avoids an inner loop.
|
|
465
|
+
dx2 = r2 - yy * yy
|
|
466
|
+
if dx2 < 0:
|
|
467
|
+
continue
|
|
468
|
+
w = int(math.isqrt(dx2))
|
|
469
|
+
self.FillRectangle(cx - w, cy + yy, 2 * w + 1, 1, color)
|
|
470
|
+
|
|
471
|
+
def _midpoint_circle(self, cx, cy, r, color, fill):
|
|
472
|
+
if r <= 0:
|
|
473
|
+
self.SetPixel(cx, cy, color)
|
|
474
|
+
return
|
|
475
|
+
if fill:
|
|
476
|
+
# Filled circle = _stamp_disc; avoids calling FillRectangle 2r+1
|
|
477
|
+
# times for each ring of the midpoint traversal.
|
|
478
|
+
self._stamp_disc(cx, cy, r, color)
|
|
479
|
+
return
|
|
480
|
+
x, y, err = r, 0, 1 - r
|
|
481
|
+
while x >= y:
|
|
482
|
+
self.SetPixel(cx + x, cy + y, color); self.SetPixel(cx - x, cy + y, color)
|
|
483
|
+
self.SetPixel(cx + x, cy - y, color); self.SetPixel(cx - x, cy - y, color)
|
|
484
|
+
self.SetPixel(cx + y, cy + x, color); self.SetPixel(cx - y, cy + x, color)
|
|
485
|
+
self.SetPixel(cx + y, cy - x, color); self.SetPixel(cx - y, cy - x, color)
|
|
486
|
+
y += 1
|
|
487
|
+
if err <= 0:
|
|
488
|
+
err += 2 * y + 1
|
|
489
|
+
else:
|
|
490
|
+
x -= 1
|
|
491
|
+
err += 2 * (y - x) + 1
|
|
492
|
+
|
|
493
|
+
def _blit_pixels(self, pixels, x, y, width, height, pixel_format):
|
|
494
|
+
if width <= 0 or height <= 0:
|
|
495
|
+
return
|
|
496
|
+
if self.PixelFormat != PixelFormat.Rgb565:
|
|
497
|
+
raise NotSupportedException(
|
|
498
|
+
f"DrawImage into a {self.PixelFormat} canvas is not implemented "
|
|
499
|
+
"in the managed prototype (Rgb565 target only).")
|
|
500
|
+
if pixel_format == PixelFormat.Rgb565:
|
|
501
|
+
src_bpp = 2
|
|
502
|
+
elif pixel_format == PixelFormat.Rgb888:
|
|
503
|
+
src_bpp = 3
|
|
504
|
+
else:
|
|
505
|
+
raise NotSupportedException(
|
|
506
|
+
f"DrawImage source format {pixel_format} is not implemented "
|
|
507
|
+
"(Rgb565 and Rgb888 only).")
|
|
508
|
+
if len(pixels) < width * height * src_bpp:
|
|
509
|
+
raise ArgumentException("Pixel buffer is smaller than width * height * bytes-per-pixel.")
|
|
510
|
+
|
|
511
|
+
# Clip (x, y, width, height) to the canvas bounds.
|
|
512
|
+
src_x = max(0, -x)
|
|
513
|
+
src_y = max(0, -y)
|
|
514
|
+
dst_x = x + src_x
|
|
515
|
+
dst_y = y + src_y
|
|
516
|
+
clip_w = min(width - src_x, self.Width - dst_x)
|
|
517
|
+
clip_h = min(height - src_y, self.Height - dst_y)
|
|
518
|
+
if clip_w <= 0 or clip_h <= 0:
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
src_stride = width * src_bpp
|
|
522
|
+
dst_stride = self.Width * 2
|
|
523
|
+
buf = self._buffer
|
|
524
|
+
|
|
525
|
+
if pixel_format == PixelFormat.Rgb565:
|
|
526
|
+
for row in range(clip_h):
|
|
527
|
+
src_off = (src_y + row) * src_stride + src_x * src_bpp
|
|
528
|
+
dst_off = (dst_y + row) * dst_stride + dst_x * 2
|
|
529
|
+
buf[dst_off: dst_off + clip_w * 2] = pixels[src_off: src_off + clip_w * 2]
|
|
530
|
+
else: # Rgb888
|
|
531
|
+
for row in range(clip_h):
|
|
532
|
+
src_off = (src_y + row) * src_stride + src_x * src_bpp
|
|
533
|
+
dst_off = (dst_y + row) * dst_stride + dst_x * 2
|
|
534
|
+
for col in range(clip_w):
|
|
535
|
+
r = pixels[src_off + col * 3 + 0]
|
|
536
|
+
g = pixels[src_off + col * 3 + 1]
|
|
537
|
+
b = pixels[src_off + col * 3 + 2]
|
|
538
|
+
px = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
|
539
|
+
buf[dst_off + col * 2 + 0] = px & 0xFF
|
|
540
|
+
buf[dst_off + col * 2 + 1] = (px >> 8) & 0xFF
|
|
541
|
+
|
|
542
|
+
def _stamp_glyph_monochrome(self, glyph, gw, gh, dst_x, dst_y, lit):
|
|
543
|
+
# Stamp an 8-bit grayscale glyph bitmap into a 1bpp Monochrome canvas.
|
|
544
|
+
# Thresholds alpha: >= 128 sets/clears the bit (anti-aliasing is
|
|
545
|
+
# meaningless at 1bpp). `glyph` is a ctypes POINTER(c_uint8).
|
|
546
|
+
src_x0 = max(0, -dst_x)
|
|
547
|
+
src_y0 = max(0, -dst_y)
|
|
548
|
+
dst_x0 = dst_x + src_x0
|
|
549
|
+
dst_y0 = dst_y + src_y0
|
|
550
|
+
clip_w = min(gw - src_x0, self.Width - dst_x0)
|
|
551
|
+
clip_h = min(gh - src_y0, self.Height - dst_y0)
|
|
552
|
+
if clip_w <= 0 or clip_h <= 0:
|
|
553
|
+
return
|
|
554
|
+
row_bytes = (self.Width + 7) // 8
|
|
555
|
+
buf = self._buffer
|
|
556
|
+
for row in range(clip_h):
|
|
557
|
+
src_row = (src_y0 + row) * gw + src_x0
|
|
558
|
+
dst_row = (dst_y0 + row) * row_bytes
|
|
559
|
+
for col in range(clip_w):
|
|
560
|
+
if glyph[src_row + col] < 128:
|
|
561
|
+
continue
|
|
562
|
+
x = dst_x0 + col
|
|
563
|
+
bit = 7 - (x & 7)
|
|
564
|
+
off = dst_row + (x >> 3)
|
|
565
|
+
if lit:
|
|
566
|
+
buf[off] |= (1 << bit)
|
|
567
|
+
else:
|
|
568
|
+
buf[off] &= ~(1 << bit) & 0xFF
|
|
569
|
+
|
|
570
|
+
def _alpha_blend_glyph(self, glyph, gw, gh, dst_x, dst_y, color):
|
|
571
|
+
# Alpha-blends an 8-bit grayscale glyph bitmap over the current RGB565
|
|
572
|
+
# canvas at (dst_x, dst_y). `glyph` is a ctypes POINTER(c_uint8).
|
|
573
|
+
src_x0 = max(0, -dst_x)
|
|
574
|
+
src_y0 = max(0, -dst_y)
|
|
575
|
+
dst_x0 = dst_x + src_x0
|
|
576
|
+
dst_y0 = dst_y + src_y0
|
|
577
|
+
clip_w = min(gw - src_x0, self.Width - dst_x0)
|
|
578
|
+
clip_h = min(gh - src_y0, self.Height - dst_y0)
|
|
579
|
+
if clip_w <= 0 or clip_h <= 0:
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
src_r, src_g, src_b = color.R, color.G, color.B
|
|
583
|
+
dst_stride = self.Width * 2
|
|
584
|
+
buf = self._buffer
|
|
585
|
+
|
|
586
|
+
for row in range(clip_h):
|
|
587
|
+
src_row = (src_y0 + row) * gw + src_x0
|
|
588
|
+
dst_row = (dst_y0 + row) * dst_stride + dst_x0 * 2
|
|
589
|
+
for col in range(clip_w):
|
|
590
|
+
a = glyph[src_row + col]
|
|
591
|
+
if a == 0:
|
|
592
|
+
continue
|
|
593
|
+
dst_off = dst_row + col * 2
|
|
594
|
+
if a == 255:
|
|
595
|
+
px = ((src_r & 0xF8) << 8) | ((src_g & 0xFC) << 3) | (src_b >> 3)
|
|
596
|
+
buf[dst_off] = px & 0xFF
|
|
597
|
+
buf[dst_off + 1] = (px >> 8) & 0xFF
|
|
598
|
+
else:
|
|
599
|
+
old = buf[dst_off] | (buf[dst_off + 1] << 8)
|
|
600
|
+
old_r = ((old >> 11) & 0x1F) << 3
|
|
601
|
+
old_g = ((old >> 5) & 0x3F) << 2
|
|
602
|
+
old_b = (old & 0x1F) << 3
|
|
603
|
+
inv = 255 - a
|
|
604
|
+
r = (src_r * a + old_r * inv) // 255
|
|
605
|
+
g = (src_g * a + old_g * inv) // 255
|
|
606
|
+
b = (src_b * a + old_b * inv) // 255
|
|
607
|
+
px = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
|
608
|
+
buf[dst_off] = px & 0xFF
|
|
609
|
+
buf[dst_off + 1] = (px >> 8) & 0xFF
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: GHIElectronics.Endpoint.Drawing
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: Off-screen 2D drawing primitives for Endpoint: Canvas, Color, Font, Image, PixelFormat. Pure pixel drawing independent of the LVGL-backed UI widget library. Hand the Canvas buffer to any display, SPI/I2C device, or file.
|
|
5
|
+
Author-email: "GHI Electronics, LLC" <support@ghielectronics.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.ghielectronics.com/
|
|
8
|
+
Project-URL: Repository, https://github.com/ghi-electronics/Endpoint-Libraries
|
|
9
|
+
Project-URL: Issues, https://github.com/ghi-electronics/Endpoint-Libraries/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: GHIElectronics.Endpoint.Core<0.6,>=0.5.3
|
|
15
|
+
|
|
16
|
+
# GHIElectronics.Endpoint.Drawing
|
|
17
|
+
|
|
18
|
+
Off-screen 2D drawing primitives for Endpoint: Canvas, Color, Font, Image, PixelFormat. Pure pixel drawing independent of the LVGL-backed UI widget library. Hand the Canvas buffer to any display, SPI/I2C device, or file.
|
|
19
|
+
|
|
20
|
+
Part of the [GHI Electronics Endpoint](https://github.com/ghi-electronics/Endpoint-Libraries) libraries. See the repository README for the full package list and getting-started guide.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
GHIElectronics/Endpoint/Drawing/__init__.py,sha256=I_R9T3uVhv6-2rdhlR8Rol9AHH_W0XS1OF16Qrd0BSo,25799
|
|
2
|
+
ghielectronics_endpoint_drawing-0.5.3.dist-info/METADATA,sha256=Z9NMDfh5L60JtML8kaXWrsphM39Uubn-GkAk1fkEznM,1287
|
|
3
|
+
ghielectronics_endpoint_drawing-0.5.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
ghielectronics_endpoint_drawing-0.5.3.dist-info/top_level.txt,sha256=NpOZNgd0iquwrRPrTOhUDxc2V9TtNFLv5Z_Dx3YZtIU,15
|
|
5
|
+
ghielectronics_endpoint_drawing-0.5.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GHIElectronics
|