From 4c76f34814cea4fed4d5dafddc551e205277f9c4 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:27:08 -0500 Subject: [PATCH 1/2] PNG saves go through pngio; pygraphics' own encoder is removed encode_png, write_png_file and FrameBuffer.to_png are gone. save_image writes PNG through pngio (built into PyDevices MicroPython firmware; pydevices-desktop over Pillow on CPython) and raises a clear ImportError when it isn't there. RGB888 framebuffers now save as PNG instead of writing nothing. --- docs/graphics-files.md | 11 +- lib/pygraphics/__init__.py | 3 - lib/pygraphics/_files.py | 40 ++++- lib/pygraphics/_framebuf_plus.py | 10 -- lib/pygraphics/_png.py | 237 ----------------------------- pygraphics.toml | 1 - src/gfx_bindings_mp.c | 67 --------- src/gfx_files.c | 30 +--- src/gfx_files.h | 3 + src/gfx_module_cpy.c | 178 ++++++++++------------ src/gfx_module_mp.c | 72 ++++----- src/gfx_png.c | 247 +++++-------------------------- src/gfx_png.h | 20 ++- src/pygraphics_qstrdefs.h | 4 +- tests/test_files.py | 93 ++++++------ 15 files changed, 264 insertions(+), 752 deletions(-) delete mode 100644 lib/pygraphics/_png.py diff --git a/docs/graphics-files.md b/docs/graphics-files.md index 02fcb1d..cd208e0 100644 --- a/docs/graphics-files.md +++ b/docs/graphics-files.md @@ -12,7 +12,7 @@ Built into `pygraphics` — see the [graphics guide](graphics-guide.md): | `pygraphics.pbm_to_framebuffer(path)` | PBM (1-bit) | | `pygraphics.pgm_to_framebuffer(path)` | PGM (grayscale) | | `pygraphics.load_image(path)` | Auto-detect PBM/PGM/BMP from header | -| `pygraphics.save_image(fb, path)` | Write PBM/PGM/BMP for supported formats | +| `pygraphics.save_image(fb, path)` | Write PBM/PGM/BMP for supported formats, or PNG for a `.png` name | | `pygraphics.FrameBuffer.from_file(path)` | Same as `load_image` | | `pygraphics.FrameBuffer.save(path)` | Same as `save_image` | | `pygraphics.FrameBuffer.export(path)` / `export_framebuffer` | **Pure-Python only.** Write importable `.py` module (`BITMAP = bytearray(...)`) | @@ -38,6 +38,8 @@ build, import them from the pure package (`lib/pygraphics/`) instead — see | `GS4_HMSB` | PGM (P5, max 15) | 4-bit grayscale | | `GS8` | PGM (P5, max 255) | 8-bit grayscale | | `RGB565` | BMP | 16-bit RGB565 Windows BMP | +| `RGB888` | PNG | Always PNG | +| `RGB565`, `GS8`, `MONO_HLSB` | PNG | When the name ends in `.png` | `MONO_VLSB`, `MONO_HMSB`, and other display-native formats are not saved directly — convert or blit to a supported buffer first. @@ -71,4 +73,9 @@ From @russhughes st7789py_mpy: ## PNG -Experimental support in utils — probe with [`tools/png_test.py`](https://github.com/PyDevices/pdwidgets/blob/main/tools/png_test.py) in the pdwidgets repo (CPython only; requires sibling pydevices-examples, `pypng`, and a local checkout of [material-design-icons](https://github.com/google/material-design-icons) with its `png/` tree, or `PDWIDGETS_PNG_DIR`). +`save_image(fb, "shot.png")` writes PNG through +[pngio](https://github.com/PyDevices/micropython-pydevices/tree/main/modules/pngio), +not an encoder of pygraphics' own. PyDevices MicroPython firmware has pngio +built in. On CPython it comes with `pydevices-desktop` and needs Pillow +(`pip install pillow`). Without it, saving a PNG raises an `ImportError` that +says what to install. Every other format works without it. diff --git a/lib/pygraphics/__init__.py b/lib/pygraphics/__init__.py index 8412c34..cb7ca60 100644 --- a/lib/pygraphics/__init__.py +++ b/lib/pygraphics/__init__.py @@ -68,8 +68,6 @@ "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"), } @@ -128,7 +126,6 @@ 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 3355765..c1c37e8 100644 --- a/lib/pygraphics/_files.py +++ b/lib/pygraphics/_files.py @@ -10,7 +10,6 @@ RGB888, FrameBuffer, ) -from ._png import encode_png, write_png_file # Framebuffer formats that ``save_image`` can write, keyed by file extension. _SAVE_FORMATS = { @@ -73,6 +72,38 @@ def load_image(filename): raise ValueError(f"Unsupported image file {filename!r} (header {header!r})") +def _save_png(fb, filename): + """PNG through pngio: MicroPython's built-in module, or on CPython + pydevices-desktop's, over Pillow. pygraphics has no PNG encoder of its own.""" + try: + import pngio + except ImportError: + raise ImportError( + "saving PNG needs pngio: PyDevices MicroPython firmware has it built in; " + "on CPython install pydevices-desktop and Pillow (pip install pillow)" + ) from None + w, h = fb.width, fb.height + if fb.format == RGB565: + fmt, buf = pngio.RGB565, fb.buffer + elif fb.format == RGB888: + fmt, buf = pngio.RGB888, fb.buffer + elif fb.format == GS8: + fmt, buf = pngio.GS8, fb.buffer + elif fb.format == MONO_HLSB: + # one bit a pixel, widened to grey + fmt, buf, row = pngio.GS8, bytearray(w * h), (w + 7) // 8 + src = fb.buffer + for y in range(h): + for x in range(w): + if (src[y * row + (x >> 3)] >> (7 - (x & 7))) & 1: + buf[y * w + x] = 255 + else: + raise ValueError(f"PNG save not supported for format {fb.format}") + data = pngio.PngEncoder().encode(buf, w, h, format=fmt) + with open(filename, "wb") as f: + f.write(data) + + def save_image(fb, filename=None): """Save a ``FrameBuffer`` to PBM, PGM, BMP, or PNG based on format or filename. @@ -83,9 +114,10 @@ def save_image(fb, filename=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) + if file_ext == "png" or fb.format == RGB888: + if file_ext != "png": + filename += ".png" + _save_png(fb, filename) return filename ext = _SAVE_FORMATS.get(fb.format) diff --git a/lib/pygraphics/_framebuf_plus.py b/lib/pygraphics/_framebuf_plus.py index 084d6d1..5e7b1b1 100644 --- a/lib/pygraphics/_framebuf_plus.py +++ b/lib/pygraphics/_framebuf_plus.py @@ -617,16 +617,6 @@ 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 deleted file mode 100644 index cc742c8..0000000 --- a/lib/pygraphics/_png.py +++ /dev/null @@ -1,237 +0,0 @@ -# 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/pygraphics.toml b/pygraphics.toml index aa267d6..ce529a8 100644 --- a/pygraphics.toml +++ b/pygraphics.toml @@ -14,7 +14,6 @@ "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/src/gfx_bindings_mp.c b/src/gfx_bindings_mp.c index f0eec91..a8ce6e9 100644 --- a/src/gfx_bindings_mp.c +++ b/src/gfx_bindings_mp.c @@ -1928,72 +1928,6 @@ 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; @@ -2078,7 +2012,6 @@ 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 dd8c825..9e08c9d 100644 --- a/src/gfx_files.c +++ b/src/gfx_files.c @@ -479,7 +479,7 @@ 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) { +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; @@ -490,34 +490,8 @@ static int gfx_files_write(const char *out_path, const uint8_t *data, size_t len } 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; + return -1; /* PNG is pngio's: the bindings save it themselves */ } size_t enc_len; diff --git a/src/gfx_files.h b/src/gfx_files.h index 07bb9be..b669268 100644 --- a/src/gfx_files.h +++ b/src/gfx_files.h @@ -63,6 +63,9 @@ int gfx_image_encode(const gfx_fb_t *fb, uint8_t *dst, size_t dst_len, size_t *o #if GFX_ENABLE_HOST_STDIO void gfx_image_fb_free(gfx_image_fb_t *img); int gfx_files_load_image(const char *path, gfx_image_fb_t *out); +/* Write len bytes to out_path; 0, or -1 when the file can't be written. */ +int gfx_files_write(const char *out_path, const uint8_t *data, size_t len); + int gfx_files_save_image(const gfx_fb_t *fb, const char *path, char *out_path, size_t out_path_len); int gfx_files_pbm_to_framebuffer(const char *path, gfx_image_fb_t *out); int gfx_files_pgm_to_framebuffer(const char *path, gfx_image_fb_t *out); diff --git a/src/gfx_module_cpy.c b/src/gfx_module_cpy.c index 18b6601..6706d19 100644 --- a/src/gfx_module_cpy.c +++ b/src/gfx_module_cpy.c @@ -1165,19 +1165,91 @@ static PyObject *framebuffer_scroll(GfxFrameBufferObject *self, PyObject *args) return area_from_gfx(&area); } -static PyObject *framebuffer_save(GfxFrameBufferObject *self, PyObject *args) { - const char *path = "screenshot"; - if (!PyArg_ParseTuple(args, "|s", &path)) { +/* Save fb to path: PNG through pngio (pydevices-desktop's, over Pillow) for a + * ".png" name or an RGB888 framebuffer, the rest through gfx_files.c. NULL on + * failure, with the error set. */ +static PyObject *cpy_save(const gfx_fb_t *fb, const char *path) { + char out_path[512]; + if (!gfx_png_wanted(fb, path)) { + if (gfx_files_save_image(fb, path, out_path, sizeof(out_path)) < 0) { + PyErr_SetString(PyExc_ValueError, "save failed"); + return NULL; + } + return PyUnicode_FromString(out_path); + } + size_t plen = strlen(path); + int named = gfx_png_named(path); + if ((named ? plen : plen + 4) + 1 > sizeof(out_path)) { + PyErr_SetString(PyExc_ValueError, "Path too long"); return NULL; } - char out_path[512]; - if (gfx_files_save_image(&self->fb, path, out_path, sizeof(out_path)) < 0) { - PyErr_SetString(PyExc_ValueError, "save failed"); + memcpy(out_path, path, plen); + if (named) { + out_path[plen] = '\0'; + } else { + memcpy(out_path + plen, ".png", 5); + } + int fmt; + const uint8_t *buf; + size_t len; + uint8_t *owned; + if (gfx_png_source(fb, &fmt, &buf, &len, &owned) < 0) { + PyErr_SetString(PyExc_ValueError, "PNG save not supported for this framebuffer format"); + return NULL; + } + PyObject *mod = PyImport_ImportModule("pngio"); + if (mod == NULL) { + free(owned); + if (PyErr_ExceptionMatches(PyExc_ImportError)) { + PyErr_SetString(PyExc_ImportError, + "saving PNG needs pngio: install pydevices-desktop and Pillow (pip install pillow)"); + } + return NULL; + } + PyObject *png = NULL, *enc = NULL, *src = NULL, *pos = NULL, *kw = NULL, *meth = NULL; + enc = PyObject_CallMethod(mod, "PngEncoder", NULL); + src = PyByteArray_FromStringAndSize((const char *)buf, (Py_ssize_t)len); + free(owned); + if (enc && src) { + meth = PyObject_GetAttrString(enc, "encode"); + pos = Py_BuildValue("(Oii)", src, (int)fb->width, (int)fb->height); + kw = Py_BuildValue("{s:i}", "format", fmt); + if (meth && pos && kw) { + png = PyObject_Call(meth, pos, kw); + } + } + Py_XDECREF(mod); + Py_XDECREF(enc); + Py_XDECREF(src); + Py_XDECREF(meth); + Py_XDECREF(pos); + Py_XDECREF(kw); + if (png == NULL) { + return NULL; + } + char *data; + Py_ssize_t dlen; + if (PyBytes_AsStringAndSize(png, &data, &dlen) < 0) { + Py_DECREF(png); + return NULL; + } + int rc = gfx_files_write(out_path, (const uint8_t *)data, (size_t)dlen); + Py_DECREF(png); + if (rc < 0) { + PyErr_SetString(PyExc_OSError, "can't write the PNG"); return NULL; } return PyUnicode_FromString(out_path); } +static PyObject *framebuffer_save(GfxFrameBufferObject *self, PyObject *args) { + const char *path = "screenshot"; + if (!PyArg_ParseTuple(args, "|s", &path)) { + return NULL; + } + return cpy_save(&self->fb, path); +} + static PyObject *framebuffer_from_file(PyObject *cls, PyObject *args) { (void)cls; const char *path; @@ -1214,19 +1286,6 @@ 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}, @@ -1238,7 +1297,6 @@ 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}, @@ -1776,84 +1834,7 @@ static PyObject *mod_save_image(PyObject *self, PyObject *args) { return NULL; } GfxFrameBufferObject *fb = (GfxFrameBufferObject *)target; - char out_path[512]; - if (gfx_files_save_image(&fb->fb, path, out_path, sizeof(out_path)) < 0) { - PyErr_SetString(PyExc_ValueError, "save_image failed"); - return NULL; - } - 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; + return cpy_save(&fb->fb, path); } /* ------------------------------------------------------------------------- */ @@ -3276,7 +3257,6 @@ 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 c406a3d..de81ab8 100644 --- a/src/gfx_module_mp.c +++ b/src/gfx_module_mp.c @@ -148,6 +148,41 @@ mp_obj_t gfxmp_load_framebuffer(const char *path, int expect_kind) { return framebuf_make_new_helper(4, tuple, MP_BUFFER_WRITE, NULL); } +/* PNG bytes for fb through pngio, which PyDevices firmware has built in; + * pygraphics has no PNG encoder of its own. */ +static mp_obj_t gfxmp_png_bytes(const gfx_fb_t *fb) { + int fmt; + const uint8_t *buf; + size_t len; + uint8_t *owned; + if (gfx_png_source(fb, &fmt, &buf, &len, &owned) < 0) { + mp_raise_ValueError(MP_ERROR_TEXT("PNG save not supported for this framebuffer format")); + } + mp_obj_t png = mp_const_none; + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + mp_obj_t mod = mp_import_name(MP_QSTR_pngio, mp_const_none, MP_OBJ_NEW_SMALL_INT(0)); + mp_obj_t enc = mp_call_function_0(mp_load_attr(mod, MP_QSTR_PngEncoder)); + mp_obj_t args[7]; + mp_load_method(enc, MP_QSTR_encode, args); + args[2] = mp_obj_new_bytearray_by_ref(len, (void *)buf); + args[3] = MP_OBJ_NEW_SMALL_INT(fb->width); + args[4] = MP_OBJ_NEW_SMALL_INT(fb->height); + args[5] = MP_OBJ_NEW_QSTR(MP_QSTR_format); + args[6] = MP_OBJ_NEW_SMALL_INT(fmt); + png = mp_call_method_n_kw(3, 1, args); + nlr_pop(); + } else { + free(owned); + if (mp_obj_exception_match(MP_OBJ_FROM_PTR(nlr.ret_val), MP_OBJ_FROM_PTR(&mp_type_ImportError))) { + mp_raise_msg(&mp_type_ImportError, MP_ERROR_TEXT("saving PNG needs pngio (micropython-pydevices modules/pngio)")); + } + nlr_jump(nlr.ret_val); + } + free(owned); + return png; +} + /* 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) { @@ -166,17 +201,7 @@ void gfxmp_save_framebuffer(const gfx_fb_t *fb, const char *path, char *out_path } 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)); + gfxmp_spew(out_path, gfxmp_png_bytes(fb)); return; } @@ -767,33 +792,8 @@ 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 index c16b2e0..fbc93f9 100644 --- a/src/gfx_png.c +++ b/src/gfx_png.c @@ -1,214 +1,9 @@ +/* When a save is PNG, and what pngio is handed (gfx_png.h). The encoding is + * pngio's. */ #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; -} +#include +#include int gfx_png_named(const char *path) { size_t n = strlen(path); @@ -219,3 +14,37 @@ int gfx_png_named(const char *path) { int gfx_png_wanted(const gfx_fb_t *fb, const char *path) { return fb->format == GFX_RGB888 || gfx_png_named(path); } + +int gfx_png_source(const gfx_fb_t *fb, int *fmt, const uint8_t **buf, size_t *len, uint8_t **owned) { + const size_t px = (size_t)fb->width * fb->height; + *owned = NULL; + switch (fb->format) { + case GFX_RGB565: + *fmt = GFX_PNGIO_RGB565, *buf = (const uint8_t *)fb->buf, *len = px * 2; + return 0; + case GFX_RGB888: + *fmt = GFX_PNGIO_RGB888, *buf = (const uint8_t *)fb->buf, *len = px * 3; + return 0; + case GFX_GS8: + *fmt = GFX_PNGIO_GS8, *buf = (const uint8_t *)fb->buf, *len = px; + return 0; + case GFX_MHLSB: { + /* one bit a pixel, widened to grey */ + uint8_t *g = (uint8_t *)malloc(px ? px : 1); + if (g == NULL) { + return -1; + } + const uint8_t *src = (const uint8_t *)fb->buf; + const size_t row = ((size_t)fb->width + 7) / 8; + for (int y = 0; y < fb->height; y++) { + for (int x = 0; x < fb->width; x++) { + g[(size_t)y * fb->width + x] = ((src[y * row + (x >> 3)] >> (7 - (x & 7))) & 1) ? 255 : 0; + } + } + *fmt = GFX_PNGIO_GS8, *buf = g, *len = px, *owned = g; + return 0; + } + default: + return -1; + } +} diff --git a/src/gfx_png.h b/src/gfx_png.h index 9ca4895..caf4bb7 100644 --- a/src/gfx_png.h +++ b/src/gfx_png.h @@ -3,14 +3,17 @@ #include "gfx_framebuffer.h" #include +#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); +/* pygraphics has no PNG encoder: PNG is pngio's (micropython-pydevices' + * modules/pngio, or pydevices-desktop's pngio.py over Pillow on CPython). + * What is here is the rule for when a save is PNG, and the pixels pngio is + * handed. */ -/* 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); +/* pngio's format codes. */ +#define GFX_PNGIO_RGB565 0 +#define GFX_PNGIO_GS8 1 +#define GFX_PNGIO_RGB888 2 /* 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 @@ -20,4 +23,9 @@ 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); +/* The pixels for pngio: its format code, and the bytes -- the framebuffer's + * own, or for MONO_HLSB a GS8 copy returned in *owned (malloc'd; the caller + * frees it). -1 for a format PNG can't take. */ +int gfx_png_source(const gfx_fb_t *fb, int *fmt, const uint8_t **buf, size_t *len, uint8_t **owned); + #endif /* GFX_PNG_H */ diff --git a/src/pygraphics_qstrdefs.h b/src/pygraphics_qstrdefs.h index e451b82..3f73ba6 100644 --- a/src/pygraphics_qstrdefs.h +++ b/src/pygraphics_qstrdefs.h @@ -73,7 +73,9 @@ Q(format) Q(color_depth) Q(load_image) Q(save_image) -Q(encode_png) +Q(pngio) +Q(PngEncoder) +Q(encode) Q(bmp_to_framebuffer) Q(pbm_to_framebuffer) Q(pgm_to_framebuffer) diff --git a/tests/test_files.py b/tests/test_files.py index 5407e00..9693fb0 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -24,7 +24,6 @@ RGB888, FrameBuffer, bmp_to_framebuffer, - encode_png, load_image, pbm_to_framebuffer, pgm_to_framebuffer, @@ -215,60 +214,56 @@ def test_bmp565_matches_framebuffer_save(self): self.assertEqual(a.read(), b.read()) -class TestPngEncoding(_TmpDirTest): - def test_rgb565_png_encoding(self): - import zlib +def _pngio(): + try: + import pngio # noqa: F401 (MicroPython's built-in, or pydevices-desktop's over Pillow) + + return True + except ImportError: + return False + + +class TestPngSave(_TmpDirTest): + """PNG is pngio's: pygraphics saves it through pngio and has no encoder.""" + + def test_no_encoder_of_its_own(self): + import pygraphics + + self.assertFalse(hasattr(pygraphics, "encode_png")) + self.assertFalse(hasattr(pygraphics, "write_png_file")) + self.assertFalse(hasattr(FrameBuffer(bytearray(8), 2, 2, RGB565), "to_png")) + + @unittest.skipUnless(_pngio(), "pngio isn't installed") + def test_png_saves_through_pngio(self): + from PIL import Image + 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") + self.assertEqual(fb.save(path), path) + im = Image.open(path).convert("RGB") + self.assertEqual([im.getpixel(p) for p in ((0, 0), (1, 0), (0, 1), (1, 1))], + [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)]) + + @unittest.skipUnless(_pngio(), "pngio isn't installed") + def test_rgb888_and_gs8_and_mono(self): + from PIL import Image + + rgb = FrameBuffer(bytearray(2 * 2 * 3), 2, 2, RGB888) + rgb.pixel(0, 0, 0xFF0000) + saved = save_image(rgb, self._path("rgb")) # RGB888 is PNG whatever the name + self.assertTrue(saved.endswith(".png")) + self.assertEqual(Image.open(saved).convert("RGB").getpixel((0, 0)), (255, 0, 0)) + g = FrameBuffer(bytearray(2 * 2), 2, 2, GS8) + g.pixel(1, 1, 128) + self.assertEqual(Image.open(save_image(g, self._path("g.png"))).getpixel((1, 1)), 128) + m = FrameBuffer(bytearray(2), 8, 2, MONO_HLSB) + m.pixel(3, 1, 1) + im = Image.open(save_image(m, self._path("m.png"))) + self.assertEqual((im.getpixel((3, 1)), im.getpixel((2, 1))), (255, 0)) if __name__ == "__main__": From 9a9d01043135ff43b6cc26bbdcf59418fc9b7ace Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 7 Oct 2026 05:45:19 -0500 Subject: [PATCH 2/2] compare_graphics: _png.py is gone from the package it stages --- tools/compare_graphics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/compare_graphics.py b/tools/compare_graphics.py index 1f24751..1086b24 100644 --- a/tools/compare_graphics.py +++ b/tools/compare_graphics.py @@ -49,7 +49,6 @@ "_draw.py", "_bmp565.py", "_files.py", - "_png.py", "framebuf.py", )