Skip to content

Commit 7990720

Browse files
committed
Add ZStandard compression
1 parent eb946f4 commit 7990720

File tree

7 files changed

+283
-6
lines changed

7 files changed

+283
-6
lines changed

src/main/php/io/streams/Compression.class.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php namespace io\streams;
22

3-
use io\streams\compress\{Algorithm, Algorithms, None, Brotli, Bzip2, Gzip};
3+
use io\streams\compress\{Algorithm, Algorithms, None, Brotli, Bzip2, Gzip, ZStandard};
44
use lang\MethodNotImplementedException;
55

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

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

2828
/**
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php namespace io\streams\compress;
2+
3+
use io\streams\{InputStream, OutputStream, Compression};
4+
5+
class ZStandard extends Algorithm {
6+
7+
/** Returns whether this algorithm is supported in the current setup */
8+
public function supported(): bool { return extension_loaded('zstd'); }
9+
10+
/** Returns the algorithm's name */
11+
public function name(): string { return 'zstandard'; }
12+
13+
/** Returns the algorithm's HTTP Content-Encoding token */
14+
public function token(): string { return 'zstd'; }
15+
16+
/** Returns the algorithm's common file extension, including a leading "." */
17+
public function extension(): string { return '.zstd'; }
18+
19+
/** Maps fastest, default and strongest levels */
20+
public function level(int $select): int {
21+
static $levels= [Compression::FASTEST => 1, Compression::DEFAULT => 3, Compression::STRONGEST => 22];
22+
return $levels[$select] ?? $select;
23+
}
24+
25+
/** Opens an input stream for reading */
26+
public function open(InputStream $in): InputStream {
27+
return new ZStandardInputStream($in);
28+
}
29+
30+
/** Opens an output stream for writing */
31+
public function create(OutputStream $out, int $level= Compression::DEFAULT): OutputStream {
32+
return new ZStandardOutputStream($out, $this->level($level));
33+
}
34+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php namespace io\streams\compress;
2+
3+
use io\IOException;
4+
use io\streams\InputStream;
5+
6+
/**
7+
* ZStandard input stream
8+
*
9+
* @ext zstd
10+
* @test io.streams.compress.unittest.BrotliInputStreamTest
11+
* @see https://github.com/kjdev/php-ext-zstd
12+
*/
13+
class ZStandardInputStream implements InputStream {
14+
private $in;
15+
private $buffer= null, $position= 0;
16+
17+
/**
18+
* Creates a new compressing output stream
19+
*
20+
* @param io.streams.InputStream $in The stream to read from
21+
*/
22+
public function __construct(InputStream $in) {
23+
$this->in= $in;
24+
}
25+
26+
/** @see https://github.com/kjdev/php-ext-zstd/issues/64 */
27+
private function buffer() {
28+
if (null === $this->buffer) {
29+
$compressed= '';
30+
while ($this->in->available()) {
31+
$compressed.= $this->in->read();
32+
}
33+
34+
$this->buffer= zstd_uncompress($compressed);
35+
if (false === $this->buffer) {
36+
$e= new IOException('Failed to uncompress');
37+
\xp::gc(__FILE__);
38+
throw $e;
39+
}
40+
}
41+
return $this->buffer;
42+
}
43+
44+
/**
45+
* Read a string
46+
*
47+
* @param int limit default 8192
48+
* @return string
49+
*/
50+
public function read($limit= 8192) {
51+
$chunk= substr($this->buffer(), $this->position, $limit);
52+
$this->position+= strlen($chunk);
53+
return $chunk;
54+
}
55+
56+
/**
57+
* Returns the number of bytes that can be read from this stream
58+
* without blocking.
59+
*
60+
* @return int
61+
*/
62+
public function available() {
63+
return strlen($this->buffer()) - $this->position;
64+
}
65+
66+
/**
67+
* Close this buffer.
68+
*
69+
* @return void
70+
*/
71+
public function close() {
72+
$this->buffer= null;
73+
$this->in->close();
74+
}
75+
76+
/**
77+
* Destructor. Ensures output stream is closed.
78+
*/
79+
public function __destruct() {
80+
$this->close();
81+
}
82+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php namespace io\streams\compress;
2+
3+
use io\streams\OutputStream;
4+
use lang\IllegalArgumentException;
5+
6+
/**
7+
* ZStandard output stream
8+
*
9+
* @ext zstd
10+
* @test io.streams.compress.unittest.ZStandardOutputStreamTest
11+
* @see https://github.com/kjdev/php-ext-zstd
12+
*/
13+
class ZStandardOutputStream implements OutputStream {
14+
private $out, $level;
15+
private $buffer= '';
16+
17+
/**
18+
* Creates a new compressing output stream
19+
*
20+
* @param io.streams.OutputStream $out The stream to write to
21+
* @param int $level
22+
* @throws lang.IllegalArgumentException
23+
*/
24+
public function __construct(OutputStream $out, $level= ZSTD_COMPRESS_LEVEL_DEFAULT) {
25+
if ($level < ZSTD_COMPRESS_LEVEL_MIN || $level > ZSTD_COMPRESS_LEVEL_MAX) {
26+
throw new IllegalArgumentException('Level must be between '.ZSTD_COMPRESS_LEVEL_MIN.' and '.ZSTD_COMPRESS_LEVEL_MAX);
27+
}
28+
29+
$this->out= $out;
30+
$this->level= $level;
31+
}
32+
33+
/**
34+
* Write a string
35+
*
36+
* @param var $arg
37+
* @return void
38+
*/
39+
public function write($arg) {
40+
$this->buffer.= $arg;
41+
}
42+
43+
/**
44+
* Flush this buffer
45+
*
46+
* @return void
47+
*/
48+
public function flush() {
49+
// NOOP
50+
}
51+
52+
/**
53+
* Closes this object. May be called more than once, which may
54+
* not fail - that is, if the object is already closed, this
55+
* method should have no effect.
56+
*
57+
* @return void
58+
*/
59+
public function close() {
60+
if (null !== $this->buffer) {
61+
$this->out->write(zstd_compress($this->buffer, $this->level));
62+
$this->buffer= null;
63+
}
64+
}
65+
}

src/test/php/io/streams/compress/unittest/CompressionTest.class.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ private function names() {
1414
yield ['gzip', 'gzip'];
1515
yield ['bzip2', 'bzip2'];
1616
yield ['brotli', 'brotli'];
17+
yield ['zstandard', 'zstandard'];
1718

1819
// File extensions
1920
yield ['.gz', 'gzip'];
2021
yield ['.bz2', 'bzip2'];
2122
yield ['.br', 'brotli'];
23+
yield ['.zstd', 'zstandard'];
2224

2325
// HTTP Content-Encoding aliases
2426
yield ['br', 'brotli'];
27+
yield ['zstd', 'zstandard'];
2528
}
2629

2730
/** @return iterable */
@@ -38,7 +41,7 @@ public function enumerating_included_algorithms() {
3841
foreach (Compression::algorithms() as $name => $algorithm) {
3942
$names[]= $name;
4043
}
41-
Assert::equals(['gzip', 'bzip2', 'brotli'], $names);
44+
Assert::equals(['gzip', 'bzip2', 'brotli', 'zstandard'], $names);
4245
}
4346

4447
#[Test]
@@ -61,17 +64,17 @@ public function algorithms_named($name, $expected) {
6164
Assert::equals($expected, Compression::algorithms()->named($name)->name());
6265
}
6366

64-
#[Test, Values([['gzip', 'zlib'], ['bzip2', 'bzip2'], ['brotli', 'brotli']])]
67+
#[Test, Values([['gzip', 'zlib'], ['bzip2', 'bzip2'], ['brotli', 'brotli'], ['zstandard', 'zstd']])]
6568
public function supported($compression, $extension) {
6669
Assert::equals(extension_loaded($extension), Compression::algorithms()->named($compression)->supported());
6770
}
6871

69-
#[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br']])]
72+
#[Test, Values([['gzip', 'gzip'], ['bzip2', 'bzip2'], ['brotli', 'br'], ['zstandard', 'zstd']])]
7073
public function token($compression, $expected) {
7174
Assert::equals($expected, Compression::algorithms()->named($compression)->token());
7275
}
7376

74-
#[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br']])]
77+
#[Test, Values([['gzip', '.gz'], ['bzip2', '.bz2'], ['brotli', '.br'], ['zstandard', '.zstd']])]
7578
public function extension($compression, $expected) {
7679
Assert::equals($expected, Compression::algorithms()->named($compression)->extension());
7780
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php namespace io\streams\compress\unittest;
2+
3+
use io\IOException;
4+
use io\streams\MemoryInputStream;
5+
use io\streams\compress\ZStandardInputStream;
6+
use test\verify\Runtime;
7+
use test\{Assert, Test, Values};
8+
9+
#[Runtime(extensions: ['zstd'])]
10+
class ZStandardInputStreamTest {
11+
12+
/** @return iterable */
13+
private function compressable() {
14+
foreach ([ZSTD_COMPRESS_LEVEL_MIN, ZSTD_COMPRESS_LEVEL_DEFAULT, ZSTD_COMPRESS_LEVEL_MAX] as $level) {
15+
yield [$level, ''];
16+
yield [$level, 'Test'];
17+
yield [$level, "GIF89a\x14\x12\x77..."];
18+
}
19+
}
20+
21+
#[Test]
22+
public function can_create() {
23+
new ZStandardInputStream(new MemoryInputStream(''));
24+
}
25+
26+
#[Test]
27+
public function read_plain() {
28+
$in= new ZStandardInputStream(new MemoryInputStream('Test'));
29+
Assert::throws(IOException::class, function() use($in) {
30+
$in->read();
31+
});
32+
$in->close();
33+
}
34+
35+
#[Test, Values(from: 'compressable')]
36+
public function read_compressed($level, $bytes) {
37+
$in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes, $level)));
38+
$read= $in->read();
39+
$rest= $in->available();
40+
$in->close();
41+
42+
Assert::equals($bytes, $read);
43+
Assert::equals(0, $rest);
44+
}
45+
46+
#[Test, Values([1, 8192, 16384])]
47+
public function read_all($length) {
48+
$bytes= random_bytes($length);
49+
$in= new ZStandardInputStream(new MemoryInputStream(zstd_compress($bytes)));
50+
51+
$read= '';
52+
while ($in->available()) {
53+
$read.= $in->read();
54+
}
55+
$in->close();
56+
57+
Assert::equals($bytes, $read);
58+
}
59+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php namespace io\streams\compress\unittest;
2+
3+
use io\streams\MemoryOutputStream;
4+
use io\streams\compress\ZStandardOutputStream;
5+
use lang\IllegalArgumentException;
6+
use test\verify\Runtime;
7+
use test\{Assert, Expect, Test, Values};
8+
9+
#[Runtime(extensions: ['zstd'])]
10+
class ZStandardOutputStreamTest {
11+
12+
#[Test]
13+
public function can_create() {
14+
new ZStandardOutputStream(new MemoryOutputStream());
15+
}
16+
17+
#[Test, Expect(IllegalArgumentException::class)]
18+
public function using_invalid_compression_level() {
19+
new ZStandardOutputStream(new MemoryOutputStream(), -1);
20+
}
21+
22+
#[Test, Values([1, 3, 22])]
23+
public function write($level) {
24+
$out= new MemoryOutputStream();
25+
26+
$fixture= new ZStandardOutputStream($out, $level);
27+
$fixture->write('Hello');
28+
$fixture->write(' ');
29+
$fixture->write('World');
30+
$fixture->close();
31+
32+
Assert::equals('Hello World', zstd_uncompress($out->bytes()));
33+
}
34+
}

0 commit comments

Comments
 (0)