Use zlib-ng to do ZIP compression
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
PySide6>=6.6
|
||||
Pillow>=10.0
|
||||
zlib-ng>=0.5
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user