508 lines
16 KiB
Python
508 lines
16 KiB
Python
# 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
|