Skip to content

Commit b633680

Browse files
committed
Merge branch 'unstable' into stable
2 parents 8c2482a + bf9cdf7 commit b633680

39 files changed

+1951
-631
lines changed

README.md

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<img src="icons/logo.svg" width="20%">
33
<h3 align="center">XL Converter</h3>
44

5-
Powerful image converter for the latest formats with support for multithreading, drag 'n drop, and downscaling.
5+
Easy-to-use image converter for modern formats. Supports multithreading, drag 'n drop, and downscaling.
66

77
Available for Windows and Linux.
88

@@ -13,53 +13,45 @@ Read the [Manual](https://xl-docs.codepoems.eu)
1313

1414
## Supported Formats
1515

16-
Encode to **JPEG XL, AVIF, WEBP, and JPG**. Convert from **HEIF** and [more](https://xl-docs.codepoems.eu/supported-formats)
16+
Encode to **JPEG XL, AVIF, WebP, and JPEG**. Convert from **HEIF, TIFF,** and [more](https://xl-docs.codepoems.eu/supported-formats)
1717

1818
## Features
1919
#### Out of the Box
2020

2121
Just drop your images and convert. XL Converter works out of the box with no setup or steep learning curve. It prioritizes user experience while granting access to cutting-edge technology.
2222

23-
#### Parallel Encoding
24-
25-
Encode images in parallel to speed up the process. Control how much CPU to use during encoding.
26-
27-
#### JPG Reconstruction
28-
29-
Losslessly transcode JPG to JPEG XL, and reverse the process when needed.
30-
31-
#### Image Proxy
32-
33-
Avoid picky encoders. A proxy is generated when an encoder doesn't support a specific format.
34-
35-
For example, this enables HEIF -> JPEG XL conversion.
36-
37-
#### Downscaling
23+
#### JPEGLI
3824

39-
Scale down images to resolution, percent, shortest (and longest) side, or file size.
25+
Generate fully compatible JPEGs with up to [35% better compression ratio](https://opensource.googleblog.com/2024/04/introducing-jpegli-new-jpeg-coding-library.html).
4026

41-
#### Smallest Lossless
27+
#### JPEG XL and AVIF
4228

43-
Utilize multiple formats to achieve the smallest size.
29+
Achieve exceptional quality at a modest size with JPEG XL and AVIF.
4430

45-
#### Intelligent Effort
31+
#### Parallel Encoding
4632

47-
Optimize `Effort` for smaller sizes.
33+
Encode images in parallel to speed up the process. Control how much CPU to use during encoding.
4834

49-
#### Metadata
35+
#### Lossless JPEG Recompression
5036

51-
Easily copy and wipe metadata using encoder parameters or ExifTool.
37+
Losslessly transcode JPEG to JPEG XL, and reverse the process when needed.
5238

53-
#### JPEGLI
39+
#### Downscaling
5440

55-
Generate the highest quality (regular old) JPGs with JPEGLI.
41+
Scale down images to resolution, percent, shortest (and longest) side, or even file size.
5642

5743
## Bug Reports
5844

5945
You can submit a bug report in 2 ways
6046
- \[public\] Submit a new [GitHub Issue](https://github.com/JacobDev1/xl-converter/issues)
6147
- \[private\] Email me at contact@codepoems.eu
6248

49+
### Sharing Files
50+
51+
You can share logs and images with me when making a bug report.
52+
53+
Upload files to a service like [Disroot Lufi](https://upload.disroot.org/) and send me a download link to contact@codepoems.eu
54+
6355
## Contributions
6456

6557
Pull requests are ignored to avoid licensing issues when reusing the code.
@@ -114,7 +106,7 @@ Install packages.
114106

115107
```bash
116108
sudo apt update
117-
sudo apt install git make
109+
sudo apt install git make curl
118110
```
119111

120112
Install [xcb QPA](https://doc.qt.io/qt-6/linux-requirements.html) dependencies.
@@ -135,7 +127,7 @@ Build and setup Python `3.11.9`.
135127

136128
```bash
137129
pyenv install 3.11.9
138-
pyenv local 3.11.9
130+
pyenv global 3.11.9
139131
```
140132

141133
Clone and set up the repo.
@@ -164,19 +156,15 @@ pip install -r requirements.txt
164156
Now, you can run it.
165157

166158
```bash
167-
make run
159+
python main.py
168160
```
169161

170-
...or build it.
162+
or build it.
171163

172164
```bash
173-
make build
165+
python build.py
174166
```
175167

176-
Extra building modes:
177-
- `make build-7z` - package to a 7z file (with an installer) (requires `p7zip-full`)
178-
- `make build-appimage` - package as an AppImage (requires `fuse`)
179-
180168
### Providing Tool Binaries
181169

182170
To build XL Converter, you need to provide various binaries. This can be quite challenging.
@@ -190,7 +178,7 @@ Binaries needed:
190178
- [libavif](https://github.com/AOMediaCodec/libavif) `1.0.4` (AOM `3.8.2`)
191179
- avifenc
192180
- avifdec
193-
- [imagemagick](https://imagemagick.org/) `7.1.1-15 Q16-HDRI`
181+
- [imagemagick](https://imagemagick.org/) `7.* Q16-HDRI`
194182
- magick - AppImage for Linux
195183
- magick.exe - Windows
196184
- [exiftool](https://exiftool.org/) `12.77`
@@ -202,7 +190,7 @@ Place them in the following directories:
202190
- `xl-converter\bin\win` for Windows (x86_64)
203191
- `xl-converter/bin/linux` for Linux (x86_64)
204192

205-
All binaries are built statically. The version numbers should match. Binaries on Windows have an `.exe` extension.
193+
All binaries should be statically linked.
206194

207195
> [!TIP]
208196
> See the official [XL Converter builds](https://github.com/JacobDev1/xl-converter/releases) for examples.
@@ -236,9 +224,11 @@ Run tests
236224
python test.py
237225
```
238226

227+
You can control which tests to run. Learn more with `python test.py --help`.
228+
239229
### Deprecated
240230

241-
`test_old.py` is a deprecated, but still accessible test suite focusing on the conversion results.
231+
`test_old.py` is a deprecated, but still accessible test suite focusing on conversion results.
242232

243233
```bash
244234
python test_old.py

build.py

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,6 @@ def move(src, dst):
5353
except OSError as err:
5454
print(f"[Error] Moving failed ({src} -> {dst}) ({err})")
5555

56-
def copyTree(src, dst):
57-
src = os.path.normpath(src)
58-
dst = os.path.normpath(dst)
59-
60-
try:
61-
shutil.copytree(src, dst, dirs_exist_ok=True)
62-
except OSError as err:
63-
print(f"[Error] Copying tree failed ({src} -> {dst}) ({err})")
64-
6556
def makedirs(path):
6657
path = os.path.normpath(path)
6758

@@ -134,20 +125,16 @@ class Args():
134125
def __init__(self):
135126
self.parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
136127
self.args = {}
137-
self.parser.add_argument("--app-image", "-a", help="package as an AppImage (Linux only)", action="store_true")
138-
self.parser.add_argument("--pack", "-p", help="package to a 7z (Linux only)", action="store_true")
128+
self.parser.add_argument("--build-type", "-b", help="Defines how to package the binaries. If not specified, vanilla build will be generated.\nPossible values: installer|portable", action="store")
129+
self.parser.add_argument("--update-file", "-u", help="Append an update file (to place on a server).", action="store_true")
139130

140131
self._parseArgs()
141132

142133
def _parseArgs(self):
143134
args = self.parser.parse_args()
144-
self.args["app_image"] = args.app_image
145-
self.args["pack"] = False if args.app_image else args.pack
135+
self.args["build_type"] = args.build_type
136+
self.args["update_file"] = args.update_file
146137

147-
if platform.system() != "Linux":
148-
args_app_image = False
149-
args_pack = False
150-
151138
def getArg(self, arg):
152139
return self.args[arg]
153140

@@ -173,9 +160,11 @@ def __init__(self):
173160
"Linux": "misc/install.sh"
174161
}
175162

176-
self.misc_path = (
163+
self.assets = (
177164
"LICENSE.txt",
178-
"LICENSE_3RD_PARTY.txt"
165+
"LICENSE_3RD_PARTY.txt",
166+
"fonts/",
167+
"sounds/",
179168
)
180169

181170
# Assets
@@ -222,17 +211,29 @@ def build(self):
222211
self._prepare()
223212
self._buildBinaries()
224213
self._copyDependencies()
225-
self._appendInstaller()
226-
self._appendDesktopEntry()
227-
self._appendMisc()
228-
self._downloadRedistributable()
229-
self._appendUpdateFile()
214+
self._copyAssets()
230215
self._finish()
231216

232-
if self.args.getArg("app_image"):
233-
self._buildAppImage()
234-
elif self.args.getArg("pack"):
235-
self._build7z()
217+
match platform.system():
218+
case "Linux":
219+
match self.args.getArg("build_type"):
220+
case "installer":
221+
self._appendDesktopEntry()
222+
self._appendInstaller()
223+
self._build7z()
224+
case "portable":
225+
self._appendDesktopEntry()
226+
self._buildAppImage()
227+
case "Windows":
228+
self.downloader.downloadRedistributable()
229+
match self.args.getArg("build_type"):
230+
case "installer":
231+
self._appendInstaller()
232+
case "portable":
233+
print("[Error] Portable build is unavailable on Windows.")
234+
235+
if self.args.getArg("update_file"):
236+
self._appendUpdateFile()
236237

237238
def _prepare(self):
238239
rmTree(self.dst_dir)
@@ -247,7 +248,7 @@ def _prepare(self):
247248
if last_platform == f"{platform.system()}_{platform.architecture()}":
248249
print("[Building] Using previously compiled cache")
249250
else:
250-
print("[Building] Platform mismatch - deleting the cache")
251+
print("[Error] Platform mismatch - deleting the cache")
251252
rmTree("build")
252253
rmTree("__pycache__")
253254
else:
@@ -266,22 +267,19 @@ def _buildBinaries(self):
266267
def _copyDependencies(self):
267268
print("[Building] Copying dependencies")
268269
bin_dir = self.bin_dir[platform.system()]
269-
copyTree(bin_dir, f"{self.internal_dir}/{bin_dir}")
270+
shutil.copytree(Path(bin_dir), Path(self.internal_dir, bin_dir))
270271

271272
def _appendInstaller(self):
272273
installer_dir = self.installer_path[platform.system()]
273274
installer_file = os.path.basename(installer_dir)
274275

276+
print("[Building] Appending an installer script")
275277
match platform.system():
276278
case "Linux":
277-
if self.args.getArg("app_image") == False:
278-
print("[Building] Appending an installer script")
279-
copy(installer_dir, self.dst_dir)
280-
if self.args.getArg("app_image") == False:
281-
print("[Building] Embedding version into an installer script")
282-
replaceLine(f"{self.dst_dir}/{installer_file}", "VERSION=", f"VERSION=\"{VERSION}\"\n")
279+
copy(installer_dir, self.dst_dir)
280+
print("[Building] Embedding version into an installer script")
281+
replaceLine(f"{self.dst_dir}/{installer_file}", "VERSION=", f"VERSION=\"{VERSION}\"\n")
283282
case "Windows":
284-
print("[Building] Appending an installer script")
285283
copy(installer_dir, self.dst_dir)
286284
print("[Building] Embedding version into an installer script")
287285
replaceLine(f"{self.dst_dir}/{installer_file}", "#define MyAppVersion", f"#define MyAppVersion \"{VERSION}\"\n")
@@ -292,19 +290,19 @@ def _appendDesktopEntry(self):
292290
print("[Building] Appending a desktop entry")
293291
copy(self.desktop_entry_path, self.dst_dir)
294292

295-
def _appendMisc(self):
293+
def _copyAssets(self):
296294
print("[Building] Appending assets")
297-
for i in self.misc_path:
298-
copy(i, self.internal_dir)
295+
296+
# Most assets
297+
for i in self.assets:
298+
if os.path.isdir(Path(i)):
299+
shutil.copytree(Path(i), Path(self.internal_dir, Path(i).name))
300+
elif os.path.isfile(Path(i)):
301+
copy(i, self.internal_dir)
302+
303+
# Icons
299304
makedirs(f"{self.internal_dir}/icons")
300305
copy(self.icon_svg_path, f"{self.internal_dir}/icons/{os.path.basename(self.icon_svg_path)}")
301-
copyTree(self.fonts_path, f"{self.internal_dir}/fonts")
302-
303-
def _downloadRedistributable(self):
304-
if platform.system() != "Windows":
305-
return
306-
307-
self.downloader.downloadRedistributable()
308306

309307
def _appendUpdateFile(self):
310308
print("[Building] Appending an update file (to place on a server)")
@@ -345,14 +343,17 @@ def _buildAppImage(self):
345343
subprocess.run((self.appimagetool_path, appdir, f"{self.dst_dir}/{self.build_appimage_name}"))
346344

347345
def _build7z(self):
346+
if platform.system() != "Linux":
347+
return
348+
348349
dst_direct = self.build_7z_name
349350
dst = f"{self.dst_dir}/{self.build_7z_name}"
350351
makedirs(dst)
351352

352353
move(f"{self.dst_dir}/{self.project_name}", dst)
353354
move(f"{self.dst_dir}/{os.path.basename(self.installer_path['Linux'])}", dst)
354355
move(f"{self.dst_dir}/{os.path.basename(self.desktop_entry_path)}", dst)
355-
subprocess.run(("7z", "a", f"{dst_direct}.7z", dst_direct), cwd=self.dst_dir)
356+
subprocess.run(("7z", "a", "-snl" , f"{dst_direct}.7z", dst_direct), cwd=self.dst_dir)
356357

357358
def _verifyTools(self):
358359
match platform.system():
@@ -372,8 +373,8 @@ def _verifyTools(self):
372373
builder = Builder()
373374
builder.build()
374375
except (KeyboardInterrupt, SystemExit):
375-
print("[Building] Interrupted")
376+
print("[Canceled] Interrupted")
376377
exit()
377378
except (Exception, OSError) as err:
378-
print(f"[Building] Error - ({err})")
379+
print(f"[Error] {err}")
379380
exit()

core/conflicts.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
from core.exceptions import GenericException
1+
import re
22

3-
def checkForConflicts(ext: str, file_format: str, downscaling=False) -> bool:
3+
from data.constants import (
4+
IMAGE_MAGICK_PATH,
5+
)
6+
from core.process import runProcessOutput
7+
from core.exceptions import GenericException, FileException
8+
9+
def checkForConflicts(ext: str, file_format: str, downscaling=False) -> None:
410
"""
5-
Raises exceptions and returns True If any conflicts occur.
11+
Checks for conflicts with animated images. Raises exceptions and returns True If any conflicts occur.
612
713
Args:
814
- ext - extension (without a dot in the beginning and lowercase)
@@ -15,20 +21,27 @@ def checkForConflicts(ext: str, file_format: str, downscaling=False) -> bool:
1521
# Animation
1622
match ext:
1723
case "gif":
18-
if file_format in ("JPEG XL", "WEBP", "PNG"):
24+
if file_format in ("JPEG XL", "WebP"):
1925
conflict = False
2026
case "apng":
2127
if file_format in ("JPEG XL"):
2228
conflict = False
2329

2430
if conflict:
25-
raise GenericException("CF0", f"Animation is not supported for {ext.upper()} -> {file_format}")
31+
raise GenericException("CF0", f"{ext.upper()} -> {file_format} conversion is not supported")
2632

2733
# Downscaling
2834
if downscaling:
29-
conflict = True
3035
raise GenericException("CF1", f"Downscaling is not supported for animation")
31-
else:
32-
conflict = False
33-
34-
return conflict
36+
37+
def checkForMultipage(src_ext: str, src_abs_path: str) -> None:
38+
"""Raises an exception if an image is multipage."""
39+
if src_ext in ("tif", "tiff", "heif", "heic"):
40+
try:
41+
layers_re = re.search(r"\d+", runProcessOutput(IMAGE_MAGICK_PATH, "identify", "-format", "%n\n", src_abs_path).decode("utf-8"))
42+
layers_n = int(layers_re.group(0))
43+
except Exception:
44+
raise FileException("CF2", "Cannot detect the number of pages.")
45+
46+
if layers_n != 1:
47+
raise FileException("CF3", "Multipage images are not supported.")

0 commit comments

Comments
 (0)