# Copyright (c) 2019-2024, Manfred Moitzi # License: MIT-License from __future__ import annotations from typing import TYPE_CHECKING, Union, Optional from typing_extensions import Self import logging from ezdxf.lldxf import validator from ezdxf.lldxf.const import ( SUBCLASS_MARKER, DXFKeyError, DXFValueError, DXFTypeError, DXFStructureError, ) from ezdxf.lldxf.attributes import ( DXFAttr, DXFAttributes, DefSubclass, RETURN_DEFAULT, group_code_mapping, ) from ezdxf.lldxf.types import is_valid_handle from ezdxf.audit import AuditError from ezdxf.entities import factory, DXFGraphic from .dxfentity import base_class, SubclassProcessor, DXFEntity from .dxfobj import DXFObject from .copy import default_copy, CopyNotSupported if TYPE_CHECKING: from ezdxf.entities import DXFNamespace, XRecord from ezdxf.lldxf.tagwriter import AbstractTagWriter from ezdxf.document import Drawing from ezdxf.audit import Auditor from ezdxf import xref __all__ = ["Dictionary", "DictionaryWithDefault", "DictionaryVar"] logger = logging.getLogger("ezdxf") acdb_dictionary = DefSubclass( "AcDbDictionary", { # If hard_owned is set to 1 the entries are owned by the DICTIONARY. # The 1 state seems to be the default value, but is not documented by # the DXF reference. # BricsCAD creates the root DICTIONARY and the top level DICTIONARY entries # without group code 280 tags, and they are all definitely hard owner of their # entries, because their entries have the DICTIONARY handle as owner handle. "hard_owned": DXFAttr( 280, default=1, optional=True, validator=validator.is_integer_bool, fixer=RETURN_DEFAULT, ), # Duplicate record cloning flag (determines how to merge duplicate entries): # 0 = not applicable # 1 = keep existing # 2 = use clone # 3 = $0$ # 4 = $0$ # 5 = Unmangle name "cloning": DXFAttr( 281, default=1, validator=validator.is_in_integer_range(0, 6), fixer=RETURN_DEFAULT, ), # 3: entry name # 350: entry handle, some DICTIONARY objects have 360 as handle group code, # this is accepted by AutoCAD but not documented by the DXF reference! # ezdxf replaces group code 360 by 350. # - group code 350 is a soft-owner handle # - group code 360 is a hard-owner handle }, ) acdb_dictionary_group_codes = group_code_mapping(acdb_dictionary) KEY_CODE = 3 VALUE_CODE = 350 # Some DICTIONARY use group code 360: SEARCH_CODES = (VALUE_CODE, 360) @factory.register_entity class Dictionary(DXFObject): """AutoCAD maintains items such as mline styles and group definitions as objects in dictionaries. Other applications are free to create and use their own dictionaries as they see fit. The prefix "ACAD_" is reserved for use by AutoCAD applications. Dictionary entries are (key, DXFEntity) pairs. DXFEntity could be a string, because at loading time not all objects are already stored in the EntityDB, and have to be acquired later. """ DXFTYPE = "DICTIONARY" DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary) def __init__(self) -> None: super().__init__() self._data: dict[str, Union[str, DXFObject]] = dict() self._value_code = VALUE_CODE def copy_data(self, entity: Self, copy_strategy=default_copy) -> None: """Copy hard owned entities but do not store the copies in the entity database, this is a second step (factory.bind), this is just real copying. """ assert isinstance(entity, Dictionary) entity._value_code = self._value_code if self.dxf.hard_owned: # Reactors are removed from the cloned DXF objects. data: dict[str, DXFEntity] = dict() for key, ent in self.items(): # ignore strings and None - these entities do not exist # in the entity database if isinstance(ent, DXFEntity): try: # todo: follow CopyStrategy.ignore_copy_errors_in_linked entities data[key] = ent.copy(copy_strategy=copy_strategy) except CopyNotSupported: if copy_strategy.settings.ignore_copy_errors_in_linked_entities: logger.warning( f"copy process ignored {str(ent)} - this may cause problems in AutoCAD" ) else: raise entity._data = data # type: ignore else: entity._data = dict(self._data) def get_handle_mapping(self, clone: Dictionary) -> dict[str, str]: """Returns handle mapping for in-object copies.""" handle_mapping: dict[str, str] = dict() if not self.is_hard_owner: return handle_mapping for key, entity in self.items(): if not isinstance(entity, DXFEntity): continue copied_entry = clone.get(key) if copied_entry: handle_mapping[entity.dxf.handle] = copied_entry.dxf.handle return handle_mapping def map_resources(self, clone: Self, mapping: xref.ResourceMapper) -> None: """Translate resources from self to the copied entity.""" assert isinstance(clone, Dictionary) super().map_resources(clone, mapping) if self.is_hard_owner: return data = dict() for key, entity in self.items(): if not isinstance(entity, DXFEntity): continue entity_copy = mapping.get_reference_of_copy(entity.dxf.handle) if entity_copy: data[key] = entity clone._data = data # type: ignore def del_source_of_copy(self) -> None: super().del_source_of_copy() for _, entity in self.items(): if isinstance(entity, DXFEntity) and entity.is_alive: entity.del_source_of_copy() def post_bind_hook(self) -> None: """Called by binding a new or copied dictionary to the document, bind hard owned sub-entities to the same document and add them to the objects section. """ if not self.dxf.hard_owned: return # copied or new dictionary: doc = self.doc assert doc is not None object_section = doc.objects owner_handle = self.dxf.handle for _, entity in self.items(): entity.dxf.owner = owner_handle factory.bind(entity, doc) # For a correct DXF export add entities to the objects section: object_section.add_object(entity) def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: dxf = super().load_dxf_attribs(processor) if processor: tags = processor.fast_load_dxfattribs( dxf, acdb_dictionary_group_codes, 1, log=False ) self.load_dict(tags) return dxf def load_dict(self, tags): entry_handle = None dict_key = None value_code = VALUE_CODE for code, value in tags: if code in SEARCH_CODES: # First store handles, because at this point, NOT all objects # are stored in the EntityDB, at first access convert the handle # to a DXFEntity object. value_code = code entry_handle = value elif code == KEY_CODE: dict_key = value if dict_key and entry_handle: # Store entity as handle string: self._data[dict_key] = entry_handle entry_handle = None dict_key = None # Use same value code as loaded: self._value_code = value_code def post_load_hook(self, doc: Drawing) -> None: super().post_load_hook(doc) db = doc.entitydb def items(): for key, handle in self.items(): entity = db.get(handle) if entity is not None and entity.is_alive: yield key, entity if len(self): for k, v in list(items()): self.__setitem__(k, v) def export_entity(self, tagwriter: AbstractTagWriter) -> None: """Export entity specific data as DXF tags.""" super().export_entity(tagwriter) tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dictionary.name) self.dxf.export_dxf_attribs(tagwriter, ["hard_owned", "cloning"]) self.export_dict(tagwriter) def export_dict(self, tagwriter: AbstractTagWriter): # key: dict key string # value: DXFEntity or handle as string # Ignore invalid handles at export, because removing can create an empty # dictionary, which is more a problem for AutoCAD than invalid handles, # and removing the whole dictionary is maybe also a problem. for key, value in self._data.items(): tagwriter.write_tag2(KEY_CODE, key) # Value can be a handle string or a DXFEntity object: if isinstance(value, DXFEntity): if value.is_alive: value = value.dxf.handle else: logger.debug( f'Key "{key}" points to a destroyed entity ' f'in {str(self)}, target replaced by "0" handle.' ) value = "0" # Use same value code as loaded: tagwriter.write_tag2(self._value_code, value) @property def is_hard_owner(self) -> bool: """Returns ``True`` if the dictionary is hard owner of entities. Hard owned entities will be destroyed by deleting the dictionary. """ return bool(self.dxf.hard_owned) def keys(self): """Returns a :class:`KeysView` of all dictionary keys.""" return self._data.keys() def items(self): """Returns an :class:`ItemsView` for all dictionary entries as (key, entity) pairs. An entity can be a handle string if the entity does not exist. """ for key in self.keys(): yield key, self.get(key) # maybe handle -> DXFEntity def __getitem__(self, key: str) -> DXFEntity: """Return self[`key`]. The returned value can be a handle string if the entity does not exist. Raises: DXFKeyError: `key` does not exist """ if key in self._data: return self._data[key] # type: ignore else: raise DXFKeyError(key) def __setitem__(self, key: str, entity: DXFObject) -> None: """Set self[`key`] = `entity`. Only DXF objects stored in the OBJECTS section are allowed as content of :class:`Dictionary` objects. DXF entities stored in layouts are not allowed. Raises: DXFTypeError: invalid DXF type """ return self.add(key, entity) def __delitem__(self, key: str) -> None: """Delete self[`key`]. Raises: DXFKeyError: `key` does not exist """ return self.remove(key) def __contains__(self, key: str) -> bool: """Returns `key` ``in`` self.""" return key in self._data def __len__(self) -> int: """Returns count of dictionary entries.""" return len(self._data) count = __len__ def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]: """Returns the :class:`DXFEntity` for `key`, if `key` exist else `default`. An entity can be a handle string if the entity does not exist. """ return self._data.get(key, default) # type: ignore def find_key(self, entity: DXFEntity) -> str: """Returns the DICTIONARY key string for `entity` or an empty string if not found. """ for key, entry in self._data.items(): if entry is entity: return key return "" def add(self, key: str, entity: DXFObject) -> None: """Add entry (key, value). If the DICTIONARY is hard owner of its entries, the :meth:`add` does NOT take ownership of the entity automatically. Raises: DXFValueError: invalid entity handle DXFTypeError: invalid DXF type """ if isinstance(entity, str): if not is_valid_handle(entity): raise DXFValueError(f"Invalid entity handle #{entity} for key {key}") elif isinstance(entity, DXFGraphic): if self.doc is not None and self.doc.is_loading: # type: ignore # AutoCAD add-ons can store graphical entities in DICTIONARIES # in the OBJECTS section and AutoCAD does not complain - so just # preserve them! # Example "ZJMC-288.dxf" in issue #585, add-on: "acdgnlsdraw.crx"? logger.warning(f"Invalid entity {str(entity)} in {str(self)}") else: # Do not allow ezdxf users to add graphical entities to a # DICTIONARY object! raise DXFTypeError(f"Graphic entities not allowed: {entity.dxftype()}") self._data[key] = entity def take_ownership(self, key: str, entity: DXFObject): """Add entry (key, value) and take ownership.""" self.add(key, entity) entity.dxf.owner = self.dxf.handle def remove(self, key: str) -> None: """Delete entry `key`. Raises :class:`DXFKeyError`, if `key` does not exist. Destroys hard owned DXF entities. """ data = self._data if key not in data: raise DXFKeyError(key) if self.is_hard_owner: assert self.doc is not None entity = self.__getitem__(key) # Presumption: hard owned DXF objects always reside in the OBJECTS # section. self.doc.objects.delete_entity(entity) # type: ignore del data[key] def discard(self, key: str) -> None: """Delete entry `key` if exists. Does not raise an exception if `key` doesn't exist and does not destroy hard owned DXF entities. """ try: del self._data[key] except KeyError: pass def clear(self) -> None: """Delete all entries from the dictionary and destroys hard owned DXF entities. """ if self.is_hard_owner: self._delete_hard_owned_entries() self._data.clear() def _delete_hard_owned_entries(self) -> None: # Presumption: hard owned DXF objects always reside in the OBJECTS section objects = self.doc.objects # type: ignore for key, entity in self.items(): if isinstance(entity, DXFEntity): objects.delete_entity(entity) # type: ignore def add_new_dict(self, key: str, hard_owned: bool = False) -> Dictionary: """Create a new sub-dictionary of type :class:`Dictionary`. Args: key: name of the sub-dictionary hard_owned: entries of the new dictionary are hard owned """ dxf_dict = self.doc.objects.add_dictionary( # type: ignore owner=self.dxf.handle, hard_owned=hard_owned ) self.add(key, dxf_dict) return dxf_dict def add_dict_var(self, key: str, value: str) -> DictionaryVar: """Add a new :class:`DictionaryVar`. Args: key: entry name as string value: entry value as string """ new_var = self.doc.objects.add_dictionary_var( # type: ignore owner=self.dxf.handle, value=value ) self.add(key, new_var) return new_var def add_xrecord(self, key: str) -> XRecord: """Add a new :class:`XRecord`. Args: key: entry name as string """ new_xrecord = self.doc.objects.add_xrecord( # type: ignore owner=self.dxf.handle, ) self.add(key, new_xrecord) return new_xrecord def set_or_add_dict_var(self, key: str, value: str) -> DictionaryVar: """Set or add new :class:`DictionaryVar`. Args: key: entry name as string value: entry value as string """ if key not in self: dict_var = self.doc.objects.add_dictionary_var( # type: ignore owner=self.dxf.handle, value=value ) self.add(key, dict_var) else: dict_var = self.get(key) dict_var.dxf.value = str(value) # type: ignore return dict_var def link_dxf_object(self, name: str, obj: DXFObject) -> None: """Add `obj` and set owner of `obj` to this dictionary. Graphical DXF entities have to reside in a layout and therefore can not be owned by a :class:`Dictionary`. Raises: DXFTypeError: `obj` has invalid DXF type """ if not isinstance(obj, DXFObject): raise DXFTypeError(f"invalid DXF type: {obj.dxftype()}") self.add(name, obj) obj.dxf.owner = self.dxf.handle def get_required_dict(self, key: str, hard_owned=False) -> Dictionary: """Get entry `key` or create a new :class:`Dictionary`, if `Key` not exist. """ dxf_dict = self.get(key) if dxf_dict is None: dxf_dict = self.add_new_dict(key, hard_owned=hard_owned) elif not isinstance(dxf_dict, Dictionary): raise DXFStructureError( f"expected a DICTIONARY entity, got {str(dxf_dict)} for key: {key}" ) return dxf_dict def audit(self, auditor: Auditor) -> None: if not self.is_alive: return super().audit(auditor) self._remove_keys_to_missing_entities(auditor) def _remove_keys_to_missing_entities(self, auditor: Auditor): trash: list[str] = [] append = trash.append db = auditor.entitydb for key, entry in self._data.items(): if isinstance(entry, str): if entry not in db: append(key) elif entry.is_alive: if entry.dxf.handle not in db: append(key) continue else: # entity is destroyed, remove key append(key) for key in trash: del self._data[key] auditor.fixed_error( code=AuditError.INVALID_DICTIONARY_ENTRY, message=f'Removed entry "{key}" with invalid handle in {str(self)}', dxf_entity=self, data=key, ) def destroy(self) -> None: if not self.is_alive: return if self.is_hard_owner: self._delete_hard_owned_entries() super().destroy() acdb_dict_with_default = DefSubclass( "AcDbDictionaryWithDefault", { "default": DXFAttr(340), }, ) acdb_dict_with_default_group_codes = group_code_mapping(acdb_dict_with_default) @factory.register_entity class DictionaryWithDefault(Dictionary): DXFTYPE = "ACDBDICTIONARYWDFLT" DXFATTRIBS = DXFAttributes(base_class, acdb_dictionary, acdb_dict_with_default) def __init__(self) -> None: super().__init__() self._default: Optional[DXFObject] = None def copy_data(self, entity: Self, copy_strategy=default_copy) -> None: super().copy_data(entity, copy_strategy=copy_strategy) assert isinstance(entity, DictionaryWithDefault) entity._default = self._default def post_load_hook(self, doc: Drawing) -> None: # Set _default to None if default object not exist - audit() replaces # a not existing default object by a placeholder object. # AutoCAD ignores not existing default objects! self._default = doc.entitydb.get(self.dxf.default) # type: ignore super().post_load_hook(doc) def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: dxf = super().load_dxf_attribs(processor) if processor: processor.fast_load_dxfattribs(dxf, acdb_dict_with_default_group_codes, 2) return dxf def export_entity(self, tagwriter: AbstractTagWriter) -> None: super().export_entity(tagwriter) tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_with_default.name) self.dxf.export_dxf_attribs(tagwriter, "default") def __getitem__(self, key: str): return self.get(key) def get(self, key: str, default: Optional[DXFObject] = None) -> Optional[DXFObject]: # `default` argument is ignored, exist only for API compatibility, """Returns :class:`DXFEntity` for `key` or the predefined dictionary wide :attr:`dxf.default` entity if `key` does not exist or ``None`` if default value also not exist. """ return super().get(key, default=self._default) def set_default(self, default: DXFObject) -> None: """Set dictionary wide default entry. Args: default: default entry as :class:`DXFEntity` """ self._default = default self.dxf.default = self._default.dxf.handle def audit(self, auditor: Auditor) -> None: def create_missing_default_object(): placeholder = self.doc.objects.add_placeholder(owner=self.dxf.handle) self.set_default(placeholder) auditor.fixed_error( code=AuditError.CREATED_MISSING_OBJECT, message=f"Created missing default object in {str(self)}.", ) if self._default is None or not self._default.is_alive: if auditor.entitydb.locked: auditor.add_post_audit_job(create_missing_default_object) else: create_missing_default_object() super().audit(auditor) acdb_dict_var = DefSubclass( "DictionaryVariables", { "schema": DXFAttr(280, default=0), # Object schema number (currently set to 0) "value": DXFAttr(1, default=""), }, ) acdb_dict_var_group_codes = group_code_mapping(acdb_dict_var) @factory.register_entity class DictionaryVar(DXFObject): """ DICTIONARYVAR objects are used by AutoCAD as a means to store named values in the database for setvar / getvar purposes without the need to add entries to the DXF HEADER section. System variables that are stored as DICTIONARYVAR objects are the following: - DEFAULTVIEWCATEGORY - DIMADEC - DIMASSOC - DIMDSEP - DRAWORDERCTL - FIELDEVAL - HALOGAP - HIDETEXT - INDEXCTL - INDEXCTL - INTERSECTIONCOLOR - INTERSECTIONDISPLAY - MSOLESCALE - OBSCOLOR - OBSLTYPE - OLEFRAME - PROJECTNAME - SORTENTS - UPDATETHUMBNAIL - XCLIPFRAME - XCLIPFRAME """ DXFTYPE = "DICTIONARYVAR" DXFATTRIBS = DXFAttributes(base_class, acdb_dict_var) @property def value(self) -> str: """Get/set the value of the :class:`DictionaryVar` as string.""" return self.dxf.get("value", "") @value.setter def value(self, data: str) -> None: self.dxf.set("value", str(data)) def load_dxf_attribs( self, processor: Optional[SubclassProcessor] = None ) -> DXFNamespace: dxf = super().load_dxf_attribs(processor) if processor: processor.fast_load_dxfattribs(dxf, acdb_dict_var_group_codes, 1) return dxf def export_entity(self, tagwriter: AbstractTagWriter) -> None: """Export entity specific data as DXF tags.""" super().export_entity(tagwriter) tagwriter.write_tag2(SUBCLASS_MARKER, acdb_dict_var.name) self.dxf.export_dxf_attribs(tagwriter, ["schema", "value"])