├── .github └── workflows │ ├── pr_checks.yml │ └── release.yml ├── .gitignore ├── LICENCE.md ├── README.md ├── pyproject.toml ├── requirements.txt ├── src ├── jxl-strip.py └── jxl_decode │ ├── __init__.py │ ├── __main__.py │ ├── common.py │ ├── core.py │ ├── jpg.py │ ├── jxl.py │ └── ppm.py └── tests ├── test_cli.py └── test_images ├── 10x10.jxl ├── 727x7.jxl ├── gradient_8x8.jpg ├── gradient_8x8.jxl ├── gradient_8x8.png ├── gradient_8x8.ppm ├── jxl.jxl ├── jxl.png ├── minimal.jxl ├── white_8x8.png └── white_8x8.ppm /.github/workflows/pr_checks.yml: -------------------------------------------------------------------------------- 1 | name: Pull request checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.x" 18 | 19 | - name: Install dependencies 20 | run: python3 -m pip install -r requirements.txt 21 | 22 | - name: Build and install program 23 | run: pip install . 24 | 25 | - name: Run tests 26 | run: pytest --verbose 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish packages 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | # Allow only one concurrent deployment 8 | concurrency: 9 | group: "release" 10 | cancel-in-progress: false 11 | 12 | jobs: 13 | build-package: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.x" 21 | 22 | - name: Install dependencies 23 | run: python3 -m pip install build twine 24 | 25 | - name: Build package 26 | run: python3 -m build 27 | 28 | - name: Check package metadata 29 | run: python3 -m twine check --strict dist/* 30 | 31 | - uses: actions/upload-artifact@v3 32 | with: 33 | name: packages 34 | path: dist/ 35 | retention-days: 10 36 | if-no-files-found: error 37 | 38 | 39 | pypi-publish: 40 | name: Publish to PyPI 41 | runs-on: ubuntu-latest 42 | needs: build-package 43 | environment: 44 | name: release 45 | permissions: 46 | id-token: write 47 | steps: 48 | - uses: actions/download-artifact@v3 49 | with: 50 | name: packages 51 | path: dist/ 52 | 53 | - name: Publish package distributions to PyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .vscode/ 162 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT Licence 2 | =========== 3 | 4 | Copyright © 2022 James Frost. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice (including the next 14 | paragraph) shall be included in all copies or substantial portions of the 15 | Software. 16 | 17 | The Software is provided "AS IS", without warranty of any kind, express or 18 | implied, including but not limited to the warranties of merchantability, fitness 19 | for a particular purpose and noninfringement. In no event shall the authors or 20 | copyright holders be liable for any claim, damages or other liability, whether 21 | in an action of contract, tort or otherwise, arising from, out of or in 22 | connection with the Software or the use or other dealings in the Software. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jxl_decode 2 | 3 | A pure python JPEG XL decoder. It is currently *very* incomplete. 4 | 5 | ## Installation 6 | 7 | jxl_decode can be installed from [PyPI](https://pypi.org/project/jxl-decode/). 8 | 9 | ```sh 10 | pip install jxl_decode 11 | ``` 12 | 13 | I am aiming to make this decoder as portable as possible. As such it will 14 | ideally have minimal dependencies outside of the standard library. I may use a 15 | dependency for PNG output, if I don't write one myself. 16 | 17 | ### Requirements 18 | 19 | - Recent [Python 3](https://www.python.org/) (developed with 3.11, but may work 20 | with some older versions) 21 | 22 | ### Development Requirements 23 | 24 | - [PyTest](https://docs.pytest.org/) 25 | 26 | ## Usage 27 | 28 | We are a long way away from it, but this is how I intend the decoder to work 29 | from the command line: 30 | 31 | ```sh 32 | jxl_decode input_file.jxl [output_file.png] 33 | ``` 34 | 35 | ## Roadmap/To Do 36 | 37 | - [ ] Add tests (and possibly some more useful methods) to Bitstream class. 38 | - [ ] Decide on internal representation of image data (NumPy array?) 39 | - [x] Define external interfaces by decoding PPM image. 40 | - [ ] PNG output of decoded images. 41 | - [ ] Decode JPEG images. 42 | - [ ] Start on JPEG XL support. 43 | 44 | 59 | 60 | 63 | 64 | ## Licence 65 | 66 | This software is available under the [MIT Licence](LICENCE.md). 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "jxl_decode" 3 | version = "0.0.2" 4 | authors = [ 5 | { name="James Frost", email="git@frost.cx" }, 6 | ] 7 | description = "A pure python JPEG XL decoder." 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | keywords = ["JPEG XL", "jxl"] 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Development Status :: 2 - Pre-Alpha", 16 | "Topic :: Multimedia :: Graphics" 17 | ] 18 | # dependencies = [] 19 | # optional-dependencies = [] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/Fraetor/jxl_decode" 23 | "Bug Tracker" = "https://github.com/Fraetor/jxl_decode/issues" 24 | 25 | [project.scripts] 26 | jxl_decode = "jxl_decode.core:cli_entrypoint" 27 | 28 | [build-system] 29 | requires = ["setuptools>=61.0"] 30 | build-backend = "setuptools.build_meta" 31 | 32 | [tools.pylint.messages_control] 33 | max-line-length = 88 34 | disable = [ 35 | "missing-docstring", 36 | "too-few-public-methods", 37 | "logging-fstring-interpolation" 38 | ] 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | build 3 | -------------------------------------------------------------------------------- /src/jxl-strip.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | """ 4 | jxl-strip 5 | 6 | Strips the container off of a jxl file, reducing the size and removing any 7 | potentially sensitive metadata. The original file is overwritten, so make sure 8 | to have a copy if you are experimenting. 9 | 10 | Currently it is possible for this program to produce invalid JXL files, if the 11 | the image is a level 10 image, in which case the container is required. This 12 | should be uncommon however, and really only effects CMYK, or gigapixel images. 13 | """ 14 | 15 | from pathlib import Path 16 | import argparse 17 | import sys 18 | 19 | 20 | def has_container(bitstream: bytes) -> bool: 21 | """ 22 | Determines the image type by sniffing the first few bytes. 23 | """ 24 | # Test for raw JXL codestream. 25 | if bitstream[:2] == bytes.fromhex("FF0A"): 26 | return False 27 | # Test for JXL box structure. http://www-internal/2022/18181-2#box-types 28 | if bitstream[:12] == bytes.fromhex("0000 000C 4A58 4C20 0D0A 870A"): 29 | return True 30 | raise ValueError("Not a JPEG XL file.") 31 | 32 | 33 | def decode_container(bitstream: bytes) -> bytes: 34 | """ 35 | Parses the ISOBMFF container, extracts the codestream, and decodes it. 36 | JXL container specification: http://www-internal/2022/18181-2 37 | """ 38 | 39 | def parse_box(bitstream: bytes, box_start: int) -> dict: 40 | LBox = int.from_bytes(bitstream[box_start : box_start + 4]) 41 | XLBox = None 42 | if 1 < LBox <= 8: 43 | raise ValueError(f"Invalid LBox at byte {box_start}.") 44 | if LBox == 1: 45 | XLBox = int.from_bytes(bitstream[box_start + 8 : box_start + 16]) 46 | if XLBox <= 16: 47 | raise ValueError(f"Invalid XLBox at byte {box_start}.") 48 | if XLBox: 49 | header_length = 16 50 | box_length = XLBox 51 | else: 52 | header_length = 8 53 | if LBox == 0: 54 | box_length = len(bitstream) - box_start 55 | else: 56 | box_length = LBox 57 | return { 58 | "length": box_length, 59 | "type": bitstream[box_start + 4 : box_start + 8], 60 | "data": bitstream[box_start + header_length : box_start + box_length], 61 | } 62 | 63 | # Reject files missing required boxes. These two boxes are required to be at 64 | # the start and contain no values, so we can manually check there presence. 65 | # Signature box. (Redundant as has already been checked.) 66 | if bitstream[:12] != bytes.fromhex("0000000C 4A584C20 0D0A870A"): 67 | raise ValueError("Invalid signature box.") 68 | # File Type box. 69 | if bitstream[12:32] != bytes.fromhex( 70 | "00000014 66747970 6A786C20 00000000 6A786C20" 71 | ): 72 | raise ValueError("Invalid file type box.") 73 | 74 | partial_codestream = [] 75 | container_pointer = 32 76 | while container_pointer < len(bitstream): 77 | box = parse_box(bitstream, container_pointer) 78 | container_pointer += box["length"] 79 | if box["type"] == b"jxll": 80 | level = int.from_bytes(box["data"]) 81 | if level != 5 or level != 10: 82 | raise ValueError("Unknown level") 83 | elif box["type"] == b"jxlc": 84 | codestream = box["data"] 85 | elif box["type"] == b"jxlp": 86 | index = int.from_bytes(box["data"][:4]) 87 | partial_codestream.append([index, box["data"][4:]]) 88 | 89 | if partial_codestream: 90 | partial_codestream.sort(key=lambda i: i[0]) 91 | codestream = b"".join([i[1] for i in partial_codestream]) 92 | 93 | return codestream 94 | 95 | 96 | def main() -> int: 97 | """Read file from the command line, and strip its box.""" 98 | 99 | parser = argparse.ArgumentParser( 100 | prog="jxl-strip", 101 | description="Strips the container from a JPEG XL image", 102 | epilog="jxl-strip will strip the container from any jxl images, reducing their size\nand removing any privacy compromising metadata.", 103 | ) 104 | parser.add_argument( 105 | "-v", 106 | "--verbose", 107 | action="store_true", 108 | help="explain what is happening.", 109 | ) 110 | parser.add_argument( 111 | "file", help="JXL file to strip, will be overwritten", type=Path 112 | ) 113 | args = parser.parse_args() 114 | 115 | # Main program logic start. 116 | try: 117 | with open(args.file, "rb") as fp: 118 | # has_container will raise a ValueError if not a jxl file, and only 119 | # reading the first 12 bytes makes this check fast. If it is a jxl 120 | # file, reads the rest of it. 121 | bitstream = fp.read(12) 122 | container = has_container(bitstream) 123 | bitstream = bitstream + fp.read() 124 | if container: 125 | # There is technically a race condition here as we are reopening the 126 | # file, but doing it otherwise is annoying, and it is unlikely that 127 | # another tool is manipulating the files at the same time. 128 | if args.verbose: 129 | print(f"Striping {args.file}", file=sys.stderr) 130 | with open(args.file, "wb") as fp: 131 | fp.write(decode_container(bitstream)) 132 | else: 133 | if args.verbose: 134 | print( 135 | f"Skipping {args.file} as it is already stripped", file=sys.stderr 136 | ) 137 | except FileNotFoundError: 138 | print(f"{args.file} not found", sys.stderr) 139 | return 1 140 | except ValueError: 141 | print(f"{args.file} is not a valid JXL file", sys.stderr) 142 | return 1 143 | 144 | return 0 145 | 146 | 147 | if __name__ == "__main__": 148 | sys.exit(main()) 149 | -------------------------------------------------------------------------------- /src/jxl_decode/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | jxl_decode 3 | 4 | A decoder of JPEG XL images. 5 | """ 6 | 7 | # Import all public functions. This defines the public API. 8 | from jxl_decode.core import load, decode, save 9 | -------------------------------------------------------------------------------- /src/jxl_decode/__main__.py: -------------------------------------------------------------------------------- 1 | """Command line entry point.""" 2 | import sys 3 | from jxl_decode.core import cli_entrypoint 4 | 5 | sys.exit(cli_entrypoint()) 6 | -------------------------------------------------------------------------------- /src/jxl_decode/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common code used by other modules. 3 | """ 4 | 5 | 6 | class RawImage: 7 | """ 8 | Raw image data stored in a List of pixels per channel. 9 | 10 | The pixels are interpreted in row-first. 11 | 12 | This could well be a dictionary, but I like it having defaults. 13 | """ 14 | 15 | def __init__(self): 16 | self.colourspace = "sRGB" 17 | self.bitdepth: int = 8 # Bit depth per channel 18 | self.width: int 19 | self.height: int 20 | self.ch0: list[int] 21 | self.ch1: list[int] 22 | self.ch2: list[int] 23 | 24 | def __str__(self) -> str: 25 | pretty_string = "\n".join( 26 | ( 27 | "RawImage (", 28 | f"Width: {self.width},", 29 | f"Height: {self.height},", 30 | f"Bitdepth: {self.bitdepth},", 31 | f"Colour Space: {self.colourspace},", 32 | f"Channel 0: {self.ch0},", 33 | f"Channel 1: {self.ch1},", 34 | f"Channel 2: {self.ch2} )", 35 | ) 36 | ) 37 | return pretty_string 38 | 39 | 40 | class Bitstream: 41 | """ 42 | A stream of bits with methods for easy handling. 43 | """ 44 | 45 | def __init__(self, bitstream: bytes) -> None: 46 | self.bitstream: int = int.from_bytes(bitstream, "little") 47 | self.shift: int = 0 48 | 49 | def get_bits(self, length: int = 1) -> int: 50 | bitmask = 2**length - 1 51 | bits = (self.bitstream >> self.shift) & bitmask 52 | self.shift += length 53 | return bits 54 | -------------------------------------------------------------------------------- /src/jxl_decode/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module implements the core code of jxl_decode, from which other modules are 3 | called. 4 | """ 5 | 6 | from pathlib import Path 7 | import logging 8 | 9 | from jxl_decode.common import RawImage 10 | from jxl_decode.ppm import decode_ppm, encode_ppm 11 | from jxl_decode.jpg import decode_jpg 12 | from jxl_decode.jxl import decode_jxl 13 | 14 | 15 | def load(file_path: Path) -> bytes: 16 | """Loads an image from disk and returns it.""" 17 | with open(file_path, "rb") as fp: 18 | return fp.read() 19 | 20 | 21 | def save(filename: Path, image: RawImage): 22 | """Saves image to disk as a PPM.""" 23 | png = encode_ppm(image) 24 | with open(filename, "wb") as fp: 25 | fp.write(png) 26 | 27 | 28 | def sniff_image_type(bitstream: bytes) -> str: 29 | """ 30 | Determines the image type by sniffing the first few bytes. 31 | """ 32 | # Test for raw JXL codestream. 33 | if bitstream[:2] == bytes.fromhex("FF0A"): 34 | return "jxl" 35 | # Test for JXL box structure. http://www-internal/2022/18181-2#box-types 36 | if bitstream[:12] == bytes.fromhex("0000 000C 4A58 4C20 0D0A 870A"): 37 | return "jxl" 38 | # Test for PPM. 39 | if bitstream[:2] == b"P6": 40 | return "ppm" 41 | # Test for JPEG/JFIF. 42 | if bitstream[:2] == bytes.fromhex("FFD8"): 43 | return "jpg" 44 | raise ValueError("Not a recognised image type.") 45 | 46 | 47 | def decode(bitstream: bytes) -> RawImage: 48 | """Decodes a bitstream using the appropriate decoder.""" 49 | match sniff_image_type(bitstream[:12]): 50 | case "jxl": 51 | return decode_jxl(bitstream) 52 | case "jpg": 53 | return decode_jpg(bitstream) 54 | case "ppm": 55 | return decode_ppm(bitstream) 56 | 57 | 58 | def cli_entrypoint(): 59 | """Command line handling.""" 60 | import argparse 61 | 62 | parser = argparse.ArgumentParser( 63 | prog="jxl_decode", 64 | description="Decodes a JPEG XL image", 65 | epilog="jxl_decode is intended to decode PPM, JPEG, and JPEG XL files, outputting a PPM.", 66 | ) 67 | parser.add_argument( 68 | "-v", 69 | "--verbose", 70 | action="count", 71 | default=0, 72 | help="explain what is happening. Can be given multiple times", 73 | ) 74 | parser.add_argument("input_file", help="file to decode", type=Path) 75 | parser.add_argument("output_file", nargs="?", type=Path, default=None) 76 | args = parser.parse_args() 77 | 78 | # Set logging verbosity 79 | if args.verbose >= 2: 80 | logging.basicConfig(level=logging.DEBUG) 81 | elif args.verbose >= 1: 82 | logging.basicConfig(level=logging.INFO) 83 | else: 84 | logging.basicConfig(level=logging.WARNING) 85 | 86 | if args.output_file: 87 | logging.info(f"Decoding {args.input_file} and saving to {args.output_file}...") 88 | save(args.output_file, decode(load(args.input_file))) 89 | else: 90 | logging.info(f"Decoding {args.input_file} and printing output...") 91 | print(decode(load(args.input_file))) 92 | -------------------------------------------------------------------------------- /src/jxl_decode/jpg.py: -------------------------------------------------------------------------------- 1 | """ 2 | JPEG JFIF Decoder 3 | """ 4 | 5 | from jxl_decode.common import RawImage 6 | 7 | 8 | def decode_jpg(bitstream: bytes) -> RawImage: 9 | """ 10 | https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format#File_format_structure 11 | https://www.w3.org/Graphics/JPEG/jfif3.pdf 12 | """ 13 | raise NotImplementedError 14 | -------------------------------------------------------------------------------- /src/jxl_decode/jxl.py: -------------------------------------------------------------------------------- 1 | """ 2 | JPEG XL Decoder 3 | """ 4 | 5 | from jxl_decode.common import RawImage, Bitstream 6 | 7 | 8 | def decode_jxl(bitstream: bytes) -> RawImage: 9 | """ 10 | Decodes a JPEG XL image. 11 | """ 12 | if bitstream[:2] == bytes.fromhex("FF0A"): 13 | image = decode_codestream(bitstream) 14 | else: 15 | image = decode_container(bitstream) 16 | return image 17 | 18 | 19 | def decode_codestream(bitstream: bytes) -> RawImage: 20 | """ 21 | Decodes the actual codestream. 22 | JXL codestream specification: http://www-internal/2022/18181-1 23 | """ 24 | 25 | # Convert codestream to int within an object to get some handy methods. 26 | codestream = Bitstream(bitstream) 27 | 28 | # Skip signature 29 | codestream.shift += 16 30 | 31 | # SizeHeader 32 | div8 = codestream.get_bits(1) 33 | if div8: 34 | height = 8 * (1 + codestream.get_bits(5)) 35 | else: 36 | distribution = codestream.get_bits(2) 37 | match distribution: 38 | case 0: 39 | height = 1 + codestream.get_bits(9) 40 | case 1: 41 | height = 1 + codestream.get_bits(13) 42 | case 2: 43 | height = 1 + codestream.get_bits(18) 44 | case 3: 45 | height = 1 + codestream.get_bits(30) 46 | ratio = codestream.get_bits(3) 47 | if div8 and not ratio: 48 | width = 8 * (1 + codestream.get_bits(5)) 49 | elif not ratio: 50 | distribution = codestream.get_bits(2) 51 | match distribution: 52 | case 0: 53 | width = 1 + codestream.get_bits(9) 54 | case 1: 55 | width = 1 + codestream.get_bits(13) 56 | case 2: 57 | width = 1 + codestream.get_bits(18) 58 | case 3: 59 | width = 1 + codestream.get_bits(30) 60 | else: 61 | match ratio: 62 | case 1: 63 | width = height 64 | case 2: 65 | width = (height * 12) // 10 66 | case 3: 67 | width = (height * 4) // 3 68 | case 4: 69 | width = (height * 3) // 2 70 | case 5: 71 | width = (height * 16) // 9 72 | case 6: 73 | width = (height * 5) // 4 74 | case 7: 75 | width = (height * 2) // 1 76 | print(f"Dimensions: {width}x{height}") 77 | 78 | # ImageMetadata 79 | raise NotImplementedError 80 | 81 | 82 | def decode_container(bitstream: bytes) -> RawImage: 83 | """ 84 | Parses the ISOBMFF container, extracts the codestream, and decodes it. 85 | JXL container specification: http://www-internal/2022/18181-2 86 | """ 87 | 88 | def parse_box(bitstream: bytes, box_start: int) -> dict: 89 | LBox = int.from_bytes(bitstream[box_start : box_start + 4]) 90 | XLBox = None 91 | if 1 < LBox <= 8: 92 | raise ValueError(f"Invalid LBox at byte {box_start}.") 93 | if LBox == 1: 94 | XLBox = int.from_bytes(bitstream[box_start + 8 : box_start + 16]) 95 | if XLBox <= 16: 96 | raise ValueError(f"Invalid XLBox at byte {box_start}.") 97 | if XLBox: 98 | header_length = 16 99 | box_length = XLBox 100 | else: 101 | header_length = 8 102 | if LBox == 0: 103 | box_length = len(bitstream) - box_start 104 | else: 105 | box_length = LBox 106 | return { 107 | "length": box_length, 108 | "type": bitstream[box_start + 4 : box_start + 8], 109 | "data": bitstream[box_start + header_length : box_start + box_length], 110 | } 111 | 112 | # Reject files missing required boxes. These two boxes are required to be at 113 | # the start and contain no values, so we can manually check there presence. 114 | # Signature box. (Redundant as has already been checked.) 115 | if bitstream[:12] != bytes.fromhex("0000000C 4A584C20 0D0A870A"): 116 | raise ValueError("Invalid signature box.") 117 | # File Type box. 118 | if bitstream[12:32] != bytes.fromhex( 119 | "00000014 66747970 6A786C20 00000000 6A786C20" 120 | ): 121 | raise ValueError("Invalid file type box.") 122 | 123 | partial_codestream = [] 124 | container_pointer = 32 125 | while container_pointer < len(bitstream): 126 | box = parse_box(bitstream, container_pointer) 127 | container_pointer += box["length"] 128 | match box["type"]: 129 | case b"jxll": 130 | level = int.from_bytes(box["data"]) 131 | if level != 5 or level != 10: 132 | raise ValueError("Unknown level") 133 | case b"jxlc": 134 | codestream = box["data"] 135 | case b"jxlp": 136 | index = int.from_bytes(box["data"][:4]) 137 | partial_codestream.append([index, box["data"][4:]]) 138 | case b"jxli": 139 | # Frame Index box. It could be useful to parse? 140 | # http://www-internal/2022/18181-2#toc17 141 | pass 142 | 143 | if partial_codestream: 144 | partial_codestream.sort(key=lambda i: i[0]) 145 | codestream = b"".join([i[1] for i in partial_codestream]) 146 | 147 | return decode_codestream(codestream) 148 | -------------------------------------------------------------------------------- /src/jxl_decode/ppm.py: -------------------------------------------------------------------------------- 1 | """ 2 | PPM decoder and encoder 3 | 4 | PPM specification: http://davis.lbl.gov/Manuals/NETPBM/doc/ppm.html 5 | 6 | Approx spec: (space can be any ASCII whitespace.) 7 | P6 WIDTH HEIGHT MAXVAL DATA... 8 | 9 | DATA scans top to bottom, left to right. E.g: 10 | RGB1 RGB2 RGB3 11 | RGB4 RGB5 RGB6 12 | RGB7 RGB8 RGB9 13 | """ 14 | 15 | from jxl_decode.common import RawImage 16 | 17 | 18 | def decode_ppm(bitstream: bytes) -> RawImage: 19 | """Creates an image from a PPM bitstream.""" 20 | sectioned = bitstream.split(maxsplit=4) 21 | 22 | image = RawImage() 23 | image.width = int(sectioned[1]) 24 | image.height = int(sectioned[2]) 25 | image.ch0 = [] 26 | image.ch1 = [] 27 | image.ch2 = [] 28 | 29 | max_val = int(sectioned[3]) 30 | data = sectioned[4] 31 | 32 | if max_val < 256: 33 | image.bitdepth = 8 34 | for y in range(image.height): 35 | h_strip = [] 36 | for x in range(image.width): 37 | h_strip.append(data[3 * x * y + 3 * x]) 38 | image.ch0.extend(h_strip) 39 | 40 | for y in range(image.height): 41 | h_strip = [] 42 | for x in range(image.width): 43 | h_strip.append(data[3 * x * y + 3 * x + 1]) 44 | image.ch1.extend(h_strip) 45 | 46 | for y in range(image.height): 47 | h_strip = [] 48 | for x in range(image.width): 49 | h_strip.append(data[3 * x * y + 3 * x + 2]) 50 | image.ch2.extend(h_strip) 51 | else: 52 | image.bitdepth = 16 53 | # PPM is big endian, so from_bytes works by default. 54 | for y in range(image.height): 55 | h_strip = [] 56 | for x in range(image.width): 57 | h_strip.append( 58 | int.from_bytes(data[6 * x * y + 6 * x : 6 * x * y + 6 * x + 2]) 59 | ) 60 | image.ch0.extend(h_strip) 61 | 62 | for y in range(image.height): 63 | h_strip = [] 64 | for x in range(image.width): 65 | h_strip.append( 66 | int.from_bytes(data[6 * x * y + 6 * x + 2 : 6 * x * y + 6 * x + 4]) 67 | ) 68 | image.ch1.extend(h_strip) 69 | 70 | for y in range(image.height): 71 | h_strip = [] 72 | for x in range(image.width): 73 | h_strip.append( 74 | int.from_bytes(data[6 * x * y + 6 * x + 4 : 6 * x * y + 6 * x + 6]) 75 | ) 76 | image.ch2.extend(h_strip) 77 | 78 | return image 79 | 80 | 81 | def encode_ppm(image: RawImage) -> bytes: 82 | """Creates a ppm bitstream from an image.""" 83 | raise NotImplementedError 84 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the command line interface.""" 2 | 3 | import subprocess 4 | 5 | 6 | def test_command_line_help(): 7 | subprocess.run(("jxl_decode", "--help"), check=False) 8 | # test verbose options. This is really just to up the coverage number. 9 | subprocess.run(("jxl_decode", "-v"), check=False) 10 | subprocess.run(("jxl_decode", "-vv"), check=False) 11 | -------------------------------------------------------------------------------- /tests/test_images/10x10.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/10x10.jxl -------------------------------------------------------------------------------- /tests/test_images/727x7.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/727x7.jxl -------------------------------------------------------------------------------- /tests/test_images/gradient_8x8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/gradient_8x8.jpg -------------------------------------------------------------------------------- /tests/test_images/gradient_8x8.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/gradient_8x8.jxl -------------------------------------------------------------------------------- /tests/test_images/gradient_8x8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/gradient_8x8.png -------------------------------------------------------------------------------- /tests/test_images/gradient_8x8.ppm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/gradient_8x8.ppm -------------------------------------------------------------------------------- /tests/test_images/jxl.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/jxl.jxl -------------------------------------------------------------------------------- /tests/test_images/jxl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/jxl.png -------------------------------------------------------------------------------- /tests/test_images/minimal.jxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/minimal.jxl -------------------------------------------------------------------------------- /tests/test_images/white_8x8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/white_8x8.png -------------------------------------------------------------------------------- /tests/test_images/white_8x8.ppm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fraetor/jxl_decode/902cd5d479f89f93df6105a22dc92f297ab77541/tests/test_images/white_8x8.ppm --------------------------------------------------------------------------------