1# Copyright (c) 2019-2020, Manfred Moitzi
2# License: MIT-License
3from typing import (
4    TYPE_CHECKING, KeysView, ItemsView, Any, Union, Dict, Optional,
5)
6import logging
7from ezdxf.lldxf import validator
8from ezdxf.lldxf.const import (
9    SUBCLASS_MARKER, DXFKeyError, DXFValueError,
10)
11from ezdxf.lldxf.attributes import (
12    DXFAttr, DXFAttributes, DefSubclass, RETURN_DEFAULT, group_code_mapping
13)
14from ezdxf.lldxf.types import is_valid_handle
15from ezdxf.audit import AuditError
16from ezdxf.entities import factory
17from .dxfentity import base_class, SubclassProcessor, DXFEntity
18from .dxfobj import DXFObject
19
20logger = logging.getLogger('ezdxf')
21
22if TYPE_CHECKING:
23    from ezdxf.eztypes import TagWriter, Drawing, DXFNamespace, Auditor
24
25__all__ = ['Dictionary', 'DictionaryWithDefault', 'DictionaryVar']
26
27acdb_dictionary = DefSubclass('AcDbDictionary', {
28    # If set to 1, indicates that elements of the dictionary are to be treated
29    # as hard-owned:
30    'hard_owned': DXFAttr(
31        280, default=0, optional=True,
32        validator=validator.is_integer_bool,
33        fixer=RETURN_DEFAULT,
34    ),
35
36    # Duplicate record cloning flag (determines how to merge duplicate entries):
37    # 0 = not applicable
38    # 1 = keep existing
39    # 2 = use clone
40    # 3 = <xref>$0$<name>
41    # 4 = $0$<name>
42    # 5 = Unmangle name
43    'cloning': DXFAttr(
44        281, default=1,
45        validator=validator.is_in_integer_range(0, 6),
46        fixer=RETURN_DEFAULT,
47    ),
48    # 3: entry name
49    # 350: entry handle, some DICTIONARY objects have 360 as handle group code,
50    # this is accepted by AutoCAD but not documented by the DXF reference!
51    # ezdxf replaces group code 360 by 350.
52})
53acdb_dictionary_group_codes = group_code_mapping(acdb_dictionary)
54KEY_CODE = 3
55VALUE_CODE = 350
56# Some DICTIONARY use group code 360:
57SEARCH_CODES = (VALUE_CODE, 360)
58
59
60@factory.register_entity
61class Dictionary(DXFObject):
62    """ AutoCAD maintains items such as mline styles and group definitions as
63    objects in dictionaries. Other applications are free to create and use
64    their own dictionaries as they see fit. The prefix "ACAD_" is reserved
65    for use by AutoCAD applications.
66
67    Dictionary entries are (key, DXFEntity) pairs. DXFEntity could be a string,
68    because at loading time not all objects are already stored in the EntityDB,
69    and have to acquired later.
70
71    """
72    DXFTYPE = 'DICTIONARY'
73    DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary)
74
75    def __init__(self):
76        super().__init__()
77        self._data: Dict[str, Union[str, DXFEntity]] = dict()
78        self._value_code = VALUE_CODE
79
80    def _copy_data(self, entity: 'Dictionary') -> None:
81        """ Copy hard owned entities but do not store the copies in the entity
82        database, this is a second step, this is just real copying.
83        """
84        entity._value_code = self._value_code
85        if self.dxf.hard_owned:
86            # Reactors are removed from the cloned DXF objects.
87            entity._data = {key: entity.copy() for key, entity in self.items()}
88        else:
89            entity._data = {key: entity for key, entity in self.items()}
90
91    def post_bind_hook(self) -> None:
92        """ Called by binding a new or copied dictionary to the document,
93        bind hard owned sub-entities to the same document and add them to the
94        objects section.
95        """
96        if not self.dxf.hard_owned:
97            return
98        # copied or new dictionary:
99        doc = self.doc
100        owner_handle = self.dxf.handle
101        for _, entity in self.items():
102            entity.dxf.owner = owner_handle
103            factory.bind(entity, doc)
104            # For a correct DXF export add entities to the objects section:
105            doc.objects.add_object(entity)
106
107    def load_dxf_attribs(
108            self, processor: SubclassProcessor = None) -> 'DXFNamespace':
109        dxf = super().load_dxf_attribs(processor)
110        if processor:
111            tags = processor.fast_load_dxfattribs(
112                dxf, acdb_dictionary_group_codes, 1, log=False)
113            self.load_dict(tags)
114        return dxf
115
116    def load_dict(self, tags):
117        entry_handle = None
118        dict_key = None
119        value_code = VALUE_CODE
120        for code, value in tags:
121            if code in SEARCH_CODES:
122                # First store handles, because at this point, NOT all objects
123                # are stored in the EntityDB, at first access convert the handle
124                # to a DXFEntity object.
125                value_code = code
126                entry_handle = value
127            elif code == KEY_CODE:
128                dict_key = value
129            if dict_key and entry_handle:
130                # Store entity as handle string:
131                self._data[dict_key] = entry_handle
132                entry_handle = None
133                dict_key = None
134        # Use same value code as loaded:
135        self._value_code = value_code
136
137    def post_load_hook(self, doc: 'Drawing') -> None:
138        super().post_load_hook(doc)
139        db = doc.entitydb
140
141        def items():
142            for key, handle in self.items():
143                entity = db.get(handle)
144                if entity is not None and entity.is_alive:
145                    yield key, entity
146
147        if len(self):
148            for k, v in list(items()):
149                self.__setitem__(k, v)
150
151    def export_entity(self, tagwriter: 'TagWriter') -> None:
152        """ Export entity specific data as DXF tags. """
153        super().export_entity(tagwriter)
154        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dictionary.name)
155        self.dxf.export_dxf_attribs(tagwriter, ['hard_owned', 'cloning'])
156        self.export_dict(tagwriter)
157
158    def export_dict(self, tagwriter: 'TagWriter'):
159        # key: dict key string
160        # value: DXFEntity or handle as string
161        # Ignore invalid handles at export, because removing can create an empty
162        # dictionary, which is more a problem for AutoCAD than invalid handles,
163        # and removing the whole dictionary is maybe also a problem.
164        for key, value in self._data.items():
165            tagwriter.write_tag2(KEY_CODE, key)
166            # Value can be a handle string or a DXFEntity object:
167            if isinstance(value, DXFEntity):
168                if value.is_alive:
169                    value = value.dxf.handle
170                else:
171                    logger.debug(
172                        f'Key "{key}" points to a destroyed entity '
173                        f'in {str(self)}, target replaced by "0" handle.'
174                    )
175                    value = '0'
176            # Use same value code as loaded:
177            tagwriter.write_tag2(self._value_code, value)
178
179    @property
180    def is_hard_owner(self) -> bool:
181        """ Returns ``True`` if :class:`Dictionary` is hard owner of entities.
182        Hard owned entities will be destroyed by deleting the dictionary.
183        """
184        return bool(self.dxf.hard_owned)
185
186    def keys(self) -> KeysView:
187        """ Returns :class:`KeysView` of all dictionary keys. """
188        return self._data.keys()
189
190    def items(self) -> ItemsView:
191        """ Returns :class:`ItemsView` for all dictionary entries as
192        (:attr:`key`, :class:`DXFEntity`) pairs.
193
194        """
195        for key in self.keys():
196            yield key, self.get(key)  # maybe handle -> DXFEntity
197
198    def __getitem__(self, key: str) -> 'DXFEntity':
199        """ Return the value for `key`, raises a :class:`DXFKeyError` if `key`
200        does not exist.
201
202        """
203        return self.get(key)
204
205    def __setitem__(self, key: str, value: 'DXFEntity') -> None:
206        """ Add item as ``(key, value)`` pair to dictionary.  """
207        return self.add(key, value)
208
209    def __delitem__(self, key: str) -> None:
210        """ Delete entry `key` from the dictionary, raises :class:`DXFKeyError`
211        if key does not exist.
212
213        """
214        return self.remove(key)
215
216    def __contains__(self, key: str) -> bool:
217        """ Returns ``True`` if `key` exist. """
218        return key in self._data
219
220    def __len__(self) -> int:
221        """ Returns count of items. """
222        return len(self._data)
223
224    count = __len__
225
226    def get(self, key: str, default: Any = DXFKeyError) -> 'DXFEntity':
227        """ Returns :class:`DXFEntity` for `key`, if `key` exist,
228        else `default` or raises a :class:`DXFKeyError` for
229        `default` = :class:`DXFKeyError`.
230        """
231        try:
232            return self._data[key]
233        except KeyError:
234            if default is DXFKeyError:
235                raise DXFKeyError(f"KeyError: '{key}'")
236            else:
237                return default
238
239    def add(self, key: str, value: 'DXFEntity') -> None:
240        """ Add entry ``(key, value)``. """
241        if isinstance(value, str):
242            if not is_valid_handle(value):
243                raise DXFValueError(
244                    f'Invalid entity handle #{value} for key {key}')
245        self._data[key] = value
246
247    def remove(self, key: str) -> None:
248        """ Delete entry `key`. Raises :class:`DXFKeyError`, if `key` does not
249        exist. Deletes also hard owned DXF objects from OBJECTS section.
250        """
251        data = self._data
252        if key not in data:
253            raise DXFKeyError(key)
254
255        if self.is_hard_owner:
256            entity = self.get(key)
257            # Presumption: hard owned DXF objects always reside in the OBJECTS
258            # section.
259            self.doc.objects.delete_entity(entity)
260        del data[key]
261
262    def discard(self, key: str) -> None:
263        """ Delete entry `key` if exists. Does NOT raise an exception if `key`
264        not exist and does not delete hard owned DXF objects.
265        """
266        try:
267            del self._data[key]
268        except KeyError:
269            pass
270
271    def clear(self) -> None:
272        """  Delete all entries from :class:`Dictionary`, deletes hard owned
273        DXF objects from OBJECTS section.
274        """
275        if self.is_hard_owner:
276            self._delete_hard_owned_entries()
277        self._data.clear()
278
279    def _delete_hard_owned_entries(self) -> None:
280        # Presumption: hard owned DXF objects always reside in the OBJECTS section
281        objects = self.doc.objects
282        for key, entity in self.items():
283            objects.delete_entity(entity)
284
285    def add_new_dict(self, key: str, hard_owned: bool = False) -> 'Dictionary':
286        """ Create a new sub :class:`Dictionary`.
287
288        Args:
289            key: name of the sub dictionary
290            hard_owned: entries of the new dictionary are hard owned
291
292        """
293        dxf_dict = self.doc.objects.add_dictionary(owner=self.dxf.handle,
294                                                   hard_owned=hard_owned)
295        self.add(key, dxf_dict)
296        return dxf_dict
297
298    def add_dict_var(self, key: str, value: str) -> 'DictionaryVar':
299        """ Add new :class:`DictionaryVar`.
300
301        Args:
302             key: entry name as string
303             value: entry value as string
304
305        """
306        new_var = self.doc.objects.add_dictionary_var(
307            owner=self.dxf.handle,
308            value=value
309        )
310        self.add(key, new_var)
311        return new_var
312
313    def set_or_add_dict_var(self, key: str, value: str) -> 'DictionaryVar':
314        """ Set or add new :class:`DictionaryVar`.
315
316        Args:
317             key: entry name as string
318             value: entry value as string
319
320        """
321        if key not in self:
322            dict_var = self.doc.objects.add_dictionary_var(
323                owner=self.dxf.handle,
324                value=value
325            )
326            self.add(key, dict_var)
327        else:
328            dict_var = self.get(key)
329            dict_var.dxf.value = str(value)
330        return dict_var
331
332    def get_required_dict(self, key: str) -> 'Dictionary':
333        """ Get entry `key` or create a new :class:`Dictionary`,
334        if `Key` not exist.
335        """
336        try:
337            dxf_dict = self.get(key)
338        except DXFKeyError:
339            dxf_dict = self.add_new_dict(key)
340        return dxf_dict
341
342    def audit(self, auditor: 'Auditor') -> None:
343        super().audit(auditor)
344        self._check_invalid_entries(auditor)
345
346    def _check_invalid_entries(self, auditor: 'Auditor'):
347        trash = []  # do not delete content while iterating
348        append = trash.append
349        db = auditor.entitydb
350        for key, entry in self._data.items():
351            if isinstance(entry, str):
352                if entry not in db:
353                    append(key)
354            elif entry.is_alive:
355                if entry.dxf.handle not in db:
356                    append(key)
357            else:  # entry is destroyed
358                append(key)
359        for key in trash:
360            del self._data[key]
361            auditor.fixed_error(
362                code=AuditError.INVALID_DICTIONARY_ENTRY,
363                message=f'Removed entry "{key}" with invalid handle in {str(self)}',
364                dxf_entity=self,
365                data=key,
366            )
367
368    def destroy(self) -> None:
369        if not self.is_alive:
370            return
371
372        if self.is_hard_owner:
373            self._delete_hard_owned_entries()
374        super().destroy()
375
376
377acdb_dict_with_default = DefSubclass('AcDbDictionaryWithDefault', {
378    'default': DXFAttr(340),
379})
380acdb_dict_with_default_group_codes = group_code_mapping(acdb_dict_with_default)
381
382
383@factory.register_entity
384class DictionaryWithDefault(Dictionary):
385    DXFTYPE = 'ACDBDICTIONARYWDFLT'
386    DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary,
387                               acdb_dict_with_default)
388
389    def __init__(self):
390        super().__init__()
391        self._default: Optional[DXFEntity] = None
392
393    def _copy_data(self, entity: 'Dictionary') -> None:
394        entity._default = self._default
395
396    def post_load_hook(self, doc: 'Drawing') -> None:
397        # Set _default to None if default object not exist - audit() replaces
398        # a not existing default object by a place holder object.
399        # AutoCAD ignores not existing default objects!
400        self._default = doc.entitydb.get(self.dxf.default)
401        super().post_load_hook(doc)
402
403    def load_dxf_attribs(
404            self, processor: SubclassProcessor = None) -> 'DXFNamespace':
405        dxf = super().load_dxf_attribs(processor)
406        if processor:
407            processor.fast_load_dxfattribs(
408                dxf, acdb_dict_with_default_group_codes, 2)
409        return dxf
410
411    def export_entity(self, tagwriter: 'TagWriter') -> None:
412        super().export_entity(tagwriter)
413        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_with_default.name)
414        self.dxf.export_dxf_attribs(tagwriter, 'default')
415
416    def get(self, key: str, default: Any = DXFKeyError) -> DXFEntity:
417        # `default` argument is ignored, exist only for API compatibility,
418        """ Returns :class:`DXFEntity` for `key` or the predefined dictionary
419        wide :attr:`dxf.default` entity if `key` does not exist or ``None``
420        if default value also not exist.
421
422        """
423        return super().get(key, default=self._default)
424
425    def set_default(self, default: DXFEntity) -> None:
426        """ Set dictionary wide default entry.
427
428        Args:
429            default: default entry as :class:`DXFEntity`
430
431        """
432        self._default = default
433        self.dxf.default = self._default.dxf.handle
434
435    def audit(self, auditor: 'Auditor') -> None:
436        def create_missing_default_object():
437            placeholder = self.doc.objects.add_placeholder(
438                owner=self.dxf.handle)
439            self.set_default(placeholder)
440            auditor.fixed_error(
441                code=AuditError.CREATED_MISSING_OBJECT,
442                message=f'Created missing default object in {str(self)}.'
443            )
444
445        if self._default is None or not self._default.is_alive:
446            if auditor.entitydb.locked:
447                auditor.add_post_audit_job(create_missing_default_object)
448            else:
449                create_missing_default_object()
450        super().audit(auditor)
451
452
453acdb_dict_var = DefSubclass('DictionaryVariables', {
454    'schema': DXFAttr(280, default=0),
455    # Object schema number (currently set to 0)
456    'value': DXFAttr(1, default=''),
457})
458acdb_dict_var_group_codes = group_code_mapping(acdb_dict_var)
459
460
461@factory.register_entity
462class DictionaryVar(DXFObject):
463    """
464    DICTIONARYVAR objects are used by AutoCAD as a means to store named values
465    in the database for setvar / getvar purposes without the need to add entries
466    to the DXF HEADER section. System variables that are stored as
467    DICTIONARYVAR objects are the following:
468
469        - DEFAULTVIEWCATEGORY
470        - DIMADEC
471        - DIMASSOC
472        - DIMDSEP
473        - DRAWORDERCTL
474        - FIELDEVAL
475        - HALOGAP
476        - HIDETEXT
477        - INDEXCTL
478        - INDEXCTL
479        - INTERSECTIONCOLOR
480        - INTERSECTIONDISPLAY
481        - MSOLESCALE
482        - OBSCOLOR
483        - OBSLTYPE
484        - OLEFRAME
485        - PROJECTNAME
486        - SORTENTS
487        - UPDATETHUMBNAIL
488        - XCLIPFRAME
489        - XCLIPFRAME
490
491    """
492    DXFTYPE = 'DICTIONARYVAR'
493    DXFATTRIBS = DXFAttributes(base_class, acdb_dict_var)
494
495    def load_dxf_attribs(self,
496                         processor: SubclassProcessor = None) -> 'DXFNamespace':
497        dxf = super().load_dxf_attribs(processor)
498        if processor:
499            processor.fast_load_dxfattribs(dxf, acdb_dict_var_group_codes, 1)
500        return dxf
501
502    def export_entity(self, tagwriter: 'TagWriter') -> None:
503        """ Export entity specific data as DXF tags. """
504        super().export_entity(tagwriter)
505        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_var.name)
506        self.dxf.export_dxf_attribs(tagwriter, ['schema', 'value'])
507