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
+52 -16
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,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
+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