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...`.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,2 +1,3 @@
|
|||||||
PySide6>=6.6
|
PySide6>=6.6
|
||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
|
zlib-ng>=0.5
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user