429 lines
14 KiB
Python
429 lines
14 KiB
Python
# Copyright (c) 2021-2022, Manfred Moitzi
|
|
# License: MIT License
|
|
from __future__ import annotations
|
|
from typing import Optional, Iterable, Any, TYPE_CHECKING
|
|
from pathlib import Path
|
|
from ezdxf.addons.browser.loader import load_section_dict
|
|
from ezdxf.lldxf.types import DXFVertex, tag_type
|
|
from ezdxf.lldxf.tags import Tags
|
|
|
|
if TYPE_CHECKING:
|
|
from ezdxf.eztypes import SectionDict
|
|
|
|
__all__ = [
|
|
"DXFDocument",
|
|
"IndexEntry",
|
|
"get_row_from_line_number",
|
|
"dxfstr",
|
|
"EntityHistory",
|
|
"SearchIndex",
|
|
]
|
|
|
|
|
|
class DXFDocument:
|
|
def __init__(self, sections: Optional[SectionDict] = None):
|
|
# Important: the section dict has to store the raw string tags
|
|
# else an association of line numbers to entities is not possible.
|
|
# Comment tags (999) are ignored, because the load_section_dict()
|
|
# function can not handle and store comments.
|
|
# Therefore comments causes incorrect results for the line number
|
|
# associations and should be stripped off before processing for precise
|
|
# debugging of DXF files (-b for backup):
|
|
# ezdxf strip -b <your.dxf>
|
|
self.sections: SectionDict = dict()
|
|
self.entity_index: Optional[EntityIndex] = None
|
|
self.valid_handles = None
|
|
self.filename = ""
|
|
if sections:
|
|
self.update(sections)
|
|
|
|
@property
|
|
def filepath(self):
|
|
return Path(self.filename)
|
|
|
|
@property
|
|
def max_line_number(self) -> int:
|
|
if self.entity_index:
|
|
return self.entity_index.max_line_number
|
|
else:
|
|
return 1
|
|
|
|
def load(self, filename: str):
|
|
self.filename = filename
|
|
self.update(load_section_dict(filename))
|
|
|
|
def update(self, sections: SectionDict):
|
|
self.sections = sections
|
|
self.entity_index = EntityIndex(self.sections)
|
|
|
|
def absolute_filepath(self):
|
|
return self.filepath.absolute()
|
|
|
|
def get_section(self, name: str) -> list[Tags]:
|
|
return self.sections.get(name) # type: ignore
|
|
|
|
def get_entity(self, handle: str) -> Optional[Tags]:
|
|
if self.entity_index:
|
|
return self.entity_index.get(handle)
|
|
return None
|
|
|
|
def get_line_number(self, entity: Tags, offset: int = 0) -> int:
|
|
if self.entity_index:
|
|
return (
|
|
self.entity_index.get_start_line_for_entity(entity) + offset * 2
|
|
)
|
|
return 0
|
|
|
|
def get_entity_at_line(self, number: int) -> Optional[Tags]:
|
|
if self.entity_index:
|
|
return self.entity_index.get_entity_at_line(number)
|
|
return None
|
|
|
|
def next_entity(self, entity: Tags) -> Optional[Tags]:
|
|
return self.entity_index.next_entity(entity) # type: ignore
|
|
|
|
def previous_entity(self, entity: Tags) -> Optional[Tags]:
|
|
return self.entity_index.previous_entity(entity) # type: ignore
|
|
|
|
def get_handle(self, entity) -> Optional[str]:
|
|
return self.entity_index.get_handle(entity) # type: ignore
|
|
|
|
|
|
class IndexEntry:
|
|
def __init__(self, tags: Tags, line: int = 0):
|
|
self.tags: Tags = tags
|
|
self.start_line_number: int = line
|
|
self.prev: Optional["IndexEntry"] = None
|
|
self.next: Optional["IndexEntry"] = None
|
|
|
|
|
|
class EntityIndex:
|
|
def __init__(self, sections: SectionDict):
|
|
# dict() entries have to be ordered since Python 3.6!
|
|
# Therefore _index.values() returns the DXF entities in file order!
|
|
self._index: dict[str, IndexEntry] = dict()
|
|
# Index dummy handle of entities without handles by the id of the
|
|
# first tag for faster retrieval of the dummy handle from tags:
|
|
# dict items: (id, handle)
|
|
self._dummy_handle_index: dict[int, str] = dict()
|
|
self._max_line_number: int = 0
|
|
self._build(sections)
|
|
|
|
def _build(self, sections: SectionDict) -> None:
|
|
start_line_number = 1
|
|
dummy_handle = 1
|
|
entity_index: dict[str, IndexEntry] = dict()
|
|
dummy_handle_index: dict[int, str] = dict()
|
|
prev_entry: Optional[IndexEntry] = None
|
|
for section in sections.values():
|
|
for tags in section:
|
|
assert isinstance(tags, Tags), "expected class Tags"
|
|
assert len(tags) > 0, "empty tags should not be possible"
|
|
try:
|
|
handle = tags.get_handle().upper()
|
|
except ValueError:
|
|
handle = f"*{dummy_handle:X}"
|
|
# index dummy handle by id of the first tag:
|
|
dummy_handle_index[id(tags[0])] = handle
|
|
dummy_handle += 1
|
|
|
|
next_entry = IndexEntry(tags, start_line_number)
|
|
if prev_entry is not None:
|
|
next_entry.prev = prev_entry
|
|
prev_entry.next = next_entry
|
|
entity_index[handle] = next_entry
|
|
prev_entry = next_entry
|
|
|
|
# calculate next start line number:
|
|
# add 2 lines for each tag: group code, value
|
|
start_line_number += len(tags) * 2
|
|
start_line_number += 2 # for removed ENDSEC tag
|
|
|
|
# subtract 1 and 2 for the last ENDSEC tag!
|
|
self._max_line_number = start_line_number - 3
|
|
self._index = entity_index
|
|
self._dummy_handle_index = dummy_handle_index
|
|
|
|
def __contains__(self, handle: str) -> bool:
|
|
return handle.upper() in self._index
|
|
|
|
@property
|
|
def max_line_number(self) -> int:
|
|
return self._max_line_number
|
|
|
|
def get(self, handle: str) -> Optional[Tags]:
|
|
index_entry = self._index.get(handle.upper())
|
|
if index_entry is not None:
|
|
return index_entry.tags
|
|
else:
|
|
return None
|
|
|
|
def get_handle(self, entity: Tags) -> Optional[str]:
|
|
if not len(entity):
|
|
return None
|
|
|
|
try:
|
|
return entity.get_handle()
|
|
except ValueError:
|
|
# fast retrieval of dummy handle which isn't stored in tags:
|
|
return self._dummy_handle_index.get(id(entity[0]))
|
|
|
|
def next_entity(self, entity: Tags) -> Tags:
|
|
handle = self.get_handle(entity)
|
|
if handle:
|
|
index_entry = self._index.get(handle)
|
|
next_entry = index_entry.next # type: ignore
|
|
# next of last entity is None!
|
|
if next_entry:
|
|
return next_entry.tags
|
|
return entity
|
|
|
|
def previous_entity(self, entity: Tags) -> Tags:
|
|
handle = self.get_handle(entity)
|
|
if handle:
|
|
index_entry = self._index.get(handle)
|
|
prev_entry = index_entry.prev # type: ignore
|
|
# prev of first entity is None!
|
|
if prev_entry:
|
|
return prev_entry.tags
|
|
return entity
|
|
|
|
def get_start_line_for_entity(self, entity: Tags) -> int:
|
|
handle = self.get_handle(entity)
|
|
if handle:
|
|
index_entry = self._index.get(handle)
|
|
if index_entry:
|
|
return index_entry.start_line_number
|
|
return 0
|
|
|
|
def get_entity_at_line(self, number: int) -> Optional[Tags]:
|
|
tags = None
|
|
for index_entry in self._index.values():
|
|
if index_entry.start_line_number > number:
|
|
return tags # tags of previous entry!
|
|
tags = index_entry.tags
|
|
return tags
|
|
|
|
|
|
def get_row_from_line_number(
|
|
entity: Tags, start_line_number: int, select_line_number: int
|
|
) -> int:
|
|
count = select_line_number - start_line_number
|
|
lines = 0
|
|
row = 0
|
|
for tag in entity:
|
|
if lines >= count:
|
|
return row
|
|
if isinstance(tag, DXFVertex):
|
|
lines += len(tag.value) * 2
|
|
else:
|
|
lines += 2
|
|
row += 1
|
|
return row
|
|
|
|
|
|
def dxfstr(tags: Tags) -> str:
|
|
return "".join(tag.dxfstr() for tag in tags)
|
|
|
|
|
|
class EntityHistory:
|
|
def __init__(self) -> None:
|
|
self._history: list[Tags] = list()
|
|
self._index: int = 0
|
|
self._time_travel: list[Tags] = list()
|
|
|
|
def __len__(self):
|
|
return len(self._history)
|
|
|
|
@property
|
|
def index(self):
|
|
return self._index
|
|
|
|
def clear(self):
|
|
self._history.clear()
|
|
self._time_travel.clear()
|
|
self._index = 0
|
|
|
|
def append(self, entity: Tags):
|
|
if self._time_travel:
|
|
self._history.extend(self._time_travel)
|
|
self._time_travel.clear()
|
|
count = len(self._history)
|
|
if count:
|
|
# only append if different to last entity
|
|
if self._history[-1] is entity:
|
|
return
|
|
self._index = count
|
|
self._history.append(entity)
|
|
|
|
def back(self) -> Optional[Tags]:
|
|
entity = None
|
|
if self._history:
|
|
index = self._index - 1
|
|
if index >= 0:
|
|
entity = self._time_wrap(index)
|
|
else:
|
|
entity = self._history[0]
|
|
return entity
|
|
|
|
def forward(self) -> Tags:
|
|
entity = None
|
|
history = self._history
|
|
if history:
|
|
index = self._index + 1
|
|
if index < len(history):
|
|
entity = self._time_wrap(index)
|
|
else:
|
|
entity = history[-1]
|
|
return entity # type: ignore
|
|
|
|
def _time_wrap(self, index) -> Tags:
|
|
self._index = index
|
|
entity = self._history[index]
|
|
self._time_travel.append(entity)
|
|
return entity
|
|
|
|
def content(self) -> list[Tags]:
|
|
return list(self._history)
|
|
|
|
|
|
class SearchIndex:
|
|
NOT_FOUND = None, -1
|
|
|
|
def __init__(self, entities: Iterable[Tags]):
|
|
self.entities: list[Tags] = list(entities)
|
|
self._current_entity_index: int = 0
|
|
self._current_tag_index: int = 0
|
|
self._search_term: Optional[str] = None
|
|
self._search_term_lower: Optional[str] = None
|
|
self._backward = False
|
|
self._end_of_index = not bool(self.entities)
|
|
self.case_insensitive = True
|
|
self.whole_words = False
|
|
self.numbers = False
|
|
self.regex = False # False = normal mode
|
|
|
|
@property
|
|
def is_end_of_index(self) -> bool:
|
|
return self._end_of_index
|
|
|
|
@property
|
|
def search_term(self) -> Optional[str]:
|
|
return self._search_term
|
|
|
|
def set_current_entity(self, entity: Tags, tag_index: int = 0):
|
|
self._current_tag_index = tag_index
|
|
try:
|
|
self._current_entity_index = self.entities.index(entity)
|
|
except ValueError:
|
|
self.reset_cursor()
|
|
|
|
def update_entities(self, entities: list[Tags]):
|
|
current_entity, index = self.current_entity()
|
|
self.entities = entities
|
|
if current_entity:
|
|
self.set_current_entity(current_entity, index)
|
|
|
|
def current_entity(self) -> tuple[Optional[Tags], int]:
|
|
if self.entities and not self._end_of_index:
|
|
return (
|
|
self.entities[self._current_entity_index],
|
|
self._current_tag_index,
|
|
)
|
|
return self.NOT_FOUND
|
|
|
|
def reset_cursor(self, backward: bool = False):
|
|
self._current_entity_index = 0
|
|
self._current_tag_index = 0
|
|
count = len(self.entities)
|
|
if count:
|
|
self._end_of_index = False
|
|
if backward:
|
|
self._current_entity_index = count - 1
|
|
entity = self.entities[-1]
|
|
self._current_tag_index = len(entity) - 1
|
|
else:
|
|
self._end_of_index = True
|
|
|
|
def cursor(self) -> tuple[int, int]:
|
|
return self._current_entity_index, self._current_tag_index
|
|
|
|
def move_cursor_forward(self) -> None:
|
|
if self.entities:
|
|
entity: Tags = self.entities[self._current_entity_index]
|
|
tag_index = self._current_tag_index + 1
|
|
if tag_index >= len(entity):
|
|
entity_index = self._current_entity_index + 1
|
|
if entity_index < len(self.entities):
|
|
self._current_entity_index = entity_index
|
|
self._current_tag_index = 0
|
|
else:
|
|
self._end_of_index = True
|
|
else:
|
|
self._current_tag_index = tag_index
|
|
|
|
def move_cursor_backward(self) -> None:
|
|
if self.entities:
|
|
tag_index = self._current_tag_index - 1
|
|
if tag_index < 0:
|
|
entity_index = self._current_entity_index - 1
|
|
if entity_index >= 0:
|
|
self._current_entity_index = entity_index
|
|
self._current_tag_index = (
|
|
len(self.entities[entity_index]) - 1
|
|
)
|
|
else:
|
|
self._end_of_index = True
|
|
else:
|
|
self._current_tag_index = tag_index
|
|
|
|
def reset_search_term(self, term: str) -> None:
|
|
self._search_term = str(term)
|
|
self._search_term_lower = self._search_term.lower()
|
|
|
|
def find(
|
|
self, term: str, backward: bool = False, reset_index: bool = True
|
|
) -> tuple[Optional[Tags], int]:
|
|
self.reset_search_term(term)
|
|
if reset_index:
|
|
self.reset_cursor(backward)
|
|
if len(self.entities) and not self._end_of_index:
|
|
if backward:
|
|
return self.find_backwards()
|
|
else:
|
|
return self.find_forward()
|
|
else:
|
|
return self.NOT_FOUND
|
|
|
|
def find_forward(self) -> tuple[Optional[Tags], int]:
|
|
return self._find(self.move_cursor_forward)
|
|
|
|
def find_backwards(self) -> tuple[Optional[Tags], int]:
|
|
return self._find(self.move_cursor_backward)
|
|
|
|
def _find(self, move_cursor) -> tuple[Optional[Tags], int]:
|
|
if self.entities and self._search_term and not self._end_of_index:
|
|
while not self._end_of_index:
|
|
entity, tag_index = self.current_entity()
|
|
move_cursor()
|
|
if self._match(*entity[tag_index]): # type: ignore
|
|
return entity, tag_index
|
|
return self.NOT_FOUND
|
|
|
|
def _match(self, code: int, value: Any) -> bool:
|
|
if tag_type(code) is not str:
|
|
if not self.numbers:
|
|
return False
|
|
value = str(value)
|
|
|
|
if self.case_insensitive:
|
|
search_term = self._search_term_lower
|
|
value = value.lower()
|
|
else:
|
|
search_term = self._search_term
|
|
|
|
if self.whole_words:
|
|
return any(search_term == word for word in value.split())
|
|
else:
|
|
return search_term in value
|