jxl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +93 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +90 -0
  5. data/Rakefile +31 -0
  6. data/docs/adr/0001-buffer-representation.md +12 -0
  7. data/docs/bench_history.md +11 -0
  8. data/docs/spec_map.md +16 -0
  9. data/exe/cjxl +21 -0
  10. data/exe/djxl +66 -0
  11. data/exe/jxlinfo +31 -0
  12. data/lib/jxl/basic_info.rb +16 -0
  13. data/lib/jxl/bit/field.rb +126 -0
  14. data/lib/jxl/bit/reader.rb +78 -0
  15. data/lib/jxl/bit/writer.rb +46 -0
  16. data/lib/jxl/codestream/reader.rb +68 -0
  17. data/lib/jxl/color/gamut.rb +80 -0
  18. data/lib/jxl/color/icc_profile.rb +243 -0
  19. data/lib/jxl/color/opsin.rb +28 -0
  20. data/lib/jxl/color/transfer.rb +108 -0
  21. data/lib/jxl/color/ycbcr.rb +63 -0
  22. data/lib/jxl/container/box.rb +7 -0
  23. data/lib/jxl/container/parser.rb +134 -0
  24. data/lib/jxl/container/signature.rb +19 -0
  25. data/lib/jxl/dct.rb +81 -0
  26. data/lib/jxl/decoder.rb +85 -0
  27. data/lib/jxl/encoder.rb +180 -0
  28. data/lib/jxl/entropy/ans_distribution.rb +212 -0
  29. data/lib/jxl/entropy/decoder.rb +226 -0
  30. data/lib/jxl/entropy/encoder.rb +69 -0
  31. data/lib/jxl/entropy/hybrid_uint.rb +55 -0
  32. data/lib/jxl/entropy/icc_stream.rb +293 -0
  33. data/lib/jxl/entropy/permutation.rb +64 -0
  34. data/lib/jxl/entropy/prefix_code.rb +177 -0
  35. data/lib/jxl/errors.rb +12 -0
  36. data/lib/jxl/features/noise.rb +140 -0
  37. data/lib/jxl/features/patches.rb +147 -0
  38. data/lib/jxl/features/splines.rb +206 -0
  39. data/lib/jxl/features/spot_colour.rb +29 -0
  40. data/lib/jxl/features/upsampling.rb +99 -0
  41. data/lib/jxl/features/upsampling_weights.bin +0 -0
  42. data/lib/jxl/filter/epf.rb +119 -0
  43. data/lib/jxl/filter/gaborish.rb +36 -0
  44. data/lib/jxl/frame/toc.rb +31 -0
  45. data/lib/jxl/headers/bit_depth.rb +32 -0
  46. data/lib/jxl/headers/colour_encoding.rb +81 -0
  47. data/lib/jxl/headers/custom_transform.rb +46 -0
  48. data/lib/jxl/headers/frame_header.rb +251 -0
  49. data/lib/jxl/headers/image_metadata.rb +128 -0
  50. data/lib/jxl/headers/size_header.rb +29 -0
  51. data/lib/jxl/image.rb +63 -0
  52. data/lib/jxl/io/npy.rb +82 -0
  53. data/lib/jxl/io/pam.rb +14 -0
  54. data/lib/jxl/io/pfm.rb +21 -0
  55. data/lib/jxl/io/pgx.rb +22 -0
  56. data/lib/jxl/io/png.rb +132 -0
  57. data/lib/jxl/io/ppm.rb +63 -0
  58. data/lib/jxl/modular/decoder.rb +371 -0
  59. data/lib/jxl/modular/group_header.rb +82 -0
  60. data/lib/jxl/modular/ma_tree.rb +95 -0
  61. data/lib/jxl/modular/predictor.rb +107 -0
  62. data/lib/jxl/modular/stream.rb +49 -0
  63. data/lib/jxl/modular/transform.rb +235 -0
  64. data/lib/jxl/modular/weighted.rb +85 -0
  65. data/lib/jxl/num.rb +37 -0
  66. data/lib/jxl/plane.rb +35 -0
  67. data/lib/jxl/render/blender.rb +61 -0
  68. data/lib/jxl/render/orientation.rb +42 -0
  69. data/lib/jxl/trace.rb +36 -0
  70. data/lib/jxl/vardct/ac_strategy.rb +65 -0
  71. data/lib/jxl/vardct/afv_basis.bin +0 -0
  72. data/lib/jxl/vardct/block_context_map.rb +63 -0
  73. data/lib/jxl/vardct/chroma_from_luma.rb +27 -0
  74. data/lib/jxl/vardct/coeff_order.rb +35 -0
  75. data/lib/jxl/vardct/decoder.rb +136 -0
  76. data/lib/jxl/vardct/default_dequant.bin.z +0 -0
  77. data/lib/jxl/vardct/dequant.rb +284 -0
  78. data/lib/jxl/vardct/hf_global.rb +30 -0
  79. data/lib/jxl/vardct/lf_global.rb +43 -0
  80. data/lib/jxl/vardct/lf_group.rb +227 -0
  81. data/lib/jxl/vardct/pass_group.rb +153 -0
  82. data/lib/jxl/vardct/quantizer.rb +27 -0
  83. data/lib/jxl/vardct/reconstructor.rb +294 -0
  84. data/lib/jxl/version.rb +5 -0
  85. data/lib/jxl.rb +216 -0
  86. data/tools/bench.rb +20 -0
  87. data/tools/compare_trace.rb +42 -0
  88. data/tools/conformance_report.rb +23 -0
  89. data/tools/fuzz.rb +51 -0
  90. data/tools/gen_afv_basis.rb +12 -0
  91. data/tools/gen_quant_tables.rb +11 -0
  92. data/tools/gen_upsampling_weights.rb +13 -0
  93. data/tools/libjxl_trace.patch +30 -0
  94. metadata +135 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fac5dc523300bacccf3012d8333be15c4aeb0b78fca35584851c63bdaf6e405e
4
+ data.tar.gz: 69c4b42c0b664e2971f2664e07ba1b762798918aa265a4193cd2559fab60b45d
5
+ SHA512:
6
+ metadata.gz: 183e914771ed5c01c309d205bf16882b8b201382b3f2ac869ba2c66a6550d7c7e7bc12489c50c79f0123fec2247d8c9a33317050a4761dcc84f2de3af64e9525
7
+ data.tar.gz: 7d212e9bff85928366f697921c27bca209bf58563a2f50a08bf071eec21b01ebe79488434881c5ef6445329cb22e07a097e9834b4e0adaa1d44f391406d88f2c
data/.rubocop.yml ADDED
@@ -0,0 +1,93 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
4
+ TargetRubyVersion: 3.2
5
+
6
+ Gemspec/RequireMFA:
7
+ Enabled: true
8
+
9
+ Metrics/BlockLength:
10
+ Exclude:
11
+ - "spec/**/*"
12
+
13
+ Metrics/MethodLength:
14
+ Max: 50
15
+ Exclude:
16
+ - "lib/jxl/modular/decoder.rb"
17
+
18
+ Metrics/ClassLength:
19
+ Max: 150
20
+ Exclude:
21
+ - "lib/jxl/entropy/decoder.rb"
22
+
23
+ Metrics/ModuleLength:
24
+ Max: 150
25
+ Exclude:
26
+ - "lib/jxl/headers/frame_header.rb"
27
+ - "lib/jxl/modular/decoder.rb"
28
+ - "lib/jxl/modular/transform.rb"
29
+ - "lib/jxl/vardct/*.rb"
30
+
31
+ Metrics/AbcSize:
32
+ Max: 30
33
+ Exclude:
34
+ - "lib/jxl/codestream/reader.rb"
35
+ - "lib/jxl/container/parser.rb"
36
+ - "lib/jxl/headers/frame_header.rb"
37
+ - "lib/jxl/modular/decoder.rb"
38
+ - "lib/jxl/modular/predictor.rb"
39
+ - "lib/jxl/modular/transform.rb"
40
+ - "lib/jxl/vardct/*.rb"
41
+ - "lib/jxl/color/*.rb"
42
+ - "lib/jxl/filter/*.rb"
43
+ - "lib/jxl/features/*.rb"
44
+ - "lib/jxl/render/*.rb"
45
+
46
+ Metrics/CyclomaticComplexity:
47
+ Exclude:
48
+ - "lib/jxl/color/*.rb"
49
+ - "lib/jxl/features/*.rb"
50
+ - "lib/jxl/render/*.rb"
51
+ - "lib/jxl/container/parser.rb"
52
+ - "lib/jxl/headers/frame_header.rb"
53
+ - "lib/jxl/modular/decoder.rb"
54
+ - "lib/jxl/modular/predictor.rb"
55
+ - "lib/jxl/modular/transform.rb"
56
+ - "lib/jxl/vardct/*.rb"
57
+
58
+ Metrics/PerceivedComplexity:
59
+ Exclude:
60
+ - "lib/jxl.rb"
61
+ - "lib/jxl/color/*.rb"
62
+ - "lib/jxl/features/*.rb"
63
+ - "lib/jxl/container/parser.rb"
64
+ - "lib/jxl/headers/frame_header.rb"
65
+ - "lib/jxl/modular/decoder.rb"
66
+ - "lib/jxl/modular/predictor.rb"
67
+ - "lib/jxl/modular/transform.rb"
68
+ - "lib/jxl/vardct/*.rb"
69
+
70
+ Metrics/ParameterLists:
71
+ Exclude:
72
+ - "lib/jxl/filter/*.rb"
73
+ - "lib/jxl/features/*.rb"
74
+ - "lib/jxl/render/*.rb"
75
+ - "lib/jxl/modular/decoder.rb"
76
+ - "lib/jxl/modular/predictor.rb"
77
+ - "lib/jxl/modular/stream.rb"
78
+ - "lib/jxl/vardct/*.rb"
79
+
80
+ Style/StringConcatenation:
81
+ Enabled: false
82
+
83
+ Naming/MethodParameterName:
84
+ Enabled: false
85
+
86
+ Naming/PredicateMethod:
87
+ Enabled: false
88
+
89
+ Style/Documentation:
90
+ Enabled: false
91
+
92
+ Style/StringLiterals:
93
+ EnforcedStyle: double_quotes
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Yudai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # jxl
2
+
3
+ `jxl` is a JPEG XL decoder and lossless Modular encoder written in Ruby without native extensions.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ bundle add jxl
9
+ ```
10
+
11
+ Ruby 3.2 or newer is required. YJIT is recommended for large images.
12
+
13
+ ## Decode
14
+
15
+ ```ruby
16
+ require "jxl"
17
+
18
+ image = JXL.decode(File.binread("input.jxl"), max_pixels: 100_000_000)
19
+ File.binwrite("output.png", JXL::IO::PNG.dump(image))
20
+ ```
21
+
22
+ `JXL.decode` accepts `pixel_format`, `color_space`, `desired_scale`, `max_pixels`, `max_memory`, `strict`,
23
+ `apply_orientation`, and `render_spot_colors`. Packed pixel data is available through `Image#to_rgba8`,
24
+ `#to_rgba16`, and `#to_float`; `pixel_format` validates the requested format for API compatibility.
25
+ Output writers support PNG, PPM/PGM, PAM, PFM, grayscale PGX, and multi-frame NPY.
26
+
27
+ For incremental input, use `JXL::Decoder#feed`, drain `#next_event`, and call `#finish` at end of input.
28
+
29
+ ```ruby
30
+ decoder = JXL::Decoder.new.subscribe(:basic_info, :frame, :full_image)
31
+ File.open("input.jxl", "rb") { |file| decoder.feed(chunk) while (chunk = file.read(65_536)) }
32
+ decoder.finish
33
+ loop do
34
+ event = decoder.next_event
35
+ break if event.nil? || event.type == :full_image
36
+ end
37
+ ```
38
+
39
+ The command-line decoder is also available:
40
+
41
+ ```sh
42
+ djxl input.jxl output.png
43
+ ```
44
+
45
+ ## Encode
46
+
47
+ The encoder writes lossless RGB Modular codestreams using RCT6, a Gradient predictor, and prefix entropy coding.
48
+
49
+ ```ruby
50
+ bytes = JXL.encode(image, bits_per_sample: 8)
51
+ File.binwrite("output.jxl", bytes)
52
+ ```
53
+
54
+ The CLI accepts binary PPM/PGM and non-interlaced 8-bit PNG input:
55
+
56
+ ```sh
57
+ cjxl input.png output.jxl
58
+ ```
59
+
60
+ ## Development
61
+
62
+ ```sh
63
+ bundle exec rake
64
+ bundle exec rake fuzz:truncate
65
+ bundle exec rake bench
66
+ ```
67
+
68
+ Set `JXL_CONFORMANCE_CASES` to the conformance suite's `main_level5.txt` to enable full reference comparisons.
69
+ After running the official conformance runner with `--results`, print its measured results with:
70
+
71
+ ```sh
72
+ JXL_CONFORMANCE_RESULTS=results.json bundle exec rake conformance:report
73
+ ```
74
+
75
+ ## Support
76
+
77
+ Version 1.0 decodes Level 5 raw codestreams and `jxlc`/`jxlp` containers, including Modular and VarDCT,
78
+ progressive frames, animation and blending, all transform strategies, restoration filters, image features,
79
+ extra channels, orientation, standardized transfer functions, and embedded ICC profiles. The lossless encoder
80
+ writes RGB Modular codestreams that round-trip through this gem and libjxl.
81
+
82
+ The current upstream Level 5 conformance manifest has 23 cases; all 23 pass the official runner in lax pixel
83
+ mode. JPEG bitstream reconstruction from `jbrd` and Brotli-compressed `brob` metadata are optional next-version
84
+ features. Preview rendering is not exposed, the event decoder buffers the complete compressed image before
85
+ emitting frame pixels, and `strict: false` only replaces non-finite decoded samples; structural corruption still
86
+ raises `JXL::Error`.
87
+
88
+ ## License
89
+
90
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
11
+
12
+ namespace :conformance do
13
+ desc "Print the JPEG XL conformance progress table"
14
+ task :report do
15
+ ruby "tools/conformance_report.rb"
16
+ end
17
+ end
18
+
19
+ namespace :fuzz do
20
+ %i[truncate bitflip box].each do |mode|
21
+ desc "Run the #{mode} decoder fuzzer"
22
+ task mode do
23
+ ruby "tools/fuzz.rb", mode.to_s
24
+ end
25
+ end
26
+ end
27
+
28
+ desc "Benchmark the configured JPEG XL corpus"
29
+ task :bench do
30
+ ruby "tools/bench.rb"
31
+ end
@@ -0,0 +1,12 @@
1
+ # ADR 0001: Image buffer representation
2
+
3
+ ## Decision
4
+
5
+ Store each image plane in one row-major Ruby `Array`, wrapped by `JXL::Plane`.
6
+ The stride equals the width and no row padding is used.
7
+
8
+ ## Rationale
9
+
10
+ Flat arrays keep neighbour access simple for Modular prediction and avoid the
11
+ pointer chasing of nested arrays. Packed strings save memory but add conversion
12
+ cost and obscure integer and floating-point handling.
@@ -0,0 +1,11 @@
1
+ # Benchmark history
2
+
3
+ Apple Silicon, Ruby 4.0.0 with YJIT. Times are best-effort local measurements.
4
+
5
+ | Date | Case | Seconds | MP/s | Change |
6
+ |---|---|---:|---:|---|
7
+ | 2026-08-28 | alpha_triangles | 12.797 | 0.082 | Baseline |
8
+ | 2026-08-28 | grayscale | 1.258 | 0.032 | Baseline |
9
+ | 2026-08-28 | lz77_flower | 3.312 | 0.061 | Baseline |
10
+ | 2026-08-28 | alpha_triangles | 5.292 | 0.198 | Allocation-free neighbour lookup |
11
+ | 2026-08-28 | grayscale_public_university | 141.070 | 0.033 | Single-pass grayscale restoration filters |
data/docs/spec_map.md ADDED
@@ -0,0 +1,16 @@
1
+ # JPEG XL implementation map
2
+
3
+ | Area | Implementation |
4
+ |---|---|
5
+ | Container and boxes | `lib/jxl/container` |
6
+ | Headers and TOC | `lib/jxl/headers`, `lib/jxl/frame/toc.rb` |
7
+ | Prefix, ANS, HybridUint, LZ77 | `lib/jxl/entropy` |
8
+ | Modular and transforms | `lib/jxl/modular` |
9
+ | VarDCT and all transform strategies | `lib/jxl/vardct`, `lib/jxl/dct.rb` |
10
+ | Restoration and image features | `lib/jxl/filter`, `lib/jxl/features` |
11
+ | Colour and ICC | `lib/jxl/color` |
12
+ | Animation, blending, orientation | `lib/jxl/render`, `lib/jxl.rb` |
13
+ | Lossless encoder | `lib/jxl/encoder.rb`, `lib/jxl/entropy/encoder.rb` |
14
+ | Streaming API | `lib/jxl/decoder.rb` |
15
+
16
+ JPEG bitstream reconstruction from `jbrd` is outside the initial codec scope.
data/exe/cjxl ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "jxl"
6
+
7
+ options = { bits_per_sample: 8 }
8
+ OptionParser.new do |parser|
9
+ parser.banner = "usage: cjxl INPUT OUTPUT [options]"
10
+ parser.on("--bits-per-sample N", Integer) { options[:bits_per_sample] = _1 }
11
+ end.parse!
12
+ abort "usage: cjxl INPUT OUTPUT [options]" unless ARGV.length == 2
13
+
14
+ input, output = ARGV
15
+ data = File.binread(input)
16
+ image = case File.extname(input).downcase
17
+ when ".ppm", ".pgm", ".pnm" then JXL::IO::PPM.load(data)
18
+ when ".png" then JXL::IO::PNG.load(data)
19
+ else abort "unsupported input extension: #{File.extname(input)}"
20
+ end
21
+ File.binwrite(output, JXL.encode(image, **options))
data/exe/djxl ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require "jxl"
7
+
8
+ options = { frame: nil, max_pixels: 100_000_000, render_spot_colors: true }
9
+ OptionParser.new do |parser|
10
+ parser.banner = "usage: djxl INPUT OUTPUT [options]"
11
+ parser.on("--frame N", Integer) { options[:frame] = _1 }
12
+ parser.on("--max-pixels N", Integer) { options[:max_pixels] = _1 }
13
+ parser.on("--icc_out FILE", "--icc-out FILE") { options[:icc_out] = _1 }
14
+ parser.on("--orig_icc_out FILE", "--orig-icc-out FILE") { options[:orig_icc_out] = _1 }
15
+ parser.on("--metadata_out FILE", "--metadata-out FILE") { options[:metadata_out] = _1 }
16
+ parser.on("--norender_spotcolors") { options[:render_spot_colors] = false }
17
+ end.parse!
18
+ abort "usage: djxl INPUT OUTPUT [options]" unless ARGV.length == 2
19
+
20
+ input, output = ARGV
21
+ data = File.binread(input)
22
+ image = JXL.decode(data, max_pixels: options[:max_pixels], render_spot_colors: options[:render_spot_colors])
23
+ if options[:frame]
24
+ frame = image.frames.fetch(options[:frame])
25
+ image = image.with(channels: frame.planes, frames: [frame])
26
+ end
27
+
28
+ File.binwrite(options[:icc_out], image.icc_profile) if options[:icc_out]
29
+ if options[:orig_icc_out]
30
+ original = JXL::Codestream::Reader.new(data, max_pixels: options[:max_pixels]).read_headers.icc_profile
31
+ File.binwrite(options[:orig_icc_out], original || image.icc_profile)
32
+ end
33
+ if options[:metadata_out]
34
+ metadata = image.metadata
35
+ depths = [metadata.bit_depth] + metadata.extra_channels.map(&:bit_depth)
36
+ extra_types = %w[Alpha Depth SpotColor SelectionMask Black CFA Thermal Optional]
37
+ frames = image.frames.map do |frame|
38
+ values = { name: frame.name }
39
+ if metadata.animation
40
+ values[:duration] = frame.duration.fdiv(metadata.animation.ticks_numerator) *
41
+ metadata.animation.ticks_denominator
42
+ end
43
+ values
44
+ end
45
+ descriptor = {
46
+ frames:,
47
+ intensity_target: metadata.tone_mapping.intensity_target,
48
+ min_nits: metadata.tone_mapping.min_nits,
49
+ relative_to_max_display: metadata.tone_mapping.relative_to_max_display ? 1 : 0,
50
+ linear_below: metadata.tone_mapping.linear_below,
51
+ extra_channel_type: metadata.extra_channels.map { |channel| extra_types.fetch(channel.type, "Unknown") },
52
+ bits_per_sample: depths.map(&:bits_per_sample),
53
+ exp_bits_per_sample: depths.map(&:exponent_bits)
54
+ }
55
+ File.write(options[:metadata_out], JSON.generate(descriptor))
56
+ end
57
+
58
+ case File.extname(output).downcase
59
+ when ".ppm", ".pgm" then File.binwrite(output, JXL::IO::PPM.dump(image))
60
+ when ".pam" then File.binwrite(output, JXL::IO::PAM.dump(image))
61
+ when ".pfm" then File.binwrite(output, JXL::IO::PFM.dump(image))
62
+ when ".pgx" then File.binwrite(output, JXL::IO::PGX.dump(image))
63
+ when ".png" then File.binwrite(output, JXL::IO::PNG.dump(image))
64
+ when ".npy" then JXL::IO::NPY.dump_image(output, image)
65
+ else abort "unsupported output extension: #{File.extname(output)}"
66
+ end
data/exe/jxlinfo ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "jxl"
6
+
7
+ verbose = false
8
+ OptionParser.new do |options|
9
+ options.banner = "usage: jxlinfo [--verbose] FILE"
10
+ options.on("-v", "--verbose") { verbose = true }
11
+ end.parse!
12
+ abort "missing JPEG XL file" unless ARGV.one?
13
+
14
+ info = JXL.info(File.binread(ARGV.first), max_pixels: 1_000_000_000)
15
+ metadata = info.metadata
16
+ depth = metadata.bit_depth
17
+ kind = metadata.animation ? "animation" : "image"
18
+ sample = depth.floating_point ? "#{depth.bits_per_sample}-bit float" : "#{depth.bits_per_sample}-bit"
19
+ channels = metadata.colour_encoding.colour_space == 1 ? "Grayscale" : "RGB"
20
+ channels += metadata.extra_channels.any? { |channel| channel.type.zero? } ? "+Alpha" : ""
21
+
22
+ puts "JPEG XL file format container (ISO/IEC 18181-2)" if info.container.each_box.any?
23
+ puts "JPEG bitstream reconstruction data available" if info.container.metadata[:jpeg_reconstruction]
24
+ puts "JPEG XL #{kind}, #{info.width}x#{info.height}, (possibly) lossless, #{sample} #{channels}"
25
+
26
+ if metadata.colour_encoding.want_icc
27
+ puts "ICC profile present" if verbose
28
+ else
29
+ colour_spaces = %w[RGB Grayscale XYB Unknown]
30
+ puts "Color space: #{colour_spaces.fetch(metadata.colour_encoding.colour_space, 'Unknown')}"
31
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ BasicInfo = Data.define(:width, :height, :metadata, :container, :bits_consumed) do
5
+ def self.read(data, max_pixels: 100_000_000)
6
+ container = Container::Parser.new(data, ignore_brob: true)
7
+ stream = container.codestream
8
+ raise SignatureError, "invalid codestream signature" unless stream.start_with?(Container::Signature::CODESTREAM)
9
+
10
+ reader = Bit::Reader.new(stream, 2)
11
+ size = Headers::SizeHeader.read(reader, max_pixels:)
12
+ metadata = Headers::ImageMetadata.read(reader, max_pixels:)
13
+ new(width: size.width, height: size.height, metadata:, container:, bits_consumed: reader.bits_read)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module Bit
5
+ module Field
6
+ Distribution = Data.define(:bits, :offset, :value)
7
+
8
+ module_function
9
+
10
+ def val(value) = Distribution.new(bits: nil, offset: nil, value:)
11
+ def bits(count) = bits_offset(count, 0)
12
+ def bits_offset(count, offset) = Distribution.new(bits: count, offset:, value: nil)
13
+
14
+ def u32(reader, *distributions)
15
+ raise ArgumentError, "U32 requires four distributions" unless distributions.length == 4
16
+
17
+ distribution = distributions.fetch(reader.read(2))
18
+ return distribution.value unless distribution.value.nil?
19
+
20
+ reader.read(distribution.bits) + distribution.offset
21
+ end
22
+
23
+ def write_u32(writer, value, *distributions)
24
+ raise ArgumentError, "U32 requires four distributions" unless distributions.length == 4
25
+
26
+ selector = distributions.index do |distribution|
27
+ distribution.value == value || (distribution.bits && value >= distribution.offset &&
28
+ (value - distribution.offset).bit_length <= distribution.bits)
29
+ end
30
+ raise ArgumentError, "value is not representable by U32" unless selector
31
+
32
+ distribution = distributions[selector]
33
+ writer.write(2, selector)
34
+ writer.write(distribution.bits, value - distribution.offset) if distribution.bits
35
+ writer
36
+ end
37
+
38
+ def u64(reader)
39
+ case reader.read(2)
40
+ when 0 then 0
41
+ when 1 then reader.read(4) + 1
42
+ when 2 then reader.read(8) + 17
43
+ else read_large_u64(reader)
44
+ end
45
+ end
46
+
47
+ def write_u64(writer, value)
48
+ case value
49
+ when 0 then writer.write(2, 0)
50
+ when 1..16 then writer.write(2, 1).write(4, value - 1)
51
+ when 17..272 then writer.write(2, 2).write(8, value - 17)
52
+ else write_large_u64(writer, value)
53
+ end
54
+ end
55
+
56
+ def write_large_u64(writer, value)
57
+ raise ArgumentError, "U64 is out of range" unless value.between?(0, (1 << 64) - 1)
58
+
59
+ writer.write(2, 3).write(12, value & 0xFFF)
60
+ shift = 12
61
+ loop do
62
+ bits = shift == 60 ? 4 : 8
63
+ more = (value >> shift).positive?
64
+ writer.write(1, more ? 1 : 0)
65
+ break unless more
66
+
67
+ writer.write(bits, (value >> shift) & ((1 << bits) - 1))
68
+ break if shift == 60
69
+
70
+ shift += 8
71
+ end
72
+ writer
73
+ end
74
+ private_class_method :write_large_u64
75
+
76
+ def f16(reader)
77
+ bits = reader.read(16)
78
+ sign = bits[15].zero? ? 1.0 : -1.0
79
+ exponent = (bits >> 10) & 0x1F
80
+ mantissa = bits & 0x3FF
81
+ raise FormatError, "F16 infinity or NaN" if exponent == 0x1F
82
+
83
+ magnitude = if exponent.zero?
84
+ mantissa * (2.0**-24)
85
+ else
86
+ (1024 + mantissa) * (2.0**(exponent - 25))
87
+ end
88
+ sign * magnitude
89
+ end
90
+
91
+ def enum(reader) = u32(reader, val(0), val(1), bits_offset(4, 2), bits_offset(6, 18))
92
+ def read_bool(reader) = !reader.read(1).zero?
93
+
94
+ def string(reader)
95
+ length = u32(reader, val(0), bits(4), bits_offset(5, 16), bits_offset(10, 48))
96
+ Array.new(length) { reader.read(8) }.pack("C*").force_encoding(Encoding::UTF_8)
97
+ end
98
+
99
+ def skip_extensions(reader)
100
+ extensions = u64(reader)
101
+ lengths = []
102
+ bit = 0
103
+ until extensions.zero?
104
+ lengths << u64(reader) unless extensions[bit].zero?
105
+ extensions &= ~(1 << bit)
106
+ bit += 1
107
+ end
108
+ reader.skip(lengths.sum)
109
+ end
110
+
111
+ def read_large_u64(reader)
112
+ value = reader.read(12)
113
+ shift = 12
114
+ until reader.read(1).zero?
115
+ bit_count = shift == 60 ? 4 : 8
116
+ value |= reader.read(bit_count) << shift
117
+ break if shift == 60
118
+
119
+ shift += 8
120
+ end
121
+ value
122
+ end
123
+ private_class_method :read_large_u64
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module Bit
5
+ class Reader
6
+ MAX_BITS = 56
7
+
8
+ attr_reader :bits_read
9
+
10
+ def initialize(data, position = 0)
11
+ unless position.is_a?(Integer) && position.between?(0, data.bytesize)
12
+ raise ArgumentError, "invalid byte position"
13
+ end
14
+
15
+ @data = data.b
16
+ @byte_position = position
17
+ @buffer = 0
18
+ @buffered_bits = 0
19
+ @bits_read = 0
20
+ @overrun = false
21
+ end
22
+
23
+ def read(bits)
24
+ raise ArgumentError, "bit count must be between 0 and #{MAX_BITS}" unless bits.between?(0, MAX_BITS)
25
+
26
+ refill while @buffered_bits < bits
27
+ value = @buffer & ((1 << bits) - 1)
28
+ @buffer >>= bits
29
+ @buffered_bits -= bits
30
+ @bits_read += bits
31
+ Trace.br(bits, value, @bits_read)
32
+ value
33
+ end
34
+
35
+ def peek(bits)
36
+ raise ArgumentError, "bit count must be between 0 and #{MAX_BITS}" unless bits.between?(0, MAX_BITS)
37
+
38
+ refill while @buffered_bits < bits
39
+ @buffer & ((1 << bits) - 1)
40
+ end
41
+
42
+ def skip(bits)
43
+ while bits > MAX_BITS
44
+ read(MAX_BITS)
45
+ bits -= MAX_BITS
46
+ end
47
+ read(bits)
48
+ nil
49
+ end
50
+
51
+ def zero_pad_to_byte
52
+ padding = (-@bits_read) & 7
53
+ raise CorruptError, "non-zero padding bits" unless read(padding).zero?
54
+ end
55
+
56
+ def byte_position = @bits_read.div(8)
57
+ def overrun? = @overrun
58
+
59
+ def close!
60
+ raise TruncatedError, "bitstream ended early" if overrun?
61
+
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ def refill
68
+ if @byte_position < @data.bytesize
69
+ @buffer |= @data.getbyte(@byte_position) << @buffered_bits
70
+ @byte_position += 1
71
+ else
72
+ @overrun = true
73
+ end
74
+ @buffered_bits += 8
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JXL
4
+ module Bit
5
+ class Writer
6
+ attr_reader :bits_written
7
+
8
+ def initialize
9
+ @bytes = +"".b
10
+ @buffer = 0
11
+ @count = 0
12
+ @bits_written = 0
13
+ end
14
+
15
+ def write(count, value)
16
+ raise ArgumentError, "invalid bit count" unless count.between?(0, 64)
17
+ raise ArgumentError, "value does not fit" if value.negative? || value.bit_length > count
18
+
19
+ @buffer |= value << @count
20
+ @count += count
21
+ @bits_written += count
22
+ flush_bytes
23
+ self
24
+ end
25
+
26
+ def align
27
+ write((-@count) & 7, 0)
28
+ end
29
+
30
+ def bytes
31
+ align
32
+ @bytes.dup
33
+ end
34
+
35
+ private
36
+
37
+ def flush_bytes
38
+ while @count >= 8
39
+ @bytes << (@buffer & 0xFF)
40
+ @buffer >>= 8
41
+ @count -= 8
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end