diff --git a/lib/debug/utils.py b/lib/debug/utils.py index 7926537eb..36e537524 100644 --- a/lib/debug/utils.py +++ b/lib/debug/utils.py @@ -311,7 +311,7 @@ def with_time(extension, remaining_args, profile_file_path: Path): import resource usage = resource.getrusage(resource.RUSAGE_SELF) log(f"Max RSS: {usage.ru_maxrss}KB") - except: # Resource isn't supported on all platforms + except Exception: # Resource isn't supported on all platforms pass diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 91afbc389..be39010ec 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -17,6 +17,7 @@ from ..update import update_inkstitch_document class InkstitchExtension(inkex.EffectExtension): """Base class for Inkstitch extensions. Not intended for direct use.""" + document: inkex.SvgDocumentElement # Set to True to hide this extension from release builds of Ink/Stitch. It will # only be available in development installations. diff --git a/lib/extensions/batch_lettering.py b/lib/extensions/batch_lettering.py index 44ae75695..a972664ec 100644 --- a/lib/extensions/batch_lettering.py +++ b/lib/extensions/batch_lettering.py @@ -4,22 +4,21 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. import json -import os import sys -import tempfile -from copy import deepcopy +import io from zipfile import ZipFile from inkex import Boolean, Group, errormsg from lxml import etree +from sanitize_filename import sanitize import pyembroidery from ..extensions.lettering_along_path import TextAlongPath from ..i18n import _ from ..lettering import get_font_by_name -from ..output import write_embroidery_file -from ..stitch_plan import stitch_groups_to_stitch_plan +from ..output import write_embroidery_file_stream +from ..stitch_plan import stitch_groups_to_stitch_plan, StitchPlan from ..svg import get_correction_transform from ..threads import ThreadCatalog from ..utils import DotDict @@ -129,33 +128,15 @@ class BatchLettering(InkstitchExtension): # The path should be labeled as "batch lettering" text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']") - path = tempfile.mkdtemp() - files = [] - for text in texts: - stitch_plan, lettering_group = self.generate_stitch_plan(text, text_positioning_path) - for file_format in file_formats: - files.append(self.generate_output_file(file_format, path, text, stitch_plan)) + with ZipFile(sys.stdout.buffer, "w") as zip_file: + for text in texts: + stitch_plan, lettering_group = self.generate_stitch_plan(text, text_positioning_path) + for file_format in file_formats: + with zip_file.open(sanitize(f"{text}.{file_format}"), "w") as output_file: + self.generate_output_file(output_file, file_format, stitch_plan) - self.reset_document(lettering_group, text_positioning_path) - - temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) - - # in windows, failure to close here will keep the file locked - temp_file.close() - - with ZipFile(temp_file.name, "w") as zip_file: - for output in files: - zip_file.write(output, os.path.basename(output)) - - # inkscape will read the file contents from stdout and copy - # to the destination file that the user chose - with open(temp_file.name, 'rb') as output_file: - sys.stdout.buffer.write(output_file.read()) - - os.remove(temp_file.name) - for output in files: - os.remove(output) - os.rmdir(path) + # Reset document for next text + self.reset_document(lettering_group, text_positioning_path) def reset_document(self, lettering_group, text_positioning_path): # reset document for the next iteration @@ -163,20 +144,18 @@ class BatchLettering(InkstitchExtension): index = parent.index(lettering_group) if text_positioning_path is not None: parent.insert(index, text_positioning_path) - parent.remove(lettering_group) - - def generate_output_file(self, file_format, path, text, stitch_plan): - text = text.replace('\n', '') - output_file = os.path.join(path, f"{text}.{file_format}") + lettering_group.delete() + def generate_output_file(self, output_file: io.IOBase, file_format: str, stitch_plan: StitchPlan) -> None: if file_format == 'svg': - document = deepcopy(self.document.getroot()) - with open(output_file, 'w', encoding='utf-8') as svg: - svg.write(etree.tostring(document).decode('utf-8')) + output_file.write(etree.tostring(self.document.getroot())) else: - write_embroidery_file(output_file, stitch_plan, self.document.getroot()) - - return output_file + # Unfortunately some of pyembroidery's writers need `seek` and `tell`, which stdout doesn't support. + # Until that changes, we need to write this to an in-memory buffer that does support these, then + # write out the result to the stdout-backed zipfile + with io.BytesIO() as buf: + write_embroidery_file_stream(buf, file_format, stitch_plan, self.document.getroot()) + output_file.write(buf.getvalue()) def generate_stitch_plan(self, text, text_positioning_path): diff --git a/lib/output.py b/lib/output.py index a16b40181..8b55418d3 100644 --- a/lib/output.py +++ b/lib/output.py @@ -6,6 +6,8 @@ import os import re import sys +from typing import Tuple, Optional, NoReturn +import io import inkex from pyembroidery.exceptions import TooManyColorChangesError @@ -14,7 +16,7 @@ import pyembroidery from .commands import global_command from .i18n import _ -from .stitch_plan import Stitch +from .stitch_plan import Stitch, StitchPlan from .svg import PIXELS_PER_MM from .utils import Point @@ -50,7 +52,15 @@ def jump_to_stop_point(pattern, svg): pattern.add_stitch_absolute(pyembroidery.JUMP, stop_position.point.x, stop_position.point.y) -def write_embroidery_file(file_path, stitch_plan, svg, settings={}): +def _compute_pattern_settings( + extension: str, + stitch_plan: StitchPlan, + svg: inkex.SvgDocumentElement, + settings: Optional[dict]) -> Tuple[pyembroidery.EmbPattern, dict]: + # Return an embroidery pattern and settings to pass to pyembroidery + if settings is None: + settings = {} + # convert from pixels to millimeters # also multiply by 10 to get tenths of a millimeter as required by pyembroidery scale = 10 / PIXELS_PER_MM @@ -89,10 +99,10 @@ def write_embroidery_file(file_path, stitch_plan, svg, settings={}): "full_jump": True, }) - if not file_path.endswith(('.col', '.edr', '.inf')): + if extension not in ('col', 'edr', 'inf'): settings['encode'] = True - if file_path.endswith('.csv'): + if extension == 'csv': # Special treatment for CSV: instruct pyembroidery not to do any post- # processing. This will allow the user to match up stitch numbers seen # in the simulator with commands in the CSV. @@ -100,6 +110,36 @@ def write_embroidery_file(file_path, stitch_plan, svg, settings={}): settings['max_jump'] = float('inf') settings['explicit_trim'] = False + return pattern, settings + + +def _too_many_color_changes(e: TooManyColorChangesError) -> NoReturn: + match = re.search("d+", str(e)) + if match: + num_color_changes = match.group() + else: + # Should never get here, the number of color changes should have been in the error's message + num_color_changes = "???" + msg = _("Couldn't save embroidery file.") + msg += '\n\n' + msg += _("There are {num_color_changes} color changes in your design. This is way too many.").format(num_color_changes=num_color_changes) + msg += '\n' + msg += _("Please reduce color changes. Find more information on our website:") + msg += '\n\n' + msg += _("https://inkstitch.org/docs/faq/#too-many-color-changes") + inkex.errormsg(msg) + sys.exit(1) + + +def write_embroidery_file( + file_path: str, + stitch_plan: StitchPlan, + svg: inkex.SvgDocumentElement, + settings: Optional[dict] = None) -> None: + """ Write embroidery file to a given path """ + + pattern, settings = _compute_pattern_settings(os.path.splitext(svg.name)[1], stitch_plan, svg, settings) + try: pyembroidery.write(pattern, file_path, settings) except IOError as e: @@ -109,13 +149,34 @@ def write_embroidery_file(file_path, stitch_plan, svg, settings={}): inkex.errormsg(msg) sys.exit(1) except TooManyColorChangesError as e: - num_color_changes = re.search("d+", str(e)).group() - msg = _("Couldn't save embrodiery file.") - msg += '\n\n' - msg += _("There are {num_color_changes} color changes in your design. This is way too many.").format(num_color_changes=num_color_changes) - msg += '\n' - msg += _("Please reduce color changes. Find more information on our website:") - msg += '\n\n' - msg += _("https://inkstitch.org/docs/faq/#too-many-color-changes") + _too_many_color_changes(e) + + +def write_embroidery_file_stream( + stream: io.IOBase, + extension: str, + stitch_plan: StitchPlan, + svg: inkex.SvgDocumentElement, + settings: Optional[dict] = None) -> None: + """ Write embroidery file to a stream """ + + pattern, settings = _compute_pattern_settings(extension, stitch_plan, svg, settings) + + try: + for file_type in pyembroidery.EmbPattern.supported_formats(): + if file_type["extension"] != extension: + continue + writer = file_type.get("writer", None) + if writer is None: + continue + + pyembroidery.EmbPattern.write_embroidery(writer, pattern, stream, settings) + break + except IOError as e: + # L10N low-level file error. %(error)s is (hopefully?) translated by + # the user's system automatically. + msg = _("Error writing: %(error)s") % dict(error=e.strerror) inkex.errormsg(msg) sys.exit(1) + except TooManyColorChangesError as e: + _too_many_color_changes(e) diff --git a/mypy.ini b/mypy.ini index 49e23d328..9e9ba3c18 100644 --- a/mypy.ini +++ b/mypy.ini @@ -35,6 +35,9 @@ ignore_missing_imports = True [mypy-winutils.*] ignore_missing_imports = True +[mypy-sanitize_filename.*] +ignore_missing_imports = True + # ... And this one is ours but is missing type information for now anyway... [mypy-pyembroidery.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 57248c4fe..ab915801c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,6 +32,7 @@ flask-cors pywinutils ; sys_platform == 'win32' pywin32 ; sys_platform == 'win32' types-pywin32; sys_platform == 'win32' +sanitize_filename # Test dependencies. # It should be okay to include these here because this list isn't the one used for bundling dependencies.