From 48bfadd9dc0c46a4dd0fe32209627d1b67eb9883 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 20:40:36 +0200 Subject: [PATCH 01/13] Add a Blob class as a bridge between string, bytes and streams --- src/main/php/io/Blob.class.php | 86 +++++++++++++++++++ .../io/streams/IterableInputStream.class.php | 62 +++++++++++++ src/test/php/io/unittest/BlobTest.class.php | 59 +++++++++++++ .../IterableInputStreamTest.class.php | 84 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100755 src/main/php/io/Blob.class.php create mode 100755 src/main/php/io/streams/IterableInputStream.class.php create mode 100755 src/test/php/io/unittest/BlobTest.class.php create mode 100755 src/test/php/io/unittest/IterableInputStreamTest.class.php diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php new file mode 100755 index 000000000..554d9da53 --- /dev/null +++ b/src/main/php/io/Blob.class.php @@ -0,0 +1,86 @@ +iterator= (function() { + while ($this->parts->available()) { + yield $this->parts->read(); + } + $this->parts->close(); + })(); + } else if ($parts instanceof Bytes || is_string($parts)) { + $this->iterator= (function() { yield (string)$this->parts; })(); + } else if (is_iterable($parts)) { + $this->iterator= (function() { + foreach ($this->parts as $part) { + yield (string)$part; + } + })(); + } else { + throw new IllegalArgumentException(sprintf( + 'Expected iterable|string|util.Bytes|io.streams.InputStream, have %s', + typeof($parts) + ); + } + + $this->parts= $parts; + } + + /** @return iterable */ + public function getIterator(): Traversable { return $this->iterator; } + + /** @return util.Bytes */ + public function bytes() { return new Bytes(...$this->iterator); } + + /** @return io.streams.InputStream */ + public function stream() { + return $this->parts instanceof InputStream + ? $this->parts + : new IterableInputStream($this->iterator) + ; + } + + /** @return string */ + public function hashCode() { return 'B'.Objects::hashOf($this->parts); } + + /** @return string */ + public function toString() { return nameof($this).'('.Objects::stringOf($this->parts).')'; } + + /** + * Comparison + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self + ? Objects::compare($this->parts, $value->parts) + : 1 + ; + } + + /** @return string */ + public function __toString() { + $bytes= ''; + foreach ($this->iterator as $chunk) { + $bytes.= $chunk; + } + return $bytes; + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/IterableInputStream.class.php b/src/main/php/io/streams/IterableInputStream.class.php new file mode 100755 index 000000000..6503f12f0 --- /dev/null +++ b/src/main/php/io/streams/IterableInputStream.class.php @@ -0,0 +1,62 @@ +iterator= $input; + } else if ($input instanceof Closure) { + $this->iterator= cast($input(), Iterator::class); + } else if (is_iterable($input)) { + $this->iterator= (function() use($input) { yield from $input; })(); + } else { + throw new IllegalArgumentException('Expected iterable|function(): Iterator, have '.typeof($input)); + } + } + + /** @return int */ + public function available() { + if (null !== $this->buffer) { + return strlen($this->buffer); + } else if ($this->iterator->valid()) { + $this->buffer= $this->iterator->current(); + $this->iterator->next(); + return strlen($this->buffer); + } else { + return 0; + } + } + + /** + * Reads up to a given limit + * + * @param int $limit + * @return string + */ + public function read($limit= 8192) { + if (null !== $this->buffer) { + // Continue draining the buffer + } else if ($this->iterator->valid()) { + $this->buffer= $this->iterator->current(); + $this->iterator->next(); + } else { + return ''; + } + + $chunk= substr($this->buffer, 0, $limit); + $this->buffer= $limit >= strlen($this->buffer) ? null : substr($this->buffer, $limit); + return $chunk; + } + + /** @return void */ + public function close() { + // NOOP + } +} \ No newline at end of file diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php new file mode 100755 index 000000000..e23d4547c --- /dev/null +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -0,0 +1,59 @@ +bytes()); + } + + #[Test, Values(from: 'cases')] + public function stream($fixture, $expected) { + $stream= $fixture->stream(); + $data= []; + while ($stream->available()) { + $data[]= $stream->read(); + } + Assert::equals($expected, $data); + } + + #[Test, Values(from: 'cases')] + public function string_cast($fixture, $expected) { + Assert::equals(implode('', $expected), (string)$fixture); + } +} \ No newline at end of file diff --git a/src/test/php/io/unittest/IterableInputStreamTest.class.php b/src/test/php/io/unittest/IterableInputStreamTest.class.php new file mode 100755 index 000000000..8e0ff3d4d --- /dev/null +++ b/src/test/php/io/unittest/IterableInputStreamTest.class.php @@ -0,0 +1,84 @@ +available()) { + $data[]= $stream->read($limit); + } + return $data; + } + + #[Test, Expect(IllegalArgumentException::class)] + public function not_from_null() { + new IterableInputStream(null); + } + + #[Test] + public function read_empty() { + Assert::equals([], $this->read([])); + } + + #[Test] + public function read_array() { + Assert::equals(['Test', 'ed'], $this->read(['Test', 'ed'])); + } + + #[Test] + public function read_iterator() { + Assert::equals(['Test', 'ed'], $this->read(new ArrayIterator(['Test', 'ed']))); + } + + #[Test] + public function read_iterator_aggregate() { + Assert::equals(['Test', 'ed'], $this->read(new ArrayObject(['Test', 'ed']))); + } + + #[Test] + public function read_closure() { + Assert::equals(['Test', 'ed'], $this->read(function() { + yield 'Test'; + yield 'ed'; + })); + } + + #[Test] + public function one_chunk_under_limit() { + Assert::equals( + [str_repeat('*', 9)], + $this->read([str_repeat('*', 9)], 10) + ); + } + + #[Test] + public function one_chunk_when_limit_reached() { + Assert::equals( + [str_repeat('*', 10)], + $this->read([str_repeat('*', 10)], 10) + ); + } + + #[Test] + public function new_chunk_when_limit_exceeded() { + Assert::equals( + [str_repeat('*', 10), '*'], + $this->read([str_repeat('*', 11)], 10) + ); + } + + #[Test] + public function chunks_when_limit_exceeded() { + Assert::equals( + [str_repeat('*', 10), str_repeat('*', 10), '*'], + $this->read([str_repeat('*', 21)], 10) + ); + } +} \ No newline at end of file From 81dc37b0d8c899047fe3db785d829530b2f0abe1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 21:08:09 +0200 Subject: [PATCH 02/13] Fix syntax error --- src/main/php/io/Blob.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 554d9da53..9f088f096 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -36,7 +36,7 @@ public function __construct($parts= []) { throw new IllegalArgumentException(sprintf( 'Expected iterable|string|util.Bytes|io.streams.InputStream, have %s', typeof($parts) - ); + )); } $this->parts= $parts; From 3b4e2abcdcfadfd8c36779ccf667c9c83065226d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 21:57:52 +0200 Subject: [PATCH 03/13] Add slices() --- src/main/php/io/Blob.class.php | 13 +++++++++++++ src/test/php/io/unittest/BlobTest.class.php | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 9f088f096..102dbf57c 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -56,6 +56,19 @@ public function stream() { ; } + /** @return iterable */ + public function slices(int $size= 8192) { + foreach ($this->iterator as $slice) { + $offset= 0; + $length= strlen($slice); + while ($length - $offset > $size) { + yield substr($slice, $offset, $size); + $offset+= $size; + } + yield $offset ? substr($slice, $offset) : $slice; + } + } + /** @return string */ public function hashCode() { return 'B'.Objects::hashOf($this->parts); } diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index e23d4547c..20c27d2f0 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -56,4 +56,9 @@ public function stream($fixture, $expected) { public function string_cast($fixture, $expected) { Assert::equals(implode('', $expected), (string)$fixture); } + + #[Test, Values([[1, ['T', 'e', 's', 't']], [2, ['Te', 'st']], [3, ['Tes', 't']], [4, ['Test']]])] + public function slices($size, $expected) { + Assert::equals($expected, iterator_to_array((new Blob('Test'))->slices($size))); + } } \ No newline at end of file From 3e348028ce3f56e82f1f5b39250ae03d4a732c11 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 22:09:31 +0200 Subject: [PATCH 04/13] Always return slice with a given size (except for the last one) --- src/main/php/io/Blob.class.php | 14 ++++++++++++-- src/test/php/io/unittest/BlobTest.class.php | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 102dbf57c..b85fa7dc5 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -58,14 +58,24 @@ public function stream() { /** @return iterable */ public function slices(int $size= 8192) { - foreach ($this->iterator as $slice) { - $offset= 0; + while ($this->iterator->valid()) { + $slice= $this->iterator->current(); $length= strlen($slice); + $offset= 0; + + while ($length < $size) { + $this->iterator->next(); + $slice.= $this->iterator->current(); + if (!$this->iterator->valid()) break; + } + while ($length - $offset > $size) { yield substr($slice, $offset, $size); $offset+= $size; } + yield $offset ? substr($slice, $offset) : $slice; + $this->iterator->next(); } } diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index 20c27d2f0..8401bbefc 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -61,4 +61,9 @@ public function string_cast($fixture, $expected) { public function slices($size, $expected) { Assert::equals($expected, iterator_to_array((new Blob('Test'))->slices($size))); } + + #[Test] + public function fill_slice() { + Assert::equals(['Test'], iterator_to_array((new Blob(['Te', 'st']))->slices())); + } } \ No newline at end of file From c9a75dc75e3fd2fb99f53fb5f1de86ea98c7ec30 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 22:25:39 +0200 Subject: [PATCH 05/13] Ensure we call rewind() on iterators --- src/main/php/io/Blob.class.php | 1 + src/main/php/io/streams/IterableInputStream.class.php | 1 + src/test/php/io/unittest/BlobTest.class.php | 10 +++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index b85fa7dc5..abef6b8bd 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -58,6 +58,7 @@ public function stream() { /** @return iterable */ public function slices(int $size= 8192) { + $this->iterator->rewind(); while ($this->iterator->valid()) { $slice= $this->iterator->current(); $length= strlen($slice); diff --git a/src/main/php/io/streams/IterableInputStream.class.php b/src/main/php/io/streams/IterableInputStream.class.php index 6503f12f0..61c189fc9 100755 --- a/src/main/php/io/streams/IterableInputStream.class.php +++ b/src/main/php/io/streams/IterableInputStream.class.php @@ -19,6 +19,7 @@ public function __construct($input) { } else { throw new IllegalArgumentException('Expected iterable|function(): Iterator, have '.typeof($input)); } + $this->iterator->rewind(); } /** @return int */ diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index 8401bbefc..c9558acbb 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -3,7 +3,7 @@ use ArrayObject; use io\Blob; use io\streams\MemoryInputStream; -use lang\IllegalArgumentException; +use lang\{IllegalArgumentException, Error}; use test\{Assert, Expect, Test, Values}; use util\Bytes; @@ -66,4 +66,12 @@ public function slices($size, $expected) { public function fill_slice() { Assert::equals(['Test'], iterator_to_array((new Blob(['Te', 'st']))->slices())); } + + #[Test] + public function cannot_fetch_slices_twice() { + $fixture= new Blob('Test'); + iterator_to_array($fixture->slices()); + + Assert::throws(Error::class, fn() => iterator_to_array($fixture->slices())); + } } \ No newline at end of file From 0bff54428b85e3759f5a680f155125d92c267643 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 22:54:31 +0200 Subject: [PATCH 06/13] Handle seekable streams --- src/main/php/io/Blob.class.php | 60 +++++++++++++-------- src/test/php/io/unittest/BlobTest.class.php | 21 ++++++-- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index abef6b8bd..5e281429b 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -1,8 +1,8 @@ iterator= (function() { - while ($this->parts->available()) { - yield $this->parts->read(); - } - $this->parts->close(); - })(); + $this->iterator= function() { + static $started= false; + + return (function() use(&$started) { + if ($started) { + if ($this->parts instanceof Seekable) { + $this->parts->seek(0); + } else { + throw new IllegalStateException('Cannot seek '.Objects::stringOf($this->parts)); + } + } + + $started= true; + while ($this->parts->available()) { + yield $this->parts->read(); + } + })(); + }; } else if ($parts instanceof Bytes || is_string($parts)) { - $this->iterator= (function() { yield (string)$this->parts; })(); + $this->iterator= fn() => (function() { yield (string)$this->parts; })(); } else if (is_iterable($parts)) { - $this->iterator= (function() { + $this->iterator= fn() => (function() { foreach ($this->parts as $part) { yield (string)$part; } @@ -43,31 +55,37 @@ public function __construct($parts= []) { } /** @return iterable */ - public function getIterator(): Traversable { return $this->iterator; } + public function getIterator(): Traversable { return ($this->iterator)(); } /** @return util.Bytes */ - public function bytes() { return new Bytes(...$this->iterator); } + public function bytes() { + return $this->parts instanceof Bytes + ? $this->parts + : new Bytes(...($this->iterator)()) + ; + } /** @return io.streams.InputStream */ public function stream() { return $this->parts instanceof InputStream ? $this->parts - : new IterableInputStream($this->iterator) + : new IterableInputStream(($this->iterator)()) ; } /** @return iterable */ public function slices(int $size= 8192) { - $this->iterator->rewind(); - while ($this->iterator->valid()) { - $slice= $this->iterator->current(); + $it= ($this->iterator)(); + $it->rewind(); + while ($it->valid()) { + $slice= $it->current(); $length= strlen($slice); $offset= 0; while ($length < $size) { - $this->iterator->next(); - $slice.= $this->iterator->current(); - if (!$this->iterator->valid()) break; + $it->next(); + $slice.= $it->current(); + if (!$it->valid()) break; } while ($length - $offset > $size) { @@ -76,7 +94,7 @@ public function slices(int $size= 8192) { } yield $offset ? substr($slice, $offset) : $slice; - $this->iterator->next(); + $it->next(); } } @@ -102,7 +120,7 @@ public function compareTo($value) { /** @return string */ public function __toString() { $bytes= ''; - foreach ($this->iterator as $chunk) { + foreach (($this->iterator)() as $chunk) { $bytes.= $chunk; } return $bytes; diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index c9558acbb..5ae392a57 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -2,8 +2,8 @@ use ArrayObject; use io\Blob; -use io\streams\MemoryInputStream; -use lang\{IllegalArgumentException, Error}; +use io\streams\{MemoryInputStream, InputStream}; +use lang\{IllegalArgumentException, IllegalStateException}; use test\{Assert, Expect, Test, Values}; use util\Bytes; @@ -68,10 +68,23 @@ public function fill_slice() { } #[Test] - public function cannot_fetch_slices_twice() { + public function fetch_slice_twice() { $fixture= new Blob('Test'); + + Assert::equals(['Test'], iterator_to_array($fixture->slices())); + Assert::equals(['Test'], iterator_to_array($fixture->slices())); + } + + #[Test] + public function cannot_fetch_slices_twice_from_non_seekable() { + $fixture= new Blob(new class() implements InputStream { + private $input= ['Test']; + public function available() { return strlen(current($this->input)); } + public function read($limit= 8192) { return array_shift($this->input); } + public function close() { $this->input= []; } + }); iterator_to_array($fixture->slices()); - Assert::throws(Error::class, fn() => iterator_to_array($fixture->slices())); + Assert::throws(IllegalStateException::class, fn() => iterator_to_array($fixture->slices())); } } \ No newline at end of file From da6fa86106c4a76baef34ff0f235b4e6e768d1f3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 23:05:43 +0200 Subject: [PATCH 07/13] Refactor: Extract seeking to Streams class --- src/main/php/io/Blob.class.php | 14 +++----------- src/main/php/io/streams/Streams.class.php | 20 ++++++++++++++++++-- src/test/php/io/unittest/BlobTest.class.php | 6 +++--- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 5e281429b..fac6a3d8e 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -1,8 +1,8 @@ parts instanceof Seekable) { - $this->parts->seek(0); - } else { - throw new IllegalStateException('Cannot seek '.Objects::stringOf($this->parts)); - } - } - - $started= true; + $started ? Streams::seek($this->parts, 0) : $started= true; while ($this->parts->available()) { yield $this->parts->read(); } diff --git a/src/main/php/io/streams/Streams.class.php b/src/main/php/io/streams/Streams.class.php index c032521fa..93180c12d 100755 --- a/src/main/php/io/streams/Streams.class.php +++ b/src/main/php/io/streams/Streams.class.php @@ -1,7 +1,6 @@ seek($offset, $whence); + } else { + throw new OperationNotSupportedException('Cannot seek instances of '.nameof($s)); + } + } + /** * Callback for fopen * diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index 5ae392a57..fdc5ec8e7 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -1,9 +1,9 @@ input= []; } }); iterator_to_array($fixture->slices()); - Assert::throws(IllegalStateException::class, fn() => iterator_to_array($fixture->slices())); + Assert::throws(OperationNotSupportedException::class, fn() => iterator_to_array($fixture->slices())); } } \ No newline at end of file From d85aadd3aeef4923b94f51d7d39eef29b57a3ebb Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 29 Mar 2026 23:20:02 +0200 Subject: [PATCH 08/13] Add meta information --- src/main/php/io/Blob.class.php | 5 ++++- src/test/php/io/unittest/BlobTest.class.php | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index fac6a3d8e..7ef600ab3 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -9,14 +9,16 @@ class Blob implements IteratorAggregate, Value { private $parts; private $iterator= null; + public $meta= []; /** * Creates a new blob from parts * * @param iterable|string|util.Bytes|io.streams.InputStream $parts + * @param [:var] $meta * @throws lang.IllegalArgumentException */ - public function __construct($parts= []) { + public function __construct($parts= [], array $meta= []) { if ($parts instanceof InputStream) { $this->iterator= function() { static $started= false; @@ -44,6 +46,7 @@ public function __construct($parts= []) { } $this->parts= $parts; + $this->meta= $meta; } /** @return iterable */ diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index fdc5ec8e7..7eb971119 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -32,6 +32,17 @@ public function not_from_null() { new Blob(null); } + #[Test] + public function meta_empty_by_default() { + Assert::equals([], (new Blob('Test'))->meta); + } + + #[Test] + public function meta() { + $meta= ['type' => 'text/plain']; + Assert::equals($meta, (new Blob('Test', $meta))->meta); + } + #[Test, Values(from: 'cases')] public function iteration($fixture, $expected) { Assert::equals($expected, iterator_to_array($fixture)); From cb9191324aafbe192f64f87c89409c04af15f5e3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 30 Mar 2026 00:13:01 +0200 Subject: [PATCH 09/13] Add encoded() method --- src/main/php/io/Blob.class.php | 22 +++++++++++++++++++++ src/test/php/io/unittest/BlobTest.class.php | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 7ef600ab3..c00e7bc27 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -68,6 +68,28 @@ public function stream() { ; } + /** Creates a new blob with the given filter applied */ + public function encoded(string $filter): self { + $it= function() use($filter) { + $fd= Streams::readableFd($this->stream()); + if (!stream_filter_append($fd, $filter, STREAM_FILTER_READ)) { + throw new OperationNotSupportedException('Cannot stream '.$filter); + } + + do { + yield fread($fd, 8192); + } while (!feof($fd)); + + fclose($fd); + }; + + $meta= $this->meta; + $meta['encoding']??= []; + $meta['encoding'][]= $filter; + + return new self($it(), $meta); + } + /** @return iterable */ public function slices(int $size= 8192) { $it= ($this->iterator)(); diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index 7eb971119..d336f6aa2 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -4,6 +4,7 @@ use io\streams\{MemoryInputStream, InputStream}; use io\{Blob, OperationNotSupportedException}; use lang\IllegalArgumentException; +use test\verify\Runtime; use test\{Assert, Expect, Test, Values}; use util\Bytes; @@ -98,4 +99,13 @@ public function close() { $this->input= []; } Assert::throws(OperationNotSupportedException::class, fn() => iterator_to_array($fixture->slices())); } + + /** @see https://bugs.php.net/bug.php?id=77069 */ + #[Test, Runtime(php: '>=7.4.14')] + public function base64_encoded() { + $base64= (new Blob('Test'))->encoded('convert.base64-encode'); + + Assert::equals(['convert.base64-encode'], $base64->meta['encoding']); + Assert::equals('VGVzdA==', (string)$base64); + } } \ No newline at end of file From 2d0f7f668712c04533fff35b4fa9cef2298bcbc6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 30 Mar 2026 17:18:36 +0200 Subject: [PATCH 10/13] Ensure file handle is closed under all circumstances --- src/main/php/io/Blob.class.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index c00e7bc27..3e70a4de4 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -73,14 +73,17 @@ public function encoded(string $filter): self { $it= function() use($filter) { $fd= Streams::readableFd($this->stream()); if (!stream_filter_append($fd, $filter, STREAM_FILTER_READ)) { + fclose($fd); throw new OperationNotSupportedException('Cannot stream '.$filter); } - do { - yield fread($fd, 8192); - } while (!feof($fd)); - - fclose($fd); + try { + do { + yield fread($fd, 8192); + } while (!feof($fd)); + } finally { + fclose($fd); + } }; $meta= $this->meta; From ae4ac9027f7b693b4e37dbe0b9877eb824423c5a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 31 Mar 2026 19:27:05 +0200 Subject: [PATCH 11/13] Use io.streams.FilterInputStream See https://github.com/xp-framework/core/pull/360#discussion_r3010509066 --- src/main/php/io/Blob.class.php | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index 3e70a4de4..d0b96ca4f 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -1,7 +1,7 @@ stream()); - if (!stream_filter_append($fd, $filter, STREAM_FILTER_READ)) { - fclose($fd); - throw new OperationNotSupportedException('Cannot stream '.$filter); - } - - try { - do { - yield fread($fd, 8192); - } while (!feof($fd)); - } finally { - fclose($fd); - } - }; - $meta= $this->meta; $meta['encoding']??= []; $meta['encoding'][]= $filter; - - return new self($it(), $meta); + return new self(new FilterInputStream($this->stream(), $filter), $meta); } /** @return iterable */ From e9612da3e4ab013c61b25ed6b38056e13ddb6ca1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 31 Mar 2026 19:41:06 +0200 Subject: [PATCH 12/13] Allow passing filter implementation --- src/main/php/io/Blob.class.php | 8 ++++---- src/test/php/io/unittest/BlobTest.class.php | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index d0b96ca4f..c83c8a5cf 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -68,12 +68,12 @@ public function stream() { ; } - /** Creates a new blob with the given filter applied */ - public function encoded(string $filter): self { + /** Creates a new blob with the given encoding applied */ + public function encoded(string $encoding, callable $filter= null): self { $meta= $this->meta; $meta['encoding']??= []; - $meta['encoding'][]= $filter; - return new self(new FilterInputStream($this->stream(), $filter), $meta); + $meta['encoding'][]= $encoding; + return new self(new FilterInputStream($this->stream(), $filter ?? $encoding), $meta); } /** @return iterable */ diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php index d336f6aa2..565d8787d 100755 --- a/src/test/php/io/unittest/BlobTest.class.php +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -108,4 +108,12 @@ public function base64_encoded() { Assert::equals(['convert.base64-encode'], $base64->meta['encoding']); Assert::equals('VGVzdA==', (string)$base64); } + + #[Test] + public function custom_encoding() { + $base64= (new Blob('Test'))->encoded('uppercase', fn($chunk) => strtoupper($chunk)); + + Assert::equals(['uppercase'], $base64->meta['encoding']); + Assert::equals('TEST', (string)$base64); + } } \ No newline at end of file From 4093c97a2c85e19b7aa56f102a7919870a7fc1bc Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 31 Mar 2026 19:43:34 +0200 Subject: [PATCH 13/13] Fix "Implicitly marking parameter $filter as nullable is deprecated" --- src/main/php/io/Blob.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php index c83c8a5cf..a353358fe 100755 --- a/src/main/php/io/Blob.class.php +++ b/src/main/php/io/Blob.class.php @@ -69,7 +69,7 @@ public function stream() { } /** Creates a new blob with the given encoding applied */ - public function encoded(string $encoding, callable $filter= null): self { + public function encoded(string $encoding, ?callable $filter= null): self { $meta= $this->meta; $meta['encoding']??= []; $meta['encoding'][]= $encoding;