# Copyright (c) 2020-2023, Manfred Moitzi # License: MIT License # type: ignore from __future__ import annotations from typing import Optional import logging import os import platform import shutil import subprocess import tempfile import time from contextlib import contextmanager from pathlib import Path import ezdxf from ezdxf.document import Drawing from ezdxf.lldxf.validator import ( is_dxf_file, dxf_info, is_binary_dxf_file, dwg_version, ) logger = logging.getLogger("ezdxf") win_exec_path = ezdxf.options.get("odafc-addon", "win_exec_path").strip('"') unix_exec_path = ezdxf.options.get("odafc-addon", "unix_exec_path").strip('"') class ODAFCError(IOError): pass class UnknownODAFCError(ODAFCError): pass class ODAFCNotInstalledError(ODAFCError): pass class UnsupportedFileFormat(ODAFCError): pass class UnsupportedPlatform(ODAFCError): pass class UnsupportedVersion(ODAFCError): pass VERSION_MAP = { "R12": "ACAD12", "R13": "ACAD13", "R14": "ACAD14", "R2000": "ACAD2000", "R2004": "ACAD2004", "R2007": "ACAD2007", "R2010": "ACAD2010", "R2013": "ACAD2013", "R2018": "ACAD2018", "AC1004": "ACAD9", "AC1006": "ACAD10", "AC1009": "ACAD12", "AC1012": "ACAD13", "AC1014": "ACAD14", "AC1015": "ACAD2000", "AC1018": "ACAD2004", "AC1021": "ACAD2007", "AC1024": "ACAD2010", "AC1027": "ACAD2013", "AC1032": "ACAD2018", } VALID_VERSIONS = { "ACAD9", "ACAD10", "ACAD12", "ACAD13", "ACAD14", "ACAD2000", "ACAD2004", "ACAD2007", "ACAD2010", "ACAD2013", "ACAD2018", } WINDOWS = "Windows" LINUX = "Linux" DARWIN = "Darwin" def is_installed() -> bool: """Returns ``True`` if the ODAFileConverter is installed.""" if platform.system() in (LINUX, DARWIN): if unix_exec_path and Path(unix_exec_path).is_file(): return True return shutil.which("ODAFileConverter") is not None # Windows: return os.path.exists(win_exec_path) def map_version(version: str) -> str: return VERSION_MAP.get(version.upper(), version.upper()) def readfile( filename: str | os.PathLike, version: Optional[str] = None, *, audit: bool = False ) -> Drawing: """Uses an installed `ODA File Converter`_ to convert a DWG/DXB/DXF file into a temporary DXF file and load this file by `ezdxf`. Args: filename: file to load by ODA File Converter version: load file as specific DXF version, by default the same version as the source file or if not detectable the latest by `ezdxf` supported version. audit: audit source file before loading Raises: FileNotFoundError: source file not found odafc.UnknownODAFCError: conversion failed for unknown reasons odafc.UnsupportedVersion: invalid DWG version specified odafc.UnsupportedFileFormat: unsupported file extension odafc.ODAFCNotInstalledError: ODA File Converter not installed """ infile = Path(filename).absolute() if not infile.is_file(): raise FileNotFoundError(f"No such file: '{infile}'") if isinstance(version, str): version = map_version(version) else: version = _detect_version(filename) with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir: args = _odafc_arguments( infile.name, str(infile.parent), tmp_dir, output_format="DXF", version=version, audit=audit, ) _execute_odafc(args) out_file = Path(tmp_dir) / infile.with_suffix(".dxf").name if out_file.exists(): doc = ezdxf.readfile(str(out_file)) doc.filename = str(infile.with_suffix(".dxf")) return doc raise UnknownODAFCError("Failed to convert file: Unknown Error") def export_dwg( doc: Drawing, filename: str | os.PathLike, version: Optional[str] = None, *, audit: bool = False, replace: bool = False, ) -> None: """Uses an installed `ODA File Converter`_ to export the DXF document `doc` as a DWG file. A temporary DXF file will be created and converted to DWG by the ODA File Converter. If `version` is not specified the DXF version of the source document is used. Args: doc: `ezdxf` DXF document as :class:`~ezdxf.drawing.Drawing` object filename: output DWG filename, the extension will be set to ".dwg" version: DWG version to export, by default the same version as the source document. audit: audit source file by ODA File Converter at exporting replace: replace existing DWG file if ``True`` Raises: FileExistsError: target file already exists, and argument `replace` is ``False`` FileNotFoundError: parent directory of target file does not exist odafc.UnknownODAFCError: exporting DWG failed for unknown reasons odafc.ODAFCNotInstalledError: ODA File Converter not installed """ if version is None: version = doc.dxfversion export_version = VERSION_MAP[version] dwg_file = Path(filename).absolute() out_folder = Path(dwg_file.parent) if dwg_file.exists(): if replace: dwg_file.unlink() else: raise FileExistsError(f"File already exists: {dwg_file}") if out_folder.exists(): with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir: dxf_file = Path(tmp_dir) / dwg_file.with_suffix(".dxf").name # Save DXF document old_filename = doc.filename doc.saveas(dxf_file) doc.filename = old_filename arguments = _odafc_arguments( dxf_file.name, tmp_dir, str(out_folder), output_format="DWG", version=export_version, audit=audit, ) _execute_odafc(arguments) else: raise FileNotFoundError(f"No such file or directory: '{str(out_folder)}'") def convert( source: str | os.PathLike, dest: str | os.PathLike = "", *, version="R2018", audit=True, replace=False, ): """Convert `source` file to `dest` file. The file extension defines the target format e.g. :code:`convert("test.dxf", "Test.dwg")` converts the source file to a DWG file. If `dest` is an empty string the conversion depends on the source file format and is DXF to DWG or DWG to DXF. To convert DXF to DXF an explicit destination filename is required: :code:`convert("r12.dxf", "r2013.dxf", version="R2013")` Args: source: source file dest: destination file, an empty string uses the source filename with the extension of the target format e.g. "test.dxf" -> "test.dwg" version: output DXF/DWG version e.g. "ACAD2018", "R2018", "AC1032" audit: audit files replace: replace existing destination file Raises: FileNotFoundError: source file or destination folder does not exist FileExistsError: destination file already exists and argument `replace` is ``False`` odafc.UnsupportedVersion: invalid DXF version specified odafc.UnsupportedFileFormat: unsupported file extension odafc.UnknownODAFCError: conversion failed for unknown reasons odafc.ODAFCNotInstalledError: ODA File Converter not installed """ version = map_version(version) if version not in VALID_VERSIONS: raise UnsupportedVersion(f"Invalid version: '{version}'") src_path = Path(source).expanduser().absolute() if not src_path.exists(): raise FileNotFoundError(f"Source file not found: '{source}'") if dest: dest_path = Path(dest) else: ext = src_path.suffix.lower() if ext == ".dwg": dest_path = src_path.with_suffix(".dxf") elif ext == ".dxf": dest_path = src_path.with_suffix(".dwg") else: raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'") if dest_path.exists() and not replace: raise FileExistsError(f"Target file already exists: '{dest_path}'") parent_dir = dest_path.parent if not parent_dir.exists() or not parent_dir.is_dir(): # Cannot copy result to destination folder! raise FileNotFoundError(f"Destination folder does not exist: '{parent_dir}'") ext = dest_path.suffix fmt = ext.upper()[1:] if fmt not in ("DXF", "DWG"): raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'") with tempfile.TemporaryDirectory(prefix="odafc_") as tmp_dir: arguments = _odafc_arguments( src_path.name, in_folder=str(src_path.parent), out_folder=str(tmp_dir), output_format=fmt, version=version, audit=audit, ) _execute_odafc(arguments) result = list(Path(tmp_dir).iterdir()) if result: try: shutil.move(result[0], dest_path) except IOError: shutil.copy(result[0], dest_path) else: UnknownODAFCError(f"Unknown error: no {fmt} file was created") def _detect_version(path: str) -> str: """Returns the DXF/DWG version of file `path` as ODAFC compatible version string. Raises: odafc.UnsupportedVersion: unknown or unsupported DWG version odafc.UnsupportedFileFormat; unsupported file extension """ version = "ACAD2018" ext = os.path.splitext(path)[1].lower() if ext == ".dxf": if is_binary_dxf_file(path): pass elif is_dxf_file(path): with open(path, "rt") as fp: info = dxf_info(fp) version = VERSION_MAP[info.version] elif ext == ".dwg": version = dwg_version(path) if version is None: raise UnsupportedVersion("Unknown or unsupported DWG version.") else: raise UnsupportedFileFormat(f"Unsupported file format: '{ext}'") return map_version(version) def _odafc_arguments( filename: str, in_folder: str, out_folder: str, output_format: str = "DXF", version: str = "ACAD2013", audit: bool = False, ) -> list[str]: """ODA File Converter command line format: ODAFileConverter "Input Folder" "Output Folder" version type recurse audit [filter] - version: output version: "ACAD9" - "ACAD2018" - type: output file type: "DWG", "DXF", "DXB" - recurse: recurse Input Folder: "0" or "1" - audit: audit each file: "0" or "1" - optional Input files filter: default "*.DWG,*.DXF" """ recurse = "0" audit_str = "1" if audit else "0" return [ in_folder, out_folder, version, output_format, recurse, audit_str, filename, ] def _get_odafc_path(system: str) -> str: """Get ODAFC application path. Raises: odafc.ODAFCNotInstalledError: ODA File Converter not installed """ # on Linux and Darwin check if UNIX_EXEC_PATH is set and exist and # return this path as exec path. # This may help if the "which" command can not find the "ODAFileConverter" # command and also adds support for AppImages provided by ODA. if system != WINDOWS and unix_exec_path: if Path(unix_exec_path).is_file(): return unix_exec_path else: logger.warning( f"command '{unix_exec_path}' not found, using 'ODAFileConverter'" ) path = shutil.which("ODAFileConverter") if not path and system == WINDOWS: path = win_exec_path if not Path(path).is_file(): path = None if not path: raise ODAFCNotInstalledError( f"Could not find ODAFileConverter in the path. " f"Install application from https://www.opendesign.com/guestfiles/oda_file_converter" ) return path @contextmanager def _linux_dummy_display(): """See xvbfwrapper library for a more feature complete xvfb interface.""" if shutil.which("Xvfb"): display = ":123" # arbitrary choice proc = subprocess.Popen( ["Xvfb", display, "-screen", "0", "800x600x24"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) time.sleep(0.1) yield display try: proc.terminate() proc.wait() except OSError: pass else: logger.warning(f"Install xvfb to prevent the ODAFileConverter GUI from opening") yield os.environ["DISPLAY"] def _run_with_no_gui( system: str, command: str, arguments: list[str] ) -> subprocess.Popen: """Execute ODAFC application without launching the GUI. Args: system: "Linux", "Windows" or "Darwin" command: application to execute arguments: ODAFC argument list Raises: odafc.UnsupportedPlatform: for unsupported platforms odafc.ODAFCNotInstalledError: ODA File Converter not installed """ if system == LINUX: with _linux_dummy_display() as display: env = os.environ.copy() env["DISPLAY"] = display proc = subprocess.run([command] + arguments, text=True, capture_output=True, env=env) elif system == DARWIN: # TODO: unknown how to prevent the GUI from appearing on macOS proc = subprocess.run([command] + arguments, text=True, capture_output=True) elif system == WINDOWS: # New code from George-Jiang to solve the GUI pop-up problem startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags = ( subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW ) startupinfo.wShowWindow = subprocess.SW_HIDE proc = subprocess.Popen( [command] + arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo, ) proc.wait() else: # The OpenDesign Alliance only provides executables for Linux, macOS # and Windows: raise UnsupportedPlatform(f"Unsupported platform: {system}") return proc def _odafc_failed(system: str, returncode: int, stderr: str) -> bool: # changed v0.18.1, see https://github.com/mozman/ezdxf/issues/707 stderr = stderr.strip() if system == LINUX: # ODAFileConverter *always* crashes on Linux even if the output was successful return stderr != "" and stderr != "Quit (core dumped)" elif returncode != 0: return True else: return stderr != "" def _execute_odafc(arguments: list[str]) -> Optional[bytes]: """Execute ODAFC application. Args: arguments: ODAFC argument list Raises: odafc.ODAFCNotInstalledError: ODA File Converter not installed odafc.UnknownODAFCError: execution failed for unknown reasons odafc.UnsupportedPlatform: for unsupported platforms """ logger.debug(f"Running ODAFileConverter with arguments: {arguments}") system = platform.system() oda_fc = _get_odafc_path(system) proc = _run_with_no_gui(system, oda_fc, arguments) returncode = proc.returncode if system == WINDOWS: stdout = proc.stdout.read().decode("utf-8") stderr = proc.stderr.read().decode("utf-8") else: stdout = proc.stdout stderr = proc.stderr if _odafc_failed(system, returncode, stderr): msg = ( f"ODA File Converter failed: return code = {returncode}.\n" f"stdout: {stdout}\nstderr: {stderr}" ) logger.debug(msg) raise UnknownODAFCError(msg) return stdout