Skip to content
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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)
}
```

Expand Down
4 changes: 2 additions & 2 deletions src/main/php/io/streams/Compression.class.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php namespace io\streams;

use io\streams\compress\{Algorithm, Algorithms, None, Brotli, Bzip2, Gzip};
use io\streams\compress\{Algorithm, Algorithms, None, Brotli, Bzip2, Gzip, ZStandard};
use lang\MethodNotImplementedException;

/**
Expand All @@ -22,7 +22,7 @@ static function __static() {
self::$NONE= new None();

// Register known algorithms included in this library
self::$algorithms= (new Algorithms())->add(new Gzip(), new Bzip2(), new Brotli());
self::$algorithms= (new Algorithms())->add(new Gzip(), new Bzip2(), new Brotli(), new ZStandard());
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/main/php/io/streams/compress/Brotli.class.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php namespace io\streams\compress;

use io\IOException;
use io\streams\{InputStream, OutputStream, Compression};

class Brotli extends Algorithm {
Expand Down Expand Up @@ -29,7 +30,12 @@ public function compress(string $data, $options= null): string {

/** Decompresses bytes */
public function decompress(string $bytes): string {
return brotli_uncompress($bytes);
if (false === ($data= brotli_uncompress($bytes))) {
$e= new IOException('Decompression failed');
\xp::gc(__FILE__);
throw $e;
}
return $data;
}

/** Opens an input stream for reading */
Expand Down
50 changes: 50 additions & 0 deletions src/main/php/io/streams/compress/ZStandard.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php namespace io\streams\compress;

use io\IOException;
use io\streams\{InputStream, OutputStream, Compression};

class ZStandard extends Algorithm {

/** Returns whether this algorithm is supported in the current setup */
public function supported(): bool { return extension_loaded('zstd'); }

/** Returns the algorithm's name */
public function name(): string { return 'zstandard'; }

/** Returns the algorithm's HTTP Content-Encoding token */
public function token(): string { return 'zstd'; }

/** Returns the algorithm's common file extension, including a leading "." */
public function extension(): string { return '.zstd'; }

/** Maps fastest, default and strongest levels */
public function level(int $select): int {
static $levels= [Compression::FASTEST => 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));
}
}
70 changes: 70 additions & 0 deletions src/main/php/io/streams/compress/ZStandardInputStream.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php namespace io\streams\compress;

use io\IOException;
use io\streams\InputStream;

/**
* ZStandard input stream
*
* @ext zstd
* @test io.streams.compress.unittest.BrotliInputStreamTest
* @see https://github.com/kjdev/php-ext-zstd
*/
class ZStandardInputStream implements InputStream {
private $in, $handle;

/**
* Creates a new compressing output stream
*
* @param io.streams.InputStream $in The stream to read from
*/
public function __construct(InputStream $in) {
$this->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();
}
}
64 changes: 64 additions & 0 deletions src/main/php/io/streams/compress/ZStandardOutputStream.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php namespace io\streams\compress;

use io\streams\OutputStream;
use lang\IllegalArgumentException;

/**
* ZStandard output stream
*
* @ext zstd
* @test io.streams.compress.unittest.ZStandardOutputStreamTest
* @see https://github.com/kjdev/php-ext-zstd
*/
class ZStandardOutputStream implements OutputStream {
private $out, $handle;

/**
* Creates a new compressing output stream
*
* @param io.streams.OutputStream $out The stream to write to
* @param int $level
* @throws lang.IllegalArgumentException
*/
public function __construct(OutputStream $out, $level= ZSTD_COMPRESS_LEVEL_DEFAULT) {
if ($level < ZSTD_COMPRESS_LEVEL_MIN || $level > 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -46,6 +49,16 @@ private function erroneous() {
if ($bzip2->supported()) {
yield [$bzip2, "BZh61AY&SY\331<Plain data>"];
}

$brotli= $algorithms->named('brotli');
if ($brotli->supported()) {
yield [$brotli, "<Plain data>"];
}

$zstandard= $algorithms->named('zstandard');
if ($zstandard->supported()) {
yield [$zstandard, "<Plain data>"];
}
}

#[Test]
Expand All @@ -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]
Expand All @@ -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());
}
Expand Down
Loading
Loading