385 lines
15 KiB
Python
385 lines
15 KiB
Python
# Copyright (c) 2011-2022, Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import TYPE_CHECKING, Iterator, cast, Optional
|
|
import logging
|
|
from ezdxf.lldxf.const import DXFKeyError, DXFValueError, DXFInternalEzdxfError
|
|
from ezdxf.lldxf.const import (
|
|
MODEL_SPACE_R2000,
|
|
PAPER_SPACE_R2000,
|
|
TMP_PAPER_SPACE_NAME,
|
|
)
|
|
from ezdxf.lldxf.validator import is_valid_table_name
|
|
from .layout import Layout, Modelspace, Paperspace
|
|
from ezdxf.entities import DXFEntity, BlockRecord
|
|
|
|
if TYPE_CHECKING:
|
|
from ezdxf.audit import Auditor
|
|
from ezdxf.document import Drawing
|
|
from ezdxf.entities import Dictionary, DXFLayout
|
|
|
|
logger = logging.getLogger("ezdxf")
|
|
|
|
|
|
def key(name: str) -> str:
|
|
"""AutoCAD uses case insensitive layout names, but stores the name case
|
|
sensitive."""
|
|
return name.upper()
|
|
|
|
|
|
MODEL = key("Model")
|
|
|
|
|
|
class Layouts:
|
|
def __init__(self, doc: Drawing):
|
|
"""Default constructor. (internal API)"""
|
|
self.doc = doc
|
|
# Store layout names in normalized form: key(name)
|
|
self._layouts: dict[str, Layout] = {}
|
|
# key: layout name as original case-sensitive string; value: DXFLayout()
|
|
self._dxf_layouts: Dictionary = cast(
|
|
"Dictionary", self.doc.rootdict["ACAD_LAYOUT"]
|
|
)
|
|
|
|
@classmethod
|
|
def setup(cls, doc: Drawing):
|
|
"""Constructor from scratch. (internal API)"""
|
|
layouts = Layouts(doc)
|
|
layouts.setup_modelspace()
|
|
layouts.setup_paperspace()
|
|
return layouts
|
|
|
|
def __len__(self) -> int:
|
|
"""Returns count of existing layouts, including the modelspace
|
|
layout."""
|
|
return len(self._layouts)
|
|
|
|
def __contains__(self, name: str) -> bool:
|
|
"""Returns ``True`` if layout `name` exist."""
|
|
assert isinstance(name, str), type(str)
|
|
return key(name) in self._layouts
|
|
|
|
def __iter__(self) -> Iterator[Layout]:
|
|
"""Returns iterable of all layouts as :class:`~ezdxf.layouts.Layout`
|
|
objects, including the modelspace layout.
|
|
"""
|
|
return iter(self._layouts.values())
|
|
|
|
def _add_layout(self, name: str, layout: Layout):
|
|
dxf_layout = layout.dxf_layout
|
|
dxf_layout.dxf.name = name
|
|
dxf_layout.dxf.owner = self._dxf_layouts.dxf.handle
|
|
self._layouts[key(name)] = layout
|
|
self._dxf_layouts[name] = dxf_layout
|
|
|
|
def _discard(self, layout: Layout):
|
|
name = layout.name
|
|
self._dxf_layouts.discard(name)
|
|
del self._layouts[key(name)]
|
|
|
|
def append_layout(self, layout: Layout) -> None:
|
|
"""Append an existing (copied) paperspace layout as last layout tab."""
|
|
index = 1
|
|
base_layout_name = layout.dxf.name
|
|
layout_name = base_layout_name
|
|
while layout_name in self:
|
|
index += 1
|
|
layout_name = base_layout_name + f" ({index})"
|
|
layout.dxf.taborder = len(self._layouts) + 1
|
|
self._add_layout(layout_name, layout)
|
|
|
|
def setup_modelspace(self):
|
|
"""Modelspace setup. (internal API)"""
|
|
self._new_special(
|
|
Modelspace, "Model", MODEL_SPACE_R2000, dxfattribs={"taborder": 0}
|
|
)
|
|
|
|
def setup_paperspace(self):
|
|
"""First layout setup. (internal API)"""
|
|
self._new_special(
|
|
Paperspace, "Layout1", PAPER_SPACE_R2000, dxfattribs={"taborder": 1}
|
|
)
|
|
|
|
def _new_special(self, cls, name: str, block_name: str, dxfattribs: dict) -> Layout:
|
|
if name in self._layouts:
|
|
raise DXFValueError(f'Layout "{name}" already exists')
|
|
dxfattribs["owner"] = self._dxf_layouts.dxf.handle
|
|
layout = cls.new(name, block_name, self.doc, dxfattribs=dxfattribs)
|
|
self._add_layout(name, layout)
|
|
return layout
|
|
|
|
def unique_paperspace_name(self) -> str:
|
|
"""Returns a unique paperspace name. (internal API)"""
|
|
blocks = self.doc.blocks
|
|
count = 0
|
|
while "*Paper_Space%d" % count in blocks:
|
|
count += 1
|
|
return "*Paper_Space%d" % count
|
|
|
|
def new(self, name: str, dxfattribs=None) -> Paperspace:
|
|
"""Returns a new :class:`~ezdxf.layouts.Paperspace` layout.
|
|
|
|
Args:
|
|
name: layout name as shown in tabs in :term:`CAD` applications
|
|
dxfattribs: additional DXF attributes for the
|
|
:class:`~ezdxf.entities.layout.DXFLayout` entity
|
|
|
|
Raises:
|
|
DXFValueError: Invalid characters in layout name.
|
|
DXFValueError: Layout `name` already exist.
|
|
|
|
"""
|
|
assert isinstance(name, str), type(str)
|
|
if not is_valid_table_name(name):
|
|
raise DXFValueError("Layout name contains invalid characters.")
|
|
|
|
if name in self:
|
|
raise DXFValueError(f'Layout "{name}" already exist.')
|
|
|
|
dxfattribs = dict(dxfattribs or {}) # copy attribs
|
|
dxfattribs["owner"] = self._dxf_layouts.dxf.handle
|
|
dxfattribs.setdefault("taborder", len(self._layouts) + 1)
|
|
block_name = self.unique_paperspace_name()
|
|
layout = Paperspace.new(name, block_name, self.doc, dxfattribs=dxfattribs)
|
|
# Default extents are ok!
|
|
# Reset limits to (0, 0) and (paper width, paper height)
|
|
layout.reset_limits()
|
|
self._add_layout(name, layout)
|
|
return layout # type: ignore
|
|
|
|
@classmethod
|
|
def load(cls, doc: "Drawing") -> "Layouts":
|
|
"""Constructor if loading from file. (internal API)"""
|
|
layouts = cls(doc)
|
|
layouts.setup_from_rootdict()
|
|
|
|
# DXF R12: block/block_record for *Model_Space and *Paper_Space
|
|
# already exist:
|
|
if len(layouts) < 2: # restore missing DXF Layouts
|
|
layouts.restore("Model", MODEL_SPACE_R2000, taborder=0)
|
|
layouts.restore("Layout1", PAPER_SPACE_R2000, taborder=1)
|
|
return layouts
|
|
|
|
def restore(self, name: str, block_record_name: str, taborder: int) -> None:
|
|
"""Restore layout from block if DXFLayout does not exist.
|
|
(internal API)"""
|
|
if name in self:
|
|
return
|
|
block_layout = self.doc.blocks.get(block_record_name)
|
|
self._new_from_block_layout(name, block_layout, taborder)
|
|
|
|
def _new_from_block_layout(self, name, block_layout, taborder: int) -> "Layout":
|
|
dxfattribs = {
|
|
"owner": self._dxf_layouts.dxf.handle,
|
|
"name": name,
|
|
"block_record_handle": block_layout.block_record_handle,
|
|
"taborder": taborder,
|
|
}
|
|
dxf_layout = cast(
|
|
"DXFLayout",
|
|
self.doc.objects.new_entity("LAYOUT", dxfattribs=dxfattribs),
|
|
)
|
|
if key(name) == MODEL:
|
|
layout = Modelspace.load(dxf_layout, self.doc)
|
|
else:
|
|
layout = Paperspace.load(dxf_layout, self.doc)
|
|
self._add_layout(name, layout)
|
|
return layout
|
|
|
|
def setup_from_rootdict(self) -> None:
|
|
"""Setup layout manager from root dictionary. (internal API)"""
|
|
layout: Layout
|
|
for name, dxf_layout in self._dxf_layouts.items():
|
|
if isinstance(dxf_layout, str):
|
|
logger.debug(f"ignore missing LAYOUT(#{dxf_layout}) entity '{name}'")
|
|
continue
|
|
if key(name) == MODEL:
|
|
layout = Modelspace(dxf_layout, self.doc)
|
|
else:
|
|
layout = Paperspace(dxf_layout, self.doc)
|
|
# assert name == layout.dxf.name
|
|
self._layouts[key(name)] = layout
|
|
|
|
def modelspace(self) -> Modelspace:
|
|
"""Returns the :class:`~ezdxf.layouts.Modelspace` layout."""
|
|
return cast(Modelspace, self.get("Model"))
|
|
|
|
def names(self) -> list[str]:
|
|
"""Returns a list of all layout names, all names in original case
|
|
sensitive form."""
|
|
return [layout.name for layout in self._layouts.values()]
|
|
|
|
def get(self, name: Optional[str]) -> Layout:
|
|
"""Returns :class:`~ezdxf.layouts.Layout` by `name`, case insensitive
|
|
"Model" == "MODEL".
|
|
|
|
Args:
|
|
name: layout name as shown in tab, e.g. ``'Model'`` for modelspace
|
|
|
|
"""
|
|
name = name or self.names_in_taborder()[1] # first paperspace layout
|
|
return self._layouts[key(name)]
|
|
|
|
def rename(self, old_name: str, new_name: str) -> None:
|
|
"""Rename a layout from `old_name` to `new_name`.
|
|
Can not rename layout ``'Model'`` and the new name of a layout must
|
|
not exist.
|
|
|
|
Args:
|
|
old_name: actual layout name, case insensitive
|
|
new_name: new layout name, case insensitive
|
|
|
|
Raises:
|
|
DXFValueError: try to rename ``'Model'``
|
|
DXFValueError: Layout `new_name` already exist.
|
|
|
|
"""
|
|
assert isinstance(old_name, str), type(old_name)
|
|
assert isinstance(new_name, str), type(new_name)
|
|
if key(old_name) == MODEL:
|
|
raise DXFValueError("Can not rename model space.")
|
|
if new_name in self:
|
|
raise DXFValueError(f'Layout "{new_name}" already exist.')
|
|
if old_name not in self:
|
|
raise DXFValueError(f'Layout "{old_name}" does not exist.')
|
|
|
|
layout = self.get(old_name)
|
|
self._discard(layout)
|
|
layout.rename(new_name)
|
|
self._add_layout(new_name, layout)
|
|
|
|
def names_in_taborder(self) -> list[str]:
|
|
"""Returns all layout names in tab order as shown in :term:`CAD`
|
|
applications."""
|
|
names = [
|
|
(layout.dxf.taborder, layout.name) for layout in self._layouts.values()
|
|
]
|
|
return [name for order, name in sorted(names)]
|
|
|
|
def get_layout_for_entity(self, entity: DXFEntity) -> Layout:
|
|
"""Returns the owner layout for a DXF `entity`."""
|
|
owner = entity.dxf.owner
|
|
if owner is None:
|
|
raise DXFKeyError("No associated layout, owner is None.")
|
|
return self.get_layout_by_key(entity.dxf.owner)
|
|
|
|
def get_layout_by_key(self, layout_key: str) -> Layout:
|
|
"""Returns a layout by its `layout_key`. (internal API)"""
|
|
assert isinstance(layout_key, str), type(layout_key)
|
|
error_msg = f'Layout with key "{layout_key}" does not exist.'
|
|
try:
|
|
block_record = self.doc.entitydb[layout_key]
|
|
except KeyError:
|
|
raise DXFKeyError(error_msg)
|
|
if not isinstance(block_record, BlockRecord):
|
|
# VERTEX, ATTRIB, SEQEND are owned by the parent entity
|
|
raise DXFKeyError(error_msg)
|
|
try:
|
|
dxf_layout = self.doc.entitydb[block_record.dxf.layout]
|
|
except KeyError:
|
|
raise DXFKeyError(error_msg)
|
|
return self.get(dxf_layout.dxf.name)
|
|
|
|
def get_active_layout_key(self):
|
|
"""Returns layout kay for the active paperspace layout.
|
|
(internal API)"""
|
|
active_layout_block_record = self.doc.block_records.get(PAPER_SPACE_R2000)
|
|
return active_layout_block_record.dxf.handle
|
|
|
|
def set_active_layout(self, name: str) -> None:
|
|
"""Set layout `name` as active paperspace layout."""
|
|
assert isinstance(name, str), type(name)
|
|
if key(name) == MODEL: # reserved layout name
|
|
raise DXFValueError("Can not set model space as active layout")
|
|
# raises KeyError if layout 'name' does not exist
|
|
new_active_layout = self.get(name)
|
|
old_active_layout_key = self.get_active_layout_key()
|
|
if old_active_layout_key == new_active_layout.layout_key:
|
|
return # layout 'name' is already the active layout
|
|
|
|
blocks = self.doc.blocks
|
|
new_active_paper_space_name = new_active_layout.block_record_name
|
|
|
|
blocks.rename_block(PAPER_SPACE_R2000, TMP_PAPER_SPACE_NAME)
|
|
blocks.rename_block(new_active_paper_space_name, PAPER_SPACE_R2000)
|
|
blocks.rename_block(TMP_PAPER_SPACE_NAME, new_active_paper_space_name)
|
|
|
|
def delete(self, name: str) -> None:
|
|
"""Delete layout `name` and destroy all entities in that layout.
|
|
|
|
Args:
|
|
name (str): layout name as shown in tabs
|
|
|
|
Raises:
|
|
DXFKeyError: if layout `name` do not exists
|
|
DXFValueError: deleting modelspace layout is not possible
|
|
DXFValueError: deleting last paperspace layout is not possible
|
|
|
|
"""
|
|
assert isinstance(name, str), type(name)
|
|
if key(name) == MODEL:
|
|
raise DXFValueError("Can not delete modelspace layout.")
|
|
|
|
layout = self.get(name)
|
|
if len(self) < 3:
|
|
raise DXFValueError("Can not delete last paperspace layout.")
|
|
if layout.layout_key == self.get_active_layout_key():
|
|
# Layout `name` is the active layout:
|
|
for layout_name in self._layouts:
|
|
# Set any other paperspace layout as active layout
|
|
if layout_name not in (key(name), MODEL):
|
|
self.set_active_layout(layout_name)
|
|
break
|
|
self._discard(layout)
|
|
layout.destroy()
|
|
|
|
def active_layout(self) -> Paperspace:
|
|
"""Returns the active paperspace layout."""
|
|
for layout in self:
|
|
if layout.is_active_paperspace:
|
|
return cast(Paperspace, layout)
|
|
raise DXFInternalEzdxfError("No active paperspace layout found.")
|
|
|
|
def audit(self, auditor: Auditor):
|
|
from ezdxf.audit import AuditError
|
|
|
|
doc = auditor.doc
|
|
|
|
# Find/remove orphaned LAYOUT objects:
|
|
layouts = (o for o in doc.objects if o.dxftype() == "LAYOUT")
|
|
for layout in layouts:
|
|
name = layout.dxf.get("name")
|
|
if name not in self:
|
|
auditor.fixed_error(
|
|
code=AuditError.ORPHANED_LAYOUT_ENTITY,
|
|
message=f'Removed orphaned {str(layout)} "{name}"',
|
|
)
|
|
doc.objects.delete_entity(layout)
|
|
|
|
# Find/remove orphaned paperspace BLOCK_RECORDS named: *Paper_Space...
|
|
psp_br_handles = {
|
|
br.dxf.handle
|
|
for br in doc.block_records
|
|
if br.dxf.name.lower().startswith("*paper_space")
|
|
}
|
|
psp_layout_br_handles = {
|
|
layout.dxf.block_record_handle
|
|
for layout in self._layouts.values()
|
|
if key(layout.name) != MODEL
|
|
}
|
|
mismatch = psp_br_handles.difference(psp_layout_br_handles)
|
|
if len(mismatch):
|
|
for handle in mismatch:
|
|
br = doc.entitydb.get(handle)
|
|
name = br.dxf.get("name") # type: ignore
|
|
auditor.fixed_error(
|
|
code=AuditError.ORPHANED_PAPER_SPACE_BLOCK_RECORD_ENTITY,
|
|
message=f'Removed orphaned layout {str(br)} "{name}"',
|
|
)
|
|
if name in doc.blocks:
|
|
doc.blocks.delete_block(name)
|
|
else:
|
|
doc.block_records.remove(name)
|
|
# Does not check the LAYOUT content this is done in the BlockSection,
|
|
# because the content of layouts is stored as blocks.
|