diff --git a/NEWS.md b/NEWS.md index 1388bcbc..b0e04ea4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,26 @@ # 📰 Picopt News +## v6.7.0 + +### Features + +- JPEG XL support, read and write, using the internal + [pillow-jxl-plugin](https://github.com/Isotr0py/pillow-jpegxl-plugin) module. +- JXL is now optimized by default. `.jxl` files that previous versions ignored + are re-encoded on a plain `picopt -r` run. Only lossless JXL is touched; lossy + (XYB encoded) JXL is left alone, as lossy WebP is. +- Lossless images convert to JXL with `-c JXL`, which now leads the preference + order for still images ahead of WEBP and PNG. +- New `--convert-jpeg-to-jxl` converts JPEG to JXL losslessly and reversibly: + the JXL stores the original JPEG bitstream and can restore it byte for byte. + Files carrying that reconstruction data are never re-encoded, so the original + JPEG stays recoverable. +- New `--convert-webp-to-jxl` converts lossless WebP to JXL. Off by default + because JXL support is still much thinner than WebP's. +- Both flags require `-c JXL` as well. JPEG and WEBP are formats picopt already + processes by default, so unlike BMP or TIFF they cannot be gated by naming + them with `-x`. A plain `-c JXL` still converts only GIF and PNG. + ## v6.6.3 ### Fixes diff --git a/README.md b/README.md index 7086b520..c2273f93 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ their current format. - JPEG images are optimized with MozJpeg's jpegtran. - WEBP Lossy images are not optimized. There is no current way to preserve information without running it through a lossy process again. +- Lossy (XYB encoded) JPEG XL images are not optimized, for the same reason. +- JPEG images are the one exception to the rule above: they may be converted to + JPEG XL losslessly, because JPEG XL can store the original JPEG bitstream and + restore it byte for byte. This is opt-in, see below. ### Lossless Images @@ -39,8 +43,11 @@ a great deal smaller than uncompressed bitmaps like BMP. As such the best practice is probably to convert all lossless images to WebP Lossless as now all major browsers support it. The only downside is that decoding WebP Lossless takes on average 50% more CPU than PNG. All major desktop and mobile browsers -support WEBP. WEBP is the lossless format of choice. Until perhaps JPEG XL -support arrives for browsers. +support WEBP. WEBP is the lossless format of choice, until JPEG XL support +arrives for browsers. + +Lossless JPEG XL is usually smaller still, and picopt can convert to it with +`-c JXL`. Browser support remains limited, so it is not the default target. ### Sequenced Images @@ -52,15 +59,15 @@ substitute. ### Conversion By default, picopt does not convert images between formats. You must turn on -conversion to PNG or WebP explicitly. +conversion to JXL, PNG or WebP explicitly. ## 🖼️ Formats -- By default picopt will optimize GIF, JPEG, PNG, and WEBP images. +- By default picopt will optimize GIF, JPEG, JXL, PNG, and WEBP images. - Picopt can optionally optimize SVG images, ZIP, ePub, and CBZ containers. - Picopt can convert many lossless images such as BMP, CBR, CUR, DIB, FITS, GIF, IMT, PCX, PIXAR, PNG, PPM, PSD, QOI, SGI, SPIDER, SUN, TGA, TIFF, XBM, and XPM - into PNG and WEBP. + into JXL, PNG and WEBP. - Picopt can convert Animated GIF, TIFF, and FLI into Animated PNG or WebP files. - Picopt can convert Animated GIF, TIFF, FLI, and PNG into Animated WebP files. @@ -84,6 +91,40 @@ picopt -x BMP -c WEBP big_old.bmp Picopt uses an internal mozjpeg python module to optimize JPEG images. +### JPEG XL + +Picopt reads and writes JPEG XL with the internal +[pillow-jxl-plugin](https://github.com/Isotr0py/pillow-jpegxl-plugin) module. + +Lossless JXL images are re-encoded at the highest size-targeting effort. Lossy +(XYB encoded) JXL images are left alone, as lossy WebP is. + +Converting JPEG to JPEG XL is lossless and reversible: JPEG XL stores the +original JPEG bitstream, so the exact original file can be restored later. + +JPEG and WebP are both formats picopt processes by default, so naming them with +`-x` cannot express a choice about them, and each has its own flag instead: + + + +```sh +picopt -c JXL --convert-jpeg-to-jxl --convert-webp-to-jxl photos +``` + +JPEG needs one because picopt otherwise never uses a lossy image as a conversion +source. WebP needs one because JPEG XL is still far less widely supported than +WebP, and converting away from a format every browser reads should be a +deliberate act. Without them, `-c JXL` converts only GIF and PNG, plus whatever +else you name with `-x`. + +Two caveats on the JPEG conversion. `--strip-metadata` cannot apply to it, since +keeping the JPEG restorable means keeping all of its bytes. And picopt will not +re-optimize a JXL that carries JPEG reconstruction data, because re-encoding it +would silently discard the ability to restore the original JPEG. + +Animated JPEG XL is not supported in either direction; the encoder cannot read +or write it. + ### PNG & APNG Picopt uses an internal oxipng python module to optimize PNG images and convert diff --git a/bun.lock b/bun.lock index a7d1afb5..6c87b2e5 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.1", - "@fsouza/prettierd": "^0.28.0", + "@fsouza/prettierd": "^0.29.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", "eslint": "^10.8.0", @@ -38,7 +38,6 @@ "prettier-plugin-nginx": "^1.0.3", "prettier-plugin-packagejson": "^3.0.2", "prettier-plugin-toml": "^2.0.6", - "prettier-plugin-xml": "^0.1.0", "remark-cli": "^12.0.1", "remark-gfm": "^4.0.1", "remark-lint-no-duplicate-headings-in-section": "^4.0.1", @@ -55,13 +54,13 @@ "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -91,7 +90,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - "@fsouza/prettierd": ["@fsouza/prettierd@0.28.0", "", { "dependencies": { "core_d": "^6.1.1", "prettier": "^3.8.3" }, "optionalDependencies": { "@babel/parser": "^7.29.7", "@typescript-eslint/typescript-estree": "^8.60.0" }, "bin": { "prettierd": "bin/prettierd" } }, "sha512-XkRkIp2en2Lcm7Qc+zqNm8ClI3RArLCBNG2A4M2xzTw43S/vYSEDQ4NcfW+6ZsUyQeoVqrFcMU5H1yAKCVkv6A=="], + "@fsouza/prettierd": ["@fsouza/prettierd@0.29.0", "", { "dependencies": { "core_d": "^6.1.1", "prettier": "^3.9.5" }, "optionalDependencies": { "@babel/parser": "^8.0.4", "@typescript-eslint/typescript-estree": "^8.64.0" }, "bin": { "prettierd": "bin/prettierd" } }, "sha512-UMBNFAVAP2Vfd8RrsH4bzchtyvqfzFp3COBYSTYmtrrU6fwK+JQLz9s6g9JNRVY6MDBWkUWNpPO63vPD/6KTFg=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -165,19 +164,19 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0" } }, "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], "@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], @@ -437,8 +436,6 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-xml-parser": ["fast-xml-parser@3.21.1", "", { "dependencies": { "strnum": "^1.0.4" }, "bin": { "xml2js": "cli.js" } }, "sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -767,8 +764,6 @@ "prettier-plugin-toml": ["prettier-plugin-toml@2.0.6", "", { "dependencies": { "@taplo/lib": "^0.5.0" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-12N/wBuHa9jd/KVy9pRP20NMKxQfQLMseQCt66lIbLaPLItvGUcSIryE1eZZMJ7loSws6Ig3M2Elc2EreNh76w=="], - "prettier-plugin-xml": ["prettier-plugin-xml@0.1.0", "", { "dependencies": { "fast-xml-parser": "^3.14.0", "prettier": ">=1.10" } }, "sha512-rywliZ83SVxLT5+OOWjT28g8jgshks61+nN+9e7ss6N519sz5UPS0/NzaS9M+qUYY9GVN0JXEa4R8aJPlMrRAA=="], - "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], @@ -977,8 +972,6 @@ "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], - "super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="], "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -1085,8 +1078,6 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@fsouza/prettierd/prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@npmcli/config/proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], @@ -1105,19 +1096,21 @@ "@npmcli/promise-spawn/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], "@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], - "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.0", "@typescript-eslint/tsconfig-utils": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A=="], - "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1185,8 +1178,6 @@ "prettier-plugin-packagejson/sort-package-json": ["sort-package-json@3.6.1", "", { "dependencies": { "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "git-hooks-list": "^4.1.1", "is-plain-obj": "^4.1.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.1", "tinyglobby": "^0.2.15" }, "bin": "cli.js" }, "sha512-Chgejw1+10p2D0U2tB7au1lHtz6TkFnxmvZktyBCRyV0GgmF6nl1IxXxAsPtJVsUyg/fo+BfCMAVVFUVRkAHrQ=="], - "prettier-plugin-xml/prettier": ["prettier@3.8.2", "", { "bin": "bin/prettier.cjs" }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="], - "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "sort-package-json/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], diff --git a/package.json b/package.json index 53adf0ee..b79dd719 100644 --- a/package.json +++ b/package.json @@ -54,19 +54,14 @@ "preset-lint-consistent", "preset-lint-markdown-style-guide", "preset-lint-recommended", - "preset-prettier", - [ - "lint-no-duplicate-headings", - false - ], - "lint-no-duplicate-headings-in-section" + "preset-prettier" ] }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "@eslint/js": "^10.0.1", "@eslint/json": "^2.0.1", - "@fsouza/prettierd": "^0.28.0", + "@fsouza/prettierd": "^0.29.0", "@prettier/plugin-xml": "^3.4.2", "@stylistic/eslint-plugin": "^5.10.0", "eslint": "^10.8.0", @@ -97,7 +92,6 @@ "prettier-plugin-nginx": "^1.0.3", "prettier-plugin-packagejson": "^3.0.2", "prettier-plugin-toml": "^2.0.6", - "prettier-plugin-xml": "^0.1.0", "remark-cli": "^12.0.1", "remark-gfm": "^4.0.1", "remark-lint-no-duplicate-headings-in-section": "^4.0.1", diff --git a/picopt/cli.py b/picopt/cli.py index 1086b38d..c2ad8d25 100644 --- a/picopt/cli.py +++ b/picopt/cli.py @@ -268,6 +268,28 @@ def get_arguments(params: tuple[str, ...] | None = None) -> Namespace: dest="png_max", help="Overzealously optimize pngs with -O5 and Zopfli.", ) + parser.add_argument( + "--convert-jpeg-to-jxl", + action="store_true", + default=None, + dest="convert_jpeg_to_jxl", + help=( + "Also convert JPEG images when converting to JXL. This is " + "lossless JPEG recompression: the original JPEG can be restored " + "from the JXL byte for byte. Requires -c JXL." + ), + ) + parser.add_argument( + "--convert-webp-to-jxl", + action="store_true", + default=None, + dest="convert_webp_to_jxl", + help=( + "Also convert lossless WebP images when converting to JXL. Off " + "by default because JXL is still far less widely supported than " + "WebP. Requires -c JXL." + ), + ) parser.add_argument( "-S", "--no-symlinks", diff --git a/picopt/config/__init__.py b/picopt/config/__init__.py index b4877555..6801f52b 100644 --- a/picopt/config/__init__.py +++ b/picopt/config/__init__.py @@ -87,7 +87,9 @@ def _build_template() -> MappingTemplate: { "after": Optional(float), "bigger": bool, + "convert_jpeg_to_jxl": bool, "convert_to": Optional(Sequence(Choice(convert_to_format_strs))), + "convert_webp_to_jxl": bool, "disable_programs": Sequence(str), "dry_run": bool, "extra_formats": Optional(Sequence(Choice(all_format_strs))), @@ -484,7 +486,9 @@ def _settings_from_attrdict(ad: Any) -> PicoptSettings: return PicoptSettings( after=ad.after, bigger=ad.bigger, + convert_jpeg_to_jxl=ad.convert_jpeg_to_jxl, convert_to=tuple(ad.convert_to) if ad.convert_to is not None else None, + convert_webp_to_jxl=ad.convert_webp_to_jxl, disable_programs=tuple(ad.disable_programs), dry_run=ad.dry_run, extra_formats=tuple(ad.extra_formats) if ad.extra_formats is not None else None, diff --git a/picopt/config/consts.py b/picopt/config/consts.py index 7c7db933..40d5e96e 100644 --- a/picopt/config/consts.py +++ b/picopt/config/consts.py @@ -19,7 +19,11 @@ TIMESTAMPS_CONFIG_KEYS: Final[frozenset[str]] = frozenset( { "bigger", + # These change which files are converted, so they must invalidate + # stamps for the same reason convert_to does. + "convert_jpeg_to_jxl", "convert_to", + "convert_webp_to_jxl", "formats", "ignore", "keep_metadata", diff --git a/picopt/config/handlers.py b/picopt/config/handlers.py index a0cd494e..fb31c6dd 100644 --- a/picopt/config/handlers.py +++ b/picopt/config/handlers.py @@ -126,12 +126,23 @@ def _print_formats_config( from_list = ", ".join(sorted(sources)) logger.info(f"Converting {from_list} to {target}") + @staticmethod + def _is_handler_config_enabled(handler_cls: type[Handler], config: Subview) -> bool: + """Honor a handler's opt-in config flag, if it declares one.""" + key = handler_cls.CONFIG_ENABLED_KEY + if not key: + return True + return key in config and bool(config[key].get(bool)) + def _set_format_handler_stages( self, handler_cls: type[Handler], handler_stages: dict[type[Handler], tuple[Tool, ...]], disabled_program_names: frozenset[str], + config: Subview, ) -> None: + if not self._is_handler_config_enabled(handler_cls, config): + return stages = _select_pipeline_for_handler(handler_cls, disabled_program_names) if stages is not None: handler_stages[handler_cls] = stages @@ -194,7 +205,7 @@ def set_format_handler_map( handler_stages: dict[type[Handler], tuple[Tool, ...]] = {} for handler_cls in _enabled_handler_classes(all_format_strs): self._set_format_handler_stages( - handler_cls, handler_stages, disabled_program_names + handler_cls, handler_stages, disabled_program_names, config ) config["computed"]["handler_stages"].set(handler_stages) diff --git a/picopt/config/settings.py b/picopt/config/settings.py index 793bf5c7..7b2a161b 100644 --- a/picopt/config/settings.py +++ b/picopt/config/settings.py @@ -70,6 +70,8 @@ class PicoptSettings: # User-facing scalar options bigger: bool + convert_jpeg_to_jxl: bool + convert_webp_to_jxl: bool dry_run: bool fail_fast: bool fail_fast_container: bool diff --git a/picopt/config_default.yaml b/picopt/config_default.yaml index 881884f7..971d6e1a 100644 --- a/picopt/config_default.yaml +++ b/picopt/config_default.yaml @@ -1,12 +1,14 @@ picopt: after: null bigger: False + convert_jpeg_to_jxl: False convert_to: [] + convert_webp_to_jxl: False disable_programs: [] dry_run: False fail_fast: False fail_fast_container: False - formats: [GIF, JPEG, PNG, WEBP] + formats: [GIF, JPEG, JXL, PNG, WEBP] ignore: [] ignore_defaults: True jobs: 0 diff --git a/picopt/pillow/jxl.py b/picopt/pillow/jxl.py new file mode 100644 index 00000000..7ec724c9 --- /dev/null +++ b/picopt/pillow/jxl.py @@ -0,0 +1,297 @@ +""" +JPEG XL support: PIL registration and lossless detection. + +Importing this module registers the ``pillow-jxl-plugin`` codec with Pillow +(the plugin registers itself as an import side effect) and patches in a +``save_all`` handler the plugin does not install itself. picopt's +``ImageHandler.pil_save`` always calls ``Image.save(save_all=True)``, which +raises ``KeyError`` for any format missing from Pillow's ``SAVE_ALL`` +registry. JPEG XL animation is not supported by the plugin at all, so +reusing the single-frame save handler is exactly right. + +The rest of this module answers one question detect_format needs and the +plugin cannot: may picopt losslessly re-encode this JXL? That is false for +two unrelated reasons, and the caller does not care which: + +* **XYB-encoded (lossy) files.** Re-encoding them losslessly is safe for + pixels but pointless — the result is always far larger and gets discarded + by the size gate — so it is wasted work on every run. +* **JPEG-reconstruction files.** A JXL carrying a ``jbrd`` box can restore + its source JPEG byte for byte. Re-encoding preserves the pixels but drops + that box, silently destroying the reconstruction data. + +Neither signal is exposed by the plugin's decoder, so the codestream header +is parsed here. This should be a part of Pillow. +""" + +from __future__ import annotations + +from contextlib import suppress +from importlib import import_module +from typing import TYPE_CHECKING, Final + +from PIL import Image + +if TYPE_CHECKING: + from typing import BinaryIO + +# Imported for its side effect alone: pillow_jxl registers the codec with +# Pillow at import time and exposes nothing picopt calls directly, so a plain +# `import` reads as unused and gets stripped by autofixers. +# Suppressed so picopt still runs when the optional codec is absent; the +# handlers then probe as unavailable and JXL files are simply skipped. +with suppress(ImportError): + import_module("pillow_jxl") + +JXL_FORMAT_STR: Final[str] = "JXL" + +# Pillow's own registry is the source of truth for whether the import above +# succeeded. +if JXL_FORMAT_STR in Image.SAVE and JXL_FORMAT_STR not in Image.SAVE_ALL: + Image.register_save_all(JXL_FORMAT_STR, Image.SAVE[JXL_FORMAT_STR]) + +_CODESTREAM_SIG: Final[bytes] = b"\xff\x0a" +_CONTAINER_SIG: Final[bytes] = b"\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a" +_JPEG_RECONSTRUCTION_BOX: Final[bytes] = b"jbrd" +_BROTLI_BOX: Final[bytes] = b"brob" +_CODESTREAM_BOX: Final[bytes] = b"jxlc" +_PARTIAL_CODESTREAM_BOX: Final[bytes] = b"jxlp" +# The partial-codestream box prefixes its payload with a 4 byte sequence index. +_JXLP_INDEX_LEN: Final[int] = 4 +_BOX_HEADER_LEN: Final[int] = 8 +_BOX_EXT_HEADER_LEN: Final[int] = 16 +# The image header is a few dozen bytes; this is a generous ceiling that also +# bounds how much of a split codestream is buffered. +_MAX_HEADER_BYTES: Final[int] = 1024 +# A sanity bound: JXL permits at most 256 extra channels, and a parse that +# claims more has certainly desynchronized. +_MAX_EXTRA_CHANNELS: Final[int] = 256 + +# U32 field distributions, as (bits, offset) pairs or a bare literal value. +# A 2 bit selector chooses among the four alternatives. +_U32_DIMENSION: Final = ((9, 1), (13, 1), (18, 1), (30, 1)) +_U32_ENUM: Final = (0, 1, 2, (6, 3)) +_U32_BITS_PER_SAMPLE: Final = (8, 10, 12, (6, 1)) +_U32_FLOAT_BITS_PER_SAMPLE: Final = (32, 16, 24, (6, 1)) +_U32_NUM_EXTRA_CHANNELS: Final = (0, 1, (4, 2), (12, 1)) +_U32_DIM_SHIFT: Final = (0, 3, 4, (3, 1)) +_U32_NAME_LEN: Final = (0, (4, 1), (5, 17), (10, 49)) +_U32_PREVIEW_DIV8: Final = (16, 32, (5, 1), (9, 33)) +_U32_PREVIEW: Final = ((6, 1), (8, 65), (10, 321), (12, 1345)) +_U32_TPS_NUMERATOR: Final = (100, 1000, (10, 1), (30, 1)) +_U32_TPS_DENOMINATOR: Final = (1, 1001, (8, 1), (10, 1)) +_U32_NUM_LOOPS: Final = (0, (3, 0), (16, 0), (32, 0)) +_U32_CFA_CHANNEL: Final = (1, (2, 0), (4, 3), (8, 19)) + +_EXTRA_CHANNEL_ALPHA: Final[int] = 0 +_EXTRA_CHANNEL_SPOT_COLOR: Final[int] = 3 +_EXTRA_CHANNEL_CFA: Final[int] = 4 +_SPOT_COLOR_BITS: Final[int] = 32 * 4 + + +class _TruncatedError(Exception): + """The header ended before the parse finished.""" + + +class _BitReader: + """Reads the JXL bitstream, which packs bits LSB-first within each byte.""" + + def __init__(self, data: bytes) -> None: + self._data = data + self._pos = 0 + + def bit(self) -> int: + """Read a single bit.""" + byte_index, bit_index = divmod(self._pos, 8) + if byte_index >= len(self._data): + raise _TruncatedError + self._pos += 1 + return (self._data[byte_index] >> bit_index) & 1 + + def bits(self, count: int) -> int: + """Read an unsigned integer of ``count`` bits.""" + value = 0 + for index in range(count): + value |= self.bit() << index + return value + + def u32(self, distributions: tuple) -> int: + """Read a U32: a 2 bit selector picks one of four encodings.""" + distribution = distributions[self.bits(2)] + if isinstance(distribution, int): + return distribution + bit_count, offset = distribution + return self.bits(bit_count) + offset + + +def _skip_size_header(reader: _BitReader) -> None: + small = reader.bit() + if small: + reader.bits(5) + else: + reader.u32(_U32_DIMENSION) + if reader.bits(3) == 0: + if small: + reader.bits(5) + else: + reader.u32(_U32_DIMENSION) + + +def _skip_preview_header(reader: _BitReader) -> None: + div8 = reader.bit() + distributions = _U32_PREVIEW_DIV8 if div8 else _U32_PREVIEW + reader.u32(distributions) + if reader.bits(3) == 0: + reader.u32(distributions) + + +def _skip_animation_header(reader: _BitReader) -> None: + reader.u32(_U32_TPS_NUMERATOR) + reader.u32(_U32_TPS_DENOMINATOR) + reader.u32(_U32_NUM_LOOPS) + reader.bit() # have_timecodes + + +def _skip_bit_depth(reader: _BitReader) -> None: + if reader.bit(): # floating point + reader.u32(_U32_FLOAT_BITS_PER_SAMPLE) + reader.bits(4) # exponent bits - 1 + else: + reader.u32(_U32_BITS_PER_SAMPLE) + + +def _skip_extra_channel_info(reader: _BitReader) -> None: + if reader.bit(): # all default: an 8 bit alpha channel + return + channel_type = reader.u32(_U32_ENUM) + _skip_bit_depth(reader) + reader.u32(_U32_DIM_SHIFT) + reader.bits(8 * reader.u32(_U32_NAME_LEN)) # name + if channel_type == _EXTRA_CHANNEL_ALPHA: + reader.bit() # alpha_associated + elif channel_type == _EXTRA_CHANNEL_SPOT_COLOR: + reader.bits(_SPOT_COLOR_BITS) + elif channel_type == _EXTRA_CHANNEL_CFA: + reader.u32(_U32_CFA_CHANNEL) + + +def _skip_extra_fields(reader: _BitReader) -> None: + reader.bits(3) # orientation + if reader.bit(): + _skip_size_header(reader) # intrinsic size + if reader.bit(): + _skip_preview_header(reader) + if reader.bit(): + _skip_animation_header(reader) + + +def _is_xyb_encoded(codestream: bytes) -> bool | None: + """ + Parse the image header far enough to read the ``xyb_encoded`` flag. + + XYB is a lossy colour transform, so an XYB-encoded file cannot be + lossless. Returns None when the header cannot be parsed, which the + caller treats as "do not touch this file". + """ + if not codestream.startswith(_CODESTREAM_SIG): + return None + reader = _BitReader(codestream[len(_CODESTREAM_SIG) :]) + try: + _skip_size_header(reader) + if reader.bit(): # ImageMetadata all_default; xyb_encoded defaults true + return True + if reader.bit(): # extra_fields + _skip_extra_fields(reader) + _skip_bit_depth(reader) + reader.bit() # modular_16bit_buffers + extra_channel_count = reader.u32(_U32_NUM_EXTRA_CHANNELS) + if extra_channel_count > _MAX_EXTRA_CHANNELS: + return None + for _ in range(extra_channel_count): + _skip_extra_channel_info(reader) + except (_TruncatedError, IndexError): + return None + else: + return bool(reader.bit()) + + +def _read_box_header(buffer: BinaryIO) -> tuple[bytes, int, int] | None: + """Return (box_type, total_box_size, header_length) or None at the end.""" + header = buffer.read(_BOX_HEADER_LEN) + if len(header) < _BOX_HEADER_LEN: + return None + size = int.from_bytes(header[:4], "big") + box_type = header[4:8] + header_len = _BOX_HEADER_LEN + if size == 1: + extended = buffer.read(_BOX_EXT_HEADER_LEN - _BOX_HEADER_LEN) + if len(extended) < _BOX_EXT_HEADER_LEN - _BOX_HEADER_LEN: + return None + size = int.from_bytes(extended, "big") + header_len = _BOX_EXT_HEADER_LEN + if size and size < header_len: + return None + return box_type, size, header_len + + +def _walk_container(buffer: BinaryIO) -> tuple[bytes, bool]: + """ + Walk the ISO BMFF boxes, returning (codestream_head, has_reconstruction). + + Payloads are skipped with seeks; only the leading codestream bytes are + buffered, and only up to the header ceiling. + """ + codestream = b"" + has_reconstruction = False + buffer.seek(len(_CONTAINER_SIG)) + while True: + box = _read_box_header(buffer) + if box is None: + break + box_type, size, header_len = box + payload_start = buffer.tell() + if box_type == _JPEG_RECONSTRUCTION_BOX: + has_reconstruction = True + elif box_type == _BROTLI_BOX: + # A brotli-compressed box names its inner type in the first four + # payload bytes. + has_reconstruction = has_reconstruction or ( + buffer.read(4) == _JPEG_RECONSTRUCTION_BOX + ) + elif box_type in (_CODESTREAM_BOX, _PARTIAL_CODESTREAM_BOX) and ( + len(codestream) < _MAX_HEADER_BYTES + ): + if box_type == _PARTIAL_CODESTREAM_BOX: + buffer.seek(payload_start + _JXLP_INDEX_LEN) + codestream += buffer.read(_MAX_HEADER_BYTES - len(codestream)) + if size == 0: + # A zero size means the box runs to the end of the file. + break + buffer.seek(payload_start + size - header_len) + return codestream, has_reconstruction + + +def is_lossless(input_buffer: BinaryIO) -> bool: + """ + Report whether picopt may losslessly re-encode this JXL. + + False for lossy (XYB-encoded) files, for files carrying JPEG + reconstruction data, and for anything that cannot be parsed. Leaves the + buffer position at 0; the caller owns closing it. + """ + try: + input_buffer.seek(0) + signature = input_buffer.read(len(_CONTAINER_SIG)) + if signature.startswith(_CODESTREAM_SIG): + input_buffer.seek(0) + codestream = input_buffer.read(_MAX_HEADER_BYTES) + has_reconstruction = False + elif signature == _CONTAINER_SIG: + codestream, has_reconstruction = _walk_container(input_buffer) + else: + return False + if has_reconstruction: + return False + return _is_xyb_encoded(codestream) is False + finally: + input_buffer.seek(0) diff --git a/picopt/plugins/base/handler.py b/picopt/plugins/base/handler.py index 32eac2bc..57cfb651 100644 --- a/picopt/plugins/base/handler.py +++ b/picopt/plugins/base/handler.py @@ -56,6 +56,11 @@ class Handler(ABC): - ``PIPELINE``: tuple-of-tuples of :class:`Tool` instances. The outer tuple is sequential pipeline tiers; each inner tuple is alternatives for that tier. This replaces the old ``PROGRAMS`` mechanism. + - ``CONFIG_ENABLED_KEY``: name of a boolean config option that must be + true for this handler to run at all. Empty means always enabled. + Handlers that fail the check are simply left out of the probed + handler map, which the routing layer already reads as "unavailable" + and falls through. """ SUFFIXES: tuple[str, ...] = () @@ -65,6 +70,7 @@ class Handler(ABC): ) INPUT_FILE_FORMATS: frozenset[FileFormat] = frozenset() PIPELINE: tuple[tuple[Tool, ...], ...] = () + CONFIG_ENABLED_KEY: str = "" # ------------------------------------------------------------------ init diff --git a/picopt/plugins/base/image.py b/picopt/plugins/base/image.py index 798f89d0..e40080dd 100644 --- a/picopt/plugins/base/image.py +++ b/picopt/plugins/base/image.py @@ -84,6 +84,19 @@ def prepare_info(self, format_str: str) -> MappingProxyType[str, Any]: } return MappingProxyType(info) + # ---------------------------------------------------------- image munging + + def prepare_image(self, image: Image.Image, format_str: str) -> Image.Image: + """ + Return the image to hand to ``Image.save``. + + A hook for encoders that accept only a subset of PIL modes; the + default saves whatever was opened. Symmetric with + :meth:`prepare_info`, which does the same job for the info dict. + """ + del format_str + return image + # ----------------------------------------------------------- pil_save def pil_save( @@ -106,7 +119,7 @@ def pil_save( info = self.prepare_info(format_str) output_buffer = BytesIO() with Image.open(input_buffer) as image: - image.save( + self.prepare_image(image, format_str).save( output_buffer, format_str, save_all=True, diff --git a/picopt/plugins/jxl.py b/picopt/plugins/jxl.py new file mode 100644 index 00000000..1aec4d47 --- /dev/null +++ b/picopt/plugins/jxl.py @@ -0,0 +1,254 @@ +""" +JPEG XL format plugin. + +Owns: JXL (still). Tool: the ``pillow-jxl-plugin`` Pillow codec, driven +in-process. Three handlers with different jobs: + +* :class:`JxlLossless` re-encodes lossless JXL in place and is the target + for every lossless still conversion. Only files that + :func:`picopt.pillow.jxl.is_lossless` accepts are routed here, so lossy + (XYB-encoded) JXL is left alone exactly as lossy WebP is. +* :class:`JxlFromJpeg` converts JPEG to JXL by lossless bitstream + reconstruction, which is reversible: the original JPEG can be restored + byte for byte. +* :class:`JxlFromWebP` converts lossless WebP to JXL. + +The last two exist only to carry their own opt-in flag, so each is doubly +gated: ``--convert-to JXL`` *and* its own option. JPEG needs that because +picopt otherwise never uses a lossy format as a conversion source. WebP +needs it because JXL support in the wider world is still thin, and +converting away from a format every browser reads deserves to be +deliberate. Both are formats picopt processes by default, so the usual +"name the source with ``-x``" gate cannot express the choice. + +Animation is deliberately absent: the codec can neither read nor write +animated JXL, so no animated route points here in either direction. +""" + +from __future__ import annotations + +import os +from io import BytesIO +from tempfile import mkstemp +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Final + +from loguru import logger +from PIL import Image +from typing_extensions import override + +from picopt.pillow.jxl import JXL_FORMAT_STR +from picopt.plugins.base import ( + Handler, + ImageHandler, + InternalTool, + PILSaveTool, + Plugin, + Route, + Tool, +) +from picopt.plugins.base.format import FileFormat +from picopt.plugins.gif import Gif +from picopt.plugins.jpeg import Jpeg +from picopt.plugins.png import Png +from picopt.plugins.webp.static import WebPLossless + +if TYPE_CHECKING: + from pathlib import Path + from typing import BinaryIO + +# effort 9 is the slowest setting that still targets size; 10 exists but +# costs disproportionately more time for a fraction of a percent. +_EFFORT: Final[int] = 9 + +# The modes pillow-jxl-plugin can encode. Anything else raises +# NotImplementedError, so it is converted first. +_VALID_MODES: Final[frozenset[str]] = frozenset({"RGB", "RGBA", "L", "LA", "I;16", "F"}) +# Substitutes for the modes the encoder rejects. Palette images need the +# transparency check, so they are resolved in code rather than here. +_MODE_SUBSTITUTES: Final[MappingProxyType[str, str]] = MappingProxyType( + { + "1": "L", + "I": "I;16", + "PA": "RGBA", + "CMYK": "RGB", + "YCbCr": "RGB", + "LAB": "RGB", + "HSV": "RGB", + } +) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +class JpegXlReconstructTool(InternalTool): + """ + Transcode a JPEG into JXL, preserving the original JPEG bitstream. + + The codec only takes its reconstruction path when PIL knows the source + *filename*, and PIL sets that only when opened from a path — never from + a file object. So this tool opens by path, spilling to a temp file for + inputs that have none (archive members). Without that, the encode + silently falls back to a plain pixel encode: still lossless, but no + longer reversible and usually larger. + """ + + name = "pil2jxl_jpeg" + module_name = "pillow_jxl" + PACKAGE_NAME = "pillow-jxl-plugin" + + _SAVE_KWARGS: Final[MappingProxyType[str, Any]] = MappingProxyType( + { + "lossless_jpeg": True, + # Belt and braces: if the reconstruction path is ever skipped, + # this keeps the pixel fallback lossless instead of quality 90. + "lossless": True, + "effort": _EFFORT, + } + ) + + @override + def run_stage(self, handler: Handler, buf: BinaryIO) -> BinaryIO: + input_path, is_tmp = self._input_path(handler, buf) + try: + output_buffer = BytesIO() + with Image.open(input_path) as image: + image.save(output_buffer, JXL_FORMAT_STR, **self._SAVE_KWARGS) + finally: + if is_tmp: + input_path.unlink(missing_ok=True) + return output_buffer + + @staticmethod + def _input_path(handler: Handler, buf: BinaryIO) -> tuple[Path, bool]: + """Return a real path for the input and whether it is ours to delete.""" + path = handler.path_info.path + if path is not None and not isinstance(buf, BytesIO): + return path, False + working_path = handler.get_working_path() + fd, tmp_str = mkstemp(prefix=working_path.name + ".", suffix=".jpg") + os.close(fd) + tmp_path = type(working_path)(tmp_str) + buf.seek(0) + with tmp_path.open("wb") as tmp_file: + tmp_file.write(buf.read()) + buf.seek(0) + return tmp_path, True + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +class JxlLossless(ImageHandler): + """Lossless still JXL handler.""" + + OUTPUT_FORMAT_STR = JXL_FORMAT_STR + OUTPUT_FILE_FORMAT = FileFormat(JXL_FORMAT_STR, lossless=True, animated=False) + # OUTPUT_FILE_FORMAT is deliberately absent: pil_save short-circuits when + # the input is already an acceptable input format, which for a + # single-tier JXL -> JXL pipeline would make re-optimization a no-op. + # Png is listed so this handler is probed whenever PNG is requested, + # which is what makes `-f PNG -c JXL` work. + INPUT_FILE_FORMATS = frozenset({Png.OUTPUT_FILE_FORMAT}) + SUFFIXES: tuple[str, ...] = (".jxl",) + + PIL2_KWARGS: MappingProxyType[str, Any] = MappingProxyType( + {"lossless": True, "effort": _EFFORT} + ) + PIPELINE: tuple[tuple[Tool, ...], ...] = ( + (PILSaveTool(target_format_str=JXL_FORMAT_STR, name="pil2jxl"),), + ) + + @override + def prepare_info(self, format_str: str) -> MappingProxyType[str, Any]: + """Suppress the encoder's own EXIF fallback when stripping metadata.""" + info = super().prepare_info(format_str) + if format_str == JXL_FORMAT_STR and not self.config.keep_metadata: + # Given no exif kwarg, the encoder reads EXIF off the opened + # image itself, which would quietly defeat --strip-metadata. + # An empty value takes the kwarg branch and writes no EXIF box. + info = MappingProxyType({**info, "exif": b""}) + return info + + def _substitute_mode(self, image: Image.Image) -> str | None: + """Return a mode the encoder accepts, or None if it accepts this one.""" + if image.mode in _VALID_MODES: + return None + if image.mode == "P": + return "RGBA" if "transparency" in image.info else "RGB" + return _MODE_SUBSTITUTES.get(image.mode, "RGB") + + @override + def prepare_image(self, image: Image.Image, format_str: str) -> Image.Image: + """Convert modes the JXL encoder cannot represent.""" + if format_str != JXL_FORMAT_STR: + return image + mode = self._substitute_mode(image) + if mode is None: + return image + msg = ( + f"Converting {self.path_info.full_output_name()} from mode " + f"{image.mode} to {mode} for JPEG XL" + ) + logger.debug(msg) + return image.convert(mode) + + +class JxlFromJpeg(ImageHandler): + """Reversible JPEG to JXL transcoder, gated behind its own option.""" + + OUTPUT_FORMAT_STR = JXL_FORMAT_STR + OUTPUT_FILE_FORMAT = JxlLossless.OUTPUT_FILE_FORMAT + INPUT_FILE_FORMATS = frozenset({Jpeg.OUTPUT_FILE_FORMAT}) + SUFFIXES: tuple[str, ...] = (".jxl",) + CONFIG_ENABLED_KEY = "convert_jpeg_to_jxl" + + PIPELINE: tuple[tuple[Tool, ...], ...] = ((JpegXlReconstructTool(),),) + + +class JxlFromWebP(JxlLossless): + """ + Lossless WebP to JXL, gated behind its own option. + + Identical to its parent in every respect but the gate. It is a separate + class only because the flag hangs off the handler, and JxlLossless also + serves PNG, GIF and native JXL, which must stay ungated. + """ + + INPUT_FILE_FORMATS = frozenset({WebPLossless.OUTPUT_FILE_FORMAT}) + CONFIG_ENABLED_KEY = "convert_webp_to_jxl" + + +# --------------------------------------------------------------------------- +# Plugin descriptor +# --------------------------------------------------------------------------- + +PLUGIN = Plugin( + name="JXL", + handlers=(JxlLossless, JxlFromJpeg, JxlFromWebP), + routes=( + # Native, plus the reverse conversions out of JXL. Converting away + # from JXL to a more widely readable format is never gated. + Route( + file_format=JxlLossless.OUTPUT_FILE_FORMAT, + native=JxlLossless, + convert=(WebPLossless, Png), + ), + # Lossless still sources that convert on a plain -c JXL. Formats + # owned by pil_convertible (BMP, TIFF, PNM, …) get JXL added to + # their convert chain there, and still need their own -x. + Route(file_format=Png.OUTPUT_FILE_FORMAT, convert=(JxlLossless,)), + Route(file_format=Gif.OUTPUT_FILE_FORMAT, convert=(JxlLossless,)), + # Sources picopt processes by default, so -x cannot gate them and + # each carries its own opt-in flag instead. + Route(file_format=Jpeg.OUTPUT_FILE_FORMAT, convert=(JxlFromJpeg,)), + Route(file_format=WebPLossless.OUTPUT_FILE_FORMAT, convert=(JxlFromWebP,)), + ), + convert_targets=(JxlLossless, JxlFromJpeg, JxlFromWebP), + default_enabled=True, +) diff --git a/picopt/plugins/pil_convertible.py b/picopt/plugins/pil_convertible.py index 802d89b8..16612cae 100644 --- a/picopt/plugins/pil_convertible.py +++ b/picopt/plugins/pil_convertible.py @@ -46,6 +46,7 @@ from picopt.plugins.base import Plugin, Route from picopt.plugins.base.format import FileFormat +from picopt.plugins.jxl import JxlLossless from picopt.plugins.png import Png, PngAnimated from picopt.plugins.webp import ( Img2WebPAnimatedLossless, @@ -147,10 +148,11 @@ def _file_format(image_class: type[Image], *, animated: bool) -> FileFormat: # --------------------------------------------------------------------------- # Convert targets for still PIL inputs, in preference order. -# WebPLossless wins because lossless WebP usually beats lossless PNG on -# size for natural images; Png is the fallback when the user has WebP -# disabled. -_STILL_CONVERT_TARGETS = (WebPLossless, Png) +# JxlLossless wins because lossless JPEG XL generally beats both; +# WebPLossless comes next because lossless WebP usually beats lossless PNG +# on size for natural images; Png is the fallback when the user has the +# others disabled. +_STILL_CONVERT_TARGETS = (JxlLossless, WebPLossless, Png) # Convert targets for animated PIL inputs (FLI, multipage TIFF, …). # webpmux is intentionally absent because it can only read existing diff --git a/picopt/walk/detect_format.py b/picopt/walk/detect_format.py index 5931c23c..47721777 100644 --- a/picopt/walk/detect_format.py +++ b/picopt/walk/detect_format.py @@ -14,6 +14,10 @@ * TIFF lossless-vs-lossy depends on the ``compression`` info key; :func:`picopt.plugins.pil_convertible.is_tiff_lossless` owns the codec set. + * JXL is lossless or lossy at the same format string too, and the codec + exposes neither that nor whether the file carries JPEG reconstruction + data; :func:`picopt.pillow.jxl.is_lossless` parses the codestream + header to decide. Every other PIL format string is checked against :func:`registry.lossless_format_strs`. @@ -35,6 +39,8 @@ from picopt import plugins as registry from picopt.exceptions import UnreadableImageError +from picopt.pillow.jxl import JXL_FORMAT_STR as _JXL_FORMAT_STR +from picopt.pillow.jxl import is_lossless as _jxl_is_lossless from picopt.pillow.webp_lossless import is_lossless as _webp_is_lossless from picopt.plugins.base.format import FileFormat from picopt.plugins.pil_convertible import is_tiff_lossless @@ -107,6 +113,9 @@ def _is_lossless( if image_format_str == _WEBP_FORMAT_STR: with path_info.fp_or_buffer() as buffer: return _webp_is_lossless(buffer) + if image_format_str == _JXL_FORMAT_STR: + with path_info.fp_or_buffer() as buffer: + return _jxl_is_lossless(buffer) if image_format_str == _TIFF_FORMAT_STR: return is_tiff_lossless(dict(info)) return image_format_str in registry.lossless_format_strs() diff --git a/pyproject.toml b/pyproject.toml index 6c61a5e8..820a1176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "oxipng-pybind>=1.0", "piexif~=1.1", "pikepdf>=10.5.1", + "pillow-jxl-plugin~=1.3", "Pillow>=12.0", "py7zr~=1.0", "python-dateutil~=2.8", @@ -31,12 +32,12 @@ dependencies = [ "typing-extensions~=4.13", ] description = "A multi format lossless image optimizer that uses external tools" -keywords = ["cbr", "cbz", "image", "jpg", "png"] +keywords = ["cbr", "cbz", "image", "jpg", "jxl", "png"] readme = "README.md" requires-python = ">=3.10" license = { text = "GPL-3.0-only" } name = "picopt" -version = "6.6.3" +version = "6.7.0" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" diff --git a/tests/test_atomic_replace.py b/tests/test_atomic_replace.py index c066c7aa..84360be7 100644 --- a/tests/test_atomic_replace.py +++ b/tests/test_atomic_replace.py @@ -50,6 +50,8 @@ def _make_settings(*, dry_run: bool = False, preserve: bool = False) -> PicoptSe ) return PicoptSettings( bigger=False, + convert_jpeg_to_jxl=False, + convert_webp_to_jxl=False, dry_run=dry_run, fail_fast=False, fail_fast_container=False, diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index cd949517..6e7c9986 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -11,6 +11,7 @@ from picopt.config import PicoptConfig from picopt.doctor import PicoptDoctor from picopt.plugins.base.tool import Tool, ToolStatus +from picopt.plugins.jxl import JxlFromJpeg, JxlFromWebP, JxlLossless __all__ = () @@ -71,6 +72,37 @@ def test_lowercase_extra_formats_and_convert_to_accepted(self) -> None: assert settings.convert_to is not None assert "WEBP" in settings.convert_to + def test_lowercase_jxl_convert_to_accepted(self) -> None: + settings = _get_settings("-c", "jxl") + assert settings.convert_to is not None + assert "JXL" in settings.convert_to + + +class TestHandlerConfigGate: + """A handler declaring CONFIG_ENABLED_KEY only runs when the flag is on.""" + + def test_gated_handlers_absent_by_default(self) -> None: + settings = _get_settings("-c", "JXL") + assert JxlFromJpeg.CONFIG_ENABLED_KEY == "convert_jpeg_to_jxl" + assert JxlFromWebP.CONFIG_ENABLED_KEY == "convert_webp_to_jxl" + assert JxlFromJpeg not in settings.computed.handler_stages + assert JxlFromWebP not in settings.computed.handler_stages + # The unconditional handler alongside them is still available. + assert JxlLossless in settings.computed.handler_stages + + def test_jpeg_to_jxl_handler_present_when_enabled(self) -> None: + settings = _get_settings("-c", "JXL", "--convert-jpeg-to-jxl") + assert settings.convert_jpeg_to_jxl is True + assert JxlFromJpeg in settings.computed.handler_stages + # Each flag gates only its own handler. + assert JxlFromWebP not in settings.computed.handler_stages + + def test_webp_to_jxl_handler_present_when_enabled(self) -> None: + settings = _get_settings("-c", "JXL", "--convert-webp-to-jxl") + assert settings.convert_webp_to_jxl is True + assert JxlFromWebP in settings.computed.handler_stages + assert JxlFromJpeg not in settings.computed.handler_stages + class _FakeTool(Tool): """Probe-only tool with a fixed availability.""" diff --git a/tests/test_files/images/test_jxl_lossless.jxl b/tests/test_files/images/test_jxl_lossless.jxl new file mode 100644 index 00000000..58c004b3 Binary files /dev/null and b/tests/test_files/images/test_jxl_lossless.jxl differ diff --git a/tests/test_files/images/test_jxl_lossy.jxl b/tests/test_files/images/test_jxl_lossy.jxl new file mode 100644 index 00000000..b273388d Binary files /dev/null and b/tests/test_files/images/test_jxl_lossy.jxl differ diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index de62c04d..d1a10c09 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -18,9 +18,11 @@ "test_jpg.jpg", "test_gif.gif", "test_webp_lossless.webp", + # A lossless JXL, which is re-encoded natively. + "test_jxl_lossless.jxl", ) CBZ_FN = "test_cbz.cbz" -FORMATS_ARG = "PNG,JPEG,GIF,WEBP,CBZ" +FORMATS_ARG = "PNG,JPEG,GIF,WEBP,JXL,CBZ" def _pixels(path: Path) -> bytes: diff --git a/tests/test_jxl.py b/tests/test_jxl.py new file mode 100644 index 00000000..335b7751 --- /dev/null +++ b/tests/test_jxl.py @@ -0,0 +1,255 @@ +"""Test the JPEG XL format.""" + +import shutil +from pathlib import Path +from types import MappingProxyType + +import piexif +import pillow_jxl +import pytest +from PIL import Image + +from picopt import PROGRAM_NAME, cli +from tests import IMAGES_DIR, assert_size_close, get_test_dir +from tests.base import BaseTest + +__all__ = () + +# orig, no convert, convert to jxl +FNS = MappingProxyType( + { + # A lossless (non XYB) JXL: re-encoded in place. + "test_jxl_lossless.jxl": (92889, 91811, ("jxl", 91811)), + # A lossy (XYB encoded) JXL: never touched, exactly like lossy WebP. + "test_jxl_lossy.jxl": (77911, 77911, ("jxl", 77911)), + # Lossless still sources convert. + "test_png.png": (7967, 4152, ("jxl", 3927)), + "test_gif.gif": (138952, 138944, ("jxl", 115204)), + # Neither of these convert without their own opt-in flag. + "test_jpg.jpg": (97373, 87913, ("jpg", 87913)), + "test_webp_lossless.webp": (5334, 3870, ("webp", 3870)), + } +) + +JPEG_SRC = "test_jpg.jpg" +JPEG_JXL_SIZE = 78762 +WEBP_SRC = "test_webp_lossless.webp" +WEBP_JXL_SIZE = 3901 + + +@pytest.mark.parametrize("fn", FNS) +class TestJxl(BaseTest): + """Test the JXL handlers over the shared image fixtures.""" + + TMP_ROOT: Path = get_test_dir() + SOURCE_DIR: Path = IMAGES_DIR + FNS: MappingProxyType[str, tuple] = FNS + + def test_no_convert(self: "TestJxl", fn: str) -> None: + """Lossless JXL is optimized in place; lossy JXL is left alone.""" + args = (PROGRAM_NAME, "-rvv", str(self.TMP_ROOT)) + cli.main(args) + path = self.TMP_ROOT / fn + assert_size_close(path.stat().st_size, FNS[fn][1]) + + def test_convert_to_jxl(self: "TestJxl", fn: str) -> None: + """Lossless stills convert; JPEG does not without its own flag.""" + args = (PROGRAM_NAME, "-rvv", "-c", "JXL", str(self.TMP_ROOT)) + cli.main(args) + suffix, size = FNS[fn][2] + path = (self.TMP_ROOT / fn).with_suffix("." + suffix) + assert_size_close(path.stat().st_size, size) + + +@pytest.mark.parametrize("fn", [JPEG_SRC]) +class TestJxlFromJpeg(BaseTest): + """Test the opt-in, reversible JPEG to JXL transcode.""" + + TMP_ROOT: Path = get_test_dir() + SOURCE_DIR: Path = IMAGES_DIR + FNS: MappingProxyType[str, tuple] = FNS + + @staticmethod + def _reconstruct(path: Path) -> tuple[bool, bytes]: + """Return (carries reconstruction data, the restored JPEG bytes).""" + result = pillow_jxl.Decoder()(path.read_bytes()) + return bool(result[0]), bytes(result[2]) + + def test_convert_jpeg_to_jxl_is_reversible( + self: "TestJxlFromJpeg", fn: str + ) -> None: + """The whole point: the original JPEG must come back byte for byte.""" + original = (self.SOURCE_DIR / fn).read_bytes() + args = ( + PROGRAM_NAME, + "-rvv", + "-c", + "JXL", + "--convert-jpeg-to-jxl", + str(self.TMP_ROOT), + ) + cli.main(args) + + path = (self.TMP_ROOT / fn).with_suffix(".jxl") + assert path.exists() + assert not (self.TMP_ROOT / fn).exists() + assert_size_close(path.stat().st_size, JPEG_JXL_SIZE) + + has_reconstruction, restored = self._reconstruct(path) + assert has_reconstruction, "encoded without JPEG reconstruction data" + assert restored == original, "JPEG did not survive the transcode intact" + + def test_reconstruction_jxl_is_never_reencoded( + self: "TestJxlFromJpeg", fn: str + ) -> None: + """A second pass must not strip the reconstruction data.""" + args = ( + PROGRAM_NAME, + "-rvv", + "-c", + "JXL", + "--convert-jpeg-to-jxl", + str(self.TMP_ROOT), + ) + cli.main(args) + path = (self.TMP_ROOT / fn).with_suffix(".jxl") + first = path.read_bytes() + + cli.main((PROGRAM_NAME, "-rvv", str(self.TMP_ROOT))) + + assert path.read_bytes() == first + has_reconstruction, _ = self._reconstruct(path) + assert has_reconstruction + + +@pytest.mark.parametrize("fn", [WEBP_SRC]) +class TestJxlFromWebP(BaseTest): + """Test the opt-in lossless WebP to JXL conversion.""" + + TMP_ROOT: Path = get_test_dir() + SOURCE_DIR: Path = IMAGES_DIR + FNS: MappingProxyType[str, tuple] = FNS + + def test_convert_webp_to_jxl(self: "TestJxlFromWebP", fn: str) -> None: + """With the flag, a lossless WebP becomes a JXL.""" + args = ( + PROGRAM_NAME, + "-rvv", + "-c", + "JXL", + "--convert-webp-to-jxl", + str(self.TMP_ROOT), + ) + cli.main(args) + path = (self.TMP_ROOT / fn).with_suffix(".jxl") + assert path.exists() + assert not (self.TMP_ROOT / fn).exists() + assert_size_close(path.stat().st_size, WEBP_JXL_SIZE) + + def test_jpeg_flag_does_not_enable_webp(self: "TestJxlFromWebP", fn: str) -> None: + """Each flag gates only its own source format.""" + args = ( + PROGRAM_NAME, + "-rvv", + "-c", + "JXL", + "--convert-jpeg-to-jxl", + str(self.TMP_ROOT), + ) + cli.main(args) + assert (self.TMP_ROOT / fn).exists() + assert not (self.TMP_ROOT / fn).with_suffix(".jxl").exists() + + +EXTRA_FNS = MappingProxyType( + { + "test_bmp.bmp": (141430, 141430, ("jxl", 40804)), + "test_pnm.pnm": (27661, 27661, ("jxl", 11840)), + "eight.tif": (59640, 59640, ("jxl", 21781)), + } +) + + +@pytest.mark.parametrize("fn", EXTRA_FNS) +class TestJxlFromExtraFormats(BaseTest): + """The pil_convertible sources reach JXL through their convert chain.""" + + TMP_ROOT: Path = get_test_dir() + SOURCE_DIR: Path = IMAGES_DIR + FNS: MappingProxyType[str, tuple] = EXTRA_FNS + + def test_convert_to_jxl(self: "TestJxlFromExtraFormats", fn: str) -> None: + """Convert BMP, PPM and TIFF to JXL.""" + args = ( + PROGRAM_NAME, + "-rvvx", + "BMP,PPM,TIFF", + "-c", + "JXL", + str(self.TMP_ROOT), + ) + cli.main(args) + suffix, size = EXTRA_FNS[fn][2] + path = (self.TMP_ROOT / fn).with_suffix("." + suffix) + assert_size_close(path.stat().st_size, size) + + +class TestJxlMetadata: + """--strip-metadata must reach the JXL encoder.""" + + TMP_ROOT: Path = get_test_dir() / "metadata" + + @classmethod + def _write_jxl_with_exif(cls, name: str) -> Path: + exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: b"picopt-test"}}) + path = cls.TMP_ROOT / name + with Image.open(IMAGES_DIR / "test_png.png") as image: + image.convert("RGB").save(path, "JXL", lossless=True, effort=7, exif=exif) + return path + + def setup_method(self: "TestJxlMetadata") -> None: + """Make a clean tmp dir.""" + shutil.rmtree(self.TMP_ROOT, ignore_errors=True) + self.TMP_ROOT.mkdir(parents=True) + + def teardown_method(self: "TestJxlMetadata") -> None: + """Remove the tmp dir.""" + shutil.rmtree(self.TMP_ROOT, ignore_errors=True) + + @staticmethod + def _has_exif(path: Path) -> bool: + with Image.open(path) as image: + return bool(image.info.get("exif")) + + def test_metadata_kept_by_default(self: "TestJxlMetadata") -> None: + """A plain run preserves EXIF.""" + path = self._write_jxl_with_exif("keep.jxl") + assert self._has_exif(path) + cli.main((PROGRAM_NAME, "-rq", str(self.TMP_ROOT))) + assert self._has_exif(path) + + def test_strip_metadata_removes_exif(self: "TestJxlMetadata") -> None: + """The encoder reads EXIF off the image unless told otherwise.""" + path = self._write_jxl_with_exif("strip.jxl") + assert self._has_exif(path) + cli.main((PROGRAM_NAME, "-rqM", str(self.TMP_ROOT))) + assert not self._has_exif(path) + + +@pytest.mark.parametrize("fn", ["test_png.png"]) +class TestJxlIdempotency(BaseTest): + """Repeated runs must converge instead of growing the file.""" + + TMP_ROOT: Path = get_test_dir() + SOURCE_DIR: Path = IMAGES_DIR + FNS: MappingProxyType[str, tuple] = FNS + + def test_repeated_runs_do_not_grow(self: "TestJxlIdempotency", fn: str) -> None: + """Convert once, then optimize twice more with no change.""" + cli.main((PROGRAM_NAME, "-rvv", "-c", "JXL", str(self.TMP_ROOT))) + path = (self.TMP_ROOT / fn).with_suffix(".jxl") + converted = path.read_bytes() + + for _ in range(2): + cli.main((PROGRAM_NAME, "-rvv", str(self.TMP_ROOT))) + assert path.stat().st_size <= len(converted) diff --git a/tests/test_jxl_detect.py b/tests/test_jxl_detect.py new file mode 100644 index 00000000..db5777e3 --- /dev/null +++ b/tests/test_jxl_detect.py @@ -0,0 +1,104 @@ +"""Test the JXL codestream sniffer.""" + +import io +from pathlib import Path + +import pytest +from PIL import Image + +# Importing this registers the JXL codec with Pillow, which the encodes below +# rely on. +from picopt.pillow.jxl import is_lossless +from tests import IMAGES_DIR + +__all__ = () + +_CONTAINER_SIG = b"\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a" +_SRC = IMAGES_DIR / "test_png.png" +_JPEG_SRC = IMAGES_DIR / "test_jpg.jpg" + + +def _encode(src: Path, mode: str | None = None, **kwargs) -> bytes: + with Image.open(src) as opened: + image = opened.convert(mode) if mode else opened + buf = io.BytesIO() + image.save(buf, "JXL", effort=7, **kwargs) + return buf.getvalue() + + +def test_codec_is_registered() -> None: + """ + Importing the module must register the codec, read and write. + + The import exists only for that side effect, which makes it look unused; + autofixers have removed it before. + """ + assert "JXL" in Image.OPEN + assert "JXL" in Image.SAVE + # picopt's pil_save always passes save_all=True, which raises KeyError + # for formats missing from this registry. + assert "JXL" in Image.SAVE_ALL + + +@pytest.mark.parametrize("mode", ["RGB", "RGBA", "L"]) +def test_lossless_encodes_are_lossless(mode: str) -> None: + """A lossless encode is detected in both container and bare form.""" + for use_container in (False, True): + data = _encode(_SRC, mode, lossless=True, use_container=use_container) + assert (data[:12] == _CONTAINER_SIG) is use_container + assert is_lossless(io.BytesIO(data)) + + +@pytest.mark.parametrize("mode", ["RGB", "RGBA", "L"]) +def test_lossy_encodes_are_not_lossless(mode: str) -> None: + """An XYB encoded file is never offered for re-encoding.""" + for use_container in (False, True): + data = _encode( + _SRC, mode, lossless=False, quality=90, use_container=use_container + ) + assert not is_lossless(io.BytesIO(data)) + + +def test_sixteen_bit_lossless() -> None: + """16 bit samples parse past the extra channel info.""" + data = _encode(IMAGES_DIR / "test_png_16rgba.png", lossless=True) + assert is_lossless(io.BytesIO(data)) + + +def test_jpeg_reconstruction_is_not_reencodable() -> None: + """Re-encoding would drop the jbrd box, so it must report False.""" + data = _encode(_JPEG_SRC, lossless_jpeg=True, lossless=True) + assert not is_lossless(io.BytesIO(data)) + + +def test_fixtures() -> None: + """The committed fixtures: one lossless, one lossy.""" + assert is_lossless(io.BytesIO((IMAGES_DIR / "test_jxl_lossless.jxl").read_bytes())) + assert not is_lossless(io.BytesIO((IMAGES_DIR / "test_jxl_lossy.jxl").read_bytes())) + + +@pytest.mark.parametrize( + "data", + [ + b"", + b"\xff\x0a", + b"\xff\x0a\x01", + _CONTAINER_SIG, + _CONTAINER_SIG + b"\x00\x00", + # A box header claiming a size smaller than the header itself. + _CONTAINER_SIG + b"\x00\x00\x00\x02jxlc", + b"\x89PNG\r\n\x1a\n" + bytes(40), + bytes(64), + ], +) +def test_junk_is_never_lossless(data: bytes) -> None: + """Truncated or foreign input must be refused, not crash.""" + assert not is_lossless(io.BytesIO(data)) + + +def test_buffer_is_rewound() -> None: + """The caller's buffer position is restored, like the webp sniffer.""" + data = _encode(_SRC, "RGB", lossless=True) + buf = io.BytesIO(data) + is_lossless(buf) + assert buf.tell() == 0 diff --git a/tests/test_timestamps.py b/tests/test_timestamps.py index fff3b1fa..fc290193 100644 --- a/tests/test_timestamps.py +++ b/tests/test_timestamps.py @@ -26,8 +26,10 @@ DEFAULT_CONFIG = { "bigger": False, + "convert_jpeg_to_jxl": False, "convert_to": [], - "formats": ["GIF", "JPEG", "PNG", "SVG", "WEBP"], + "convert_webp_to_jxl": False, + "formats": ["GIF", "JPEG", "JXL", "PNG", "SVG", "WEBP"], "keep_metadata": True, "ignore": [], "near_lossless": False, diff --git a/uv.lock b/uv.lock index 85051a03..2be090d6 100644 --- a/uv.lock +++ b/uv.lock @@ -171,15 +171,15 @@ wheels = [ [[package]] name = "backrefs" -version = "7.0" +version = "8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, ] [[package]] @@ -1640,7 +1640,7 @@ wheels = [ [[package]] name = "picopt" -version = "6.6.3" +version = "6.7.0" source = { editable = "." } dependencies = [ { name = "confuse" }, @@ -1653,6 +1653,7 @@ dependencies = [ { name = "piexif" }, { name = "pikepdf" }, { name = "pillow" }, + { name = "pillow-jxl-plugin" }, { name = "py7zr" }, { name = "python-dateutil" }, { name = "rarfile" }, @@ -1713,6 +1714,7 @@ requires-dist = [ { name = "piexif", specifier = "~=1.1" }, { name = "pikepdf", specifier = ">=10.5.1" }, { name = "pillow", specifier = ">=12.0" }, + { name = "pillow-jxl-plugin", specifier = "~=1.3" }, { name = "py7zr", specifier = "~=1.0" }, { name = "python-dateutil", specifier = "~=2.8" }, { name = "rarfile", specifier = "~=4.0" }, @@ -1918,6 +1920,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] +[[package]] +name = "pillow-jxl-plugin" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/75/810608d489c2d453e16a0866de5edea2bedd728e8830da22a1ec633f3b80/pillow_jxl_plugin-1.3.8.tar.gz", hash = "sha256:4430fd7757899742079c548a7d2537d6d63cf0f1d323180095bc0f6f03d9d4fa", size = 1602233, upload-time = "2026-07-17T03:23:50.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/2f/13fcf0700d7de9e270dede2448a39f2f39764356f48b030e93aa00b378c3/pillow_jxl_plugin-1.3.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:8e5917812ab609951211c771726cfc99f161238b09a9175ea1fb122c94227b22", size = 2360623, upload-time = "2026-07-17T03:22:54.101Z" }, + { url = "https://files.pythonhosted.org/packages/95/96/b9e8c5521590308a9ae441f163a593af403130ff12f6e954f9710d18d369/pillow_jxl_plugin-1.3.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1f80e18334215195eb7f5a19e20b8448778740aab6ffb058da551c1c1d95b70", size = 2667999, upload-time = "2026-07-17T03:22:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/ce07ec0035dce02cfc00df05900b8b2f129c170d55e62eca57470353c49b/pillow_jxl_plugin-1.3.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b4c60dadc9656e2d3093310c64fc8b521dfcea299913838bef9ff496e777f65", size = 2791586, upload-time = "2026-07-17T03:22:18.684Z" }, + { url = "https://files.pythonhosted.org/packages/53/e6/f72e4c8e3ea000bb9472cd83bf3a7e78f5f6ab34ed22ff6c2e1743d5bcf7/pillow_jxl_plugin-1.3.8-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06a0d4d530c8e36f098301f426611dac4c3a7693cfb3121663f1ca8f71ba4517", size = 2504840, upload-time = "2026-07-17T03:22:27.829Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fa/92212283b7fe3c71cbabb9443b8a8ed940fb73804bdebc86d08e52cf8349/pillow_jxl_plugin-1.3.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bd82982ff69470b6dfdfdca7f4308cb32c1c6910f95511db58dc59fc0411e59", size = 2508263, upload-time = "2026-07-17T03:22:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d5/40f52b1892105d33510d0ff472f6743d570e750c33c37d3e9697fde99f94/pillow_jxl_plugin-1.3.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:be5ea80860966bcea91a6e91aaaa3dd510d537cdf5245c811d41cd3a5538f8f1", size = 9730153, upload-time = "2026-07-17T03:23:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/59/ba/e232759c07440d07479973d753a906715000cc71dd56fe7df70fd58304df/pillow_jxl_plugin-1.3.8-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:537a6f702b066bd8c69ba8202f1350a3d1e05e23bbe28f0d5f1567588a5bb16e", size = 8391469, upload-time = "2026-07-17T03:23:15.114Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ab/410e078dddb769bcbf238837add920a788cd063aea43c04fbff29ea9d02b/pillow_jxl_plugin-1.3.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:85d8496e35035e1a228daf52578bde6f94fcd908202324f32dfe3b7b0430efd0", size = 9037205, upload-time = "2026-07-17T03:23:26.945Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a0/3dcd7f6aa750ec4d01f467abb481aacca000f06f67ed1724beac6dcdfff3/pillow_jxl_plugin-1.3.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a04950cff6e0550574117512b2cf5dc3d084d2abd95c752bd7ab219f5511e79", size = 9237632, upload-time = "2026-07-17T03:23:38.387Z" }, + { url = "https://files.pythonhosted.org/packages/bc/27/3cfca1046155b0c8561d4f68fc12971853c9f3fd25aa9dae5a44544615d7/pillow_jxl_plugin-1.3.8-cp310-cp310-win32.whl", hash = "sha256:812a8f0a24d1bc751bbe4119ead7d458726558b2c1af72f99e6c381cb6d48025", size = 4557913, upload-time = "2026-07-17T03:24:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/339b34a676805d8949350d607bdd0247401cf887c565e8fab03ba2a019ec/pillow_jxl_plugin-1.3.8-cp310-cp310-win_amd64.whl", hash = "sha256:ec20f55bfad78d96376b7274671870311f53ef724e07d9d5af8bba36c363f77f", size = 4942011, upload-time = "2026-07-17T03:23:57.29Z" }, + { url = "https://files.pythonhosted.org/packages/db/46/9591ffeae92e62b70b0a297cf0de858ee69cda369a9a540b8989850f5c38/pillow_jxl_plugin-1.3.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:42bfc37327ca71e9b9c4a3d569d338db6789e0966eb89dd1fffef5fcf3c2462d", size = 2361103, upload-time = "2026-07-17T03:22:55.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/1dc8ddbfa056e39307d67286f45c8300695c9a0137ed98f60775a2c37322/pillow_jxl_plugin-1.3.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ceaca73f77fa0dc6409e85ca77da24560270d0929bdbee579714da05614c67ec", size = 2667692, upload-time = "2026-07-17T03:22:47.217Z" }, + { url = "https://files.pythonhosted.org/packages/a0/27/018351fecb8ebf35103408484dca3072f1a0ff28247193f46377ff6341f0/pillow_jxl_plugin-1.3.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c3109317d54918fe10d25d9978c2e445edefee04546f08ef47e077dc3dbe7e6", size = 2791235, upload-time = "2026-07-17T03:22:20.337Z" }, + { url = "https://files.pythonhosted.org/packages/12/09/cd6dff8524153196150bc778bfb828f44b65e301e06c34a1c7ca39049c49/pillow_jxl_plugin-1.3.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9fd3a2c5d48faca23200176015e00b4004862f4fb9016fd17a0fa374019574e", size = 2505571, upload-time = "2026-07-17T03:22:29.564Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/453f3268eb67190930294875628890b8eacd297364a2abadf206d715cdcf/pillow_jxl_plugin-1.3.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97a627c02fc660e6d36010c2bf57f1c346ce2a58cd7e83e43241f5ad174c3f18", size = 2508619, upload-time = "2026-07-17T03:22:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/a5859f365485fbc9e741d48c596f3913fa47fb2f4df77a134560d7766380/pillow_jxl_plugin-1.3.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:17fd143e6fe5c2cbb8eac1862046d63ada83ed48b6cea4753b9615a6df6f4d08", size = 9729439, upload-time = "2026-07-17T03:23:04.669Z" }, + { url = "https://files.pythonhosted.org/packages/2b/51/afa9751b8a397ad91e19a5e8afde925511abdf2a70fe98da13f6d77a567b/pillow_jxl_plugin-1.3.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a86df5b196225d230c51e391f1005e86e136142d0fce98267df285651f3b0621", size = 8390938, upload-time = "2026-07-17T03:23:17.05Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/656248d1e5ce08d6154a3c904332a12093c48f22daef3779ed344a449c02/pillow_jxl_plugin-1.3.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bcd116d028fe87379c934238cd3a984325bb4fde80c86526873a513a4d3c3b81", size = 9037333, upload-time = "2026-07-17T03:23:28.992Z" }, + { url = "https://files.pythonhosted.org/packages/34/35/d669758c1b49b04673b95e6bb275d59ed9f901d7a8346c8f560d5b6136fa/pillow_jxl_plugin-1.3.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2f11b1d5d20013a8bdab06d9d3244488db0e00a5d57e5daaefa78f67eb70f474", size = 9237613, upload-time = "2026-07-17T03:23:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/af/15ddbdd53a96005c8f048ce1aafab2e825e6445a3565408989e9f37bc237/pillow_jxl_plugin-1.3.8-cp311-cp311-win32.whl", hash = "sha256:d8ff168cbc04a5978f0a9320958e1ac2111e53ba4f7f7ae14a09f49c04b4e936", size = 4556704, upload-time = "2026-07-17T03:24:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/dc/57/ff6381e1df975840a0af201adca7d54bb6d7cd116514527c5179f8f599f5/pillow_jxl_plugin-1.3.8-cp311-cp311-win_amd64.whl", hash = "sha256:85649ffbb6a21b2830022ce723de8625fc4d33621ed09ec4687eaf57b5044f4c", size = 4941456, upload-time = "2026-07-17T03:23:58.841Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/8c9bad406251c6a7e828641102e9bae75d2e3880b866855a699bf8b6e62b/pillow_jxl_plugin-1.3.8-cp311-cp311-win_arm64.whl", hash = "sha256:d72cbe293981677e989cf4314cb18e743e42ac84269508c0693e06c8f7e66604", size = 2333977, upload-time = "2026-07-17T03:23:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/bb/093aced52ebaafcd050eb21ab07ddcab7efffaa687e264f64d3f0423a4ba/pillow_jxl_plugin-1.3.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f6fd0fa12dd0d98bb55df6bbfff7ad913e4ea27306fbdf5e8543fe1fa40772c", size = 2361426, upload-time = "2026-07-17T03:22:56.906Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9f/f272fd205ab511475b135772e85bcaa71e5f3d081e6604b08e6e1d3982a6/pillow_jxl_plugin-1.3.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01b632dbca07b59f7d6e106dad3ac8391e9ffdab1f94f2068a3e8edff1bbd180", size = 2660368, upload-time = "2026-07-17T03:22:48.663Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/3445d2b4f6d6e4e26f10e9ccd75b60a14295b2d8eb280f0b1bd0bd7caac3/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6459d131af2cbccab20742f6fe43a3022b021dcd1ee20b05f99b1fb54d0ec525", size = 2788244, upload-time = "2026-07-17T03:22:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/3855e83fd1e4043bd7ab286d3f0ca434fbf935598fae57f1af2777620891/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b9a546ac71757f53f0ca2b14ea8f99c33b70e298a7873477f4548e2d83f817", size = 2500550, upload-time = "2026-07-17T03:22:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/f0/13/d9befae0e705474e550b16c90bb6d33c3f07837af63a0e8ce4c84fb546fb/pillow_jxl_plugin-1.3.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd2afd5153e2a7ef230a3e48a679a9e1cb3e59032f79c78bb2f78010a4e9bfc1", size = 2504336, upload-time = "2026-07-17T03:22:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/52/2c/3e671c8dd84f6ed4e47d588df1dc15aae61b0df0302f4bfff7b5c743045b/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b987fb202dd1c71d7f6e103363afd1128d347ffcf540d8416c257c8f1041ff3", size = 9727025, upload-time = "2026-07-17T03:23:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/a63b75d9c05deb0017ff51ddf3674cef19dbfe9c2fb74117d7e4a66b9a93/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c05cf62a992f48434a6b876fe010e5cae12bd54039854ce9ae0f05c13a58c7ad", size = 8386443, upload-time = "2026-07-17T03:23:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4f/b6ead8f5a50103e2c6b264a6d4778b8fcd08006c957b2ad0bccbbb306e97/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0392e5349b67522130ecff9d8dca4713e4c84662a310d2170291b229965e8fd1", size = 9032022, upload-time = "2026-07-17T03:23:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/ea/53/b9aa7c156427ad4d94d8c90bd87cb502e7d6326d2689c309aa574baf1ab7/pillow_jxl_plugin-1.3.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:88b06a4e5fc0d882b188824990b6571e201ad775d22a09487c8d5ab8aed9c3b7", size = 9233286, upload-time = "2026-07-17T03:23:42.022Z" }, + { url = "https://files.pythonhosted.org/packages/32/e3/b25194909cfef9081f6a1f0e40e699e95ea96b211cae30bbf0f965d6b450/pillow_jxl_plugin-1.3.8-cp312-cp312-win32.whl", hash = "sha256:3345a45a09ba3149001c3e9a1b6991be8aab72c33097c0633e414a254cd283fa", size = 4552597, upload-time = "2026-07-17T03:24:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c9/01efd23cb4827f8a1731e1a7652023a06375e933f85e3169e0761d916d6f/pillow_jxl_plugin-1.3.8-cp312-cp312-win_amd64.whl", hash = "sha256:5b1c0d7a0c7e451014b288f0504545bff0f543f2ee1581af628e01e885b75f50", size = 4937252, upload-time = "2026-07-17T03:24:00.294Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/b3aee5725df66399f529c41d97f71893e2b38f665c9278a99519112f03f3/pillow_jxl_plugin-1.3.8-cp312-cp312-win_arm64.whl", hash = "sha256:230a1d086ef5b66055e0dbc9837914becf1d1ca58d45d1404660de0a11fb63c3", size = 2330507, upload-time = "2026-07-17T03:23:53.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/10/18bb2d6ed82184e2427f7501a17c1b413eb50bdcd4ebb42bbfb187bb4290/pillow_jxl_plugin-1.3.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:fe24e957016dcb0c2ec35f72ff3bb8ae78a9468b282f0d423485556de492975c", size = 2361184, upload-time = "2026-07-17T03:22:58.376Z" }, + { url = "https://files.pythonhosted.org/packages/89/3e/5e1156e5c5b402d84ef924153e046ba937ac46e8b14decb3ab6e01a6f40e/pillow_jxl_plugin-1.3.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d648ef965002bee0c98dbf144b9b3c994146856fa4d3c54008b75dda10f69915", size = 2659163, upload-time = "2026-07-17T03:22:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fe/e610196142343f80c98502fd204f28e901a1101db6988be6ccc40c0e8ca0/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34685b27d01e55aabb09d9436d4ddb1f446e8805da82ffd6448c07bbf563354c", size = 2787568, upload-time = "2026-07-17T03:22:23.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/a862b140f82be55ce62c681b63c38bf61c962f0c27e32797ebebc0d47548/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:288eb07090c91891ee0dc782786d937af8e53a117835606d7982c29e31072b90", size = 2499957, upload-time = "2026-07-17T03:22:32.414Z" }, + { url = "https://files.pythonhosted.org/packages/59/29/d430cd5f82c08d2950e292d195081e9dd4e2a038a7e1722d79ba7c6e7ed9/pillow_jxl_plugin-1.3.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b39ac44cbe5a98b8a364ed98a16c8c1e197ee997e32731be4be727c55e629e4", size = 2504367, upload-time = "2026-07-17T03:22:41.501Z" }, + { url = "https://files.pythonhosted.org/packages/f4/68/6806df96fdd625943c795beca932ac033c255a323b63b051903df96935b6/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0e1550f30768a0aa3f756da10b7807f6fac7fd2e5a18991eb8e00ec5908089d8", size = 9726010, upload-time = "2026-07-17T03:23:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/d12f42d6e31bb47e2002fcba2db9d92aad19dc3db9ae7d4b2b76152e268a/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b13f860c280d9d5770f49a1909926f15c6e26e50935da52ecb78700f7c377857", size = 8386305, upload-time = "2026-07-17T03:23:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/92/ee/4dc11ad5a90aa6d2eeae6eb0be2376cde0c365423036a078746b3476eca2/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:273908ffb85f68a2374b0934700939c572f32b355c9d128d4e3b6924e72ef274", size = 9031514, upload-time = "2026-07-17T03:23:32.948Z" }, + { url = "https://files.pythonhosted.org/packages/ae/76/b3125f13c9551862ad3ae7137d86959cde159b759083f3e7b5e720525fd0/pillow_jxl_plugin-1.3.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2be721646718af722f2ca5c1d6b06bf056a61a0db355cf944df53c3723119d4b", size = 9233009, upload-time = "2026-07-17T03:23:44.276Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/1aa669ae22a6c36810a925bbbfb1f4521df1d0402180052331b39ef8dee0/pillow_jxl_plugin-1.3.8-cp313-cp313-win32.whl", hash = "sha256:a2716f1367ed9647da36ce15882a12b4d424892cb5a03ab4b0783039d701c7c2", size = 4551381, upload-time = "2026-07-17T03:24:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e7/823f58aaea0d969f876f593a39c85c39fefe1b8ee9ca10a6ffbafe1a254a/pillow_jxl_plugin-1.3.8-cp313-cp313-win_amd64.whl", hash = "sha256:2388374b3493df9add4ef3d97cc70d6a5f51ed188dfe10d2b83ebc55e82e48d4", size = 4936720, upload-time = "2026-07-17T03:24:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/3e/12/e04b73832d1b8af3c5c4646127949a362f9ca3d30f42b05156ca5ffc61ea/pillow_jxl_plugin-1.3.8-cp313-cp313-win_arm64.whl", hash = "sha256:48d13d81d1c4933e5d42dd09366a9827583808be0a2fe842abd7358653c7d92f", size = 2330087, upload-time = "2026-07-17T03:23:54.604Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/c37d161345302ebda07f29d8230b15ba83b43c586f14fec98d088b18e982/pillow_jxl_plugin-1.3.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:30b72accb8505a46b05f709871da40f09975aea3a6b6f3cb1d646ff0a9aaba5e", size = 2362140, upload-time = "2026-07-17T03:22:59.694Z" }, + { url = "https://files.pythonhosted.org/packages/a7/be/01613af0307c31e77aced40bdf9cf3d25ce1333b3e99bb85575bdcbfeca5/pillow_jxl_plugin-1.3.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b5bbc933487207e634802f21990c365b6e61473cea34f6621420f4eda8ffd1df", size = 2660381, upload-time = "2026-07-17T03:22:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/89636d315a31eb805681e60e569fe38d5eef3d09f13b1b6f576574387942/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1d06fd4eb038f431db27f1ab2e483fd6967bafb21a7757e1bd5f76b6fd93649", size = 2787875, upload-time = "2026-07-17T03:22:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e0/7b9bdc388d89773a5f9ae74ff0be3b06080ef1a60be57b5897d929012622/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f65361ce18db59e8949c60599bfa46b7de187e28917fc39b5d816ab4b81cb16d", size = 2500834, upload-time = "2026-07-17T03:22:33.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/0903ba5a4c8d948df9217e48182ff7d872098ddf86fe512402569f4041cc/pillow_jxl_plugin-1.3.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b881a40737c647a24270b73cd3f506dd16d634202e5214cb492f9270bd1b02", size = 2504805, upload-time = "2026-07-17T03:22:42.911Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ef/77a15b2f3227854d4ef2563250ea57b03538ed962f406f1248c9caf045aa/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4049e48f09947a38047fc5d70817c6dffa90ffb5f14585c8c00e8b534d17c9b", size = 9725768, upload-time = "2026-07-17T03:23:10.845Z" }, + { url = "https://files.pythonhosted.org/packages/c6/95/486fc1fabd1e6cb8b520dce9898b51f2e29e991ba54e632381fe7f287300/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6ce20ef0887efb6dace5245f714f94b4f070ef39a1ec45161b35879c48faf30a", size = 8386447, upload-time = "2026-07-17T03:23:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/05/28/7979927eb5dc79e40b31e4e07d074057722656743d324182f4a68056c2a9/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:845e61add39272ca8545f4a0e6ead977ebb7e89fa017014bb8e17273a7a09c6c", size = 9032664, upload-time = "2026-07-17T03:23:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/92/b7/f606ed6fcb5fd9e177ce2b15a613a405ac22ceafc2c03bb3f58cd500ca04/pillow_jxl_plugin-1.3.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:915b8fee6a97aaf5486d0d625f26faae2e9f0d9b5892f1076f5a29b8103125a4", size = 9233868, upload-time = "2026-07-17T03:23:46.277Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6d/d10a4426fc3b394d2222cb694774451e67ab052df2e15a488dfac22be561/pillow_jxl_plugin-1.3.8-cp314-cp314-win32.whl", hash = "sha256:d63d1c3566a8c9ebdd1ec6b75da816b9475d95c4f69656827d6d3957d2594ecb", size = 4554691, upload-time = "2026-07-17T03:24:11.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/0f0297c265f1743784b6d1df719e62e49dabd1d9ede8efb0a9ba93174293/pillow_jxl_plugin-1.3.8-cp314-cp314-win_amd64.whl", hash = "sha256:6b23c83aabf4615b92ae609293d8597ca1a8d14d01ab16c703bb3a3df359796b", size = 4939714, upload-time = "2026-07-17T03:24:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/2db398fb1a41c46fd382bad96ee90283589eaed0ec2c50cfe4afda3df72c/pillow_jxl_plugin-1.3.8-cp314-cp314-win_arm64.whl", hash = "sha256:60c36bec3d8d4ef23cf2aed0f792213f999312dd3d8cd5225ae1494c430e16f6", size = 2330667, upload-time = "2026-07-17T03:23:55.913Z" }, + { url = "https://files.pythonhosted.org/packages/58/a6/2e08df50151b3a399ee18b4c8987e25dd754547060d1a368cda4edb2513d/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e33d2b965d4306271ca811dfbb14f8dc313e7bc6408617760fae7ae275686218", size = 2362588, upload-time = "2026-07-17T03:23:01.11Z" }, + { url = "https://files.pythonhosted.org/packages/72/3e/70b1c9c18839b7c6980edb6f4e0c4dd6b52be96df380adf2da55be8e6f7d/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:323ba7ab8fe6aceddbf571853b47c8900a293c6430649be31c3a743b12d0bcfc", size = 2668471, upload-time = "2026-07-17T03:22:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6f/295bd97058531eee2d3e3a0c73795c95aace572c918d13688201d9f45abd/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66a1bbe948021af26a5550b9bccad011e1131f6e10a90821690e3c19a417f59b", size = 2793160, upload-time = "2026-07-17T03:22:26.35Z" }, + { url = "https://files.pythonhosted.org/packages/3a/62/74ae963d945cf8c0da64a1a5b1a8e32b49cb1ac8cf3f81e1b63c25466250/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9adee3403a29b42b203b6903923ef84cff70bb33aa6ed069775b6b8f721fe7c7", size = 2506776, upload-time = "2026-07-17T03:22:35.262Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4f/0f00dab8c1b4b026cb62909e01b26a8f12f9f536bb6b002c71a15a638004/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40c82ead318b058804211a7f6d6e2715f65015f73792dee63813731105f74c", size = 2509592, upload-time = "2026-07-17T03:22:44.31Z" }, + { url = "https://files.pythonhosted.org/packages/53/f6/1ea001b109ff39165e013e663822adba393340d1599e165dbc62f68772a8/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:81864c18c03084754c4a9d673a0ec9d6161af5f7695ab1d4bb112059af2e968b", size = 9732562, upload-time = "2026-07-17T03:23:13.06Z" }, + { url = "https://files.pythonhosted.org/packages/d0/87/1ea5bb4cd84c5be48b542cbb579f43932c7977f5317d9992d674f409377b/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:42f39d55dfb7ba29509cea3f0270bd0e8a2fb924d979f2caa43f445e30d709e5", size = 8392645, upload-time = "2026-07-17T03:23:25.036Z" }, + { url = "https://files.pythonhosted.org/packages/0a/45/1e39b708d0260eb04aeb672558522bea63df4f7df3a9c27710faef0736b6/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:027f091b8ff8ecb26712d709847d25359994eb6a6543736d7fbc07b7b6b86fd7", size = 9039036, upload-time = "2026-07-17T03:23:36.555Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d9/c54bce2efd22f3d79e83fa9948fa5ca0184422c6bd9c38c4d28861ee9a50/pillow_jxl_plugin-1.3.8-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:75351073d967c85c24a330f9e375b17b809bff9779cd08c8d0e90d659b39be5a", size = 9239329, upload-time = "2026-07-17T03:23:48.617Z" }, +] + [[package]] name = "platformdirs" version = "4.11.0" @@ -2701,27 +2783,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.63" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/cbeaa5c7576fec643609dfbf200d59493523b1cc0481d4e7a5effcbf0630/ty-0.0.63.tar.gz", hash = "sha256:c2f66439393b3acac69306c117d4ae44638ce5fffa4a20c21046e85bd473359f", size = 6280695, upload-time = "2026-07-23T11:41:39.845Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/e4/d17a8e113ab15c692fe6fb9c422112d4bee7e829d80ced35826b45d98d98/ty-0.0.63-py3-none-linux_armv6l.whl", hash = "sha256:9a4ef7782e3af314fb63d006bec5ac3025bd70fee774b4dcfb5e44e8564f1994", size = 12056302, upload-time = "2026-07-23T11:41:03.5Z" }, - { url = "https://files.pythonhosted.org/packages/be/0b/357234c815dc4bfcc88c3f860aa0983fe4228c8aa2a5bb16c35ee08a94af/ty-0.0.63-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:01eab0ab70d51ad10298aa2d4b058b387a1fe93e5ee52d2a1ee23e9c69ba8354", size = 11737674, upload-time = "2026-07-23T11:41:05.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/13/193d9aeeb6774690351cff9fafabd3ae9b54cc225d125e07fa004ce23bdc/ty-0.0.63-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a671b61eaad16178389e05b9c108c9cb75ae8d84968fe55906484f55d6268338", size = 11264191, upload-time = "2026-07-23T11:41:07.963Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b7/c5843e16759a2b25fc8a7197473474038e585ac9fdaa6fa16aeb6384bf6a/ty-0.0.63-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b255cc83d95c51bb9ee4931303fdbece8cb1d6d7c655eb77a3f2c8f349fb64d6", size = 11818890, upload-time = "2026-07-23T11:41:09.885Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/adf83ae1fc570d9bb449605e1eeeaa523ad2329ca838a636698b5c135d11/ty-0.0.63-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0da1e8183aa4f893421a173904478021498f542ee41273660538da6649a9631c", size = 11853141, upload-time = "2026-07-23T11:41:12.151Z" }, - { url = "https://files.pythonhosted.org/packages/ea/90/f8effd846e3ee13486ea08257c13094d58b9f188c5641bf609d6a7d5c09f/ty-0.0.63-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128f38eb5a67199e3811426386f7ec96f41251ddf45e04ddc0bcbb29d4853245", size = 12545184, upload-time = "2026-07-23T11:41:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/b8/76/aac3a30d40431eb1d329acea016b248f5b6255e30ef3d28e19447e344be2/ty-0.0.63-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bedf34aff8b0557f2a7119b314a68a9c9e58c1d21554d0e2748f2c5fe6a1f638", size = 13062375, upload-time = "2026-07-23T11:41:16.441Z" }, - { url = "https://files.pythonhosted.org/packages/62/3d/0158733932893e17f6008dadd74c8d7aed422fefc66e47fe77888014fe8c/ty-0.0.63-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7328d63c34587606dce02935a25404f54cd0161bfe413b8f7fc21d174aead612", size = 12619461, upload-time = "2026-07-23T11:41:18.538Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/e280ad095b050778f16f493d49a073fa5f6d8f301d3e2e59be6a672ba05c/ty-0.0.63-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504c4457f3a62afe836c1f26a2c9a12549299095f9cc4146778558df51a7515c", size = 12376402, upload-time = "2026-07-23T11:41:20.482Z" }, - { url = "https://files.pythonhosted.org/packages/57/57/619788bf335cb86b090470b557ae1eed33613dc24e12142505d32b462e58/ty-0.0.63-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7394ed39424b027c89d5f0c818871c791e33958b88f5bb13e14bd4a12a3ca631", size = 12671377, upload-time = "2026-07-23T11:41:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/07/a2/0aff2cdd3c98e2c4729337542b8da0ce1980790e1600e0c4a717a1f46293/ty-0.0.63-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:94c3f155230490f8fb505911655e940c630c25cc0c35a76690cb413254fb4437", size = 11768090, upload-time = "2026-07-23T11:41:24.558Z" }, - { url = "https://files.pythonhosted.org/packages/43/4a/cdb5f1d26154144dfee08e1bd671daf827238170f7cee9dad43990bb2016/ty-0.0.63-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c16e1d8b4f0ae99106d5f13a894034a202baf6cdab61e2bb1a239de82904f839", size = 11867747, upload-time = "2026-07-23T11:41:26.794Z" }, - { url = "https://files.pythonhosted.org/packages/7e/26/ecc09ecb70bc9fbfdf42fa57ff29568f173e8200df575a0d72dc2b8486f9/ty-0.0.63-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b66293dad89eaed4b9fbf3b661614dbeb85b59ba037178a3f9a0adedf264da5c", size = 12120000, upload-time = "2026-07-23T11:41:28.731Z" }, - { url = "https://files.pythonhosted.org/packages/14/07/862822f9c2c397785b69b25cf79b4dfc3c0d55684b9adf11ac194450f8e1/ty-0.0.63-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8b29cc832e2ec73502c97dd7325d6ae0e085b34a33529a0f785192317d9862fc", size = 12477862, upload-time = "2026-07-23T11:41:30.847Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c2/50b08b641578d6f48e8aa28a91d0de32c91aa24dd926ed199e8afd2d37ea/ty-0.0.63-py3-none-win32.whl", hash = "sha256:f0a8fbfd1f990c0c5d85cce018d438969ac79e49247e2192c9745394bb7d17ab", size = 11433155, upload-time = "2026-07-23T11:41:33.28Z" }, - { url = "https://files.pythonhosted.org/packages/92/2d/d422a5568f0d1f317186be22241288dc6e08b71dc24aca223f22038ac2d2/ty-0.0.63-py3-none-win_amd64.whl", hash = "sha256:2aa2370bdd6f42e9f37518c812379bef70b9162f9e5aad511495229b0b71cb93", size = 12450036, upload-time = "2026-07-23T11:41:35.559Z" }, - { url = "https://files.pythonhosted.org/packages/35/97/2c9748e28ead0650c7ad3e5f74f178832ceabd7cb5c272a882f29eb32ee4/ty-0.0.63-py3-none-win_arm64.whl", hash = "sha256:95ac1a62162c3c7ac204731e95ebf766d62a46a7bfa238a6acbe923fcb772cb1", size = 11826243, upload-time = "2026-07-23T11:41:37.749Z" }, +version = "0.0.64" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" }, + { url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" }, + { url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" }, ] [[package]]