From 879469dd8741c18f185fec20ff1d3417d055e118 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Sat, 19 Oct 2019 00:41:30 +0800 Subject: [PATCH] First Release --- .editorconfig | 9 +++ .gitignore | 9 +++ .travis.yml | 6 ++ LICENSE | 21 ++++++ README.md | 74 +++++++++++++++++++ shard.yml | 11 +++ spec/brotli_spec.cr | 99 +++++++++++++++++++++++++ spec/spec_helper.cr | 13 ++++ src/brotli.cr | 42 +++++++++++ src/brotli/lib.cr | 118 ++++++++++++++++++++++++++++++ src/brotli/reader.cr | 145 +++++++++++++++++++++++++++++++++++++ src/brotli/writer.cr | 168 +++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 715 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/brotli_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 src/brotli.cr create mode 100644 src/brotli/lib.cr create mode 100644 src/brotli/reader.cr create mode 100644 src/brotli/writer.cr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbd4a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..765f0e9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: crystal + +# Uncomment the following if you'd like Travis to run specs and check code formatting +# script: +# - crystal spec +# - crystal tool format --check diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c5623f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Ali Naqvi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..142124c --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Crystal Brotli + +Crystal bindings to the [Brotli](https://github.com/google/brotli) compression library. + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + brotli: + github: naqvis/brotli.cr + ``` + +2. Run `shards install` + +## Usage + +```crystal +require "brotli" +``` + +`brotli` shard provides both `Brotli::Reader` and `Brotli::Writer` , as well as `Brotli#decode` and `Brotli#encode` methods for quick usage. + +Refer to `specs` for sample usage. + +## Example: decompress an brotli file +# +```crystal +require "brotli" + +string = File.open("file.br") do |file| + Brotli::Reader.open(file) do |brotli| + brotli.gets_to_end + end +end +pp string +``` + +## Example: compress to brotli compression format +# +```crystal +require "brotli" + +File.write("file.txt", "abcd") + +File.open("./file.txt", "r") do |input_file| + File.open("./file.br", "w") do |output_file| + Brotli::Writer.open(output_file) do |brotli| + IO.copy(input_file, brotli) + end + end +end +``` + +## Development + +To run all tests: + +``` +crystal spec +``` + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [Ali Naqvi](https://github.com/naqvis) - creator and maintainer diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..0cc37e7 --- /dev/null +++ b/shard.yml @@ -0,0 +1,11 @@ +name: brotli +version: 0.1.0 + +authors: + - Ali Naqvi +description: | + Crystal bindings to the Brotli compression library. + +crystal: 0.31.1 + +license: MIT diff --git a/spec/brotli_spec.cr b/spec/brotli_spec.cr new file mode 100644 index 0000000..7b86d91 --- /dev/null +++ b/spec/brotli_spec.cr @@ -0,0 +1,99 @@ +require "./spec_helper" + +describe Brotli do + # TODO: Write tests + + it "Test Encode No Write" do + buf = IO::Memory.new + br = Brotli::Writer.new(buf) + br.close + + # check write after close + expect_raises(IO::Error) do + br.write "hi".to_slice + end + end + + it "Test Encode Empty Write" do + buf = IO::Memory.new + Brotli::Writer.open(buf, options: Brotli::WriterOptions.new(quality: 5_u32)) do |br| + br.write Bytes.empty + end + end + + it "Test Writer" do + # Test basic encoder usage + input = "

Hello world

" + buf = IO::Memory.new + inp = IO::Memory.new(input) + enc = Brotli::Writer.new(buf, options: Brotli::WriterOptions.new(quality: 1_u32)) + IO.copy inp, enc + enc.close + buf.rewind + check_compressed_data buf.to_slice, input.to_slice + inp.close + buf.close + end + + it "Test Encoder Stream" do + # Test that output is streamed. + # Adjust window size to ensure the encoder outputs at least enough bytes + # to fill the window + lgwin = 16 + win_size = Math.pw2ceil(lgwin) + input = Bytes.new(8 * win_size) + Random.new.random_bytes(input) + half_input = input[0, input.size//2] + buf = IO::Memory.new + Brotli::Writer.open(buf, options: Brotli::WriterOptions.new(lgwin: lgwin.to_u32)) do |br| + br.write half_input + end + # We've fed more data than the sliding window size. Check that some + # compressed data has been output + fail "Output length is 0 after #{half_input.size} bytes written" if buf.size == 0 + + check_compressed_data(buf.to_slice, half_input) + end + + it "Test Encoder Large Input" do + input = Bytes.new(1000000) + Random.new.random_bytes(input) + buf = IO::Memory.new + Brotli::Writer.open(buf, options: Brotli::WriterOptions.new(quality: 5_u32)) do |br| + br.write input + end + buf.rewind + check_compressed_data(buf.to_slice, input) + end + + it "Test Encoder Flush" do + input = Bytes.new(1000) + Random.new.random_bytes(input) + buf = IO::Memory.new + Brotli::Writer.open(buf, options: Brotli::WriterOptions.new(quality: 5_u32)) do |br| + br.write input + br.flush + fail "0 bytes written after flush" if buf.size == 0 + end + buf.rewind + check_compressed_data(buf.to_slice, input) + end + + it "Test Reader" do + data = "hello crystal!" * 10000 + compressed = Brotli.encode(data, Brotli::WriterOptions.new(quality: 5_u32)) + uncompressed = Brotli.decode(compressed) + + data.to_slice.should eq(uncompressed) + end + + it "Test Decode Trailing Data" do + data = "hello crystal!" * 10000 + compressed = Brotli.encode(data, Brotli::WriterOptions.new(quality: 5_u32)) + corrupt = Bytes.new(compressed.size + 1) + corrupt.copy_from(compressed.to_unsafe, compressed.size) + expect_raises(Brotli::BrotliError, "excessive input") do + Brotli.decode(corrupt) + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..babf84d --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,13 @@ +require "spec" +require "../src/brotli" + +def check_compressed_data(compressed_data : Slice, want : Slice) + uncompressed = Brotli.decode(compressed_data) + if uncompressed != want + fail "Data doesn't uncompress to the original value \n" + + "Length of original: #{want.size}\n" + + "Length of uncompressed: #{uncompressed.size}" + end + + uncompressed.should eq(want) +end diff --git a/src/brotli.cr b/src/brotli.cr new file mode 100644 index 0000000..2a3523b --- /dev/null +++ b/src/brotli.cr @@ -0,0 +1,42 @@ +# `Brotli` Crystal Wrapper +module Brotli + VERSION = "0.1.0" + + class BrotliError < Exception + end + + def self.decode(compressed : Slice) + buf = IO::Memory.new(compressed) + uncompressed = Reader.open(buf) do |br| + br.gets_to_end + end + uncompressed.to_slice + end + + def self.encode(content : String, options : WriterOptions = WriterOptions.default) + encode(content.to_slice, options) + end + + def self.encode(content : Slice, options : WriterOptions = WriterOptions.default) + buf = IO::Memory.new + Brotli::Writer.open(buf) do |br| + br.write content + end + buf.rewind + buf.to_slice + end + + def self.decoder_version_string + version_string LibBrotli.decoder_version + end + + def self.encoder_version_string + version_string LibBrotli.encoder_version + end + + private def self.version_string(v) + sprintf "%d.%d.%d", [v >> 24, (v >> 12) & 0xFFF, v & 0xFFF] + end +end + +require "./brotli/*" diff --git a/src/brotli/lib.cr b/src/brotli/lib.cr new file mode 100644 index 0000000..a4b22ec --- /dev/null +++ b/src/brotli/lib.cr @@ -0,0 +1,118 @@ +module Brotli + @[Link(ldflags: "`command -v pkg-config > /dev/null && pkg-config --libs libbrotlicommon libbrotlidec libbrotlienc 2> /dev/null|| printf %s '--llbrotlicommon --llbrotlidec --llbrotlienc'`")] + + lib LibBrotli + alias Uint8T = UInt8 + alias Uint32T = LibC::UInt + TRUE = 1 + FALSE = 0 + MIN_WINDOW_BITS = 10 + MAX_WINDOW_BITS = 24 + LARGE_MAX_WINDOW_BITS = 30 + MIN_INPUT_BLOCK_BITS = 16 + MAX_INPUT_BLOCK_BITS = 24 + MIN_QUALITY = 0 + MAX_QUALITY = 11 + DEFAULT_QUALITY = 11 + DEFAULT_WINDOW = 22 + + alias DecoderStateStruct = Void + + fun decoder_set_parameter = BrotliDecoderSetParameter(state : DecoderState, param : DecoderParameter, value : Uint32T) : LibC::Int + type DecoderState = Void* + enum DecoderParameter + DecoderParamDisableRingBufferReallocation = 0 + DecoderParamLargeWindow = 1 + end + + fun decoder_create_instance = BrotliDecoderCreateInstance(alloc_func : BrotliAllocFunc, free_func : BrotliFreeFunc, opaque : Void*) : DecoderState + alias BrotliAllocFunc = (Void*, LibC::SizeT -> Void*) + alias BrotliFreeFunc = (Void*, Void* -> Void) + fun decoder_destroy_instance = BrotliDecoderDestroyInstance(state : DecoderState) + fun decoder_decompress = BrotliDecoderDecompress(encoded_size : LibC::SizeT, encoded_buffer : Uint8T*, decoded_size : LibC::SizeT*, decoded_buffer : Uint8T*) : DecoderResult + + enum DecoderResult + DecoderResultError = 0 + DecoderResultSuccess = 1 + DecoderResultNeedsMoreInput = 2 + DecoderResultNeedsMoreOutput = 3 + end + fun decoder_decompress_stream = BrotliDecoderDecompressStream(state : DecoderState, available_in : LibC::SizeT*, next_in : Uint8T**, available_out : LibC::SizeT*, next_out : Uint8T**, total_out : LibC::SizeT*) : DecoderResult + fun decoder_has_more_output = BrotliDecoderHasMoreOutput(state : DecoderState) : LibC::Int + fun decoder_take_output = BrotliDecoderTakeOutput(state : DecoderState, size : LibC::SizeT*) : Uint8T* + fun decoder_is_used = BrotliDecoderIsUsed(state : DecoderState) : LibC::Int + fun decoder_is_finished = BrotliDecoderIsFinished(state : DecoderState) : LibC::Int + fun decoder_get_error_code = BrotliDecoderGetErrorCode(state : DecoderState) : DecoderErrorCode + enum DecoderErrorCode : Int64 + DecoderNoError = 0 + DecoderSuccess = 1 + DecoderNeedsMoreInput = 2 + DecoderNeedsMoreOutput = 3 + DecoderErrorFormatExuberantNibble = -1 + DecoderErrorFormatReserved = -2 + DecoderErrorFormatExuberantMetaNibble = -3 + DecoderErrorFormatSimpleHuffmanAlphabet = -4 + DecoderErrorFormatSimpleHuffmanSame = -5 + DecoderErrorFormatClSpace = -6 + DecoderErrorFormatHuffmanSpace = -7 + DecoderErrorFormatContextMapRepeat = -8 + DecoderErrorFormatBlockLength1 = -9 + DecoderErrorFormatBlockLength2 = -10 + DecoderErrorFormatTransform = -11 + DecoderErrorFormatDictionary = -12 + DecoderErrorFormatWindowBits = -13 + DecoderErrorFormatPadding1 = -14 + DecoderErrorFormatPadding2 = -15 + DecoderErrorFormatDistance = -16 + DecoderErrorDictionaryNotSet = -19 + DecoderErrorInvalidArguments = -20 + DecoderErrorAllocContextModes = -21 + DecoderErrorAllocTreeGroups = -22 + DecoderErrorAllocContextMap = -25 + DecoderErrorAllocRingBuffer1 = -26 + DecoderErrorAllocRingBuffer2 = -27 + DecoderErrorAllocBlockTypeTrees = -30 + DecoderErrorUnreachable = -31 + + def to_s + String.new LibBrotli.decoder_error_string(self) + end + end + fun decoder_error_string = BrotliDecoderErrorString(c : DecoderErrorCode) : LibC::Char* + fun decoder_version = BrotliDecoderVersion : Uint32T + alias EncoderStateStruct = Void + fun encoder_set_parameter = BrotliEncoderSetParameter(state : EncoderState, param : EncoderParameter, value : Uint32T) : LibC::Int + type EncoderState = Void* + enum EncoderParameter + ParamMode = 0 + ParamQuality = 1 + ParamLgwin = 2 + ParamLgblock = 3 + ParamDisableLiteralContextModeling = 4 + ParamSizeHint = 5 + ParamLargeWindow = 6 + ParamNpostfix = 7 + ParamNdirect = 8 + end + fun encoder_create_instance = BrotliEncoderCreateInstance(alloc_func : BrotliAllocFunc, free_func : BrotliFreeFunc, opaque : Void*) : EncoderState + fun encoder_destroy_instance = BrotliEncoderDestroyInstance(state : EncoderState) + fun encoder_max_compressed_size = BrotliEncoderMaxCompressedSize(input_size : LibC::SizeT) : LibC::SizeT + fun encoder_compress = BrotliEncoderCompress(quality : LibC::Int, lgwin : LibC::Int, mode : EncoderMode, input_size : LibC::SizeT, input_buffer : Uint8T*, encoded_size : LibC::SizeT*, encoded_buffer : Uint8T*) : LibC::Int + enum EncoderMode + ModeGeneric = 0 + ModeText = 1 + ModeFont = 2 + end + fun encoder_compress_stream = BrotliEncoderCompressStream(state : EncoderState, op : EncoderOperation, available_in : LibC::SizeT*, next_in : Uint8T**, available_out : LibC::SizeT*, next_out : Uint8T**, total_out : LibC::SizeT*) : LibC::Int + enum EncoderOperation + OperationProcess = 0 + OperationFlush = 1 + OperationFinish = 2 + OperationEmitMetadata = 3 + end + fun encoder_is_finished = BrotliEncoderIsFinished(state : EncoderState) : LibC::Int + fun encoder_has_more_output = BrotliEncoderHasMoreOutput(state : EncoderState) : LibC::Int + fun encoder_take_output = BrotliEncoderTakeOutput(state : EncoderState, size : LibC::SizeT*) : Uint8T* + fun encoder_version = BrotliEncoderVersion : Uint32T + end +end diff --git a/src/brotli/reader.cr b/src/brotli/reader.cr new file mode 100644 index 0000000..37ae8b1 --- /dev/null +++ b/src/brotli/reader.cr @@ -0,0 +1,145 @@ +# A read-only `IO` object to decompress data in the Brotli format. +# +# Instances of this class wrap another IO object. When you read from this instance +# instance, it reads data from the underlying IO, decompresses it, and returns +# it to the caller. +# ## Example: decompress an brotli file +# ```crystal +# require "brotli" + +# string = File.open("file.br") do |file| +# Brotli::Reader.open(file) do |br| +# br.gets_to_end +# end +# end +# pp string +# ``` +class Brotli::Reader < IO + include IO::Buffered + + # If `#sync_close?` is `true`, closing this IO will close the underlying IO. + property? sync_close : Bool + + # Returns `true` if this reader is closed. + getter? closed = false + + @state : LibBrotli::DecoderState + + # buffer size that avoids execessive round-trips between C and Crystal but doesn't waste too much + # memory on buffering. Its arbitrarily chosen to be equal to the constant used in IO::copy + BUF_SIZE = 4096 + + # Creates an instance of XZ::Reader. + def initialize(@io : IO, @sync_close : Bool = false) + @buffer = Bytes.new(BUF_SIZE) + @chunk = Bytes.empty + alloc = LibBrotli::BrotliAllocFunc.new { |_, size| GC.malloc(size) } + free = LibBrotli::BrotliFreeFunc.new { |_, address| GC.free(address) } + @state = LibBrotli.decoder_create_instance(alloc, free, nil) + raise BrotliError.new("Unable to create brotli decoder instance") if @state.nil? + end + + # Creates a new reader from the given *io*, yields it to the given block, + # and closes it at its end. + def self.open(io : IO, sync_close : Bool = false) + reader = new(io, sync_close: sync_close) + yield reader ensure reader.close + end + + # Creates a new reader from the given *filename*. + def self.new(filename : String) + new(::File.new(filename), sync_close: true) + end + + # Creates a new reader from the given *io*, yields it to the given block, + # and closes it at the end. + def self.open(io : IO, sync_close = false) + reader = new(io, sync_close: sync_close) + yield reader ensure reader.close + end + + # Creates a new reader from the given *filename*, yields it to the given block, + # and closes it at the end. + def self.open(filename : String) + reader = new(filename) + yield reader ensure reader.close + end + + # Always raises `IO::Error` because this is a read-only `IO`. + def unbuffered_write(slice : Bytes) + raise IO::Error.new "Can't write to Brotli::Reader" + end + + def unbuffered_read(slice : Bytes) + check_open + + if LibBrotli.decoder_has_more_output(@state) == 0 && @chunk.empty? + m = @io.read(@buffer) + return m if m == 0 + @chunk = @buffer[0, m] + end + + return 0 if slice.empty? + + n = 0 + loop do + in_remaining = @chunk.size.to_u64 + out_remaining = slice.size.to_u64 + + in_ptr = @chunk.to_unsafe + out_ptr = slice.to_unsafe + + result = LibBrotli.decoder_decompress_stream(@state, pointerof(in_remaining), pointerof(in_ptr), pointerof(out_remaining), pointerof(out_ptr), nil) + n = slice.size - out_remaining + consumed = @chunk.size - in_remaining + @chunk = @chunk[consumed..] + + case result + when LibBrotli::DecoderResult::DecoderResultSuccess + raise BrotliError.new("excessive input") unless @chunk.size == 0 + return n + when LibBrotli::DecoderResult::DecoderResultError + raise BrotliError.new LibBrotli.decoder_get_error_code(@state).to_s + when LibBrotli::DecoderResult::DecoderResultNeedsMoreOutput + raise BrotliError.new("Short input buffer") if n == 0 + return n + when LibBrotli::DecoderResult::DecoderResultNeedsMoreInput + end + raise BrotliError.new("invalid state") unless @chunk.size == 0 + + # calling @io.read may block. Don't block if we have data to return + return n if n > 0 + + # Top off the buffer + enc_n = @io.read(@buffer) + return 0 if enc_n == 0 + @chunk = @buffer[0, enc_n] + end + n + end + + def unbuffered_flush + raise IO::Error.new "Can't flush Brotli::Reader" + end + + # Closes this reader. + def unbuffered_close + return if @closed || @state.nil? + @closed = true + + LibBrotli.decoder_destroy_instance(@state) + @io.close if @sync_close + end + + def unbuffered_rewind + check_open + + @io.rewind + initialize(@io, @sync_close) + end + + # :nodoc: + def inspect(io : IO) : Nil + to_s(io) + end +end diff --git a/src/brotli/writer.cr b/src/brotli/writer.cr new file mode 100644 index 0000000..83b3e9f --- /dev/null +++ b/src/brotli/writer.cr @@ -0,0 +1,168 @@ +# A write-only `IO` object to compress data in the Brotli format. +# +# Instances of this class wrap another `IO` object. When you write to this +# instance, it compresses the data and writes it to the underlying `IO`. +# +# NOTE: unless created with a block, `close` must be invoked after all +# data has been written to a `Brotli::Writer` instance. +# +# ### Example: compress a file +# +# ``` +# require "brotli" +# +# File.write("file.txt", "abcd") +# +# File.open("./file.txt", "r") do |input_file| +# File.open("./file.br", "w") do |output_file| +# Brotli::Writer.open(output_file) do |br| +# IO.copy(input_file, br) +# end +# end +# end +# ``` +class Brotli::Writer < IO + # If `#sync_close?` is `true`, closing this IO will close the underlying IO. + property? sync_close : Bool + + def initialize(@output : IO, options : Brotli::WriterOptions = Brotli::WriterOptions.default, @sync_close : Bool = false) + alloc = LibBrotli::BrotliAllocFunc.new { |_, size| GC.malloc(size) } + free = LibBrotli::BrotliFreeFunc.new { |_, address| GC.free(address) } + @state = LibBrotli.encoder_create_instance(alloc, free, nil) + @closed = false + raise BrotliError.new("Unable to create brotli encoder instance") if @state.nil? + configure(options) + end + + # Creates a new writer to the given *filename*. + def self.new(filename : String, options : Brotli::WriterOptions = Brotli::WriterOptions.default) + new(::File.new(filename, "w"), options: options, sync_close: true) + end + + # Creates a new writer to the given *io*, yields it to the given block, + # and closes it at the end. + def self.open(io : IO, options : Brotli::WriterOptions = Brotli::WriterOptions.default, sync_close = false) + writer = new(io, preset: preset, sync_close: sync_close) + yield writer ensure writer.close + end + + # Creates a new writer to the given *filename*, yields it to the given block, + # and closes it at the end. + def self.open(filename : String, options : Brotli::WriterOptions = Brotli::WriterOptions.default) + writer = new(filename, options: options) + yield writer ensure writer.close + end + + # Creates a new writer for the given *io*, yields it to the given block, + # and closes it at its end. + def self.open(io : IO, options : Brotli::WriterOptions = Brotli::WriterOptions.default, sync_close : Bool = false) + writer = new(io, options: options, sync_close: sync_close) + yield writer ensure writer.close + end + + private def configure(options) + return if options.default? + LibBrotli.encoder_set_parameter(@state, LibBrotli::EncoderParameter::ParamMode, options.mode) + LibBrotli.encoder_set_parameter(@state, LibBrotli::EncoderParameter::ParamQuality, options.quality) + LibBrotli.encoder_set_parameter(@state, LibBrotli::EncoderParameter::ParamLgwin, options.lgwin) + end + + # Always raises `IO::Error` because this is a write-only `IO`. + def read(slice : Bytes) + raise IO::Error.new "Can't read from Brotli::Writer" + end + + # See `IO#write`. + def write(slice : Bytes) : Nil + check_open + + return if slice.empty? + write_chunk slice, LibBrotli::EncoderOperation::OperationProcess + end + + # See `IO#flush`. + def flush + return if @closed + + write_chunk Bytes.empty, LibBrotli::EncoderOperation::OperationFlush + end + + # Closes this writer. Must be invoked after all data has been written. + def close + return if @closed || @state.nil? + write_chunk Bytes.empty, LibBrotli::EncoderOperation::OperationFinish + LibBrotli.encoder_destroy_instance(@state) + @closed = true + @output.close if @sync_close + end + + # Returns `true` if this IO is closed. + def closed? + @closed + end + + # :nodoc: + def inspect(io : IO) : Nil + to_s(io) + end + + private def write_chunk(chunk : Slice, op : LibBrotli::EncoderOperation) + raise BrotliError.new("Writer closed") if @closed || @state.nil? + + loop do + size = chunk.size + avail_in = size.to_u64 + avail_out = 0_u64 + ptr_in = chunk.to_unsafe + result = LibBrotli.encoder_compress_stream(@state, op, pointerof(avail_in), pointerof(ptr_in), pointerof(avail_out), nil, nil) + raise BrotliError.new("encode error") if result == 0 + + bytes_consumed = size - avail_in + output = LibBrotli.encoder_take_output(@state, out output_data_size) + has_more = LibBrotli.encoder_has_more_output(@state) == 1 + + chunk = chunk[bytes_consumed..] + if output_data_size != 0 + @output.write output.to_slice(output_data_size) + end + break if chunk.size == 0 && !has_more + end + end +end + +struct Brotli::WriterOptions + # compression mode + property mode : LibBrotli::EncoderMode + # controls the compression speed vs compression density tradeoff. Higher the quality, + # slower the compression. Range is 0 to 11. Defaults to 11 + getter quality : UInt32 + # Base 2 logarithm of the maximum input block size. Range is 10 to 24. Defaults to 22. + getter lgwin : UInt32 + + def initialize(@mode = LibBrotli::EncoderMode::ModeGeneric, @quality = 11_u32, @lgwin = 22_u32) + end + + def quality=(val) + unless 0 <= val <= 11 + raise ArgumentError.new("Invalid quality level: #{val} (must be in 0..11)") + end + @quality = val + end + + def lgwin=(val) + unless 10 <= val <= 24 + raise ArgumentError.new("Invalid lgwin value: #{val} (must be in 10..24)") + end + @quality = val + end + + def self.default + new + end + + def default? + self.mode == LibBrotli::EncoderMode::ModeGeneric && + self.quality == 11 && + self.lgwin == 22 + end +end