649 lines
22 KiB
Python
649 lines
22 KiB
Python
# Copyright (c) 2020-2022, Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import Any, Optional, Union, Iterable, TYPE_CHECKING, Set
|
|
import logging
|
|
import itertools
|
|
from ezdxf import options
|
|
from ezdxf.lldxf import const
|
|
from ezdxf.lldxf.attributes import XType, DXFAttributes, DXFAttr
|
|
from ezdxf.lldxf.types import cast_value, dxftag
|
|
from ezdxf.lldxf.tags import Tags
|
|
|
|
if TYPE_CHECKING:
|
|
from ezdxf.lldxf.extendedtags import ExtendedTags
|
|
from ezdxf.entities import DXFEntity
|
|
from ezdxf.lldxf.tagwriter import AbstractTagWriter
|
|
|
|
|
|
__all__ = ["DXFNamespace", "SubclassProcessor"]
|
|
logger = logging.getLogger("ezdxf")
|
|
|
|
ERR_INVALID_DXF_ATTRIB = 'Invalid DXF attribute "{}" for entity {}'
|
|
ERR_DXF_ATTRIB_NOT_EXITS = 'DXF attribute "{}" does not exist'
|
|
|
|
# supported event handler called by setting DXF attributes
|
|
# for usage, implement a method named like the dict-value, that accepts the new
|
|
# value as argument e.g.:
|
|
# Polyline.on_layer_change(name) -> changes also layers of all vertices
|
|
|
|
SETTER_EVENTS = {
|
|
"layer": "on_layer_change",
|
|
"linetype": "on_linetype_change",
|
|
"style": "on_style_change",
|
|
"dimstyle": "on_dimstyle_change",
|
|
}
|
|
EXCLUDE_FROM_UPDATE = frozenset(["_entity", "handle", "owner"])
|
|
|
|
|
|
class DXFNamespace:
|
|
""":class:`DXFNamespace` manages all named DXF attributes of an entity.
|
|
|
|
The DXFNamespace.__dict__ is used as DXF attribute storage, therefore only
|
|
valid Python names can be used as attrib name.
|
|
|
|
The namespace can only contain immutable objects: string, int, float, bool,
|
|
Vec3. Because of the immutability, copy and deepcopy are the same.
|
|
|
|
(internal class)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
processor: Optional[SubclassProcessor] = None,
|
|
entity: Optional[DXFEntity] = None,
|
|
):
|
|
if processor:
|
|
base_class = processor.base_class
|
|
handle_code = 105 if base_class[0].value == "DIMSTYLE" else 5
|
|
# CLASS entities have no handle.
|
|
# TABLE entities have no handle if loaded from a DXF R12 file.
|
|
# Owner tag is None if loaded from a DXF R12 file
|
|
handle = None
|
|
owner = None
|
|
for tag in base_class:
|
|
group_code = tag.code
|
|
if group_code == handle_code:
|
|
handle = tag.value
|
|
if owner:
|
|
break
|
|
elif group_code == 330:
|
|
owner = tag.value
|
|
if handle:
|
|
break
|
|
self.rewire(entity, handle, owner)
|
|
else:
|
|
self.reset_handles()
|
|
self.rewire(entity)
|
|
|
|
def copy(self, entity: DXFEntity):
|
|
namespace = self.__class__()
|
|
for k, v in self.__dict__.items():
|
|
namespace.__dict__[k] = v
|
|
namespace.rewire(entity)
|
|
return namespace
|
|
|
|
def __deepcopy__(self, memodict: Optional[dict] = None):
|
|
return self.copy(self._entity)
|
|
|
|
def reset_handles(self):
|
|
"""Reset handle and owner to None."""
|
|
self.__dict__["handle"] = None
|
|
self.__dict__["owner"] = None
|
|
|
|
def rewire(
|
|
self,
|
|
entity: Optional[DXFEntity],
|
|
handle: Optional[str] = None,
|
|
owner: Optional[str] = None,
|
|
) -> None:
|
|
"""Rewire DXF namespace with parent entity
|
|
|
|
Args:
|
|
entity: new associated entity
|
|
handle: new handle or None
|
|
owner: new entity owner handle or None
|
|
|
|
"""
|
|
# bypass __setattr__()
|
|
self.__dict__["_entity"] = entity
|
|
if handle is not None:
|
|
self.__dict__["handle"] = handle
|
|
if owner is not None:
|
|
self.__dict__["owner"] = owner
|
|
|
|
def __getattr__(self, key: str) -> Any:
|
|
"""Called if DXF attribute `key` does not exist, returns the DXF
|
|
default value or ``None``.
|
|
|
|
Raises:
|
|
DXFAttributeError: attribute `key` is not supported
|
|
|
|
"""
|
|
attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
|
|
if attrib_def:
|
|
if attrib_def.xtype == XType.callback:
|
|
return attrib_def.get_callback_value(self._entity)
|
|
else:
|
|
return attrib_def.default
|
|
else:
|
|
raise const.DXFAttributeError(
|
|
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
|
|
)
|
|
|
|
def __setattr__(self, key: str, value: Any) -> None:
|
|
"""Set DXF attribute `key` to `value`.
|
|
|
|
Raises:
|
|
DXFAttributeError: attribute `key` is not supported
|
|
|
|
"""
|
|
|
|
def entity() -> str:
|
|
# DXFNamespace is maybe not assigned to the entity yet:
|
|
handle = self.get("handle")
|
|
_entity = self._entity
|
|
if _entity:
|
|
return _entity.dxftype() + f"(#{handle})"
|
|
else:
|
|
return f"#{handle}"
|
|
|
|
def check(value):
|
|
value = cast_value(attrib_def.code, value)
|
|
if not attrib_def.is_valid_value(value):
|
|
if attrib_def.fixer:
|
|
value = attrib_def.fixer(value)
|
|
logger.debug(
|
|
f'Fixed invalid attribute "{key}" in entity'
|
|
f' {entity()} to "{str(value)}".'
|
|
)
|
|
else:
|
|
raise const.DXFValueError(
|
|
f'Invalid value {str(value)} for attribute "{key}" in '
|
|
f"entity {entity()}."
|
|
)
|
|
return value
|
|
|
|
attrib_def: Optional[DXFAttr] = self.dxfattribs.get(key)
|
|
if attrib_def:
|
|
if attrib_def.xtype == XType.callback:
|
|
attrib_def.set_callback_value(self._entity, value)
|
|
else:
|
|
self.__dict__[key] = check(value)
|
|
else:
|
|
raise const.DXFAttributeError(
|
|
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
|
|
)
|
|
|
|
if key in SETTER_EVENTS:
|
|
handler = getattr(self._entity, SETTER_EVENTS[key], None)
|
|
if handler:
|
|
handler(value)
|
|
|
|
def __delattr__(self, key: str) -> None:
|
|
"""Delete DXF attribute `key`.
|
|
|
|
Raises:
|
|
DXFAttributeError: attribute `key` does not exist
|
|
|
|
"""
|
|
if self.hasattr(key):
|
|
del self.__dict__[key]
|
|
else:
|
|
raise const.DXFAttributeError(ERR_DXF_ATTRIB_NOT_EXITS.format(key))
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""Returns value of DXF attribute `key` or the given `default` value
|
|
not DXF default value for unset attributes.
|
|
|
|
Raises:
|
|
DXFAttributeError: attribute `key` is not supported
|
|
|
|
"""
|
|
# callback values should not exist as attribute in __dict__
|
|
if self.hasattr(key):
|
|
# do not return the DXF default value
|
|
return self.__dict__[key]
|
|
attrib_def: Optional["DXFAttr"] = self.dxfattribs.get(key)
|
|
if attrib_def:
|
|
if attrib_def.xtype == XType.callback:
|
|
return attrib_def.get_callback_value(self._entity)
|
|
else:
|
|
return default # return give default
|
|
else:
|
|
raise const.DXFAttributeError(
|
|
ERR_INVALID_DXF_ATTRIB.format(key, self.dxftype)
|
|
)
|
|
|
|
def get_default(self, key: str) -> Any:
|
|
"""Returns DXF default value for unset DXF attribute `key`."""
|
|
value = self.get(key, None)
|
|
return self.dxf_default_value(key) if value is None else value
|
|
|
|
def set(self, key: str, value: Any) -> None:
|
|
"""Set DXF attribute `key` to `value`.
|
|
|
|
Raises:
|
|
DXFAttributeError: attribute `key` is not supported
|
|
|
|
"""
|
|
self.__setattr__(key, value)
|
|
|
|
def unprotected_set(self, key: str, value: Any) -> None:
|
|
"""Set DXF attribute `key` to `value` without any validity checks.
|
|
|
|
Used for fast attribute setting without validity checks at loading time.
|
|
|
|
(internal API)
|
|
"""
|
|
self.__dict__[key] = value
|
|
|
|
def all_existing_dxf_attribs(self) -> dict:
|
|
"""Returns all existing DXF attributes, except DXFEntity back-link."""
|
|
attribs = dict(self.__dict__)
|
|
del attribs["_entity"]
|
|
return attribs
|
|
|
|
def update(
|
|
self,
|
|
dxfattribs: dict[str, Any],
|
|
*,
|
|
exclude: Optional[Set[str]] = None,
|
|
ignore_errors=False,
|
|
) -> None:
|
|
"""Update DXF namespace attributes from a dict."""
|
|
if exclude is None:
|
|
exclude = EXCLUDE_FROM_UPDATE # type: ignore
|
|
else: # always exclude "_entity" back-link
|
|
exclude = {"_entity"} | exclude
|
|
|
|
set_attribute = self.__setattr__
|
|
for k, v in dxfattribs.items():
|
|
if k not in exclude: # type: ignore
|
|
try:
|
|
set_attribute(k, v)
|
|
except (AttributeError, ValueError):
|
|
if not ignore_errors:
|
|
raise
|
|
|
|
def discard(self, key: str) -> None:
|
|
"""Delete DXF attribute `key` silently without any exception."""
|
|
try:
|
|
del self.__dict__[key]
|
|
except KeyError:
|
|
pass
|
|
|
|
def is_supported(self, key: str) -> bool:
|
|
"""Returns True if DXF attribute `key` is supported else False.
|
|
Does not grant that attribute `key` really exists and does not
|
|
check if the actual DXF version of the document supports this
|
|
attribute, unsupported attributes will be ignored at export.
|
|
|
|
"""
|
|
return key in self.dxfattribs
|
|
|
|
def hasattr(self, key: str) -> bool:
|
|
"""Returns True if attribute `key` really exists else False."""
|
|
return key in self.__dict__
|
|
|
|
@property
|
|
def dxftype(self) -> str:
|
|
"""Returns the DXF entity type."""
|
|
return self._entity.DXFTYPE
|
|
|
|
@property
|
|
def dxfattribs(self) -> DXFAttributes:
|
|
"""Returns the DXF attribute definition."""
|
|
return self._entity.DXFATTRIBS
|
|
|
|
def dxf_default_value(self, key: str) -> Any:
|
|
"""Returns the default value as defined in the DXF standard."""
|
|
attrib: Optional[DXFAttr] = self.dxfattribs.get(key)
|
|
if attrib:
|
|
return attrib.default
|
|
else:
|
|
return None
|
|
|
|
def export_dxf_attribs(
|
|
self, tagwriter: AbstractTagWriter, attribs: Union[str, Iterable]
|
|
) -> None:
|
|
"""Exports DXF attribute `name` by `tagwriter`. Non-optional attributes
|
|
are forced and optional tags are only written if different to default
|
|
value. DXF version check is always on: does not export DXF attribs
|
|
which are not supported by tagwriter.dxfversion.
|
|
|
|
Args:
|
|
tagwriter: tag writer object
|
|
attribs: DXF attribute name as string or an iterable of names
|
|
|
|
"""
|
|
if isinstance(attribs, str):
|
|
self._export_dxf_attribute_optional(tagwriter, attribs)
|
|
else:
|
|
for name in attribs:
|
|
self._export_dxf_attribute_optional(tagwriter, name)
|
|
|
|
def _export_dxf_attribute_optional(
|
|
self, tagwriter: AbstractTagWriter, name: str
|
|
) -> None:
|
|
"""Exports DXF attribute `name` by `tagwriter`. Optional tags are only
|
|
written if different to default value.
|
|
|
|
Args:
|
|
tagwriter: tag writer object
|
|
name: DXF attribute name
|
|
|
|
"""
|
|
export_dxf_version = tagwriter.dxfversion
|
|
not_force_optional = not tagwriter.force_optional
|
|
attrib: Optional[DXFAttr] = self.dxfattribs.get(name)
|
|
|
|
if attrib:
|
|
optional = attrib.optional
|
|
default = attrib.default
|
|
value = self.get(name, None)
|
|
# Force default value e.g. layer
|
|
if value is None and not optional:
|
|
# Default value could be None
|
|
value = default
|
|
|
|
# Do not export None values
|
|
if (value is not None) and (
|
|
export_dxf_version >= attrib.dxfversion
|
|
):
|
|
# Do not write explicit optional attribs if equal to default
|
|
# value
|
|
if (
|
|
optional
|
|
and not_force_optional
|
|
and default is not None
|
|
and default == value
|
|
):
|
|
return
|
|
# Just export x, y for 2D points, if value is a 3D point
|
|
if attrib.xtype == XType.point2d and len(value) > 2:
|
|
try: # Vec3
|
|
value = (value.x, value.y)
|
|
except AttributeError:
|
|
value = value[:2]
|
|
|
|
if isinstance(value, str):
|
|
assert "\n" not in value, "line break '\\n' not allowed"
|
|
assert "\r" not in value, "line break '\\r' not allowed"
|
|
tag = dxftag(attrib.code, value)
|
|
tagwriter.write_tag(tag)
|
|
else:
|
|
raise const.DXFAttributeError(
|
|
ERR_INVALID_DXF_ATTRIB.format(name, self.dxftype)
|
|
)
|
|
|
|
|
|
BASE_CLASS_CODES = {0, 5, 102, 330}
|
|
|
|
|
|
class SubclassProcessor:
|
|
"""Helper class for loading tags into entities. (internal class)"""
|
|
|
|
def __init__(self, tags: ExtendedTags, dxfversion: Optional[str] = None):
|
|
if len(tags.subclasses) == 0:
|
|
raise ValueError("Invalid tags.")
|
|
self.subclasses: list[Tags] = list(tags.subclasses) # copy subclasses
|
|
self.embedded_objects: list[Tags] = tags.embedded_objects or []
|
|
self.dxfversion: Optional[str] = dxfversion
|
|
# DXF R12 and prior have no subclass marker system, all tags of an
|
|
# entity in one flat list.
|
|
# Later DXF versions have at least 2 subclasses base_class and
|
|
# AcDbEntity.
|
|
# Exception: CLASS has also only one subclass and no subclass marker,
|
|
# handled as DXF R12 entity
|
|
self.r12: bool = (dxfversion == const.DXF12) or (
|
|
len(self.subclasses) == 1
|
|
)
|
|
self.name: str = tags.dxftype()
|
|
self.handle: str
|
|
try:
|
|
self.handle = tags.get_handle()
|
|
except const.DXFValueError:
|
|
self.handle = "<?>"
|
|
|
|
@property
|
|
def base_class(self):
|
|
return self.subclasses[0]
|
|
|
|
def log_unprocessed_tags(
|
|
self,
|
|
unprocessed_tags: Iterable,
|
|
subclass="<?>",
|
|
handle: Optional[str] = None,
|
|
) -> None:
|
|
if options.log_unprocessed_tags:
|
|
for tag in unprocessed_tags:
|
|
entity = ""
|
|
if handle:
|
|
entity = f" in entity #{handle}"
|
|
logger.info(
|
|
f"ignored {repr(tag)} in subclass {subclass}" + entity
|
|
)
|
|
|
|
def find_subclass(self, name: str) -> Optional[Tags]:
|
|
for subclass in self.subclasses:
|
|
if len(subclass) and subclass[0].value == name:
|
|
return subclass
|
|
return None
|
|
|
|
def subclass_by_index(self, index: int) -> Optional[Tags]:
|
|
try:
|
|
return self.subclasses[index]
|
|
except IndexError:
|
|
return None
|
|
|
|
def detect_implementation_version(
|
|
self, subclass_index: int, group_code: int, default: int
|
|
) -> int:
|
|
subclass = self.subclass_by_index(subclass_index)
|
|
if subclass and len(subclass) > 1:
|
|
# the version tag has to be the 2nd tag after the subclass marker
|
|
tag = subclass[1]
|
|
if tag.code == group_code:
|
|
return tag.value
|
|
return default
|
|
|
|
# TODO: rename to complex_dxfattribs_loader()
|
|
def fast_load_dxfattribs(
|
|
self,
|
|
dxf: DXFNamespace,
|
|
group_code_mapping: dict[int, Union[str, list]],
|
|
subclass: Union[int, str, Tags],
|
|
*,
|
|
recover=False,
|
|
log=True,
|
|
) -> Tags:
|
|
"""Load DXF attributes into the DXF namespace and returns the
|
|
unprocessed tags without leading subclass marker(100, AcDb...).
|
|
Bypasses the DXF attribute validity checks.
|
|
|
|
Args:
|
|
dxf: entity DXF namespace
|
|
group_code_mapping: group code to DXF attribute name mapping,
|
|
callback attributes have to be marked with a leading "*"
|
|
subclass: subclass by index, by name or as Tags()
|
|
recover: recover graphic attributes
|
|
log: enable/disable logging of unprocessed tags
|
|
|
|
"""
|
|
if self.r12:
|
|
tags = self.subclasses[0]
|
|
else:
|
|
if isinstance(subclass, int):
|
|
tags = self.subclass_by_index(subclass) # type: ignore
|
|
elif isinstance(subclass, str):
|
|
tags = self.find_subclass(subclass) # type: ignore
|
|
else:
|
|
tags = subclass
|
|
|
|
unprocessed_tags = Tags()
|
|
if tags is None or len(tags) == 0:
|
|
return unprocessed_tags
|
|
|
|
processed_names: set[str] = set()
|
|
# Localize attributes:
|
|
get_attrib_name = group_code_mapping.get
|
|
append_unprocessed_tag = unprocessed_tags.append
|
|
unprotected_set_attrib = dxf.unprotected_set
|
|
mark_attrib_as_processed = processed_names.add
|
|
|
|
# Ignore (100, "AcDb...") or (0, "ENTITY") tag in case of DXF R12
|
|
start = 1 if tags[0].code in (0, 100) else 0
|
|
for tag in tags[start:]:
|
|
name = get_attrib_name(tag.code)
|
|
if isinstance(name, list): # process group code duplicates:
|
|
names = name
|
|
# If all existing attrib names are used, treat this tag
|
|
# like an unprocessed tag.
|
|
name = None
|
|
# The attribute names are processed in the order of their
|
|
# definition:
|
|
for name_ in names:
|
|
if name_ not in processed_names:
|
|
name = name_
|
|
mark_attrib_as_processed(name_)
|
|
break
|
|
if name:
|
|
# Ignore callback attributes and group codes explicit marked
|
|
# as "*IGNORE":
|
|
if name[0] != "*":
|
|
unprotected_set_attrib(
|
|
name, cast_value(tag.code, tag.value) # type: ignore
|
|
)
|
|
else:
|
|
append_unprocessed_tag(tag)
|
|
|
|
if self.r12:
|
|
# R12 has always unprocessed tags, because there are all tags in one
|
|
# subclass and one subclass definition never covers all tags e.g.
|
|
# handle is processed in DXFEntity, so it is an unprocessed tag in
|
|
# AcDbEntity.
|
|
return unprocessed_tags
|
|
|
|
# Only DXF R13+
|
|
if recover and len(unprocessed_tags):
|
|
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
|
|
# most old DXF R12 entities
|
|
unprocessed_tags = recover_graphic_attributes(unprocessed_tags, dxf)
|
|
if len(unprocessed_tags) and log:
|
|
# First tag is the subclass specifier (100, "AcDb...")
|
|
name = tags[0].value
|
|
self.log_unprocessed_tags(
|
|
tags, subclass=name, handle=dxf.get("handle")
|
|
)
|
|
return unprocessed_tags
|
|
|
|
def append_base_class_to_acdb_entity(self) -> None:
|
|
"""It is valid to mix up the base class with AcDbEntity class.
|
|
This method appends all none base class group codes to the
|
|
AcDbEntity class.
|
|
"""
|
|
# This is only needed for DXFEntity, so applying this method
|
|
# automatically to all entities is waste of runtime
|
|
# -> DXFGraphic.load_dxf_attribs()
|
|
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
|
|
# most old DXF R12 entities
|
|
if self.r12:
|
|
return
|
|
|
|
acdb_entity_tags = self.subclasses[1]
|
|
if acdb_entity_tags[0] == (100, "AcDbEntity"):
|
|
acdb_entity_tags.extend(
|
|
tag
|
|
for tag in self.subclasses[0]
|
|
if tag.code not in BASE_CLASS_CODES
|
|
)
|
|
|
|
def simple_dxfattribs_loader(
|
|
self, dxf: DXFNamespace, group_code_mapping: dict[int, str]
|
|
) -> None:
|
|
# tested in test suite 201 for the POINT entity
|
|
"""Load DXF attributes from all subclasses into the DXF namespace.
|
|
|
|
Can not handle same group codes in different subclasses, does not remove
|
|
processed tags or log unprocessed tags and bypasses the DXF attribute
|
|
validity checks.
|
|
|
|
This method ignores the subclass structure and can load data from
|
|
very malformed DXF files, like such in issue #604.
|
|
This method works only for very simple DXF entities with unique group
|
|
codes in all subclasses, the old DXF R12 entities:
|
|
|
|
- POINT
|
|
- LINE
|
|
- CIRCLE
|
|
- ARC
|
|
- INSERT
|
|
- SHAPE
|
|
- SOLID/TRACE/3DFACE
|
|
- TEXT (ATTRIB/ATTDEF bypasses TEXT loader)
|
|
- BLOCK/ENDBLK
|
|
- POLYLINE/VERTEX/SEQEND
|
|
- DIMENSION and subclasses
|
|
- all table entries: LAYER, LTYPE, ...
|
|
|
|
And the newer DXF entities:
|
|
|
|
- ELLIPSE
|
|
- RAY/XLINE
|
|
|
|
The recover mode for graphical attributes is automatically included.
|
|
Logging of unprocessed tags is not possible but also not required for
|
|
this simple and well known entities.
|
|
|
|
Args:
|
|
dxf: entity DXF namespace
|
|
group_code_mapping: group code name mapping for all DXF attributes
|
|
from all subclasses, callback attributes have to be marked with
|
|
a leading "*"
|
|
|
|
"""
|
|
tags = itertools.chain.from_iterable(self.subclasses)
|
|
get_attrib_name = group_code_mapping.get
|
|
unprotected_set_attrib = dxf.unprotected_set
|
|
for tag in tags:
|
|
name = get_attrib_name(tag.code)
|
|
if isinstance(name, str) and not name.startswith("*"):
|
|
unprotected_set_attrib(
|
|
name, cast_value(tag.code, tag.value)
|
|
)
|
|
|
|
|
|
GRAPHIC_ATTRIBUTES_TO_RECOVER = {
|
|
8: "layer",
|
|
6: "linetype",
|
|
62: "color",
|
|
67: "paperspace",
|
|
370: "lineweight",
|
|
48: "ltscale",
|
|
60: "invisible",
|
|
420: "true_color",
|
|
430: "color_name",
|
|
440: "transparency",
|
|
284: "shadow_mode",
|
|
347: "material_handle",
|
|
348: "visualstyle_handle",
|
|
380: "plotstyle_enum",
|
|
390: "plotstyle_handle",
|
|
}
|
|
|
|
|
|
# TODO: maybe obsolete if simple_dxfattribs_loader() is used for
|
|
# most old DXF R12 entities
|
|
def recover_graphic_attributes(tags: Tags, dxf: DXFNamespace) -> Tags:
|
|
unprocessed_tags = Tags()
|
|
for tag in tags:
|
|
attrib_name = GRAPHIC_ATTRIBUTES_TO_RECOVER.get(tag.code)
|
|
# Don't know if the unprocessed tag is really a misplaced tag,
|
|
# so check if the attribute already exist!
|
|
if attrib_name and not dxf.hasattr(attrib_name):
|
|
dxf.set(attrib_name, tag.value)
|
|
else:
|
|
unprocessed_tags.append(tag)
|
|
return unprocessed_tags
|