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...`. - Optional remaining-time plate-number encoding through `M73 R...`.
- Combined mode: all input rows become one packed 3MF. - Combined mode: all input rows become one packed 3MF.
- Individual batch mode: every input row becomes its own 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`. - GUI settings are saved to the program folder as `settings.json`.
## Installation for source use ## Installation for source use
@@ -352,6 +353,7 @@ Saved options include:
- Final swap switch. - Final swap switch.
- G-code patch switch. - G-code patch switch.
- Metadata mode. - Metadata mode.
- ZIP compression level.
- Individual batch mode. - Individual batch mode.
- Input handling options. - Input handling options.
- Output directory. - Output directory.
+52 -16
View File
@@ -6,6 +6,7 @@ import re
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
@@ -25,7 +26,7 @@ from .metadata import (
safe_float, safe_float,
update_first_slice_info, 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 from .patches import apply_gcode_patches, parse_patch_config
try: try:
@@ -37,6 +38,34 @@ except Exception: # pragma: no cover
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE) PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
MAX_COMPOSITE_PREVIEW_INPUTS = 9 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]: 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: 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() 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)
gcode_member = resolve_output_gcode_member(src, sources[0].member_name) with use_zlib_ng_for_zipfile():
preview_members = preview_members_for_gcode_member(gcode_member) with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(
for item in src.infolist(): output_3mf,
name = item.filename "w",
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name): compression=zipfile.ZIP_DEFLATED,
continue compresslevel=compress_level,
data = src.read(name) ) as dst:
if name == "Metadata/slice_info.config": gcode_member = resolve_output_gcode_member(src, sources[0].member_name)
data = update_first_slice_info(data, sources, options) preview_members = preview_members_for_gcode_member(gcode_member)
elif options.add_preview_label and name in preview_members: for item in src.infolist():
data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png")) name = item.filename
dst.writestr(item, data) if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
dst.writestr(gcode_member, gcode_bytes) continue
dst.writestr(f"{gcode_member}.md5", md5) 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 return md5
+3
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from . import APP_NAME, APP_TITLE, __version__ from . import APP_NAME, APP_TITLE, __version__
from .core import ( from .core import (
BuildOptions, BuildOptions,
DEFAULT_ZIP_COMPRESS_LEVEL,
PlateJob, PlateJob,
build_packed_3mf, build_packed_3mf,
list_swap_gcode_files, list_swap_gcode_files,
@@ -46,6 +47,7 @@ def build_command(args: argparse.Namespace) -> int:
add_preview_label=not args.no_preview_label, add_preview_label=not args.no_preview_label,
apply_gcode_patches=not args.no_gcode_patches, apply_gcode_patches=not args.no_gcode_patches,
swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None, 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) result = build_packed_3mf(jobs, options)
print(f"Output: {result.output_3mf}") 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("--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("--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("--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-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.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) build.set_defaults(func=build_command)
+2
View File
@@ -33,6 +33,7 @@ from .metadata import (
) )
from .models import ( from .models import (
DEFAULT_INSERT_BEFORE_MARKER, DEFAULT_INSERT_BEFORE_MARKER,
DEFAULT_ZIP_COMPRESS_LEVEL,
BuildOptions, BuildOptions,
BuildResult, BuildResult,
BuildSummary, BuildSummary,
@@ -59,6 +60,7 @@ from .planning import (
__all__ = [ __all__ = [
"DEFAULT_INSERT_BEFORE_MARKER", "DEFAULT_INSERT_BEFORE_MARKER",
"DEFAULT_OUTPUT_PATTERN", "DEFAULT_OUTPUT_PATTERN",
"DEFAULT_ZIP_COMPRESS_LEVEL",
"GCODE_MEMBER_RE", "GCODE_MEMBER_RE",
"M73_RE", "M73_RE",
"MD5_MEMBER_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 . import APP_NAME, APP_TITLE
from .core import ( from .core import (
BuildOptions, BuildOptions,
DEFAULT_ZIP_COMPRESS_LEVEL,
PlateJob, PlateJob,
ThreeMfSummary, ThreeMfSummary,
build_packed_3mf, build_packed_3mf,
@@ -446,6 +447,14 @@ class MainWindow(QMainWindow):
self.metadata_combo.setFixedWidth(260) self.metadata_combo.setFixedWidth(260)
options_layout.addRow("3MF metadata", self.metadata_combo) 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 = QCheckBox("Individual batch mode")
self.individual_batch_check.setToolTip( self.individual_batch_check.setToolTip(
"Build each input row as a separate output file, using that row's copy count." "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.swap_final_check.stateChanged.connect(self.save_current_settings)
self.patch_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.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.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled)
self.clear_after_build_check.stateChanged.connect(self.save_current_settings) self.clear_after_build_check.stateChanged.connect(self.save_current_settings)
self.skip_duplicates_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) metadata_index = self.metadata_combo.findData(metadata_mode)
if metadata_index >= 0: if metadata_index >= 0:
self.metadata_combo.setCurrentIndex(metadata_index) 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") swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode")
if swap_gcode: if swap_gcode:
index = self.swap_gcode_combo.findData(str(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(), "swap_after_final": self.swap_final_check.isChecked(),
"apply_gcode_patches": self.patch_check.isChecked(), "apply_gcode_patches": self.patch_check.isChecked(),
"metadata_mode": self.metadata_combo.currentData(), "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(), "individual_batch_mode": self.individual_batch_check.isChecked(),
"clear_after_build": self.clear_after_build_check.isChecked(), "clear_after_build": self.clear_after_build_check.isChecked(),
"skip_duplicates": self.skip_duplicates_check.isChecked(), "skip_duplicates": self.skip_duplicates_check.isChecked(),
@@ -1062,6 +1080,7 @@ class MainWindow(QMainWindow):
swap_after_final=self.swap_final_check.isChecked(), swap_after_final=self.swap_final_check.isChecked(),
metadata_mode=self.metadata_combo.currentData(), metadata_mode=self.metadata_combo.currentData(),
apply_gcode_patches=self.patch_check.isChecked(), 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: def log_build_result(self, result: Any) -> None:
+2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from typing import Literal from typing import Literal
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound=========" DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
DEFAULT_ZIP_COMPRESS_LEVEL = 7
MetadataMode = Literal["source", "sum"] MetadataMode = Literal["source", "sum"]
LineEnding = Literal["lf", "crlf"] LineEnding = Literal["lf", "crlf"]
@@ -45,6 +46,7 @@ class BuildOptions:
add_preview_label: bool = True add_preview_label: bool = True
apply_gcode_patches: bool = True apply_gcode_patches: bool = True
swap_gcode_dir: Path | None = None swap_gcode_dir: Path | None = None
zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL
@dataclass @dataclass
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6 PySide6>=6.6
Pillow>=10.0 Pillow>=10.0
zlib-ng>=0.5
+39
View File
@@ -7,6 +7,7 @@ import zipfile
from pathlib import Path from pathlib import Path
from a1_swap_mod_packer.builder import ( from a1_swap_mod_packer.builder import (
normalized_zip_compress_level,
preview_members_for_gcode_member, preview_members_for_gcode_member,
resolve_output_gcode_member, resolve_output_gcode_member,
select_preview_sources, select_preview_sources,
@@ -62,6 +63,44 @@ class ActivePlateMemberTest(unittest.TestCase):
self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5) self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5)
self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest()) 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: def test_preview_composite_uses_first_nine_unique_input_files(self) -> None:
if Image is None: if Image is None:
self.skipTest("Pillow is not installed") self.skipTest("Pillow is not installed")