1# Copyright (c) 2011-2020, Manfred Moitzi
2# License: MIT License
3from typing import TYPE_CHECKING, Dict, Iterable, List, cast, Optional
4import logging
5from ezdxf.lldxf.const import DXFKeyError, DXFValueError, DXFInternalEzdxfError
6from ezdxf.lldxf.const import (
7    MODEL_SPACE_R2000, PAPER_SPACE_R2000,
8    TMP_PAPER_SPACE_NAME,
9)
10from ezdxf.lldxf.validator import is_valid_table_name
11from .layout import Layout, Modelspace, Paperspace
12from ezdxf.entities import DXFEntity
13
14if TYPE_CHECKING:
15    from ezdxf.eztypes import Dictionary, Drawing, Auditor
16
17logger = logging.getLogger('ezdxf')
18
19
20def key(name: str) -> str:
21    """ AutoCAD uses case insensitive layout names, but stores the name case
22    sensitive. """
23    return name.upper()
24
25
26MODEL = key('Model')
27
28
29class Layouts:
30    def __init__(self, doc: 'Drawing'):
31        """ Default constructor. (internal API) """
32        self.doc = doc
33        # Store layout names in normalized form: key(name)
34        self._layouts: Dict[str, Layout] = {}
35        # key: layout name as original case sensitive string; value: DXFLayout()
36        self._dxf_layouts: 'Dictionary' = cast('Dictionary',
37                                               self.doc.rootdict['ACAD_LAYOUT'])
38
39    @classmethod
40    def setup(cls, doc: 'Drawing'):
41        """ Constructor from scratch. (internal API) """
42        layouts = Layouts(doc)
43        layouts.setup_modelspace()
44        layouts.setup_paperspace()
45        return layouts
46
47    def __len__(self) -> int:
48        """ Returns count of existing layouts, including the modelspace
49        layout. """
50        return len(self._layouts)
51
52    def __contains__(self, name: str) -> bool:
53        """ Returns ``True`` if layout `name` exist. """
54        assert isinstance(name, str), type(str)
55        return key(name) in self._layouts
56
57    def __iter__(self) -> Iterable['Layout']:
58        """ Returns iterable of all layouts as :class:`~ezdxf.layouts.Layout`
59        objects, including the modelspace layout.
60        """
61        return iter(self._layouts.values())
62
63    def _add_layout(self, name: str, layout: Layout):
64        layout.dxf.name = name
65        self._layouts[key(name)] = layout
66        self._dxf_layouts[name] = layout.dxf_layout
67
68    def _discard(self, layout: 'Layout'):
69        name = layout.name
70        self._dxf_layouts.discard(name)
71        del self._layouts[key(name)]
72
73    def setup_modelspace(self):
74        """ Modelspace setup. (internal API) """
75        self._new_special(Modelspace, 'Model', MODEL_SPACE_R2000,
76                          dxfattribs={'taborder': 0})
77
78    def setup_paperspace(self):
79        """ First layout setup. (internal API) """
80        self._new_special(Paperspace, 'Layout1', PAPER_SPACE_R2000,
81                          dxfattribs={'taborder': 1})
82
83    def _new_special(self, cls, name: str, block_name: str,
84                     dxfattribs: dict) -> 'Layout':
85        if name in self._layouts:
86            raise DXFValueError(f'Layout "{name}" already exists')
87        dxfattribs['owner'] = self._dxf_layouts.dxf.handle
88        layout = cls.new(name, block_name, self.doc, dxfattribs=dxfattribs)
89        self._add_layout(name, layout)
90        return layout
91
92    def unique_paperspace_name(self) -> str:
93        """ Returns a unique paperspace name. (internal API)"""
94        blocks = self.doc.blocks
95        count = 0
96        while "*Paper_Space%d" % count in blocks:
97            count += 1
98        return "*Paper_Space%d" % count
99
100    def new(self, name: str, dxfattribs: dict = None) -> Paperspace:
101        """ Returns a new :class:`~ezdxf.layouts.Paperspace` layout.
102
103        Args:
104            name: layout name as shown in tabs in :term:`CAD` applications
105            dxfattribs: additional DXF attributes for the
106                :class:`~ezdxf.entities.layout.DXFLayout` entity
107
108        Raises:
109            DXFValueError: Invalid characters in layout name.
110            DXFValueError: Layout `name` already exist.
111
112        """
113        assert isinstance(name, str), type(str)
114        if not is_valid_table_name(name):
115            raise DXFValueError('Layout name contains invalid characters.')
116
117        if name in self:
118            raise DXFValueError(f'Layout "{name}" already exist.')
119
120        dxfattribs = dict(dxfattribs or {})  # copy attribs
121        dxfattribs['owner'] = self._dxf_layouts.dxf.handle
122        dxfattribs.setdefault('taborder', len(self._layouts) + 1)
123        block_name = self.unique_paperspace_name()
124        layout = Paperspace.new(name, block_name, self.doc,
125                                dxfattribs=dxfattribs)
126        # Default extents are ok!
127        # Reset limits to (0, 0) and (paper width, paper height)
128        layout.reset_limits()
129        self._add_layout(name, layout)
130        return layout
131
132    @classmethod
133    def load(cls, doc: 'Drawing') -> 'Layouts':
134        """ Constructor if loading from file. (internal API) """
135        layouts = cls(doc)
136        layouts.setup_from_rootdict()
137
138        # DXF R12: block/block_record for *Model_Space and *Paper_Space
139        # already exist:
140        if len(layouts) < 2:  # restore missing DXF Layouts
141            layouts.restore('Model', MODEL_SPACE_R2000, taborder=0)
142            layouts.restore('Layout1', PAPER_SPACE_R2000, taborder=1)
143        return layouts
144
145    def restore(self, name: str, block_record_name: str, taborder: int) -> None:
146        """ Restore layout from block if DXFLayout does not exist.
147        (internal API) """
148        if name in self:
149            return
150        block_layout = self.doc.blocks.get(block_record_name)
151        self._new_from_block_layout(name, block_layout, taborder)
152
153    def _new_from_block_layout(self, name, block_layout,
154                               taborder: int) -> 'Layout':
155        dxfattribs = {
156            'owner': self._dxf_layouts.dxf.handle,
157            'name': name,
158            'block_record_handle': block_layout.block_record_handle,
159            'taborder': taborder,
160        }
161        dxf_layout = cast('DXFLayout', self.doc.objects.new_entity(
162            'LAYOUT', dxfattribs=dxfattribs))
163        if key(name) == MODEL:
164            layout = Modelspace.load(dxf_layout, self.doc)
165        else:
166            layout = Paperspace.load(dxf_layout, self.doc)
167        self._add_layout(name, layout)
168        return layout
169
170    def setup_from_rootdict(self) -> None:
171        """ Setup layout manger from root dictionary. (internal API) """
172        for name, dxf_layout in self._dxf_layouts.items():
173            if key(name) == MODEL:
174                layout = Modelspace(dxf_layout, self.doc)
175            else:
176                layout = Paperspace(dxf_layout, self.doc)
177            # assert name == layout.dxf.name
178            self._layouts[key(name)] = layout
179
180    def modelspace(self) -> Modelspace:
181        """ Returns the :class:`~ezdxf.layouts.Modelspace` layout. """
182        return cast(Modelspace, self.get('Model'))
183
184    def names(self) -> List[str]:
185        """ Returns a list of all layout names, all names in original case
186        sensitive form. """
187        return [layout.name for layout in self._layouts.values()]
188
189    def get(self, name: Optional[str]) -> 'Layout':
190        """ Returns :class:`~ezdxf.layouts.Layout` by `name`, case insensitive
191        "Model" == "MODEL".
192
193        Args:
194            name: layout name as shown in tab, e.g. ``'Model'`` for modelspace
195
196        """
197        name = name or self.names_in_taborder()[1]  # first paperspace layout
198        return self._layouts[key(name)]
199
200    def rename(self, old_name: str, new_name: str) -> None:
201        """ Rename a layout from `old_name` to `new_name`.
202        Can not rename layout ``'Model'`` and the new name of a layout must
203        not exist.
204
205        Args:
206            old_name: actual layout name, case insensitive
207            new_name: new layout name, case insensitive
208
209        Raises:
210            DXFValueError: try to rename ``'Model'``
211            DXFValueError: Layout `new_name` already exist.
212
213        """
214        assert isinstance(old_name, str), type(old_name)
215        assert isinstance(new_name, str), type(new_name)
216        if key(old_name) == MODEL:
217            raise DXFValueError('Can not rename model space.')
218        if new_name in self:
219            raise DXFValueError(f'Layout "{new_name}" already exist.')
220        if old_name not in self:
221            raise DXFValueError(f'Layout "{old_name}" does not exist.')
222
223        layout = self.get(old_name)
224        self._discard(layout)
225        layout.rename(new_name)
226        self._add_layout(new_name, layout)
227
228    def names_in_taborder(self) -> List[str]:
229        """ Returns all layout names in tab order as shown in :term:`CAD`
230        applications. """
231        names = [(layout.dxf.taborder, layout.name) for layout in
232                 self._layouts.values()]
233        return [name for order, name in sorted(names)]
234
235    def get_layout_for_entity(self, entity: 'DXFEntity') -> 'Layout':
236        """ Returns the owner layout for a DXF `entity`. """
237        owner = entity.dxf.owner
238        if owner is None:
239            raise DXFKeyError('No associated layout, owner is None.')
240        return self.get_layout_by_key(entity.dxf.owner)
241
242    def get_layout_by_key(self, layout_key: str) -> 'Layout':
243        """ Returns a layout by its `layout_key`. (internal API) """
244        assert isinstance(layout_key, str), type(layout_key)
245        try:
246            block_record = self.doc.entitydb[layout_key]
247            dxf_layout = self.doc.entitydb[block_record.dxf.layout]
248        except KeyError:
249            raise DXFKeyError(f'Layout with key "{layout_key}" does not exist.')
250        return self.get(dxf_layout.dxf.name)
251
252    def get_active_layout_key(self):
253        """ Returns layout kay for the active paperspace layout.
254        (internal API) """
255        active_layout_block_record = self.doc.block_records.get(
256            PAPER_SPACE_R2000)
257        return active_layout_block_record.dxf.handle
258
259    def set_active_layout(self, name: str) -> None:
260        """ Set layout `name` as active paperspace layout. """
261        assert isinstance(name, str), type(name)
262        if key(name) == MODEL:  # reserved layout name
263            raise DXFValueError('Can not set model space as active layout')
264        # raises KeyError if layout 'name' does not exist
265        new_active_layout = self.get(name)
266        old_active_layout_key = self.get_active_layout_key()
267        if old_active_layout_key == new_active_layout.layout_key:
268            return  # layout 'name' is already the active layout
269
270        blocks = self.doc.blocks
271        new_active_paper_space_name = new_active_layout.block_record_name
272
273        blocks.rename_block(PAPER_SPACE_R2000, TMP_PAPER_SPACE_NAME)
274        blocks.rename_block(new_active_paper_space_name, PAPER_SPACE_R2000)
275        blocks.rename_block(TMP_PAPER_SPACE_NAME, new_active_paper_space_name)
276
277    def delete(self, name: str) -> None:
278        """ Delete layout `name` and destroy all entities in that layout.
279
280        Args:
281            name (str): layout name as shown in tabs
282
283        Raises:
284            DXFKeyError: if layout `name` do not exists
285            DXFValueError: deleting modelspace layout is not possible
286            DXFValueError: deleting last paperspace layout is not possible
287
288        """
289        assert isinstance(name, str), type(name)
290        if key(name) == MODEL:
291            raise DXFValueError("Can not delete modelspace layout.")
292
293        layout = self.get(name)
294        if len(self) < 3:
295            raise DXFValueError("Can not delete last paperspace layout.")
296        if layout.layout_key == self.get_active_layout_key():
297            # Layout `name` is the active layout:
298            for layout_name in self._layouts:
299                # Set any other paperspace layout as active layout
300                if layout_name not in (key(name), MODEL):
301                    self.set_active_layout(layout_name)
302                    break
303        self._discard(layout)
304        layout.destroy()
305
306    def active_layout(self) -> Paperspace:
307        """ Returns the active paperspace layout. """
308        for layout in self:
309            if layout.is_active_paperspace:
310                return cast(Paperspace, layout)
311        raise DXFInternalEzdxfError('No active paperspace layout found.')
312
313    def audit(self, auditor: 'Auditor'):
314        from ezdxf.audit import AuditError
315        doc = auditor.doc
316
317        # Find/remove orphaned LAYOUT objects:
318        layouts = (o for o in doc.objects if o.dxftype() == 'LAYOUT')
319        for layout in layouts:
320            name = layout.dxf.get('name')
321            if name not in self:
322                auditor.fixed_error(
323                    code=AuditError.ORPHANED_LAYOUT_ENTITY,
324                    message=f'Removed orphaned {str(layout)} "{name}"'
325                )
326                doc.objects.delete_entity(layout)
327
328        # Find/remove orphaned paperspace BLOCK_RECORDS named: *Paper_Space...
329        psp_br_handles = {
330            br.dxf.handle for br in doc.block_records if
331            br.dxf.name.lower().startswith('*paper_space')
332        }
333        psp_layout_br_handles = {
334            layout.dxf.block_record_handle for layout in
335            self._layouts.values() if key(layout.name) != MODEL
336        }
337        mismatch = psp_br_handles.difference(psp_layout_br_handles)
338        if len(mismatch):
339            for handle in mismatch:
340                br = doc.entitydb.get(handle)
341                name = br.dxf.get('name')
342                auditor.fixed_error(
343                    code=AuditError.ORPHANED_PAPER_SPACE_BLOCK_RECORD_ENTITY,
344                    message=f'Removed orphaned layout {str(br)} "{name}"'
345                )
346                if name in doc.blocks:
347                    doc.blocks.delete_block(name)
348                else:
349                    doc.block_records.remove(name)
350