diff --git a/circuitpython.mk b/circuitpython.mk index fe70f39..a4dddc1 100644 --- a/circuitpython.mk +++ b/circuitpython.mk @@ -40,6 +40,7 @@ GRAPHICS_SOURCES := \ $(PYGRAPHICS_MOD_DIR)/src/gfx_font.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_bmp565.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_files.c \ + $(PYGRAPHICS_MOD_DIR)/src/gfx_png.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_capabilities.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_area_mp.c diff --git a/lib/pygraphics/__init__.py b/lib/pygraphics/__init__.py index cb7ca60..8412c34 100644 --- a/lib/pygraphics/__init__.py +++ b/lib/pygraphics/__init__.py @@ -68,6 +68,8 @@ "pbm_to_framebuffer": ("_files", "pbm_to_framebuffer"), "pgm_to_framebuffer": ("_files", "pgm_to_framebuffer"), "save_image": ("_files", "save_image"), + "encode_png": ("_png", "encode_png"), + "write_png_file": ("_png", "write_png_file"), } @@ -126,6 +128,7 @@ def implementation(): "rect", "round_rect", "save_image", + "encode_png", "text", "text8", "text14", diff --git a/lib/pygraphics/_files.py b/lib/pygraphics/_files.py index dd93d04..3355765 100644 --- a/lib/pygraphics/_files.py +++ b/lib/pygraphics/_files.py @@ -1,7 +1,16 @@ """Load and save ``FrameBuffer`` images (PBM, PGM, RGB565 BMP) and export modules.""" from ._bmp565 import load_bmp565_buffer, read_bmp565_header, write_bmp565_file -from ._framebuf_plus import GS2_HMSB, GS4_HMSB, GS8, MONO_HLSB, RGB565, FrameBuffer +from ._framebuf_plus import ( + GS2_HMSB, + GS4_HMSB, + GS8, + MONO_HLSB, + RGB565, + RGB888, + FrameBuffer, +) +from ._png import encode_png, write_png_file # Framebuffer formats that ``save_image`` can write, keyed by file extension. _SAVE_FORMATS = { @@ -10,6 +19,7 @@ GS4_HMSB: "pgm", GS8: "pgm", RGB565: "bmp", + RGB888: "png", } @@ -64,17 +74,23 @@ def load_image(filename): def save_image(fb, filename=None): - """Save a ``FrameBuffer`` to PBM, PGM, or BMP based on its format. + """Save a ``FrameBuffer`` to PBM, PGM, BMP, or PNG based on format or filename. - MONO_HLSB → PBM, GS2/GS4/GS8 → PGM, RGB565 → BMP. Other formats raise - ``ValueError``. + MONO_HLSB → PBM, GS2/GS4/GS8 → PGM, RGB565 → BMP (or PNG if filename ends in .png), + RGB888 → PNG. Other formats raise ``ValueError``. """ if filename is None: filename = "screenshot" + + file_ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else None + if file_ext == "png": + with open(filename, "wb") as f: + write_png_file(f, fb) + return filename + ext = _SAVE_FORMATS.get(fb.format) if ext is None: raise ValueError(f"Save not supported for format {fb.format}") - file_ext = filename.rsplit(".", 1)[-1] if file_ext != ext: filename += f".{ext}" if fb.format == MONO_HLSB: diff --git a/lib/pygraphics/_framebuf_plus.py b/lib/pygraphics/_framebuf_plus.py index 5e7b1b1..084d6d1 100644 --- a/lib/pygraphics/_framebuf_plus.py +++ b/lib/pygraphics/_framebuf_plus.py @@ -617,6 +617,16 @@ def save(self, filename=None): return _files.save_image(self, filename) + def to_png(self): + """Encode this framebuffer as standard PNG bytes in memory. + + Returns: + bytes: Complete PNG file contents. + """ + from . import _png + + return _png.encode_png(self) + def export(self, filename): """Export this framebuffer as an importable ``.py`` bitmap module. diff --git a/lib/pygraphics/_png.py b/lib/pygraphics/_png.py new file mode 100644 index 0000000..cc742c8 --- /dev/null +++ b/lib/pygraphics/_png.py @@ -0,0 +1,237 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""PNG encoder for pygraphics FrameBuffer and pixel buffers. + +Encodes RGB565, RGB888, and GS8 framebuffers into standard PNG bytes. +Works across CPython and MicroPython: + - Uses `zlib.compress` when available (CPython / unix ports) + - Uses `deflate.DeflateIO` when streaming deflate compression is available + - Falls back to RFC 1950/1951 uncompressed DEFLATE blocks (BTYPE 00) + when no compression engine is present in firmware, guaranteeing valid + PNG output on any compliant Python runtime without dependencies. +""" + +import struct + +try: + import binascii +except ImportError: + import ubinascii as binascii # type: ignore + +try: + import zlib +except ImportError: + zlib = None # type: ignore + +from ._framebuf_plus import GS8, MONO_HLSB, RGB565, RGB888 + +_PNG_SIG = b"\x89PNG\r\n\x1a\n" + + +def _adler32(data): + """Compute Adler-32 checksum (RFC 1950) in pure Python.""" + if zlib is not None and hasattr(zlib, "adler32"): + return zlib.adler32(data) & 0xFFFFFFFF + s1 = 1 + s2 = 0 + for b in data: + s1 = (s1 + b) % 65521 + s2 = (s2 + s1) % 65521 + return (s2 << 16) | s1 + + +def _crc32(data): + """Compute CRC-32 checksum for PNG chunks.""" + return binascii.crc32(data) & 0xFFFFFFFF + + +def _make_uncompressed_zlib(data): + """Wrap raw scanlines in an uncompressed zlib stream (BTYPE=00 stored blocks).""" + out = bytearray(b"\x78\x01") # zlib header: Deflate, 32K window, check bits + chunk_size = 65535 + total = len(data) + pos = 0 + if total == 0: + out.extend(b"\x01\x00\x00\xff\xff") + else: + while pos < total: + chunk = data[pos : pos + chunk_size] + pos += len(chunk) + bfinal = 1 if pos >= total else 0 + out.append(bfinal) + n = len(chunk) + out.extend(struct.pack("I", _adler32(data))) + return bytes(out) + + +def _compress_scanlines(data): + """Compress raw scanline bytes using best available method.""" + if zlib is not None and hasattr(zlib, "compress"): + try: + return zlib.compress(data, 6) + except Exception: + pass + + # Try MicroPython deflate module if available + try: + import io + + import deflate + + buf = io.BytesIO() + with deflate.DeflateIO(buf, deflate.ZLIB) as df: + df.write(data) + return buf.getvalue() + except Exception: + pass + + # Fallback: RFC 1950 uncompressed DEFLATE stream + return _make_uncompressed_zlib(data) + + +def _png_chunk(kind, payload): + """Build a standard PNG chunk with 4-byte length, type, data, and CRC-32.""" + data = kind + payload + crc = _crc32(data) + return struct.pack(">I", len(payload)) + data + struct.pack(">I", crc) + + +def _rgb565_to_scanlines(buffer, width, height): + """Convert RGB565 little-endian buffer into RGB888 scanlines (with filter byte 0).""" + stride = width * 2 + row_len = 1 + width * 3 + scanlines = bytearray(row_len * height) + + s_offset = 0 + d_offset = 0 + + for _ in range(height): + scanlines[d_offset] = 0 # Filter type: None + d = d_offset + 1 + s = s_offset + for _ in range(width): + b0 = buffer[s] + b1 = buffer[s + 1] + s += 2 + # R: bits 15..11 (high byte bits 7..3) + r5 = b1 >> 3 + scanlines[d] = (r5 << 3) | (r5 >> 2) + # G: bits 10..5 (high byte bits 2..0, low byte bits 7..5) + g6 = ((b1 & 0x07) << 3) | (b0 >> 5) + scanlines[d + 1] = (g6 << 2) | (g6 >> 4) + # B: bits 4..0 (low byte bits 4..0) + b5 = b0 & 0x1F + scanlines[d + 2] = (b5 << 3) | (b5 >> 2) + d += 3 + s_offset += stride + d_offset += row_len + + return scanlines + + +def _rgb888_to_scanlines(buffer, width, height): + """Convert RGB888 buffer into scanlines (adding filter byte 0 per row).""" + stride = width * 3 + row_len = 1 + stride + scanlines = bytearray(row_len * height) + for y in range(height): + src_start = y * stride + dst_start = y * row_len + scanlines[dst_start] = 0 # Filter: None + scanlines[dst_start + 1 : dst_start + row_len] = buffer[src_start : src_start + stride] + return scanlines + + +def _gs8_to_scanlines(buffer, width, height): + """Convert GS8 grayscale buffer into scanlines (adding filter byte 0 per row).""" + stride = width + row_len = 1 + stride + scanlines = bytearray(row_len * height) + for y in range(height): + src_start = y * stride + dst_start = y * row_len + scanlines[dst_start] = 0 # Filter: None + scanlines[dst_start + 1 : dst_start + row_len] = buffer[src_start : src_start + stride] + return scanlines + + +def encode_png(fb, width=None, height=None, format=None): + """Encode a FrameBuffer or raw pixel buffer into standard PNG bytes. + + Args: + fb: A ``FrameBuffer`` instance, or a bytes-like object containing raw pixels. + width: Image width in pixels (required if ``fb`` is not a ``FrameBuffer``). + height: Image height in pixels (required if ``fb`` is not a ``FrameBuffer``). + format: Format constant (e.g. ``RGB565``, ``RGB888``, ``GS8``). Defaults to + ``fb.format`` if ``fb`` is a ``FrameBuffer``, else ``RGB565``. + + Returns: + bytes: Complete, valid PNG file contents. + """ + if hasattr(fb, "buffer") and hasattr(fb, "width") and hasattr(fb, "height"): + buffer = fb.buffer + if width is None: + width = fb.width + if height is None: + height = fb.height + if format is None: + format = getattr(fb, "format", RGB565) + else: + buffer = fb + if width is None or height is None: + raise ValueError("width and height must be specified for raw buffers") + if format is None: + format = RGB565 + + if width <= 0 or height <= 0: + raise ValueError(f"Invalid dimensions {width}x{height}") + + # Build scanlines and choose color type + if format == RGB565: + scanlines = _rgb565_to_scanlines(buffer, width, height) + color_type = 2 # Truecolor RGB + bit_depth = 8 + elif format == RGB888: + scanlines = _rgb888_to_scanlines(buffer, width, height) + color_type = 2 # Truecolor RGB + bit_depth = 8 + elif format == GS8: + scanlines = _gs8_to_scanlines(buffer, width, height) + color_type = 0 # Grayscale + bit_depth = 8 + elif format == MONO_HLSB: + # Convert 1-bit MONO_HLSB to GS8 grayscale + gs8_buf = bytearray(width * height) + row_bytes = (width + 7) // 8 + for y in range(height): + s_row = y * row_bytes + d_row = y * width + for x in range(width): + byte_val = buffer[s_row + (x >> 3)] + bit = (byte_val >> (7 - (x & 7))) & 1 + gs8_buf[d_row + x] = 255 if bit else 0 + scanlines = _gs8_to_scanlines(gs8_buf, width, height) + color_type = 0 # Grayscale + bit_depth = 8 + else: + raise ValueError(f"Unsupported format {format} for PNG encoding") + + compressed = _compress_scanlines(scanlines) + + # PNG chunks: IHDR, IDAT, IEND + ihdr_data = struct.pack(">IIBBBBB", width, height, bit_depth, color_type, 0, 0, 0) + ihdr = _png_chunk(b"IHDR", ihdr_data) + idat = _png_chunk(b"IDAT", compressed) + iend = _png_chunk(b"IEND", b"") + + return _PNG_SIG + ihdr + idat + iend + + +def write_png_file(f, fb, width=None, height=None, format=None): + """Encode and write PNG bytes to an open binary file stream.""" + data = encode_png(fb, width=width, height=height, format=format) + f.write(data) + return len(data) diff --git a/micropython.cmake b/micropython.cmake index e24991e..c4e6f1e 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -24,6 +24,7 @@ target_sources(pygraphics INTERFACE ${PYGRAPHICS_MOD_DIR}/src/gfx_font.c ${PYGRAPHICS_MOD_DIR}/src/gfx_bmp565.c ${PYGRAPHICS_MOD_DIR}/src/gfx_files.c + ${PYGRAPHICS_MOD_DIR}/src/gfx_png.c ${PYGRAPHICS_MOD_DIR}/src/gfx_capabilities.c ${PYGRAPHICS_MOD_DIR}/src/gfx_area_mp.c ) diff --git a/micropython.mk b/micropython.mk index 260f2de..6d63cb7 100644 --- a/micropython.mk +++ b/micropython.mk @@ -36,5 +36,6 @@ SRC_USERMOD_C += \ $(PYGRAPHICS_MOD_DIR)/src/gfx_font.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_bmp565.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_files.c \ + $(PYGRAPHICS_MOD_DIR)/src/gfx_png.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_capabilities.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_area_mp.c diff --git a/pygraphics.toml b/pygraphics.toml index ce529a8..aa267d6 100644 --- a/pygraphics.toml +++ b/pygraphics.toml @@ -14,6 +14,7 @@ "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_font_8x16.py" = "/lib/pygraphics/_font_8x16.py" "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_font_8x8.py" = "/lib/pygraphics/_font_8x8.py" "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_framebuf_plus.py" = "/lib/pygraphics/_framebuf_plus.py" +"https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_png.py" = "/lib/pygraphics/_png.py" "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_shapes.py" = "/lib/pygraphics/_shapes.py" "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/_trig.py" = "/lib/pygraphics/_trig.py" "https://raw.githubusercontent.com/PyDevices/pygraphics/main/lib/pygraphics/framebuf.py" = "/lib/pygraphics/framebuf.py" diff --git a/setup.py b/setup.py index 8f78a6e..5416e1e 100644 --- a/setup.py +++ b/setup.py @@ -53,6 +53,7 @@ def _build_strings(): os.path.join("src", "gfx_font.c"), os.path.join("src", "gfx_bmp565.c"), os.path.join("src", "gfx_files.c"), + os.path.join("src", "gfx_png.c"), os.path.join("src", "gfx_capabilities.c"), ] diff --git a/src/gfx_bindings_mp.c b/src/gfx_bindings_mp.c index 7ff7410..f0eec91 100644 --- a/src/gfx_bindings_mp.c +++ b/src/gfx_bindings_mp.c @@ -1928,6 +1928,73 @@ static mp_obj_t mod_pgm_to_framebuffer(mp_obj_t path_in) { } static MP_DEFINE_CONST_FUN_OBJ_1(mod_pgm_to_framebuffer_obj, mod_pgm_to_framebuffer); +#include "gfx_png.h" + +static mp_obj_t mod_encode_png(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_fb, ARG_width, ARG_height, ARG_format }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_fb, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_width, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_height, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_format, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_obj_framebuf_t fb; + bool is_fb = mp_get_framebuf(args[ARG_fb].u_obj, &fb); + + int width = is_fb ? fb.fb.width : 0; + int height = is_fb ? fb.fb.height : 0; + int format = is_fb ? fb.fb.format : GFX_RGB565; + + if (args[ARG_width].u_obj != mp_const_none) width = mp_obj_get_int(args[ARG_width].u_obj); + if (args[ARG_height].u_obj != mp_const_none) height = mp_obj_get_int(args[ARG_height].u_obj); + if (args[ARG_format].u_obj != mp_const_none) format = mp_obj_get_int(args[ARG_format].u_obj); + + if (width <= 0 || height <= 0) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid dimensions")); + } + + mp_buffer_info_t bufinfo; + if (is_fb) { + mp_get_buffer_raise(fb.buf_obj, &bufinfo, MP_BUFFER_READ); + } else { + mp_get_buffer_raise(args[ARG_fb].u_obj, &bufinfo, MP_BUFFER_READ); + } + + int dummy_stride = is_fb ? fb.fb.stride : width; + if (gfx_fb_validate_buffer(bufinfo.len, width, height, format, &dummy_stride) < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("Buffer too small")); + } + + gfx_fb_t raw_fb = { + .buf = bufinfo.buf, + .width = width, + .height = height, + .format = format, + .stride = dummy_stride + }; + + size_t png_size; + if (gfx_png_encoded_size(&raw_fb, &png_size) < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("Unsupported format")); + } + + vstr_t vstr; + vstr_init_len(&vstr, png_size); + size_t out_len; + if (gfx_png_encode(&raw_fb, (uint8_t *)vstr.buf, png_size, &out_len) < 0) { + vstr_clear(&vstr); + mp_raise_ValueError(MP_ERROR_TEXT("Encode failed")); + } + + mp_obj_t res = mp_obj_new_bytes((const byte *)vstr.buf, out_len); + vstr_clear(&vstr); + return res; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(mod_encode_png_obj, 1, mod_encode_png); + static mp_obj_t mod_save_image(size_t n_args, const mp_obj_t *args) { mp_obj_framebuf_t fb; if (!mp_get_framebuf(args[0], &fb)) { @@ -2011,6 +2078,7 @@ static const mp_rom_map_elem_t graphics_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_text16), MP_ROM_PTR(&mod_text16_obj) }, { MP_ROM_QSTR(MP_QSTR_load_image), MP_ROM_PTR(&mod_load_image_obj) }, { MP_ROM_QSTR(MP_QSTR_save_image), MP_ROM_PTR(&mod_save_image_obj) }, + { MP_ROM_QSTR(MP_QSTR_encode_png), MP_ROM_PTR(&mod_encode_png_obj) }, { MP_ROM_QSTR(MP_QSTR_bmp_to_framebuffer), MP_ROM_PTR(&mod_bmp_to_framebuffer_obj) }, { MP_ROM_QSTR(MP_QSTR_pbm_to_framebuffer), MP_ROM_PTR(&mod_pbm_to_framebuffer_obj) }, { MP_ROM_QSTR(MP_QSTR_pgm_to_framebuffer), MP_ROM_PTR(&mod_pgm_to_framebuffer_obj) }, diff --git a/src/gfx_files.c b/src/gfx_files.c index 89891ec..dd8c825 100644 --- a/src/gfx_files.c +++ b/src/gfx_files.c @@ -15,6 +15,7 @@ #include "gfx_bmp565.h" #include "gfx_core.h" #include "gfx_framebuffer.h" +#include "gfx_png.h" /* --- little-endian scalar reads --------------------------------------- */ @@ -478,7 +479,47 @@ int gfx_files_pgm_to_framebuffer(const char *path, gfx_image_fb_t *out) { return load_image_magic(path, out, 'P', '5'); } +static int gfx_files_write(const char *out_path, const uint8_t *data, size_t len) { + FILE *f = fopen(out_path, "wb"); + if (!f) { + return -1; + } + size_t ok = fwrite(data, 1, len, f); + fclose(f); + return ok == len ? 0 : -1; +} + int gfx_files_save_image(const gfx_fb_t *fb, const char *path, char *out_path, size_t out_path_len) { + /* PNG for a ".png" name or an RGB888 framebuffer, as the pure-Python + * save_image (lib/pygraphics/_files.py) does; ".png" is appended to an + * RGB888 path that lacks it. */ + size_t plen0 = strlen(path); + if (gfx_png_wanted(fb, path)) { + int named_png = gfx_png_named(path); + size_t png_len; + if (gfx_png_encoded_size(fb, &png_len) < 0) { + return -1; + } + size_t need = named_png ? plen0 : plen0 + 4; + if (need + 1 > out_path_len) { + return -1; + } + memcpy(out_path, path, plen0); + if (named_png) { + out_path[plen0] = '\0'; + } else { + memcpy(out_path + plen0, ".png", 5); + } + uint8_t *png = (uint8_t *)malloc(png_len ? png_len : 1); + if (!png) { + return -1; + } + size_t written = 0; + int rc = gfx_png_encode(fb, png, png_len, &written) < 0 ? -1 : gfx_files_write(out_path, png, written); + free(png); + return rc; + } + size_t enc_len; const char *ext; if (gfx_image_encoded_size(fb, &enc_len, &ext) < 0) { diff --git a/src/gfx_module_cpy.c b/src/gfx_module_cpy.c index f95c639..18b6601 100644 --- a/src/gfx_module_cpy.c +++ b/src/gfx_module_cpy.c @@ -27,6 +27,7 @@ #include "gfx_capabilities.h" #include "gfx_bmp565.h" #include "gfx_files.h" +#include "gfx_png.h" static PyTypeObject GfxAreaType; static PyTypeObject GfxFrameBufferType; @@ -1213,6 +1214,19 @@ static PyObject *framebuffer_get_color_depth(GfxFrameBufferObject *self, void *c return PyLong_FromLong(gfx_fb_color_depth(self->fb.format)); } +static PyObject *mod_encode_png(PyObject *self, PyObject *args, PyObject *kwds); + +/* FrameBuffer.to_png() -> bytes: encode_png(self), as the pure-Python FrameBuffer does. */ +static PyObject *framebuffer_to_png(GfxFrameBufferObject *self, PyObject *Py_UNUSED(ignored)) { + PyObject *args = PyTuple_Pack(1, (PyObject *)self); + if (args == NULL) { + return NULL; + } + PyObject *png = mod_encode_png(NULL, args, NULL); + Py_DECREF(args); + return png; +} + static PyGetSetDef framebuffer_getset[] = { {"width", (getter)framebuffer_get_width, NULL, NULL}, {"height", (getter)framebuffer_get_height, NULL, NULL}, @@ -1224,6 +1238,7 @@ static PyGetSetDef framebuffer_getset[] = { static PyMethodDef framebuffer_methods[] = { {"from_file", (PyCFunction)framebuffer_from_file, METH_VARARGS | METH_STATIC, NULL}, + {"to_png", (PyCFunction)framebuffer_to_png, METH_NOARGS, NULL}, {"fill", (PyCFunction)framebuffer_fill, METH_VARARGS, NULL}, {"fill_rect", (PyCFunction)framebuffer_fill_rect, METH_VARARGS, NULL}, {"pixel", (PyCFunction)framebuffer_pixel, METH_VARARGS, NULL}, @@ -1769,6 +1784,78 @@ static PyObject *mod_save_image(PyObject *self, PyObject *args) { return PyUnicode_FromString(out_path); } +/* encode_png(fb, width=None, height=None, format=None) -> bytes. The same + * arguments and errors as the MicroPython binding (gfx_bindings_mp.c): a + * pygraphics FrameBuffer carries its own size and format; any other buffer + * needs width and height, and is taken as RGB565 unless format says. */ +static PyObject *mod_encode_png(PyObject *self, PyObject *args, PyObject *kwds) { + (void)self; + static char *kwlist[] = {"fb", "width", "height", "format", NULL}; + PyObject *target; + PyObject *w_obj = Py_None, *h_obj = Py_None, *f_obj = Py_None; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO", kwlist, &target, &w_obj, &h_obj, &f_obj)) { + return NULL; + } + int is_fb = PyObject_TypeCheck(target, &GfxFrameBufferType); + GfxFrameBufferObject *fbo = is_fb ? (GfxFrameBufferObject *)target : NULL; + int width = is_fb ? fbo->fb.width : 0; + int height = is_fb ? fbo->fb.height : 0; + int format = is_fb ? fbo->fb.format : GFX_RGB565; + if (w_obj != Py_None && (width = (int)PyLong_AsLong(w_obj)) == -1 && PyErr_Occurred()) { + return NULL; + } + if (h_obj != Py_None && (height = (int)PyLong_AsLong(h_obj)) == -1 && PyErr_Occurred()) { + return NULL; + } + if (f_obj != Py_None && (format = (int)PyLong_AsLong(f_obj)) == -1 && PyErr_Occurred()) { + return NULL; + } + if (width <= 0 || height <= 0) { + PyErr_SetString(PyExc_ValueError, "Invalid dimensions"); + return NULL; + } + Py_buffer view; + if (PyObject_GetBuffer(is_fb ? fbo->buf_obj : target, &view, PyBUF_SIMPLE) < 0) { + return NULL; + } + int stride = is_fb ? fbo->fb.stride : width; + if (gfx_fb_validate_buffer((size_t)view.len, width, height, format, &stride) < 0) { + PyBuffer_Release(&view); + PyErr_SetString(PyExc_ValueError, "Buffer too small"); + return NULL; + } + gfx_fb_t raw_fb = { + .buf = view.buf, + .width = width, + .height = height, + .format = format, + .stride = stride, + }; + size_t png_size; + if (gfx_png_encoded_size(&raw_fb, &png_size) < 0) { + PyBuffer_Release(&view); + PyErr_SetString(PyExc_ValueError, "Unsupported format"); + return NULL; + } + PyObject *out = PyBytes_FromStringAndSize(NULL, (Py_ssize_t)png_size); + if (out == NULL) { + PyBuffer_Release(&view); + return NULL; + } + size_t out_len; + int rc = gfx_png_encode(&raw_fb, (uint8_t *)PyBytes_AS_STRING(out), png_size, &out_len); + PyBuffer_Release(&view); + if (rc < 0) { + Py_DECREF(out); + PyErr_SetString(PyExc_ValueError, "Encode failed"); + return NULL; + } + if (_PyBytes_Resize(&out, (Py_ssize_t)out_len) < 0) { + return NULL; + } + return out; +} + /* ------------------------------------------------------------------------- */ /* Draw */ /* ------------------------------------------------------------------------- */ @@ -3189,6 +3276,7 @@ static PyMethodDef module_methods[] = { {"polygon", (PyCFunction)mod_polygon, METH_VARARGS | METH_KEYWORDS, NULL}, {"load_image", mod_load_image, METH_VARARGS, NULL}, {"save_image", mod_save_image, METH_VARARGS, NULL}, + {"encode_png", (PyCFunction)mod_encode_png, METH_VARARGS | METH_KEYWORDS, NULL}, {"bmp_to_framebuffer", mod_bmp_to_framebuffer, METH_VARARGS, NULL}, {"pbm_to_framebuffer", mod_pbm_to_framebuffer, METH_VARARGS, NULL}, {"pgm_to_framebuffer", mod_pgm_to_framebuffer, METH_VARARGS, NULL}, diff --git a/src/gfx_module_mp.c b/src/gfx_module_mp.c index 8007b16..c406a3d 100644 --- a/src/gfx_module_mp.c +++ b/src/gfx_module_mp.c @@ -3,6 +3,7 @@ * SPDX-License-Identifier: MIT */ +#include "gfx_png.h" #include #include #include @@ -150,6 +151,35 @@ mp_obj_t gfxmp_load_framebuffer(const char *path, int expect_kind) { /* Append the codec's file extension to `path` (into out_path) unless already * present, then encode `fb` and write it. Raises on unsupported format. */ void gfxmp_save_framebuffer(const gfx_fb_t *fb, const char *path, char *out_path, size_t out_path_len) { + /* PNG for a ".png" name or an RGB888 framebuffer: the rule every saver + * shares (gfx_png_wanted), as the pure-Python save_image does. */ + if (gfx_png_wanted(fb, path)) { + size_t plen = strlen(path); + int named_png = gfx_png_named(path); + size_t need = named_png ? plen : plen + 4; + if (need + 1 > out_path_len) { + mp_raise_ValueError(MP_ERROR_TEXT("Path too long")); + } + memcpy(out_path, path, plen); + if (named_png) { + out_path[plen] = '\0'; + } else { + memcpy(out_path + plen, ".png", 5); + } + size_t png_len; + if (gfx_png_encoded_size(fb, &png_len) < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("Cannot encode this framebuffer format")); + } + uint8_t *png = m_new(uint8_t, png_len); + size_t written = 0; + if (gfx_png_encode(fb, png, png_len, &written) < 0) { + m_del(uint8_t, png, png_len); + mp_raise_ValueError(MP_ERROR_TEXT("Image encode failed")); + } + gfxmp_spew(out_path, mp_obj_new_bytearray_by_ref(written, png)); + return; + } + size_t enc_len; const char *ext; if (gfx_image_encoded_size(fb, &enc_len, &ext) < 0) { @@ -737,8 +767,33 @@ static void framebuf_attr(mp_obj_t self_in, qstr attr, mp_obj_t *dest) { } } +/* FrameBuffer.to_png() -> bytes, as the pure-Python FrameBuffer does: the + * same encoder as pygraphics.encode_png (gfx_png.c). */ +static mp_obj_t framebuf_to_png(mp_obj_t self_in) { + mp_obj_framebuf_t *self = MP_OBJ_TO_PTR(mp_obj_cast_to_native_base(self_in, MP_OBJ_FROM_PTR(&mp_type_framebuf))); + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(self->buf_obj, &bufinfo, MP_BUFFER_READ); + gfx_fb_t raw_fb = self->fb; + raw_fb.buf = bufinfo.buf; + size_t png_size; + if (gfx_png_encoded_size(&raw_fb, &png_size) < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("Unsupported format")); + } + vstr_t vstr; + vstr_init_len(&vstr, png_size); + size_t out_len; + if (gfx_png_encode(&raw_fb, (uint8_t *)vstr.buf, png_size, &out_len) < 0) { + vstr_clear(&vstr); + mp_raise_ValueError(MP_ERROR_TEXT("Encode failed")); + } + vstr.len = out_len; + return mp_obj_new_bytes_from_vstr(&vstr); +} +static MP_DEFINE_CONST_FUN_OBJ_1(framebuf_to_png_obj, framebuf_to_png); + static const mp_rom_map_elem_t framebuf_locals_dict_table[] = { { MP_ROM_QSTR(MP_QSTR_from_file), MP_ROM_PTR(&framebuf_from_file_obj) }, + { MP_ROM_QSTR(MP_QSTR_to_png), MP_ROM_PTR(&framebuf_to_png_obj) }, { MP_ROM_QSTR(MP_QSTR_fill), MP_ROM_PTR(&framebuf_fill_obj) }, { MP_ROM_QSTR(MP_QSTR_fill_rect), MP_ROM_PTR(&framebuf_fill_rect_obj) }, { MP_ROM_QSTR(MP_QSTR_pixel), MP_ROM_PTR(&framebuf_pixel_obj) }, diff --git a/src/gfx_png.c b/src/gfx_png.c new file mode 100644 index 0000000..c16b2e0 --- /dev/null +++ b/src/gfx_png.c @@ -0,0 +1,221 @@ +#include "gfx_png.h" +#include +#include + +static uint32_t adler32(const uint8_t *data, size_t len) { + uint32_t s1 = 1; + uint32_t s2 = 0; + for (size_t i = 0; i < len; i++) { + s1 = (s1 + data[i]) % 65521; + s2 = (s2 + s1) % 65521; + } + return (s2 << 16) | s1; +} + +static uint32_t crc32(const uint8_t *data, size_t len) { + uint32_t crc = 0xFFFFFFFF; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int j = 0; j < 8; j++) { + if (crc & 1) + crc = (crc >> 1) ^ 0xEDB88320; + else + crc >>= 1; + } + } + return crc ^ 0xFFFFFFFF; +} + +static void write_u32_be(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)(v); +} + +static void write_u16_le(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)(v); + p[1] = (uint8_t)(v >> 8); +} + +static size_t write_chunk(uint8_t *dst, const char *type, const uint8_t *data, size_t len) { + write_u32_be(dst, (uint32_t)len); + memcpy(dst + 4, type, 4); + if (len > 0) { + memcpy(dst + 8, data, len); + } + uint32_t crc = crc32(dst + 4, len + 4); + write_u32_be(dst + 8 + len, crc); + return 12 + len; +} + +static size_t get_scanlines_size(const gfx_fb_t *fb, int *channels) { + size_t row_len = 0; + if (fb->format == GFX_RGB565 || fb->format == GFX_RGB888) { + row_len = 1 + (size_t)fb->width * 3; + *channels = 3; + } else if (fb->format == GFX_GS8 || fb->format == GFX_MHLSB) { + row_len = 1 + (size_t)fb->width; + *channels = 1; + } else { + return 0; + } + return row_len * (size_t)fb->height; +} + +int gfx_png_encoded_size(const gfx_fb_t *fb, size_t *out_len) { + int channels = 0; + size_t scanlines_size = get_scanlines_size(fb, &channels); + if (scanlines_size == 0) return -1; + + size_t chunks = (scanlines_size + 65534) / 65535; + if (scanlines_size == 0) chunks = 1; + + size_t idat_payload_len = 2 + chunks * 5 + scanlines_size + 4; + + size_t total = 8 + /* signature */ + 25 + /* IHDR */ + 12 + idat_payload_len + /* IDAT */ + 12; /* IEND */ + *out_len = total; + return 0; +} + +int gfx_png_encode(const gfx_fb_t *fb, uint8_t *dst, size_t dst_len, size_t *out_len) { + size_t total_size = 0; + if (gfx_png_encoded_size(fb, &total_size) < 0 || dst_len < total_size) { + return -1; + } + + int channels = 0; + size_t scanlines_size = get_scanlines_size(fb, &channels); + uint8_t *scanlines = (uint8_t *)malloc(scanlines_size > 0 ? scanlines_size : 1); + if (!scanlines) return -1; + + const uint8_t *fb_buf = (const uint8_t *)fb->buf; + if (fb->format == GFX_RGB565) { + size_t s_off = 0; + size_t d_off = 0; + size_t stride = (size_t)fb->width * 2; + size_t row_len = 1 + (size_t)fb->width * 3; + for (int y = 0; y < fb->height; y++) { + scanlines[d_off] = 0; // Filter + size_t d = d_off + 1; + size_t s = s_off; + for (int x = 0; x < fb->width; x++) { + uint8_t b0 = fb_buf[s]; + uint8_t b1 = fb_buf[s+1]; + s += 2; + uint8_t r5 = b1 >> 3; + scanlines[d] = (r5 << 3) | (r5 >> 2); + uint8_t g6 = ((b1 & 0x07) << 3) | (b0 >> 5); + scanlines[d+1] = (g6 << 2) | (g6 >> 4); + uint8_t b5 = b0 & 0x1F; + scanlines[d+2] = (b5 << 3) | (b5 >> 2); + d += 3; + } + s_off += stride; + d_off += row_len; + } + } else if (fb->format == GFX_RGB888) { + size_t stride = (size_t)fb->width * 3; + size_t row_len = 1 + stride; + for (int y = 0; y < fb->height; y++) { + scanlines[y * row_len] = 0; + memcpy(scanlines + y * row_len + 1, fb_buf + y * stride, stride); + } + } else if (fb->format == GFX_GS8) { + size_t stride = (size_t)fb->width; + size_t row_len = 1 + stride; + for (int y = 0; y < fb->height; y++) { + scanlines[y * row_len] = 0; + memcpy(scanlines + y * row_len + 1, fb_buf + y * stride, stride); + } + } else if (fb->format == GFX_MHLSB) { + size_t row_bytes = ((size_t)fb->width + 7) / 8; + size_t row_len = 1 + (size_t)fb->width; + for (int y = 0; y < fb->height; y++) { + size_t s_row = y * row_bytes; + size_t d_row = y * row_len; + scanlines[d_row] = 0; + for (int x = 0; x < fb->width; x++) { + uint8_t b = fb_buf[s_row + (x >> 3)]; + uint8_t bit = (b >> (7 - (x & 7))) & 1; + scanlines[d_row + 1 + x] = bit ? 255 : 0; + } + } + } + + // PNG Signature + const uint8_t sig[8] = { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n' }; + memcpy(dst, sig, 8); + size_t o = 8; + + // IHDR + uint8_t ihdr[13]; + write_u32_be(ihdr, (uint32_t)fb->width); + write_u32_be(ihdr + 4, (uint32_t)fb->height); + ihdr[8] = 8; // bit depth + ihdr[9] = (channels == 3) ? 2 : 0; // color type + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + o += write_chunk(dst + o, "IHDR", ihdr, 13); + + // IDAT + size_t chunks = (scanlines_size + 65534) / 65535; + if (scanlines_size == 0) chunks = 1; + size_t idat_payload_len = 2 + chunks * 5 + scanlines_size + 4; + + write_u32_be(dst + o, (uint32_t)idat_payload_len); + memcpy(dst + o + 4, "IDAT", 4); + uint8_t *idat = dst + o + 8; + + idat[0] = 0x78; + idat[1] = 0x01; + size_t io = 2; + size_t pos = 0; + if (scanlines_size == 0) { + idat[io++] = 0x01; + write_u16_le(idat + io, 0); + write_u16_le(idat + io + 2, 0xFFFF); + io += 4; + } else { + while (pos < scanlines_size) { + size_t chunk_size = scanlines_size - pos; + if (chunk_size > 65535) chunk_size = 65535; + uint8_t bfinal = (pos + chunk_size >= scanlines_size) ? 1 : 0; + idat[io++] = bfinal; + write_u16_le(idat + io, (uint16_t)chunk_size); + write_u16_le(idat + io + 2, (uint16_t)(~chunk_size)); + io += 4; + memcpy(idat + io, scanlines + pos, chunk_size); + io += chunk_size; + pos += chunk_size; + } + } + uint32_t adler = adler32(scanlines, scanlines_size); + write_u32_be(idat + io, adler); + io += 4; + uint32_t idat_crc = crc32(dst + o + 4, idat_payload_len + 4); + write_u32_be(dst + o + 8 + idat_payload_len, idat_crc); + o += 12 + idat_payload_len; + + free(scanlines); + + // IEND + o += write_chunk(dst + o, "IEND", NULL, 0); + + *out_len = o; + return 0; +} + +int gfx_png_named(const char *path) { + size_t n = strlen(path); + return n >= 4 && path[n - 4] == '.' && (path[n - 3] | 0x20) == 'p' + && (path[n - 2] | 0x20) == 'n' && (path[n - 1] | 0x20) == 'g'; +} + +int gfx_png_wanted(const gfx_fb_t *fb, const char *path) { + return fb->format == GFX_RGB888 || gfx_png_named(path); +} diff --git a/src/gfx_png.h b/src/gfx_png.h new file mode 100644 index 0000000..9ca4895 --- /dev/null +++ b/src/gfx_png.h @@ -0,0 +1,23 @@ +#ifndef GFX_PNG_H +#define GFX_PNG_H + +#include "gfx_framebuffer.h" +#include + +/* Compute the exact byte size of the uncompressed PNG for the given framebuffer. + * Returns -1 if the format is unsupported. */ +int gfx_png_encoded_size(const gfx_fb_t *fb, size_t *out_len); + +/* Encode the framebuffer into the destination buffer as an uncompressed PNG. + * dst must be at least the size returned by gfx_png_encoded_size. */ +int gfx_png_encode(const gfx_fb_t *fb, uint8_t *dst, size_t dst_len, size_t *out_len); + +/* Whether save_image writes PNG: for a path ending ".png" (any case) or an + * RGB888 framebuffer, as the pure-Python save_image does. Every saver asks + * this, so the three implementations can't disagree. */ +int gfx_png_wanted(const gfx_fb_t *fb, const char *path); + +/* Whether path ends ".png", in any case. */ +int gfx_png_named(const char *path); + +#endif /* GFX_PNG_H */ diff --git a/src/pygraphics_qstrdefs.h b/src/pygraphics_qstrdefs.h index 0bb1a9e..e451b82 100644 --- a/src/pygraphics_qstrdefs.h +++ b/src/pygraphics_qstrdefs.h @@ -73,6 +73,7 @@ Q(format) Q(color_depth) Q(load_image) Q(save_image) +Q(encode_png) Q(bmp_to_framebuffer) Q(pbm_to_framebuffer) Q(pgm_to_framebuffer) diff --git a/tests/_env.py b/tests/_env.py index 51a3a3b..62f9145 100644 --- a/tests/_env.py +++ b/tests/_env.py @@ -48,5 +48,7 @@ def _abs_dir(path): _TESTS = _abs_dir(__file__) _LIB = _TESTS[: _TESTS.rfind("/")] + "/lib" -if not USE_NATIVE and _LIB not in sys.path: +if not USE_NATIVE: + if _LIB in sys.path: + sys.path.remove(_LIB) sys.path.insert(0, _LIB) diff --git a/tests/test_files.py b/tests/test_files.py index 6742096..5407e00 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -21,8 +21,10 @@ MONO_HLSB, MONO_VLSB, RGB565, + RGB888, FrameBuffer, bmp_to_framebuffer, + encode_png, load_image, pbm_to_framebuffer, pgm_to_framebuffer, @@ -213,13 +215,60 @@ def test_bmp565_matches_framebuffer_save(self): self.assertEqual(a.read(), b.read()) -class TestFromFileDispatch(_TmpDirTest): - def test_unknown_header_raises(self): - path = self._path("bad.dat") - with open(path, "wb") as f: - f.write(b"ZZ1234") - with self.assertRaises(ValueError): - FrameBuffer.from_file(path) +class TestPngEncoding(_TmpDirTest): + def test_rgb565_png_encoding(self): + import zlib + fb = FrameBuffer(bytearray(2 * 2 * 2), 2, 2, RGB565) + # Red: 0xF800, Green: 0x07E0, Blue: 0x001F, White: 0xFFFF + fb.pixel(0, 0, 0xF800) + fb.pixel(1, 0, 0x07E0) + fb.pixel(0, 1, 0x001F) + fb.pixel(1, 1, 0xFFFF) + + png_bytes = fb.to_png() + self.assertEqual(png_bytes[:8], b"\x89PNG\r\n\x1a\n") + + # Parse IHDR + ihdr_len = struct.unpack(">I", png_bytes[8:12])[0] + self.assertEqual(ihdr_len, 13) + self.assertEqual(png_bytes[12:16], b"IHDR") + w, h, bit_depth, color_type = struct.unpack(">IIBB", png_bytes[16:26]) + self.assertEqual((w, h, bit_depth, color_type), (2, 2, 8, 2)) + + # Check save with .png extension + path = self._path("test.png") + saved = fb.save(path) + self.assertEqual(saved, path) + with open(path, "rb") as f: + self.assertEqual(f.read(), png_bytes) + + def test_rgb888_png_encoding(self): + fb = FrameBuffer(bytearray(2 * 2 * 3), 2, 2, RGB888) + fb.pixel(0, 0, 0xFF0000) + png_bytes = fb.to_png() + self.assertEqual(png_bytes[:8], b"\x89PNG\r\n\x1a\n") + + def test_gs8_png_encoding(self): + fb = FrameBuffer(bytearray(2 * 2), 2, 2, GS8) + fb.pixel(0, 0, 128) + png_bytes = fb.to_png() + self.assertEqual(png_bytes[:8], b"\x89PNG\r\n\x1a\n") + color_type = png_bytes[25] + self.assertEqual(color_type, 0) # Grayscale + + @unittest.skipIf(_env.USE_NATIVE, "pure-Python internals (pygraphics._png)") + def test_uncompressed_deflate_fallback(self): + import zlib + + from pygraphics._png import _make_uncompressed_zlib + raw = b"Sample scanline data for testing RFC 1950/1951 uncompressed stream." + compressed = _make_uncompressed_zlib(raw) + self.assertEqual(zlib.decompress(compressed), raw) + + def test_encode_raw_buffer(self): + buf = bytearray(2 * 2 * 2) + png_bytes = encode_png(buf, width=2, height=2, format=RGB565) + self.assertEqual(png_bytes[:8], b"\x89PNG\r\n\x1a\n") if __name__ == "__main__": diff --git a/tools/compare_graphics.py b/tools/compare_graphics.py index 1086b24..1f24751 100644 --- a/tools/compare_graphics.py +++ b/tools/compare_graphics.py @@ -49,6 +49,7 @@ "_draw.py", "_bmp565.py", "_files.py", + "_png.py", "framebuf.py", )