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.
- checksums.yaml +7 -0
- data/.rubocop.yml +93 -0
- data/LICENSE.txt +21 -0
- data/README.md +90 -0
- data/Rakefile +31 -0
- data/docs/adr/0001-buffer-representation.md +12 -0
- data/docs/bench_history.md +11 -0
- data/docs/spec_map.md +16 -0
- data/exe/cjxl +21 -0
- data/exe/djxl +66 -0
- data/exe/jxlinfo +31 -0
- data/lib/jxl/basic_info.rb +16 -0
- data/lib/jxl/bit/field.rb +126 -0
- data/lib/jxl/bit/reader.rb +78 -0
- data/lib/jxl/bit/writer.rb +46 -0
- data/lib/jxl/codestream/reader.rb +68 -0
- data/lib/jxl/color/gamut.rb +80 -0
- data/lib/jxl/color/icc_profile.rb +243 -0
- data/lib/jxl/color/opsin.rb +28 -0
- data/lib/jxl/color/transfer.rb +108 -0
- data/lib/jxl/color/ycbcr.rb +63 -0
- data/lib/jxl/container/box.rb +7 -0
- data/lib/jxl/container/parser.rb +134 -0
- data/lib/jxl/container/signature.rb +19 -0
- data/lib/jxl/dct.rb +81 -0
- data/lib/jxl/decoder.rb +85 -0
- data/lib/jxl/encoder.rb +180 -0
- data/lib/jxl/entropy/ans_distribution.rb +212 -0
- data/lib/jxl/entropy/decoder.rb +226 -0
- data/lib/jxl/entropy/encoder.rb +69 -0
- data/lib/jxl/entropy/hybrid_uint.rb +55 -0
- data/lib/jxl/entropy/icc_stream.rb +293 -0
- data/lib/jxl/entropy/permutation.rb +64 -0
- data/lib/jxl/entropy/prefix_code.rb +177 -0
- data/lib/jxl/errors.rb +12 -0
- data/lib/jxl/features/noise.rb +140 -0
- data/lib/jxl/features/patches.rb +147 -0
- data/lib/jxl/features/splines.rb +206 -0
- data/lib/jxl/features/spot_colour.rb +29 -0
- data/lib/jxl/features/upsampling.rb +99 -0
- data/lib/jxl/features/upsampling_weights.bin +0 -0
- data/lib/jxl/filter/epf.rb +119 -0
- data/lib/jxl/filter/gaborish.rb +36 -0
- data/lib/jxl/frame/toc.rb +31 -0
- data/lib/jxl/headers/bit_depth.rb +32 -0
- data/lib/jxl/headers/colour_encoding.rb +81 -0
- data/lib/jxl/headers/custom_transform.rb +46 -0
- data/lib/jxl/headers/frame_header.rb +251 -0
- data/lib/jxl/headers/image_metadata.rb +128 -0
- data/lib/jxl/headers/size_header.rb +29 -0
- data/lib/jxl/image.rb +63 -0
- data/lib/jxl/io/npy.rb +82 -0
- data/lib/jxl/io/pam.rb +14 -0
- data/lib/jxl/io/pfm.rb +21 -0
- data/lib/jxl/io/pgx.rb +22 -0
- data/lib/jxl/io/png.rb +132 -0
- data/lib/jxl/io/ppm.rb +63 -0
- data/lib/jxl/modular/decoder.rb +371 -0
- data/lib/jxl/modular/group_header.rb +82 -0
- data/lib/jxl/modular/ma_tree.rb +95 -0
- data/lib/jxl/modular/predictor.rb +107 -0
- data/lib/jxl/modular/stream.rb +49 -0
- data/lib/jxl/modular/transform.rb +235 -0
- data/lib/jxl/modular/weighted.rb +85 -0
- data/lib/jxl/num.rb +37 -0
- data/lib/jxl/plane.rb +35 -0
- data/lib/jxl/render/blender.rb +61 -0
- data/lib/jxl/render/orientation.rb +42 -0
- data/lib/jxl/trace.rb +36 -0
- data/lib/jxl/vardct/ac_strategy.rb +65 -0
- data/lib/jxl/vardct/afv_basis.bin +0 -0
- data/lib/jxl/vardct/block_context_map.rb +63 -0
- data/lib/jxl/vardct/chroma_from_luma.rb +27 -0
- data/lib/jxl/vardct/coeff_order.rb +35 -0
- data/lib/jxl/vardct/decoder.rb +136 -0
- data/lib/jxl/vardct/default_dequant.bin.z +0 -0
- data/lib/jxl/vardct/dequant.rb +284 -0
- data/lib/jxl/vardct/hf_global.rb +30 -0
- data/lib/jxl/vardct/lf_global.rb +43 -0
- data/lib/jxl/vardct/lf_group.rb +227 -0
- data/lib/jxl/vardct/pass_group.rb +153 -0
- data/lib/jxl/vardct/quantizer.rb +27 -0
- data/lib/jxl/vardct/reconstructor.rb +294 -0
- data/lib/jxl/version.rb +5 -0
- data/lib/jxl.rb +216 -0
- data/tools/bench.rb +20 -0
- data/tools/compare_trace.rb +42 -0
- data/tools/conformance_report.rb +23 -0
- data/tools/fuzz.rb +51 -0
- data/tools/gen_afv_basis.rb +12 -0
- data/tools/gen_quant_tables.rb +11 -0
- data/tools/gen_upsampling_weights.rb +13 -0
- data/tools/libjxl_trace.patch +30 -0
- 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
|