diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93a9f57..da70ebb 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,14 +46,35 @@ jobs: sudo cp brotli.ini /etc/php/${{ matrix.php-versions }}/mods-available/ && sudo phpenmod -v ${{ matrix.php-versions }} brotli + - name: Add ZStandard extension (Ubuntu) + if: runner.os != 'Windows' && matrix.php-versions == '8.4' + run: > + git clone --recursive --depth=1 https://github.com/kjdev/php-ext-zstd.git && + cd php-ext-zstd && + phpize && + ./configure --enable-zstd && + make -j $(nproc) && + echo "extension=zstd.so" > zstd.ini && + sudo make install && + sudo cp zstd.ini /etc/php/${{ matrix.php-versions }}/mods-available/ && + sudo phpenmod -v ${{ matrix.php-versions }} zstd + - name: Add Brotli extension (Windows) if: runner.os == 'Windows' && matrix.php-versions == '8.4' run: > curl -sSL -o brotli.zip https://github.com/kjdev/php-ext-brotli/releases/download/0.18.0/php_brotli-0.18.0-8.4-nts-vs17-x86_64.zip && unzip brotli.zip && - cp *.dll C:/tools/php/ext/php_brotli.dll && + cp php_brotli*.dll C:/tools/php/ext/php_brotli.dll && echo "extension=brotli" >> C:/tools/php/php.ini + - name: Add ZStandard extension (Windows) + if: runner.os == 'Windows' && matrix.php-versions == '8.4' + run: > + curl -sSL -o zstd.zip https://github.com/kjdev/php-ext-zstd/releases/download/0.15.0/php_zstd-0.15.0-8.4-nts-vs17-x86_64.zip && + unzip zstd.zip && + cp php_zstd*.dll C:/tools/php/ext/php_zstd.dll && + echo "extension=zstd" >> C:/tools/php/php.ini + - name: Validate composer.json and composer.lock run: composer validate diff --git a/ChangeLog.md b/ChangeLog.md index 554d751..b3a5943 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,8 @@ Compression streams ChangeLog ## 2.0.0 / ????-??-?? +* Merged PR #8: Add ZStandard compression, based on the `zstd` extension + from https://github.com/kjdev/php-ext-zstd/ - see issue #7. * **Heads up:** Dropped support for PHP < 7.4, see xp-framework/rfc#343 (@thekid) * **Heads up:** Algorithm implementations must change their `compress` diff --git a/README.md b/README.md index 00abb51..8f4dafb 100755 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Compression streams [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/compression/version.svg)](https://packagist.org/packages/xp-forge/compression) -Compressing output and decompressing input streams including GZip, BZip2 and Brotli. +Compressing output and decompressing input streams including GZip, BZip2, ZStandard and Brotli. Examples -------- @@ -44,6 +44,7 @@ Compression algorithms are implemented in C and thus require a specific PHP exte * **GZip** - requires PHP's ["zlib" extension](https://www.php.net/zlib) * **Bzip2** - requires PHP's ["bzip2" extension](https://www.php.net/bzip2) * **Brotli** - requires https://github.com/kjdev/php-ext-brotli +* **ZStandard** - requires https://github.com/kjdev/php-ext-zstd Accessing these algorithms can be done via the `Compression` API: @@ -96,6 +97,7 @@ io.streams.compress.Algorithms@{ io.streams.compress.Gzip(token: gzip, extension: .gz, supported: true, levels: 1..9) io.streams.compress.Bzip2(token: bzip2, extension: .bz2, supported: false, levels: 1..9) io.streams.compress.Brotli(token: br, extension: .br, supported: true, levels: 1..11) + io.streams.compress.ZStandard(token: zstd, extension: .zstd, supported: true, levels: 1..22) } ``` diff --git a/src/main/php/io/streams/Compression.class.php b/src/main/php/io/streams/Compression.class.php index b1534d8..712dd10 100755 --- a/src/main/php/io/streams/Compression.class.php +++ b/src/main/php/io/streams/Compression.class.php @@ -1,6 +1,6 @@ add(new Gzip(), new Bzip2(), new Brotli()); + self::$algorithms= (new Algorithms())->add(new Gzip(), new Bzip2(), new Brotli(), new ZStandard()); } /** diff --git a/src/main/php/io/streams/compress/Brotli.class.php b/src/main/php/io/streams/compress/Brotli.class.php index f53350b..373a8ad 100755 --- a/src/main/php/io/streams/compress/Brotli.class.php +++ b/src/main/php/io/streams/compress/Brotli.class.php @@ -1,5 +1,6 @@ 1, Compression::DEFAULT => 3, Compression::STRONGEST => 22]; + return $levels[$select] ?? $select; + } + + /** Compresses data */ + public function compress(string $data, $options= null): string { + return zstd_compress($data, $this->level(Options::from($options)->level)); + } + + /** Decompresses bytes */ + public function decompress(string $bytes): string { + if (false === ($data= zstd_uncompress($bytes))) { + $e= new IOException('Decompression failed'); + \xp::gc(__FILE__); + throw $e; + } + return $data; + } + + /** Opens an input stream for reading */ + public function open(InputStream $in): InputStream { + return new ZStandardInputStream($in); + } + + /** Opens an output stream for writing */ + public function create(OutputStream $out, $options= null): OutputStream { + return new ZStandardOutputStream($out, $this->level(Options::from($options)->level)); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/ZStandardInputStream.class.php b/src/main/php/io/streams/compress/ZStandardInputStream.class.php new file mode 100755 index 0000000..abe2429 --- /dev/null +++ b/src/main/php/io/streams/compress/ZStandardInputStream.class.php @@ -0,0 +1,70 @@ +in= $in; + $this->handle= zstd_uncompress_init(); + } + + /** + * Read a string + * + * @param int limit default 8192 + * @return string + */ + public function read($limit= 8192) { + $bytes= zstd_uncompress_add($this->handle, $this->in->read($limit)); + if (false === $bytes) { + $e= new IOException('Failed to uncompress'); + \xp::gc(__FILE__); + throw $e; + } + return $bytes; + } + + /** + * Returns the number of bytes that can be read from this stream + * without blocking. + * + * @return int + */ + public function available() { + return $this->in->available(); + } + + /** + * Close this buffer. + * + * @return void + */ + public function close() { + if ($this->handle) { + $this->handle= null; + $this->in->close(); + } + } + + /** + * Destructor. Ensures output stream is closed. + */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/compress/ZStandardOutputStream.class.php b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php new file mode 100755 index 0000000..2321ef1 --- /dev/null +++ b/src/main/php/io/streams/compress/ZStandardOutputStream.class.php @@ -0,0 +1,64 @@ + ZSTD_COMPRESS_LEVEL_MAX) { + throw new IllegalArgumentException('Level must be between '.ZSTD_COMPRESS_LEVEL_MIN.' and '.ZSTD_COMPRESS_LEVEL_MAX); + } + + $this->out= $out; + $this->handle= zstd_compress_init($level); + } + + /** + * Write a string + * + * @param var $arg + * @return void + */ + public function write($arg) { + $this->out->write(zstd_compress_add($this->handle, $arg, false)); + } + + /** + * Flush this buffer + * + * @return void + */ + public function flush() { + // NOOP + } + + /** + * Closes this object. May be called more than once, which may + * not fail - that is, if the object is already closed, this + * method should have no effect. + * + * @return void + */ + public function close() { + if (null !== $this->handle) { + $this->out->write(zstd_compress_add($this->handle, '', true)); + $this->handle= null; + } + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php index 10fc1e9..9445010 100755 --- a/src/test/php/io/streams/compress/unittest/CompressionTest.class.php +++ b/src/test/php/io/streams/compress/unittest/CompressionTest.class.php @@ -15,14 +15,17 @@ private function names() { yield ['gzip', 'gzip']; yield ['bzip2', 'bzip2']; yield ['brotli', 'brotli']; + yield ['zstandard', 'zstandard']; // File extensions yield ['.gz', 'gzip']; yield ['.bz2', 'bzip2']; yield ['.br', 'brotli']; + yield ['.zstd', 'zstandard']; // HTTP Content-Encoding aliases yield ['br', 'brotli']; + yield ['zstd', 'zstandard']; } /** @return iterable */ @@ -46,6 +49,16 @@ private function erroneous() { if ($bzip2->supported()) { yield [$bzip2, "BZh61AY&SY\331"]; } + + $brotli= $algorithms->named('brotli'); + if ($brotli->supported()) { + yield [$brotli, ""]; + } + + $zstandard= $algorithms->named('zstandard'); + if ($zstandard->supported()) { + yield [$zstandard, ""]; + } } #[Test] @@ -54,7 +67,7 @@ public function enumerating_included_algorithms() { foreach (Compression::algorithms() as $name => $algorithm) { $names[]= $name; } - Assert::equals(['gzip', 'bzip2', 'brotli'], $names); + Assert::equals(['gzip', 'bzip2', 'brotli', 'zstandard'], $names); } #[Test] @@ -77,17 +90,17 @@ public function algorithms_named($name, $expected) { Assert::equals($expected, Compression::algorithms()->named($name)->name()); } - #[Test, Values([['gzip', 'zlib'], ['bzip2', 'bz2'], ['brotli', 'brotli']])] + #[Test, Values([['gzip', 'zlib'], ['bzip2', 'bz2'], ['brotli', 'brotli'], ['zstandard', 'zstd']])] public function supported($compression, $extension) { Assert::equals(extension_loaded($extension), Compression::algorithms()->named($compression)->supported()); } - #[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br']])] + #[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br'], ['zstandard', 'zstd']])] public function token($compression, $expected) { Assert::equals($expected, Compression::algorithms()->named($compression)->token()); } - #[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br']])] + #[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br'], ['zstandard', '.zstd']])] public function extension($compression, $expected) { Assert::equals($expected, Compression::algorithms()->named($compression)->extension()); } diff --git a/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php new file mode 100755 index 0000000..bd8870c --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/ZStandardInputStreamTest.class.php @@ -0,0 +1,59 @@ +read(); + }); + $in->close(); + } + + #[Test, Values(from: 'compressable')] + public function read_compressed($level, $bytes) { + $in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes, $level))); + $read= $in->read(); + $rest= $in->available(); + $in->close(); + + Assert::equals($bytes, $read); + Assert::equals(0, $rest); + } + + #[Test, Values([1, 8192, 16384])] + public function read_all($length) { + $bytes= random_bytes($length); + $in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes))); + + $read= ''; + while ($in->available()) { + $read.= $in->read(); + } + $in->close(); + + Assert::equals($bytes, $read); + } +} \ No newline at end of file diff --git a/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php b/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php new file mode 100755 index 0000000..523878a --- /dev/null +++ b/src/test/php/io/streams/compress/unittest/ZStandardOutputStreamTest.class.php @@ -0,0 +1,34 @@ +write('Hello'); + $fixture->write(' '); + $fixture->write('World'); + $fixture->close(); + + Assert::equals('Hello World', zstd_uncompress($out->bytes())); + } +} \ No newline at end of file