From 26b259178691dc1cbdbddbd2deee6bec370230b1 Mon Sep 17 00:00:00 2001 From: hyyz17200 Date: Fri, 8 May 2026 11:02:41 +0800 Subject: [PATCH] Use zlib-ng to do ZIP compression --- README.md | 2 + a1_swap_mod_packer/builder.py | 68 +++++++++++++++++++++++-------- a1_swap_mod_packer/cli.py | 3 ++ a1_swap_mod_packer/core.py | 2 + a1_swap_mod_packer/gui.py | 19 +++++++++ a1_swap_mod_packer/models.py | 2 + requirements.txt | 1 + tests/test_active_plate_member.py | 39 ++++++++++++++++++ 8 files changed, 120 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ce621f5..14cbc50 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ It takes one or more A1-sliced `.3mf` files, repeats their plate G-code accordin - Optional remaining-time plate-number encoding through `M73 R...`. - Combined mode: all input rows become one packed 3MF. - Individual batch mode: every input row becomes its own packed 3MF. +- zlib-ng Deflate ZIP compression level, defaulting to Level 7. - GUI settings are saved to the program folder as `settings.json`. ## Installation for source use @@ -352,6 +353,7 @@ Saved options include: - Final swap switch. - G-code patch switch. - Metadata mode. +- ZIP compression level. - Individual batch mode. - Input handling options. - Output directory. diff --git a/a1_swap_mod_packer/builder.py b/a1_swap_mod_packer/builder.py index 657985e..1c5caeb 100644 --- a/a1_swap_mod_packer/builder.py +++ b/a1_swap_mod_packer/builder.py @@ -6,6 +6,7 @@ import re import shutil import tempfile import zipfile +from contextlib import contextmanager from pathlib import Path from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members @@ -25,7 +26,7 @@ from .metadata import ( safe_float, update_first_slice_info, ) -from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource +from .models import DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource from .patches import apply_gcode_patches, parse_patch_config try: @@ -37,6 +38,34 @@ except Exception: # pragma: no cover PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE) MAX_COMPOSITE_PREVIEW_INPUTS = 9 +MIN_ZIP_COMPRESS_LEVEL = 1 +MAX_ZIP_COMPRESS_LEVEL = 9 + + +def normalized_zip_compress_level(level: int | None) -> int: + try: + value = int(level if level is not None else DEFAULT_ZIP_COMPRESS_LEVEL) + except (TypeError, ValueError): + value = DEFAULT_ZIP_COMPRESS_LEVEL + return min(MAX_ZIP_COMPRESS_LEVEL, max(MIN_ZIP_COMPRESS_LEVEL, value)) + + +def load_zlib_ng_module() -> object: + try: + from zlib_ng import zlib_ng + except ImportError as exc: # pragma: no cover - exercised when dependency is missing + raise RuntimeError("zlib-ng is required for 3MF ZIP compression. Install it with: pip install zlib-ng") from exc + return zlib_ng + + +@contextmanager +def use_zlib_ng_for_zipfile() -> object: + original_zlib = zipfile.zlib + zipfile.zlib = load_zlib_ng_module() + try: + yield + finally: + zipfile.zlib = original_zlib def load_plate_sources(job: PlateJob) -> list[PlateSource]: @@ -293,21 +322,28 @@ def preview_members_for_gcode_member(gcode_member: str) -> set[str]: def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str: md5 = hashlib.md5(gcode_bytes).hexdigest() - with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(output_3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as dst: - gcode_member = resolve_output_gcode_member(src, sources[0].member_name) - preview_members = preview_members_for_gcode_member(gcode_member) - for item in src.infolist(): - name = item.filename - if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name): - continue - data = src.read(name) - if name == "Metadata/slice_info.config": - data = update_first_slice_info(data, sources, options) - elif options.add_preview_label and name in preview_members: - data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png")) - dst.writestr(item, data) - dst.writestr(gcode_member, gcode_bytes) - dst.writestr(f"{gcode_member}.md5", md5) + compress_level = normalized_zip_compress_level(options.zip_compress_level) + with use_zlib_ng_for_zipfile(): + with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile( + output_3mf, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=compress_level, + ) as dst: + gcode_member = resolve_output_gcode_member(src, sources[0].member_name) + preview_members = preview_members_for_gcode_member(gcode_member) + for item in src.infolist(): + name = item.filename + if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name): + continue + data = src.read(name) + if name == "Metadata/slice_info.config": + data = update_first_slice_info(data, sources, options) + elif options.add_preview_label and name in preview_members: + data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png")) + dst.writestr(item, data) + dst.writestr(gcode_member, gcode_bytes) + dst.writestr(f"{gcode_member}.md5", md5) return md5 diff --git a/a1_swap_mod_packer/cli.py b/a1_swap_mod_packer/cli.py index 05fc44c..96a0af9 100644 --- a/a1_swap_mod_packer/cli.py +++ b/a1_swap_mod_packer/cli.py @@ -7,6 +7,7 @@ from pathlib import Path from . import APP_NAME, APP_TITLE, __version__ from .core import ( BuildOptions, + DEFAULT_ZIP_COMPRESS_LEVEL, PlateJob, build_packed_3mf, list_swap_gcode_files, @@ -46,6 +47,7 @@ def build_command(args: argparse.Namespace) -> int: add_preview_label=not args.no_preview_label, apply_gcode_patches=not args.no_gcode_patches, swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None, + zip_compress_level=args.zip_level, ) result = build_packed_3mf(jobs, options) print(f"Output: {result.output_3mf}") @@ -91,6 +93,7 @@ def create_parser() -> argparse.ArgumentParser: build.add_argument("--no-swap-after-final", action="store_true", help="Do not run the swap G-code after the last plate.") build.add_argument("--metadata-mode", choices=("source", "sum"), default="source", help="How to write slice_info prediction and weight.") build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="Line ending for the generated G-code.") + build.add_argument("--zip-level", type=int, choices=range(1, 10), default=DEFAULT_ZIP_COMPRESS_LEVEL, metavar="1-9", help="zlib-ng Deflate compression level for the output 3MF. Default: 7.") build.add_argument("--no-preview-label", action="store_true", help="Do not add a plate-count label to the first preview image.") build.add_argument("--no-gcode-patches", action="store_true", help=f"Do not apply editable patches from {default_patch_config_path()}.") build.set_defaults(func=build_command) diff --git a/a1_swap_mod_packer/core.py b/a1_swap_mod_packer/core.py index d9cd21a..1452e2c 100644 --- a/a1_swap_mod_packer/core.py +++ b/a1_swap_mod_packer/core.py @@ -33,6 +33,7 @@ from .metadata import ( ) from .models import ( DEFAULT_INSERT_BEFORE_MARKER, + DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, BuildSummary, @@ -59,6 +60,7 @@ from .planning import ( __all__ = [ "DEFAULT_INSERT_BEFORE_MARKER", "DEFAULT_OUTPUT_PATTERN", + "DEFAULT_ZIP_COMPRESS_LEVEL", "GCODE_MEMBER_RE", "M73_RE", "MD5_MEMBER_RE", diff --git a/a1_swap_mod_packer/gui.py b/a1_swap_mod_packer/gui.py index 6ce0c24..30b7671 100644 --- a/a1_swap_mod_packer/gui.py +++ b/a1_swap_mod_packer/gui.py @@ -46,6 +46,7 @@ except ImportError as exc: # pragma: no cover from . import APP_NAME, APP_TITLE from .core import ( BuildOptions, + DEFAULT_ZIP_COMPRESS_LEVEL, PlateJob, ThreeMfSummary, build_packed_3mf, @@ -446,6 +447,14 @@ class MainWindow(QMainWindow): self.metadata_combo.setFixedWidth(260) options_layout.addRow("3MF metadata", self.metadata_combo) + self.zip_level_combo = QComboBox() + for level in range(1, 10): + self.zip_level_combo.addItem(f"Level {level}", level) + self.zip_level_combo.setCurrentIndex(DEFAULT_ZIP_COMPRESS_LEVEL - 1) + self.zip_level_combo.setFixedWidth(120) + self.zip_level_combo.setToolTip("zlib-ng Deflate compression level for the output 3MF.") + options_layout.addRow("ZIP compression", self.zip_level_combo) + self.individual_batch_check = QCheckBox("Individual batch mode") self.individual_batch_check.setToolTip( "Build each input row as a separate output file, using that row's copy count." @@ -528,6 +537,7 @@ class MainWindow(QMainWindow): self.swap_final_check.stateChanged.connect(self.save_current_settings) self.patch_check.stateChanged.connect(self.save_current_settings) self.metadata_combo.currentIndexChanged.connect(self.save_current_settings) + self.zip_level_combo.currentIndexChanged.connect(self.save_current_settings) self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled) self.clear_after_build_check.stateChanged.connect(self.save_current_settings) self.skip_duplicates_check.stateChanged.connect(self.save_current_settings) @@ -554,6 +564,13 @@ class MainWindow(QMainWindow): metadata_index = self.metadata_combo.findData(metadata_mode) if metadata_index >= 0: self.metadata_combo.setCurrentIndex(metadata_index) + try: + zip_level = int(options.get("zip_compress_level", DEFAULT_ZIP_COMPRESS_LEVEL)) + except (TypeError, ValueError): + zip_level = DEFAULT_ZIP_COMPRESS_LEVEL + zip_level_index = self.zip_level_combo.findData(min(9, max(1, zip_level))) + if zip_level_index >= 0: + self.zip_level_combo.setCurrentIndex(zip_level_index) swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode") if swap_gcode: index = self.swap_gcode_combo.findData(str(swap_gcode)) @@ -573,6 +590,7 @@ class MainWindow(QMainWindow): "swap_after_final": self.swap_final_check.isChecked(), "apply_gcode_patches": self.patch_check.isChecked(), "metadata_mode": self.metadata_combo.currentData(), + "zip_compress_level": int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL), "individual_batch_mode": self.individual_batch_check.isChecked(), "clear_after_build": self.clear_after_build_check.isChecked(), "skip_duplicates": self.skip_duplicates_check.isChecked(), @@ -1062,6 +1080,7 @@ class MainWindow(QMainWindow): swap_after_final=self.swap_final_check.isChecked(), metadata_mode=self.metadata_combo.currentData(), apply_gcode_patches=self.patch_check.isChecked(), + zip_compress_level=int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL), ) def log_build_result(self, result: Any) -> None: diff --git a/a1_swap_mod_packer/models.py b/a1_swap_mod_packer/models.py index ec0ba28..9d7a7ca 100644 --- a/a1_swap_mod_packer/models.py +++ b/a1_swap_mod_packer/models.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Literal DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound=========" +DEFAULT_ZIP_COMPRESS_LEVEL = 7 MetadataMode = Literal["source", "sum"] LineEnding = Literal["lf", "crlf"] @@ -45,6 +46,7 @@ class BuildOptions: add_preview_label: bool = True apply_gcode_patches: bool = True swap_gcode_dir: Path | None = None + zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL @dataclass diff --git a/requirements.txt b/requirements.txt index 02cca77..e37cb97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PySide6>=6.6 Pillow>=10.0 +zlib-ng>=0.5 diff --git a/tests/test_active_plate_member.py b/tests/test_active_plate_member.py index 2f41264..4ef4697 100644 --- a/tests/test_active_plate_member.py +++ b/tests/test_active_plate_member.py @@ -7,6 +7,7 @@ import zipfile from pathlib import Path from a1_swap_mod_packer.builder import ( + normalized_zip_compress_level, preview_members_for_gcode_member, resolve_output_gcode_member, select_preview_sources, @@ -62,6 +63,44 @@ class ActivePlateMemberTest(unittest.TestCase): self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5) self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest()) + def test_write_output_honors_zip_compression_level(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source_3mf = root / "plate2.3mf" + level_1_output = root / "level1.3mf" + level_7_output = root / "level7.3mf" + self.write_plate2_archive(source_3mf) + sources = [ + PlateSource( + source_3mf=source_3mf, + member_name="Metadata/plate_2.gcode", + gcode_text="G1 X0\n", + ) + ] + gcode_bytes = (b"G1 X123.456 Y789.012 E0.03456 ; repeated movement\n" * 20000) + + for output_3mf, zip_level in ((level_1_output, 1), (level_7_output, 7)): + options = BuildOptions( + swap_gcode="", + output_3mf=output_3mf, + add_preview_label=False, + apply_gcode_patches=False, + zip_compress_level=zip_level, + ) + write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) + + with zipfile.ZipFile(level_1_output, "r") as level_1_archive, zipfile.ZipFile(level_7_output, "r") as level_7_archive: + level_1_info = level_1_archive.getinfo("Metadata/plate_2.gcode") + level_7_info = level_7_archive.getinfo("Metadata/plate_2.gcode") + self.assertEqual(level_1_info.compress_type, zipfile.ZIP_DEFLATED) + self.assertEqual(level_7_info.compress_type, zipfile.ZIP_DEFLATED) + self.assertLess(level_7_info.compress_size, level_1_info.compress_size) + + def test_zip_compression_level_is_clamped(self) -> None: + self.assertEqual(normalized_zip_compress_level(None), 7) + self.assertEqual(normalized_zip_compress_level(0), 1) + self.assertEqual(normalized_zip_compress_level(10), 9) + def test_preview_composite_uses_first_nine_unique_input_files(self) -> None: if Image is None: self.skipTest("Pillow is not installed")