1# Copyright (c) 2019-2021 Manfred Moitzi
2# License: MIT License
3import enum
4import math
5import logging
6from typing import (
7    TYPE_CHECKING, Union, Tuple, List, Iterable, Optional, Callable, cast,
8)
9
10from ezdxf.lldxf import const, validator
11from ezdxf.lldxf.attributes import (
12    DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT,
13    group_code_mapping,
14)
15from ezdxf.lldxf.const import SUBCLASS_MARKER, DXF2000, DXF2018
16from ezdxf.lldxf.types import DXFTag, dxftag
17from ezdxf.lldxf.tags import (
18    Tags, find_begin_and_end_of_encoded_xdata_tags, NotFoundException,
19)
20
21from ezdxf.math import Vec3, Matrix44, OCS, UCS, NULLVEC, Z_AXIS, X_AXIS
22from ezdxf.math.transformtools import transform_extrusion
23from ezdxf.colors import rgb2int
24from ezdxf.tools.text import (
25    split_mtext_string, escape_dxf_line_endings, plain_mtext,
26)
27from . import factory
28from .dxfentity import base_class, SubclassProcessor
29from .dxfgfx import DXFGraphic, acdb_entity
30from .xdata import XData
31
32if TYPE_CHECKING:
33    from ezdxf.eztypes import (
34        TagWriter, DXFNamespace, DXFEntity, Vertex, Auditor, Drawing, EntityDB,
35    )
36
37__all__ = ['MText', 'MTextColumns', 'ColumnType']
38
39logger = logging.getLogger('ezdxf')
40
41BG_FILL_MASK = 1 + 2 + 16
42
43acdb_mtext = DefSubclass('AcDbMText', {
44    'insert': DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
45
46    # Nominal (initial) text height
47    'char_height': DXFAttr(
48        40, default=2.5,
49        validator=validator.is_greater_zero,
50        fixer=RETURN_DEFAULT,
51    ),
52
53    # Reference column width
54    'width': DXFAttr(41, optional=True),
55
56    # Found in BricsCAD export:
57    'defined_height': DXFAttr(46, dxfversion='AC1021'),
58
59    # Attachment point: enum const.MTextEntityAlignment
60    # 1 = Top left
61    # 2 = Top center
62    # 3 = Top right
63    # 4 = Middle left
64    # 5 = Middle center
65    # 6 = Middle right
66    # 7 = Bottom left
67    # 8 = Bottom center
68    # 9 = Bottom right
69    'attachment_point': DXFAttr(
70        71, default=1,
71        validator=validator.is_in_integer_range(1, 10),
72        fixer=RETURN_DEFAULT,
73    ),
74
75    # Flow direction: enum MTextFlowDirection
76    # 1 = Left to right
77    # 3 = Top to bottom
78    # 5 = By style (the flow direction is inherited from the associated
79    #     text style)
80    'flow_direction': DXFAttr(
81        72, default=1, optional=True,
82        validator=validator.is_one_of({1, 3, 5}),
83        fixer=RETURN_DEFAULT,
84    ),
85
86    # Content text:
87    # group code 1: text
88    # group code 3: additional text (optional)
89
90    # Text style name:
91    'style': DXFAttr(
92        7, default='Standard', optional=True,
93        validator=validator.is_valid_table_name,  # do not fix!
94    ),
95    'extrusion': DXFAttr(
96        210, xtype=XType.point3d, default=Z_AXIS, optional=True,
97        validator=validator.is_not_null_vector,
98        fixer=RETURN_DEFAULT,
99    ),
100
101    # x-axis direction vector (in WCS)
102    # If rotation and text_direction are present, text_direction wins
103    'text_direction': DXFAttr(
104        11, xtype=XType.point3d, default=X_AXIS, optional=True,
105        validator=validator.is_not_null_vector,
106        fixer=RETURN_DEFAULT,
107    ),
108
109    # Horizontal width of the characters that make up the mtext entity.
110    # This value will always be equal to or less than the value of *width*,
111    # (read-only, ignored if supplied)
112    'rect_width': DXFAttr(42, optional=True),
113
114    # Vertical height of the mtext entity (read-only, ignored if supplied)
115    'rect_height': DXFAttr(43, optional=True),
116
117    # Text rotation in degrees -  Error in DXF reference, which claims radians
118    'rotation': DXFAttr(50, default=0, optional=True),
119
120    # Line spacing style (optional): enum const.MTextLineSpacing
121    # 1 = At least (taller characters will override)
122    # 2 = Exact (taller characters will not override)
123    'line_spacing_style': DXFAttr(
124        73, default=1, optional=True,
125        validator=validator.is_one_of({1, 2}),
126        fixer=RETURN_DEFAULT,
127    ),
128
129    # Line spacing factor (optional): Percentage of default (3-on-5) line
130    # spacing to be applied. Valid values range from 0.25 to 4.00
131    'line_spacing_factor': DXFAttr(
132        44, default=1, optional=True,
133        validator=validator.is_in_float_range(0.25, 4.00),
134        fixer=validator.fit_into_float_range(0.25, 4.00),
135    ),
136
137    # Determines how much border there is around the text.
138    # (45) + (90) + (63) all three required, if one of them is used
139    'box_fill_scale': DXFAttr(45, dxfversion='AC1021'),
140
141    # background fill type flags: enum const.MTextBackgroundColor
142    # 0 = off
143    # 1 = color -> (63) < (421) or (431)
144    # 2 = drawing window color
145    # 3 = use background color (1 & 2)
146    # 16 = text frame ODA specification 20.4.46
147    # 2021-05-14: text frame only is supported bg_fill = 16,
148    # but scaling is always 1.5 and tags 45 + 63 are not present
149    'bg_fill': DXFAttr(
150        90, dxfversion='AC1021',
151        validator=validator.is_valid_bitmask(BG_FILL_MASK),
152        fixer=validator.fix_bitmask(BG_FILL_MASK)
153    ),
154
155    # background fill color as ACI, required even true color is used
156    'bg_fill_color': DXFAttr(
157        63, dxfversion='AC1021',
158        validator=validator.is_valid_aci_color,
159    ),
160
161    # 420-429? : background fill color as true color value, (63) also required
162    # but ignored
163    'bg_fill_true_color': DXFAttr(421, dxfversion='AC1021'),
164
165    # 430-439? : background fill color as color name ???, (63) also required
166    # but ignored
167    'bg_fill_color_name': DXFAttr(431, dxfversion='AC1021'),
168
169    # background fill color transparency - not used by AutoCAD/BricsCAD
170    'bg_fill_transparency': DXFAttr(441, dxfversion='AC1021'),
171
172})
173acdb_mtext_group_codes = group_code_mapping(acdb_mtext)
174
175
176# -----------------------------------------------------------------------
177# For more information go to docs/source/dxfinternals/entities/mtext.rst
178# -----------------------------------------------------------------------
179
180# MTEXT column support:
181# MTEXT columns have the same appearance and handling for all DXF versions
182# as a single MTEXT entity like in DXF R2018.
183
184
185class ColumnType(enum.IntEnum):
186    NONE = 0
187    STATIC = 1
188    DYNAMIC = 2
189
190
191class MTextColumns:
192    """The column count is not stored explicit in the columns definition for
193    DXF versions R2018+.
194
195    If column_type is DYNAMIC and auto_height is True the column
196    count is defined by the content. The exact calculation of the column count
197    requires an accurate rendering of the MTEXT content like AutoCAD does!
198
199    If the column count is not defined, ezdxf tries to calculate the column
200    count from total_width, width and gutter_width, if these attributes are set
201    properly.
202
203    """
204
205    def __init__(self):
206        self.column_type: ColumnType = ColumnType.STATIC
207        # The embedded object in R2018 does not store the column count for
208        # column type DYNAMIC and auto_height is True!
209        self.count: int = 1
210        self.auto_height: bool = False
211        self.reversed_column_flow: bool = False
212        self.defined_height: float = 0.0
213        self.width: float = 0.0
214        self.gutter_width: float = 0.0
215        self.total_width: float = 0.0
216        self.total_height: float = 0.0
217        # Storage for handles of linked MTEXT entities at loading stage:
218        self.linked_handles: Optional[List[str]] = None
219        # Storage for linked MTEXT entities for DXF versions < R2018:
220        self.linked_columns: List['MText'] = []
221        # R2018+: heights of all columns if auto_height is False
222        self.heights: List[float] = []
223
224    def deep_copy(self) -> 'MTextColumns':
225        columns = self.shallow_copy()
226        columns.linked_columns = [mtext.copy() for mtext in self.linked_columns]
227        return columns
228
229    def shallow_copy(self) -> 'MTextColumns':
230        columns = MTextColumns()
231        columns.count = self.count
232        columns.column_type = self.column_type
233        columns.auto_height = self.auto_height
234        columns.reversed_column_flow = self.reversed_column_flow
235        columns.defined_height = self.defined_height
236        columns.width = self.width
237        columns.gutter_width = self.gutter_width
238        columns.total_width = self.total_width
239        columns.total_height = self.total_height
240        columns.linked_columns = list(self.linked_columns)
241        columns.heights = list(self.heights)
242        return columns
243
244    @classmethod
245    def new_static_columns(cls, count: int, width: float, gutter_width: float,
246                           height: float) -> 'MTextColumns':
247        columns = cls()
248        columns.column_type = ColumnType.STATIC
249        columns.count = int(count)
250        columns.width = float(width)
251        columns.gutter_width = float(gutter_width)
252        columns.defined_height = float(height)
253        columns.update_total_width()
254        columns.update_total_height()
255        return columns
256
257    @classmethod
258    def new_dynamic_auto_height_columns(
259            cls, count: int, width: float, gutter_width: float,
260            height: float) -> 'MTextColumns':
261        columns = cls()
262        columns.column_type = ColumnType.DYNAMIC
263        columns.auto_height = True
264        columns.count = int(count)
265        columns.width = float(width)
266        columns.gutter_width = float(gutter_width)
267        columns.defined_height = float(height)
268        columns.update_total_width()
269        columns.update_total_height()
270        return columns
271
272    @classmethod
273    def new_dynamic_manual_height_columns(
274            cls, width: float, gutter_width: float,
275            heights: Iterable[float]) -> 'MTextColumns':
276        columns = cls()
277        columns.column_type = ColumnType.DYNAMIC
278        columns.auto_height = False
279        columns.width = float(width)
280        columns.gutter_width = float(gutter_width)
281        columns.defined_height = 0.0
282        columns.heights = list(heights)
283        columns.count = len(columns.heights)
284        columns.update_total_width()
285        columns.update_total_height()
286        return columns
287
288    def update_total_width(self):
289        count = self.count
290        if count > 0:
291            self.total_width = count * self.width + \
292                               (count - 1) * self.gutter_width
293        else:
294            self.total_width = 0.0
295
296    def update_total_height(self):
297        if self.has_dynamic_manual_height:
298            self.total_height = max(self.heights)
299        else:
300            self.total_height = self.defined_height
301
302    @property
303    def has_dynamic_auto_height(self) -> bool:
304        return self.column_type == ColumnType.DYNAMIC and self.auto_height
305
306    @property
307    def has_dynamic_manual_height(self) -> bool:
308        return self.column_type == ColumnType.DYNAMIC and not self.auto_height
309
310    def link_columns(self, doc: 'Drawing'):
311        # DXF R2018+ has no linked MTEXT entities.
312        if doc.dxfversion >= DXF2018 or not self.linked_handles:
313            return
314        db = doc.entitydb
315        assert db is not None, "entity database not initialized"
316        linked_columns = []
317        for handle in self.linked_handles:
318            mtext = cast('MText', db.get(handle))
319            if mtext:
320                linked_columns.append(mtext)
321            else:
322                logger.debug(f"Linked MTEXT column #{handle} does not exist.")
323        self.linked_handles = None
324        self.linked_columns = linked_columns
325
326    def transform(self, m: Matrix44, hscale: float = 1, vscale: float = 1):
327        self.width *= hscale
328        self.gutter_width *= hscale
329        self.total_width *= hscale
330        self.total_height *= vscale
331        self.defined_height *= vscale
332        self.heights = [h * vscale for h in self.heights]
333        for mtext in self.linked_columns:
334            mtext.transform(m)
335
336    def acad_mtext_column_info_xdata(self) -> Tags:
337        tags = Tags([
338            DXFTag(1000, "ACAD_MTEXT_COLUMN_INFO_BEGIN"),
339            DXFTag(1070, 75), DXFTag(1070, int(self.column_type)),
340            DXFTag(1070, 79), DXFTag(1070, int(self.auto_height)),
341            DXFTag(1070, 76), DXFTag(1070, self.count),
342            DXFTag(1070, 78), DXFTag(1070, int(self.reversed_column_flow)),
343            DXFTag(1070, 48), DXFTag(1040, self.width),
344            DXFTag(1070, 49), DXFTag(1040, self.gutter_width),
345        ])
346        if self.has_dynamic_manual_height:
347            tags.extend([DXFTag(1070, 50), DXFTag(1070, len(self.heights))])
348            tags.extend(DXFTag(1040, height) for height in self.heights)
349        tags.append(DXFTag(1000, "ACAD_MTEXT_COLUMN_INFO_END"))
350        return tags
351
352    def acad_mtext_columns_xdata(self) -> Tags:
353        tags = Tags([
354            DXFTag(1000, "ACAD_MTEXT_COLUMNS_BEGIN"),
355            DXFTag(1070, 47), DXFTag(1070, self.count),  # incl. main MTEXT
356        ])
357        tags.extend(  # writes only (count - 1) handles!
358            DXFTag(1005, handle) for handle in self.mtext_handles())
359        tags.append(DXFTag(1000, "ACAD_MTEXT_COLUMNS_END"))
360        return tags
361
362    def mtext_handles(self) -> List[str]:
363        """ Returns a list of all linked MTEXT handles. """
364        if self.linked_handles:
365            return self.linked_handles
366        handles = []
367        for column in self.linked_columns:
368            if column.is_alive:
369                handle = column.dxf.handle
370                if handle is None:
371                    raise const.DXFStructureError(
372                        "Linked MTEXT column has no handle.")
373                handles.append(handle)
374            else:
375                raise const.DXFStructureError("Linked MTEXT column deleted!")
376        return handles
377
378    def acad_mtext_defined_height_xdata(self) -> Tags:
379        return Tags([
380            DXFTag(1000, "ACAD_MTEXT_DEFINED_HEIGHT_BEGIN"),
381            DXFTag(1070, 46), DXFTag(1040, self.defined_height),
382            DXFTag(1000, "ACAD_MTEXT_DEFINED_HEIGHT_END"),
383        ])
384
385
386def load_columns_from_embedded_object(
387        dxf: 'DXFNamespace', embedded_obj: Tags) -> MTextColumns:
388    columns = MTextColumns()
389    insert = dxf.get('insert')  # mandatory attribute, but what if ...
390    text_direction = dxf.get('text_direction')  # optional attribute
391    reference_column_width = dxf.get('width')  # optional attribute
392    for code, value in embedded_obj:
393        # Update duplicated attributes if MTEXT attributes are not set:
394        if code == 10 and text_direction is None:
395            dxf.text_direction = Vec3(value)
396            # rotation is not needed anymore:
397            dxf.discard('rotation')
398        elif code == 11 and insert is None:
399            dxf.insert = Vec3(value)
400        elif code == 40 and reference_column_width is None:
401            dxf.width = value
402        elif code == 41:
403            # Column height if auto height is True.
404            columns.defined_height = value
405            # Keep in sync with DXF attribute:
406            dxf.defined_height = value
407        elif code == 42:
408            columns.total_width = value
409        elif code == 43:
410            columns.total_height = value
411        elif code == 44:
412            # All columns have the same width.
413            columns.width = value
414        elif code == 45:
415            # All columns have the same gutter width = space between columns.
416            columns.gutter_width = value
417        elif code == 71:
418            columns.column_type = ColumnType(value)
419        elif code == 72:  # column height count
420            # The column height count can be 0 in some cases (dynamic & auto
421            # height) in DXF version R2018+.
422            columns.count = value
423        elif code == 73:
424            columns.auto_height = bool(value)
425        elif code == 74:
426            columns.reversed_column_flow = bool(value)
427        elif code == 46:  # column heights
428            # The last column height is 0; takes the rest?
429            columns.heights.append(value)
430
431    # The column count is not defined explicit:
432    if columns.count == 0:
433        if columns.heights:  # very unlikely
434            columns.count = len(columns.heights)
435        elif columns.total_width > 0:
436            # calculate column count from total_width
437            g = columns.gutter_width
438            wg = abs(columns.width + g)
439            if wg > 1e-6:
440                columns.count = int(round((columns.total_width + g) / wg))
441    return columns
442
443
444def load_mtext_column_info(tags: Tags) -> Optional[MTextColumns]:
445    try:  # has column info?
446        start, end = find_begin_and_end_of_encoded_xdata_tags(
447            "ACAD_MTEXT_COLUMN_INFO", tags)
448    except NotFoundException:
449        return None
450    columns = MTextColumns()
451    height_count = 0
452    group_code = None
453    for code, value in tags[start + 1: end]:
454        if height_count:
455            if code == 1040:
456                columns.heights.append(value)
457                height_count -= 1
458                continue
459            else:  # error
460                logger.error("missing column heights in MTEXT entity")
461                height_count = 0
462
463        if group_code is None:
464            group_code = value
465            continue
466
467        if group_code == 75:
468            columns.column_type = ColumnType(value)
469        elif group_code == 79:
470            columns.auto_height = bool(value)
471        elif group_code == 76:
472            columns.count = int(value)
473        elif group_code == 78:
474            columns.reversed_column_flow = bool(value)
475        elif group_code == 48:
476            columns.width = value
477        elif group_code == 49:
478            columns.gutter_width = value
479        elif group_code == 50:
480            height_count = int(value)
481        group_code = None
482    return columns
483
484
485def load_mtext_linked_column_handles(tags: Tags) -> List[str]:
486    handles = []
487    try:
488        start, end = find_begin_and_end_of_encoded_xdata_tags(
489            "ACAD_MTEXT_COLUMNS", tags)
490    except NotFoundException:
491        return handles
492    for code, value in tags[start:end]:
493        if code == 1005:
494            handles.append(value)
495    return handles
496
497
498def load_mtext_defined_height(tags: Tags) -> float:
499    # The defined height stored in the linked MTEXT entities, is not required:
500    #
501    # If all columns have the same height (static & dynamic auto height), the
502    # "defined_height" is stored in the main MTEXT, but the linked MTEXT entities
503    # also have a "ACAD_MTEXT_DEFINED_HEIGHT" group in the ACAD section of XDATA.
504    #
505    # If the columns have different heights (dynamic manual height), these
506    # height values are only stored in the main MTEXT. The linked MTEXT
507    # entities do not have an ACAD section at all.
508
509    height = 0.0
510    try:
511        start, end = find_begin_and_end_of_encoded_xdata_tags(
512            "ACAD_MTEXT_DEFINED_HEIGHT", tags)
513    except NotFoundException:
514        return height
515
516    for code, value in tags[start:end]:
517        if code == 1040:
518            height = value
519    return height
520
521
522def load_columns_from_xdata(dxf: 'DXFNamespace',
523                            xdata: XData) -> Optional[MTextColumns]:
524    # The ACAD section in XDATA of the main MTEXT entity stores all column
525    # related information:
526    if "ACAD" in xdata:
527        acad = xdata.get("ACAD")
528    else:
529        return None
530
531    name = f"MTEXT(#{dxf.get('handle')})"
532    try:
533        columns = load_mtext_column_info(acad)
534    except const.DXFStructureError:
535        logger.error(f"Invalid ACAD_MTEXT_COLUMN_INFO in {name}")
536        return None
537
538    if columns is None:  # no columns defined
539        return None
540
541    try:
542        columns.linked_handles = load_mtext_linked_column_handles(acad)
543    except const.DXFStructureError:
544        logger.error(f"Invalid ACAD_MTEXT_COLUMNS in {name}")
545
546    columns.update_total_width()
547    if columns.heights:  # dynamic columns, manual heights
548        # This is correct even if the last column is the tallest, which height
549        # is not known. The height of last column is always stored as 0.
550        columns.total_height = max(columns.heights)
551    else:  # all columns have the same "defined" height
552        try:
553            columns.defined_height = load_mtext_defined_height(acad)
554        except const.DXFStructureError:
555            logger.error(f"Invalid ACAD_MTEXT_DEFINED_HEIGHT in {name}")
556        columns.total_height = columns.defined_height
557
558    return columns
559
560
561def extract_mtext_text_frame_handles(xdata: XData) -> List[str]:
562    # Stores information about the text frame until DXF R2018.
563    # Newer CAD applications do not need that information nor the separated
564    # LWPOLYLINE as text frame entity.
565    handles = []
566    if "ACAD" in xdata:
567        acad = xdata.get("ACAD")
568    else:
569        return handles
570
571    try:
572        start, end = find_begin_and_end_of_encoded_xdata_tags(
573            "ACAD_MTEXT_TEXT_BORDERS", acad)
574    except NotFoundException:
575        return handles
576
577    for code, value in acad[start:end]:
578        # multiple handles to a LWPOLYLINE entity could be present:
579        if code == 1005:
580            handles.append(value)
581
582    # remove MTEXT_TEXT_BORDERS data
583    del acad[start:end]
584    if len(acad) < 2:
585        xdata.discard("ACAD")
586    return handles
587
588
589@factory.register_entity
590class MText(DXFGraphic):
591    """ DXF MTEXT entity """
592    DXFTYPE = 'MTEXT'
593    DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_mtext)
594    MIN_DXF_VERSION_FOR_EXPORT = DXF2000
595
596    def __init__(self):
597        super().__init__()
598        self.text: str = ""
599        # Linked MText columns do not have a MTextColumns() object!
600        self._columns: Optional[MTextColumns] = None
601
602    @property
603    def columns(self) -> Optional[MTextColumns]:
604        """ Returns a copy of the column configuration. """
605        # The column configuration is deliberately not editable.
606        # Can't prevent access to _columns, but you are on your own if do this!
607        return self._columns.shallow_copy() if self._columns else None
608
609    @property
610    def has_columns(self) -> bool:
611        return self._columns is not None
612
613    def _copy_data(self, entity: 'MText') -> None:
614        entity.text = self.text
615        if self.has_columns:
616            # copies also the linked MTEXT column entities!
617            entity._columns = self._columns.deep_copy()
618
619    def load_dxf_attribs(
620            self, processor: SubclassProcessor = None) -> 'DXFNamespace':
621        dxf = super().load_dxf_attribs(processor)
622        if processor:
623            tags = Tags(self.load_mtext_content(processor.subclass_by_index(2)))
624            processor.fast_load_dxfattribs(
625                dxf, acdb_mtext_group_codes, subclass=tags, recover=True)
626            if processor.embedded_objects:
627                obj = processor.embedded_objects[0]
628                self._columns = load_columns_from_embedded_object(dxf, obj)
629            elif self.xdata:
630                self._columns = load_columns_from_xdata(dxf, self.xdata)
631        self.embedded_objects = None  # todo: remove
632        return dxf
633
634    def post_load_hook(self, doc: 'Drawing') -> Optional[Callable]:
635        def destroy_text_frame_entity():
636            entitydb = doc.entitydb
637            if entitydb:
638                for handle in extract_mtext_text_frame_handles(self.xdata):
639                    text_frame = entitydb.get(handle)
640                    if text_frame:
641                        text_frame.destroy()
642
643        def unlink_mtext_columns_from_layout():
644            """ Unlinked MTEXT entities from layout entity space. """
645            layout = self.get_layout()
646            if layout is not None:
647                for mtext in self._columns.linked_columns:
648                    layout.unlink_entity(mtext)
649            else:
650                for mtext in self._columns.linked_columns:
651                    mtext.dxf.owner = None
652
653        super().post_load_hook(doc)
654        if self.xdata:
655            destroy_text_frame_entity()
656
657        if self.has_columns:
658            # Link columns, one MTEXT entity for each column, to the main MTEXT
659            # entity (DXF version <R2018).
660            self._columns.link_columns(doc)
661            return unlink_mtext_columns_from_layout
662        return None
663
664    def preprocess_export(self, tagwriter: 'TagWriter') -> bool:
665        """ Pre requirement check and pre processing for export.
666
667        Returns False if MTEXT should not be exported at all.
668
669        (internal API)
670        """
671        columns = self._columns
672        if columns and tagwriter.dxfversion < const.DXF2018:
673            if columns.count != len(columns.linked_columns) + 1:
674                logger.debug(
675                    f"{str(self)}: column count does not match linked columns")
676                return False
677            if not all(column.is_alive for column in columns.linked_columns):
678                logger.debug(
679                    f"{str(self)}: contains destroyed linked columns")
680                return False
681            self.sync_common_attribs_of_linked_columns()
682        return True
683
684    def export_dxf(self, tagwriter: 'TagWriter') -> None:
685        super().export_dxf(tagwriter)
686        # Linked MTEXT entities are not stored in the layout entity space!
687        if self.has_columns and tagwriter.dxfversion < const.DXF2018:
688            self.export_linked_entities(tagwriter)
689
690    def export_entity(self, tagwriter: 'TagWriter') -> None:
691        """ Export entity specific data as DXF tags. """
692        super().export_entity(tagwriter)
693        tagwriter.write_tag2(SUBCLASS_MARKER, acdb_mtext.name)
694        self.dxf.export_dxf_attribs(tagwriter, [
695            'insert', 'char_height', 'width', 'defined_height',
696            'attachment_point', 'flow_direction',
697        ])
698        self.export_mtext_content(tagwriter)
699        self.dxf.export_dxf_attribs(tagwriter, [
700            'style', 'extrusion', 'text_direction', 'rect_width', 'rect_height',
701            'rotation', 'line_spacing_style', 'line_spacing_factor',
702            'box_fill_scale', 'bg_fill', 'bg_fill_color', 'bg_fill_true_color',
703            'bg_fill_color_name', 'bg_fill_transparency',
704        ])
705        columns = self._columns
706        if columns is None or columns.column_type == ColumnType.NONE:
707            return
708
709        if tagwriter.dxfversion >= DXF2018:
710            self.export_embedded_object(tagwriter)
711        else:
712            self.set_column_xdata()
713            self.set_linked_columns_xdata()
714
715    def load_mtext_content(self, tags: Tags) -> Iterable['DXFTag']:
716        tail = ""
717        parts = []
718        for tag in tags:
719            if tag.code == 1:
720                tail = tag.value
721            elif tag.code == 3:
722                parts.append(tag.value)
723            else:
724                yield tag
725        parts.append(tail)
726        self.text = escape_dxf_line_endings("".join(parts))
727
728    def export_mtext_content(self, tagwriter: 'TagWriter') -> None:
729        txt = escape_dxf_line_endings(self.text)
730        str_chunks = split_mtext_string(txt, size=250)
731        if len(str_chunks) == 0:
732            str_chunks.append("")
733        while len(str_chunks) > 1:
734            tagwriter.write_tag2(3, str_chunks.pop(0))
735        tagwriter.write_tag2(1, str_chunks[0])
736
737    def export_embedded_object(self, tagwriter: 'TagWriter'):
738        dxf = self.dxf
739        cols = self._columns
740
741        tagwriter.write_tag2(101, 'Embedded Object')
742        tagwriter.write_tag2(70, 1)  # unknown meaning
743        tagwriter.write_tag(dxftag(10, dxf.text_direction))
744        tagwriter.write_tag(dxftag(11, dxf.insert))
745        tagwriter.write_tag2(40, dxf.width)  # repeated reference column width
746        tagwriter.write_tag2(41, cols.defined_height)
747        tagwriter.write_tag2(42, cols.total_width)
748        tagwriter.write_tag2(43, cols.total_height)
749        tagwriter.write_tag2(71, int(cols.column_type))
750
751        if cols.has_dynamic_auto_height:
752            count = 0
753        else:
754            count = cols.count
755        tagwriter.write_tag2(72, count)
756
757        tagwriter.write_tag2(44, cols.width)
758        tagwriter.write_tag2(45, cols.gutter_width)
759        tagwriter.write_tag2(73, int(cols.auto_height))
760        tagwriter.write_tag2(74, int(cols.reversed_column_flow))
761        for height in cols.heights:
762            tagwriter.write_tag2(46, height)
763
764    def export_linked_entities(self, tagwriter: 'TagWriter'):
765        for mtext in self._columns.linked_columns:
766            if mtext.dxf.handle is None:
767                raise const.DXFStructureError(
768                    "Linked MTEXT column has no handle.")
769            # Export linked columns as separated DXF entities:
770            mtext.export_dxf(tagwriter)
771
772    def sync_common_attribs_of_linked_columns(self):
773        common_attribs = self.dxfattribs(drop={
774            'handle', 'insert', 'rect_width', 'rect_height'})
775        for mtext in self._columns.linked_columns:
776            mtext.update_dxf_attribs(common_attribs)
777
778    def set_column_xdata(self):
779        if self.xdata is None:
780            self.xdata = XData()
781        cols = self._columns
782        acad = cols.acad_mtext_column_info_xdata()
783        acad.extend(cols.acad_mtext_columns_xdata())
784        if not cols.has_dynamic_manual_height:
785            acad.extend(cols.acad_mtext_defined_height_xdata())
786
787        xdata = self.xdata
788        # Replace existing column data and therefore also removes
789        # ACAD_MTEXT_TEXT_BORDERS information!
790        xdata.discard('ACAD')
791        xdata.add('ACAD', acad)
792
793    def set_linked_columns_xdata(self):
794        cols = self._columns
795        for column in cols.linked_columns:
796            column.discard_xdata('ACAD')
797        if not cols.has_dynamic_manual_height:
798            tags = cols.acad_mtext_defined_height_xdata()
799            for column in cols.linked_columns:
800                column.set_xdata('ACAD', tags)
801
802    def get_rotation(self) -> float:
803        """ Get text rotation in degrees, independent if it is defined by
804        :attr:`dxf.rotation` or :attr:`dxf.text_direction`.
805
806        """
807        if self.dxf.hasattr('text_direction'):
808            vector = self.dxf.text_direction
809            radians = math.atan2(vector[1], vector[0])  # ignores z-axis
810            rotation = math.degrees(radians)
811        else:
812            rotation = self.dxf.get('rotation', 0)
813        return rotation
814
815    def set_rotation(self, angle: float) -> 'MText':
816        """ Set attribute :attr:`rotation` to `angle` (in degrees) and deletes
817        :attr:`dxf.text_direction` if present.
818
819        """
820        # text_direction has higher priority than rotation, therefore delete it
821        self.dxf.discard('text_direction')
822        self.dxf.rotation = angle
823        return self  # fluent interface
824
825    def set_location(self, insert: 'Vertex', rotation: float = None,
826                     attachment_point: int = None) -> 'MText':
827        """ Set attributes :attr:`dxf.insert`, :attr:`dxf.rotation` and
828        :attr:`dxf.attachment_point`, ``None`` for :attr:`dxf.rotation` or
829        :attr:`dxf.attachment_point` preserves the existing value.
830
831        """
832        self.dxf.insert = Vec3(insert)
833        if rotation is not None:
834            self.set_rotation(rotation)
835        if attachment_point is not None:
836            self.dxf.attachment_point = attachment_point
837        return self  # fluent interface
838
839    def set_bg_color(self, color: Union[int, str, Tuple[int, int, int], None],
840                     scale: float = 1.5, text_frame=False):
841        """ Set background color as :ref:`ACI` value or as name string or as RGB
842        tuple ``(r, g, b)``.
843
844        Use special color name ``canvas``, to set background color to canvas
845        background color.
846
847        Use `color` = ``None`` to remove the background filling.
848
849        Setting only a text border is supported (`color`=``None``), but in this
850        case the scaling is always 1.5.
851
852        Args:
853            color: color as :ref:`ACI`, string, RGB tuple or ``None``
854            scale: determines how much border there is around the text, the
855                value is based on the text height, and should be in the range
856                of [1, 5], where 1 fits exact the MText entity.
857            text_frame: draw a text frame in text color if ``True``
858
859        """
860        if 1 <= scale <= 5:
861            self.dxf.box_fill_scale = scale
862        else:
863            raise ValueError('argument scale has to be in range from 1 to 5.')
864
865        text_frame = const.MTEXT_TEXT_FRAME if text_frame else 0
866        if color is None:
867            self.dxf.discard('bg_fill')
868            self.dxf.discard('box_fill_scale')
869            self.dxf.discard('bg_fill_color')
870            self.dxf.discard('bg_fill_true_color')
871            self.dxf.discard('bg_fill_color_name')
872            if text_frame:
873                # special case, text frame only with scaling factor = 1.5
874                self.dxf.bg_fill = 16
875        elif color == 'canvas':  # special case for use background color
876            self.dxf.bg_fill = const.MTEXT_BG_CANVAS_COLOR | text_frame
877            self.dxf.bg_fill_color = 0  # required but ignored
878        else:
879            self.dxf.bg_fill = const.MTEXT_BG_COLOR | text_frame
880            if isinstance(color, int):
881                self.dxf.bg_fill_color = color
882            elif isinstance(color, str):
883                self.dxf.bg_fill_color = 0  # required but ignored
884                self.dxf.bg_fill_color_name = color
885            elif isinstance(color, tuple):
886                self.dxf.bg_fill_color = 0  # required but ignored
887                self.dxf.bg_fill_true_color = rgb2int(color)
888        return self  # fluent interface
889
890    def __iadd__(self, text: str) -> 'MText':
891        """ Append `text` to existing content (:attr:`text` attribute). """
892        self.text += text
893        return self
894
895    append = __iadd__
896
897    def get_text_direction(self) -> Vec3:
898        """ Returns the horizontal text direction as :class:`~ezdxf.math.Vec3`
899        object, even if only the text rotation is defined.
900
901        """
902        dxf = self.dxf
903        # "text_direction" has higher priority than "rotation"
904        if dxf.hasattr("text_direction"):
905            return dxf.text_direction
906        if dxf.hasattr("rotation"):
907            # MTEXT is not an OCS entity, but I don't know how else to convert
908            # a rotation angle for an entity just defined by an extrusion vector.
909            # It's correct for the most common case: extrusion=(0, 0, 1)
910            return OCS(dxf.extrusion).to_wcs(Vec3.from_deg_angle(dxf.rotation))
911        return X_AXIS
912
913    def convert_rotation_to_text_direction(self):
914        """ Convert text rotation into text direction and discard text rotation.
915        """
916        dxf = self.dxf
917        if dxf.hasattr('rotation'):
918            if not dxf.hasattr('text_direction'):
919                dxf.text_direction = self.get_text_direction()
920            dxf.discard('rotation')
921
922    def ucs(self) -> UCS:
923        """ Returns the :class:`~ezdxf.math.UCS` of the :class:`MText` entity,
924        defined by the insert location (origin), the text direction or rotation
925        (x-axis) and the extrusion vector (z-axis).
926
927        """
928        dxf = self.dxf
929        return UCS(
930            origin=dxf.insert,
931            ux=self.get_text_direction(),
932            uz=dxf.extrusion,
933        )
934
935    def transform(self, m: Matrix44) -> 'MText':
936        """ Transform the MTEXT entity by transformation matrix `m` inplace. """
937        dxf = self.dxf
938        old_extrusion = Vec3(dxf.extrusion)
939        new_extrusion, _ = transform_extrusion(old_extrusion, m)
940        self.convert_rotation_to_text_direction()
941
942        old_text_direction = Vec3(dxf.text_direction)
943        new_text_direction = m.transform_direction(old_text_direction)
944
945        old_vertical_direction = old_extrusion.cross(old_text_direction)
946        old_char_height_vec = old_vertical_direction.normalize(dxf.char_height)
947        new_char_height_vec = m.transform_direction(old_char_height_vec)
948        oblique = new_text_direction.angle_between(new_char_height_vec)
949        dxf.char_height = new_char_height_vec.magnitude * math.sin(oblique)
950
951        if dxf.hasattr('width'):
952            width_vec = old_text_direction.normalize(dxf.width)
953            dxf.width = m.transform_direction(width_vec).magnitude
954
955        dxf.insert = m.transform(dxf.insert)
956        dxf.text_direction = new_text_direction
957        dxf.extrusion = new_extrusion
958        if self.has_columns:
959            hscale = m.transform_direction(
960                old_text_direction.normalize()).magnitude
961            vscale = m.transform_direction(
962                old_vertical_direction.normalize()).magnitude
963            self._columns.transform(m, hscale, vscale)
964        return self
965
966    def plain_text(self, split=False) -> Union[List[str], str]:
967        """ Returns the text content without inline formatting codes.
968
969        Args:
970            split: split content text at line breaks if ``True`` and
971                returns a list of strings without line endings
972
973        """
974        return plain_mtext(self.text, split=split)
975
976    def all_columns_plain_text(self, split=False) -> Union[List[str], str]:
977        """ Returns the text content of all columns without inline formatting
978        codes.
979
980        Args:
981            split: split content text at line breaks if ``True`` and
982                returns a list of strings without line endings
983
984        .. versionadded: 0.17
985
986        """
987
988        def merged_content():
989            content = [plain_mtext(self.text, split=False)]
990            if self.has_columns:
991                for c in self._columns.linked_columns:
992                    content.append(c.plain_text(split=False))
993            return "".join(content)
994
995        def split_content():
996            content = plain_mtext(self.text, split=True)
997            if self.has_columns:
998                if content and content[-1] == "":
999                    content.pop()
1000                for c in self._columns.linked_columns:
1001                    content.extend(c.plain_text(split=True))
1002                    if content and content[-1] == "":
1003                        content.pop()
1004            return content
1005
1006        if split:
1007            return split_content()
1008        else:
1009            return merged_content()
1010
1011    def all_columns_raw_content(self) -> str:
1012        """ Returns the text content of all columns as a single string
1013        including the inline formatting codes.
1014
1015        .. versionadded: 0.17
1016
1017        """
1018        content = [self.text]
1019        if self.has_columns:
1020            for column in self._columns.linked_columns:
1021                content.append(column.text)
1022        return "".join(content)
1023
1024    def audit(self, auditor: 'Auditor'):
1025        """ Validity check. """
1026        if not self.is_alive:
1027            return
1028        if self.dxf.owner is not None:
1029            # Kills linked columns, because owner (None) does not exist!
1030            super().audit(auditor)
1031        else:  # linked columns: owner is None
1032            # TODO: special audit for linked columns
1033            pass
1034        auditor.check_text_style(self)
1035        # TODO: audit column structure
1036
1037    def destroy(self) -> None:
1038        if not self.is_alive:
1039            return
1040
1041        if self.has_columns:
1042            for column in self._columns.linked_columns:
1043                column.destroy()
1044
1045        del self._columns
1046        super().destroy()
1047
1048    # Linked MTEXT columns are not the same structure as
1049    # POLYLINE & INSERT with sub-entities and SEQEND :(
1050    def add_sub_entities_to_entitydb(self, db: 'EntityDB') -> None:
1051        """ Add linked columns (MTEXT) entities to entity database `db`,
1052        called from EntityDB. (internal API)
1053
1054        """
1055        if self.is_alive and self._columns:
1056            doc = self.doc
1057            for column in self._columns.linked_columns:
1058                if column.is_alive and column.is_virtual:
1059                    column.doc = doc
1060                    db.add(column)
1061
1062    def process_sub_entities(self, func: Callable[['DXFEntity'], None]):
1063        """ Call `func` for linked columns. (internal API)
1064        """
1065        if self.is_alive and self._columns:
1066            for entity in self._columns.linked_columns:
1067                if entity.is_alive:
1068                    func(entity)
1069
1070    def setup_columns(self, columns: MTextColumns,
1071                      linked: bool = False) -> None:
1072        assert columns.column_type != ColumnType.NONE
1073        assert columns.count > 0, "one or more columns required"
1074        assert columns.width > 0, "column width has to be > 0"
1075        assert columns.gutter_width >= 0, "gutter width has to be >= 0"
1076
1077        if self.has_columns:
1078            raise const.DXFStructureError('Column setup already exist.')
1079        self._columns = columns
1080        self.dxf.width = columns.width
1081        self.dxf.defined_height = columns.defined_height
1082        if columns.total_height < 1e-6:
1083            columns.total_height = columns.defined_height
1084        if columns.total_width < 1e-6:
1085            columns.update_total_width()
1086        if linked:
1087            self._create_linked_columns()
1088
1089    def _create_linked_columns(self) -> None:
1090        """ Create linked MTEXT columns for DXF versions before R2018. """
1091        # creates virtual MTEXT entities
1092        dxf = self.dxf
1093        attribs = self.dxfattribs(drop={'handle', 'owner'})
1094        doc = self.doc
1095        cols = self._columns
1096
1097        insert = dxf.get('insert', Vec3())
1098        default_direction = Vec3.from_deg_angle(dxf.get('rotation', 0))
1099        text_direction = Vec3(dxf.get('text_direction', default_direction))
1100        offset = text_direction.normalize(cols.width + cols.gutter_width)
1101        linked_columns = cols.linked_columns
1102        for _ in range(cols.count - 1):
1103            insert += offset
1104            column = MText.new(dxfattribs=attribs, doc=doc)
1105            column.dxf.insert = insert
1106            linked_columns.append(column)
1107
1108    def remove_dependencies(self, other: 'Drawing' = None) -> None:
1109        if not self.is_alive:
1110            return
1111
1112        super().remove_dependencies()
1113        has_style = (bool(other) and (self.dxf.style in other.styles))
1114        if not has_style:
1115            self.dxf.style = 'Standard'
1116
1117        if self.has_columns:
1118            for column in self._columns.linked_columns:
1119                column.remove_dependencies(other)
1120