Use zlib-ng to do ZIP compression

This commit is contained in:
hyyz17200
2026-05-08 11:02:41 +08:00
parent 8bfaa1d1fe
commit 26b2591786
8 changed files with 120 additions and 16 deletions
+2
View File
@@ -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.
+38 -2
View File
@@ -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,7 +322,14 @@ 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:
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():
+3
View File
@@ -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)
+2
View File
@@ -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",
+19
View File
@@ -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:
+2
View File
@@ -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
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6
Pillow>=10.0
zlib-ng>=0.5
+39
View File
@@ -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")