From 35ec1107b6e7eec6ee65e6a9cbd36d49543d7d38 Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Tue, 30 Jun 2026 17:08:18 +0200 Subject: [PATCH 1/3] Add PixelBuffer#to_bytes for zero-overhead RGBA pixel access Exposes a GSI method on tl::PixelBuffer that returns the raw ARGB32 contents as a byte vector (4 bytes per pixel, row-major, top to bottom) via a single memcpy. Unlike to_png_data this skips all image encoding, which is useful when the caller just wants direct pixel data. --- src/gsi/gsi/gsiDeclTlPixelBuffer.cc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc index c78cd1b1d..a0fae9f7a 100644 --- a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc +++ b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc @@ -126,6 +126,14 @@ static std::vector pixel_buffer_to_png (const tl::PixelBuffer *pb) #endif } +// Returns the raw ARGB32 pixel data as a byte vector (no encoding overhead) +static std::vector pixel_buffer_to_bytes (const tl::PixelBuffer *pb) +{ + const char *p = (const char *) pb->data (); + size_t n = (size_t) pb->width () * (size_t) pb->height () * 4; + return std::vector (p, p + n); +} + Class decl_PixelBuffer ("lay", "PixelBuffer", gsi::constructor ("new", &create_pixel_buffer, gsi::arg ("width"), gsi::arg ("height"), @@ -188,6 +196,14 @@ Class decl_PixelBuffer ("lay", "PixelBuffer", "\n" "This method may not be available if PNG support is not compiled into KLayout." ) + + gsi::method_ext ("to_bytes", &pixel_buffer_to_bytes, + "@brief Converts the pixel buffer to a raw byte stream\n" + "\n" + "Returns the raw ARGB32 pixel data with 4 bytes per pixel in row-major order, " + "top to bottom. Unlike \\to_png_data this method has zero encoding overhead.\n" + "\n" + "This method has been added in version 0.30.9." + ) + gsi::method ("patch", &tl::PixelBuffer::patch, gsi::arg ("other"), "@brief Patches another pixel buffer into this one\n" "\n" From 2fa7b2434563c90aefb2c9a29a41bf7027d67873 Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Thu, 16 Jul 2026 19:03:34 +0200 Subject: [PATCH 2/3] Add PixelBuffer#from_bytes and width/height header to to_bytes --- src/gsi/gsi/gsiDeclTlPixelBuffer.cc | 43 ++++++++++++++++++++++++----- testdata/python/layPixelBuffer.py | 21 ++++++++++++++ testdata/ruby/layPixelBuffer.rb | 26 +++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc index a0fae9f7a..a02a98eeb 100644 --- a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc +++ b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc @@ -126,12 +126,32 @@ static std::vector pixel_buffer_to_png (const tl::PixelBuffer *pb) #endif } -// Returns the raw ARGB32 pixel data as a byte vector (no encoding overhead) +// Returns an 8-byte header (width, height as uint32) followed by the raw ARGB32 pixel data static std::vector pixel_buffer_to_bytes (const tl::PixelBuffer *pb) { - const char *p = (const char *) pb->data (); - size_t n = (size_t) pb->width () * (size_t) pb->height () * 4; - return std::vector (p, p + n); + uint32_t w = pb->width (), h = pb->height (); + size_t n = (size_t) w * (size_t) h * 4; + std::vector data (8 + n); + memcpy (data.data (), &w, 4); + memcpy (data.data () + 4, &h, 4); + memcpy (data.data () + 8, pb->data (), n); + return data; +} + +// Inverse of to_bytes: reads width/height from the header and checks the length +static tl::PixelBuffer pixel_buffer_from_bytes (const std::vector &data) +{ + uint32_t w = 0, h = 0; + if (data.size () >= 8) { + memcpy (&w, data.data (), 4); + memcpy (&h, data.data () + 4, 4); + } + if (data.size () != 8 + (size_t) w * (size_t) h * 4) { + throw tl::Exception (tl::to_string (tr ("Invalid PixelBuffer byte stream: length does not match the width and height in the header"))); + } + tl::PixelBuffer pb (w, h, (const tl::color_t *) (data.data () + 8)); + pb.set_transparent (true); + return pb; } @@ -199,10 +219,19 @@ Class decl_PixelBuffer ("lay", "PixelBuffer", gsi::method_ext ("to_bytes", &pixel_buffer_to_bytes, "@brief Converts the pixel buffer to a raw byte stream\n" "\n" - "Returns the raw ARGB32 pixel data with 4 bytes per pixel in row-major order, " - "top to bottom. Unlike \\to_png_data this method has zero encoding overhead.\n" + "The stream starts with an 8-byte header (width and height as 32-bit unsigned integers) " + "followed by the raw ARGB32 pixel data with 4 bytes per pixel in row-major order, " + "top to bottom. Unlike \\to_png_data this method has zero encoding overhead. " + "Use \\from_bytes to reconstruct the pixel buffer.\n" + "\n" + "This method has been added in version 0.30.10." + ) + + gsi::method ("from_bytes", &pixel_buffer_from_bytes, gsi::arg ("data"), + "@brief Reconstructs a pixel buffer from a byte stream produced by \\to_bytes\n" + "\n" + "The width and height are taken from the header and the stream length is checked against them.\n" "\n" - "This method has been added in version 0.30.9." + "This method has been added in version 0.30.10." ) + gsi::method ("patch", &tl::PixelBuffer::patch, gsi::arg ("other"), "@brief Patches another pixel buffer into this one\n" diff --git a/testdata/python/layPixelBuffer.py b/testdata/python/layPixelBuffer.py index 8a91ea189..77e24183d 100644 --- a/testdata/python/layPixelBuffer.py +++ b/testdata/python/layPixelBuffer.py @@ -57,6 +57,27 @@ def test_1(self): pb_copy = pya.PixelBuffer.read_png(tmp) self.assertEqual(compare(pb, pb_copy), True) + def test_2(self): + + # to_bytes / from_bytes round-trip + + import struct + + pb = pya.PixelBuffer(10, 20) + pb.transparent = True + pb.fill(0xf0010203) + pb.set_pixel(1, 2, 0x80102030) + + data = bytes(pb.to_bytes()) + self.assertEqual(len(data), 8 + 10 * 20 * 4) + self.assertEqual(struct.unpack("=II", data[0:8]), (10, 20)) # width, height header + + self.assertEqual(pb == pya.PixelBuffer.from_bytes(data), True) + + # a mismatched stream is rejected + with self.assertRaises(Exception): + pya.PixelBuffer.from_bytes(data[:-4]) + # run unit tests if __name__ == '__main__': diff --git a/testdata/ruby/layPixelBuffer.rb b/testdata/ruby/layPixelBuffer.rb index abcb3254b..e8c0db63f 100644 --- a/testdata/ruby/layPixelBuffer.rb +++ b/testdata/ruby/layPixelBuffer.rb @@ -152,6 +152,32 @@ def test_4 end + def test_5 + + # to_bytes / from_bytes round-trip + + pb = RBA::PixelBuffer::new(10, 20) + pb.transparent = true + pb.fill(0xf0010203) + pb.set_pixel(1, 2, 0x80102030) + + bytes = pb.to_bytes + assert_equal(bytes.size, 8 + 10 * 20 * 4) + assert_equal(bytes[0, 8].unpack("VV"), [ 10, 20 ]) # width, height header + + assert_equal(pb == RBA::PixelBuffer.from_bytes(bytes), true) + + # a mismatched stream is rejected + error = false + begin + RBA::PixelBuffer.from_bytes(bytes[0, bytes.size - 4]) + rescue + error = true + end + assert_equal(error, true) + + end + def test_11 pb = RBA::BitmapBuffer::new From 9a606ec4e9f6979c4828815865d8fed6ae87e9d4 Mon Sep 17 00:00:00 2001 From: Kirill Osipov Date: Thu, 16 Jul 2026 19:03:34 +0200 Subject: [PATCH 3/3] Cap PixelBuffer.from_bytes dimensions at 64k x 64k Following review feedback, the reader now rejects a header claiming a width or height beyond 65536, guarding against malformed byte streams that would otherwise trigger an unreasonable allocation. The writer stays full 32-bit ('unlimited'), and native little-endian order and the transparency mask are kept as-is. Adds Ruby and Python tests for the oversized-header rejection. Ref: https://github.com/KLayout/klayout/pull/2387#issuecomment-4984941409 --- src/gsi/gsi/gsiDeclTlPixelBuffer.cc | 9 ++++++++- testdata/python/layPixelBuffer.py | 4 ++++ testdata/ruby/layPixelBuffer.rb | 9 +++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc index a02a98eeb..56b45a655 100644 --- a/src/gsi/gsi/gsiDeclTlPixelBuffer.cc +++ b/src/gsi/gsi/gsiDeclTlPixelBuffer.cc @@ -146,6 +146,12 @@ static tl::PixelBuffer pixel_buffer_from_bytes (const std::vector &data) memcpy (&w, data.data (), 4); memcpy (&h, data.data () + 4, 4); } + // sanity guard against malformed headers: reader caps dimensions at 64k x 64k + // (generous for finely-resolved drawings), writer stays full 32-bit + const uint32_t max_dim = 65536; + if (w > max_dim || h > max_dim) { + throw tl::Exception (tl::to_string (tr ("Invalid PixelBuffer byte stream: width or height exceeds the 65536 x 65536 limit"))); + } if (data.size () != 8 + (size_t) w * (size_t) h * 4) { throw tl::Exception (tl::to_string (tr ("Invalid PixelBuffer byte stream: length does not match the width and height in the header"))); } @@ -229,7 +235,8 @@ Class decl_PixelBuffer ("lay", "PixelBuffer", gsi::method ("from_bytes", &pixel_buffer_from_bytes, gsi::arg ("data"), "@brief Reconstructs a pixel buffer from a byte stream produced by \\to_bytes\n" "\n" - "The width and height are taken from the header and the stream length is checked against them.\n" + "The width and height are taken from the header and the stream length is checked against them. " + "The dimensions are capped at 65536 x 65536; a header exceeding this limit raises an error.\n" "\n" "This method has been added in version 0.30.10." ) + diff --git a/testdata/python/layPixelBuffer.py b/testdata/python/layPixelBuffer.py index 77e24183d..f04ebfde1 100644 --- a/testdata/python/layPixelBuffer.py +++ b/testdata/python/layPixelBuffer.py @@ -78,6 +78,10 @@ def test_2(self): with self.assertRaises(Exception): pya.PixelBuffer.from_bytes(data[:-4]) + # a header beyond the 64k x 64k limit is rejected + with self.assertRaises(Exception): + pya.PixelBuffer.from_bytes(struct.pack("=II", 65537, 1)) + # run unit tests if __name__ == '__main__': diff --git a/testdata/ruby/layPixelBuffer.rb b/testdata/ruby/layPixelBuffer.rb index e8c0db63f..d6054c453 100644 --- a/testdata/ruby/layPixelBuffer.rb +++ b/testdata/ruby/layPixelBuffer.rb @@ -176,6 +176,15 @@ def test_5 end assert_equal(error, true) + # a header beyond the 64k x 64k limit is rejected + error = false + begin + RBA::PixelBuffer.from_bytes([ 65537, 1 ].pack("VV")) + rescue + error = true + end + assert_equal(error, true) + end def test_11