1# Copyright (c) 2019-2020 Manfred Moitzi
2# License: MIT License
3import math
4from typing import TYPE_CHECKING, Tuple, Union
5
6from ezdxf.lldxf import validator
7from ezdxf.lldxf import const
8from ezdxf.lldxf.attributes import (
9    DXFAttr, DXFAttributes, DefSubclass, XType, RETURN_DEFAULT,
10    group_code_mapping,
11)
12from ezdxf.lldxf.const import (
13    DXF12, SUBCLASS_MARKER, DXFValueError,
14)
15from ezdxf.math import Vec3, Matrix44, NULLVEC, Z_AXIS
16from ezdxf.math.transformtools import OCSTransform
17from ezdxf.audit import Auditor
18from ezdxf.tools.text import plain_text
19
20from .dxfentity import base_class, SubclassProcessor
21from .dxfgfx import DXFGraphic, acdb_entity, elevation_to_z_axis
22from .factory import register_entity
23
24if TYPE_CHECKING:
25    from ezdxf.eztypes import TagWriter, Vertex, DXFNamespace, Drawing
26
27__all__ = ['Text', 'acdb_text']
28
29acdb_text = DefSubclass('AcDbText', {
30    # First alignment point (in OCS):
31    'insert': DXFAttr(10, xtype=XType.point3d, default=NULLVEC),
32
33    # Text height
34    'height': DXFAttr(
35        40, default=2.5,
36        validator=validator.is_greater_zero,
37        fixer=RETURN_DEFAULT,
38    ),
39
40    # Text content as sting:
41    'text': DXFAttr(
42        1, default='',
43        validator=validator.is_valid_one_line_text,
44        fixer=validator.fix_one_line_text,
45    ),
46
47    # Text rotation in degrees (optional)
48    'rotation': DXFAttr(50, default=0, optional=True),
49
50    # Oblique angle in degrees, vertical = 0 deg (optional)
51    'oblique': DXFAttr(51, default=0, optional=True),
52
53    # Text style name (optional), given text style must have an entry in the
54    # text-styles tables.
55    'style': DXFAttr(7, default='Standard', optional=True),
56
57    # Relative X scale factor—width (optional)
58    # This value is also adjusted when fit-type text is used
59    'width': DXFAttr(
60        41, default=1, optional=True,
61        validator=validator.is_greater_zero,
62        fixer=RETURN_DEFAULT,
63    ),
64
65    # Text generation flags (optional)
66    # 2 = backward (mirror-x),
67    # 4 = upside down (mirror-y)
68    'text_generation_flag': DXFAttr(
69        71, default=0, optional=True,
70        validator=validator.is_one_of({0, 2, 4, 6}),
71        fixer=RETURN_DEFAULT,
72    ),
73
74    # Horizontal text justification type (optional) horizontal justification
75    # 0 = Left
76    # 1 = Center
77    # 2 = Right
78    # 3 = Aligned (if vertical alignment = 0)
79    # 4 = Middle (if vertical alignment = 0)
80    # 5 = Fit (if vertical alignment = 0)
81    # This value is meaningful only if the value of a 72 or 73 group is nonzero
82    # (if the justification is anything other than baseline/left)
83    'halign': DXFAttr(
84        72, default=0, optional=True,
85        validator=validator.is_in_integer_range(0, 6),
86        fixer=RETURN_DEFAULT
87    ),
88
89    # Second alignment point (in OCS) (optional)
90    'align_point': DXFAttr(11, xtype=XType.point3d, optional=True),
91
92    # Elevation is a legacy feature from R11 and prior, do not use this
93    # attribute, store the entity elevation in the z-axis of the vertices.
94    # ezdxf does not export the elevation attribute!
95    'elevation': DXFAttr(38, default=0, optional=True),
96
97    # Thickness in extrusion direction, only supported for SHX font in
98    # AutoCAD/BricsCAD (optional), can be negative
99    'thickness': DXFAttr(39, default=0, optional=True),
100
101    # Extrusion direction (optional)
102    'extrusion': DXFAttr(
103        210, xtype=XType.point3d, default=Z_AXIS,
104        optional=True,
105        validator=validator.is_not_null_vector,
106        fixer=RETURN_DEFAULT
107    ),
108})
109acdb_text_group_codes = group_code_mapping(acdb_text)
110acdb_text2 = DefSubclass('AcDbText', {
111    # Vertical text justification type (optional)
112    # 0 = Baseline
113    # 1 = Bottom
114    # 2 = Middle
115    # 3 = Top
116    'valign': DXFAttr(
117        73, default=0, optional=True,
118        validator=validator.is_in_integer_range(0, 4),
119        fixer=RETURN_DEFAULT,
120    )
121})
122acdb_text2_group_codes = group_code_mapping(acdb_text2)
123
124
125# Formatting codes:
126# %%d: '°'
127# %%u in TEXT start underline formatting until next %%u or until end of line
128
129@register_entity
130class Text(DXFGraphic):
131    """ DXF TEXT entity """
132    DXFTYPE = 'TEXT'
133    DXFATTRIBS = DXFAttributes(base_class, acdb_entity, acdb_text, acdb_text2)
134    # horizontal align values
135    LEFT = 0
136    CENTER = 1
137    RIGHT = 2
138    # vertical align values
139    BASELINE = 0
140    BOTTOM = 1
141    MIDDLE = 2
142    TOP = 3
143    # text generation flags
144    MIRROR_X = 2
145    MIRROR_Y = 4
146    BACKWARD = MIRROR_X
147    UPSIDE_DOWN = MIRROR_Y
148
149    def load_dxf_attribs(
150            self, processor: SubclassProcessor = None) -> 'DXFNamespace':
151        """ Loading interface. (internal API) """
152        dxf = super().load_dxf_attribs(processor)
153        if processor:
154            processor.fast_load_dxfattribs(
155                dxf, acdb_text_group_codes, 2, recover=True)
156            processor.fast_load_dxfattribs(
157                dxf, acdb_text2_group_codes, 3, recover=True)
158            if processor.r12:
159                # Transform elevation attribute from R11 to z-axis values:
160                elevation_to_z_axis(dxf, ('insert', 'align_point'))
161        return dxf
162
163    def export_entity(self, tagwriter: 'TagWriter') -> None:
164        """ Export entity specific data as DXF tags. (internal API) """
165        super().export_entity(tagwriter)
166        self.export_acdb_text(tagwriter)
167        self.export_acdb_text2(tagwriter)
168
169    def export_acdb_text(self, tagwriter: 'TagWriter') -> None:
170        """ Export TEXT data as DXF tags. (internal API) """
171        if tagwriter.dxfversion > DXF12:
172            tagwriter.write_tag2(SUBCLASS_MARKER, acdb_text.name)
173        self.dxf.export_dxf_attribs(tagwriter, [
174            'insert', 'height', 'text', 'thickness', 'rotation', 'oblique',
175            'style', 'width', 'text_generation_flag', 'halign', 'align_point',
176            'extrusion',
177        ])
178
179    def export_acdb_text2(self, tagwriter: 'TagWriter') -> None:
180        """ Export TEXT data as DXF tags. (internal API) """
181        if tagwriter.dxfversion > DXF12:
182            tagwriter.write_tag2(SUBCLASS_MARKER, acdb_text2.name)
183        self.dxf.export_dxf_attribs(tagwriter, 'valign')
184
185    def set_pos(self, p1: 'Vertex', p2: 'Vertex' = None,
186                align: str = None) -> 'Text':
187        """  Set text alignment, valid alignments are:
188
189        ============   =============== ================= =====
190        Vertical       Left            Center            Right
191        ============   =============== ================= =====
192        Top            TOP_LEFT        TOP_CENTER        TOP_RIGHT
193        Middle         MIDDLE_LEFT     MIDDLE_CENTER     MIDDLE_RIGHT
194        Bottom         BOTTOM_LEFT     BOTTOM_CENTER     BOTTOM_RIGHT
195        Baseline       LEFT            CENTER            RIGHT
196        ============   =============== ================= =====
197
198        Alignments "ALIGNED" and "FIT" are special, they require a
199        second alignment point, text is aligned on the virtual line between
200        these two points and sit vertical at the base line.
201
202        - "ALIGNED": Text is stretched or compressed to fit exactly between
203          `p1` and `p2` and the text height is also adjusted to preserve
204          height/width ratio.
205        - "FIT": Text is stretched or compressed to fit exactly between `p1`
206          and `p2` but only the text width is adjusted, the text height is fixed
207          by the :attr:`dxf.height` attribute.
208        - "MIDDLE": also a special adjustment, centered text like
209          "MIDDLE_CENTER", but vertical centred at the total height of the
210          text.
211
212        Args:
213            p1: first alignment point as (x, y[, z]) tuple
214            p2: second alignment point as (x, y[, z]) tuple, required for
215                "ALIGNED" and "FIT" else ignored
216            align: new alignment, ``None`` for preserve existing alignment.
217
218        """
219        if align is None:
220            align = self.get_align()
221        align = align.upper()
222        self.set_align(align)
223        self.set_dxf_attrib('insert', p1)
224        if align in ('ALIGNED', 'FIT'):
225            if p2 is None:
226                raise DXFValueError(
227                    f"Alignment '{align}' requires a second alignment point."
228                )
229        else:
230            p2 = p1
231        self.set_dxf_attrib('align_point', p2)
232        return self
233
234    def get_pos(self) -> Tuple[str, Vec3, Union[Vec3, None]]:
235        """ Returns a tuple (`align`, `p1`, `p2`), `align` is the alignment
236        method, `p1` is the alignment point, `p2` is only relevant if `align`
237        is "ALIGNED" or "FIT", otherwise it is ``None``.
238
239        """
240        p1 = Vec3(self.dxf.insert)
241        p2 = Vec3(self.get_dxf_attrib('align_point', NULLVEC))
242        align = self.get_align()
243        if align == 'LEFT':
244            return align, p1, None
245        if align in ('FIT', 'ALIGNED'):
246            return align, p1, p2
247        return align, p2, None
248
249    def set_align(self, align: str = 'LEFT') -> 'Text':
250        """ Just for experts: Sets the text alignment without setting the
251        alignment points, set adjustment points attr:`dxf.insert` and
252        :attr:`dxf.align_point` manually.
253
254        Args:
255            align: test alignment, see also :meth:`set_pos`
256
257        """
258        align = align.upper()
259        halign, valign = const.TEXT_ALIGN_FLAGS[align.upper()]
260        self.set_dxf_attrib('halign', halign)
261        self.set_dxf_attrib('valign', valign)
262        return self
263
264    def get_align(self) -> str:
265        """ Returns the actual text alignment as string, see also :meth:`set_pos`.
266        """
267        halign = self.get_dxf_attrib('halign', 0)
268        valign = self.get_dxf_attrib('valign', 0)
269        if halign > 2:
270            valign = 0
271        return const.TEXT_ALIGNMENT_BY_FLAGS.get((halign, valign), 'LEFT')
272
273    def transform(self, m: Matrix44) -> 'Text':
274        """ Transform the TEXT entity by transformation matrix `m` inplace.
275        """
276        dxf = self.dxf
277        if not dxf.hasattr('align_point'):
278            dxf.align_point = dxf.insert
279        ocs = OCSTransform(self.dxf.extrusion, m)
280        dxf.insert = ocs.transform_vertex(dxf.insert)
281        dxf.align_point = ocs.transform_vertex(dxf.align_point)
282        old_rotation = dxf.rotation
283        new_rotation = ocs.transform_deg_angle(old_rotation)
284        x_scale = ocs.transform_length(Vec3.from_deg_angle(old_rotation))
285        y_scale = ocs.transform_length(
286            Vec3.from_deg_angle(old_rotation + 90.0))
287
288        if not ocs.scale_uniform:
289            oblique_vec = Vec3.from_deg_angle(
290                old_rotation + 90.0 - dxf.oblique)
291            new_oblique_deg = new_rotation + 90.0 - ocs.transform_direction(
292                oblique_vec).angle_deg
293            dxf.oblique = new_oblique_deg
294            y_scale *= math.cos(math.radians(new_oblique_deg))
295
296        dxf.width *= x_scale / y_scale
297        dxf.height *= y_scale
298        dxf.rotation = new_rotation
299
300        if dxf.hasattr('thickness'):  # can be negative
301            dxf.thickness = ocs.transform_length((0, 0, dxf.thickness),
302                                                 reflection=dxf.thickness)
303        dxf.extrusion = ocs.new_extrusion
304        return self
305
306    def translate(self, dx: float, dy: float, dz: float) -> 'Text':
307        """ Optimized TEXT/ATTRIB/ATTDEF translation about `dx` in x-axis, `dy`
308        in y-axis and `dz` in z-axis, returns `self`.
309
310        """
311        ocs = self.ocs()
312        dxf = self.dxf
313        vec = Vec3(dx, dy, dz)
314
315        dxf.insert = ocs.from_wcs(vec + ocs.to_wcs(dxf.insert))
316        if dxf.hasattr('align_point'):
317            dxf.align_point = ocs.from_wcs(vec + ocs.to_wcs(dxf.align_point))
318        return self
319
320    def remove_dependencies(self, other: 'Drawing' = None) -> None:
321        """ Remove all dependencies from actual document.
322
323        (internal API)
324        """
325        if not self.is_alive:
326            return
327
328        super().remove_dependencies()
329        has_style = (bool(other) and (self.dxf.style in other.styles))
330        if not has_style:
331            self.dxf.style = 'Standard'
332
333    def plain_text(self) -> str:
334        """ Returns text content without formatting codes. """
335        return plain_text(self.dxf.text)
336
337    def audit(self, auditor: Auditor):
338        """ Validity check. """
339        super().audit(auditor)
340        auditor.check_text_style(self)
341
342    @property
343    def is_backward(self) -> bool:
344        """ Get/set text generation flag BACKWARDS, for mirrored text along the
345        x-axis.
346        """
347        return bool(self.dxf.text_generation_flag & const.BACKWARD)
348
349    @is_backward.setter
350    def is_backward(self, state) -> None:
351        self.set_flag_state(const.BACKWARD, state, 'text_generation_flag')
352
353    @property
354    def is_upside_down(self) -> bool:
355        """ Get/set text generation flag UPSIDE_DOWN, for mirrored text along
356        the y-axis.
357
358        """
359        return bool(self.dxf.text_generation_flag & const.UPSIDE_DOWN)
360
361    @is_upside_down.setter
362    def is_upside_down(self, state) -> None:
363        self.set_flag_state(const.UPSIDE_DOWN, state, 'text_generation_flag')
364
365    def wcs_transformation_matrix(self) -> Matrix44:
366        return text_transformation_matrix(self)
367
368    def font_name(self) -> str:
369        """ Returns the font name of the associated :class:`Textstyle`. """
370        font_name = 'arial.ttf'
371        style_name = self.dxf.style
372        if self.doc:
373            try:
374                style = self.doc.styles.get(style_name)
375                font_name = style.dxf.font
376            except ValueError:
377                pass
378        return font_name
379
380    def fit_length(self) -> float:
381        """ Returns the text length for alignments "FIT" and "ALIGNED", defined
382        by the distance from the insertion point to the align point or 0 for all
383        other alignments.
384
385        """
386        length = 0
387        align, p1, p2 = self.get_pos()
388        if align in ('FIT', 'ALIGNED'):
389            # text is stretch between p1 and p2
390            length = p1.distance(p2)
391        return length
392
393
394def text_transformation_matrix(entity: Text) -> Matrix44:
395    """ Apply rotation, width factor, translation to the insertion point
396    and if necessary transformation from OCS to WCS.
397    """
398    angle = math.radians(entity.dxf.rotation)
399    width_factor = entity.dxf.width
400    align, p1, p2 = entity.get_pos()
401    mirror_x = -1 if entity.is_backward else 1
402    mirror_y = -1 if entity.is_upside_down else 1
403    oblique = math.radians(entity.dxf.oblique)
404    location = p1
405    if align in ('ALIGNED', 'FIT'):
406        width_factor = 1.0  # text goes from p1 to p2, no stretching applied
407        location = p1.lerp(p2, factor=0.5)
408        angle = (p2 - p1).angle  # override stored angle
409
410    m = Matrix44()
411    if oblique:
412        m *= Matrix44.shear_xy(angle_x=oblique)
413    sx = width_factor * mirror_x
414    sy = mirror_y
415    if sx != 1 or sy != 1:
416        m *= Matrix44.scale(sx, sy, 1)
417    if angle:
418        m *= Matrix44.z_rotate(angle)
419    if location:
420        m *= Matrix44.translate(location.x, location.y, location.z)
421
422    ocs = entity.ocs()
423    if ocs.transform:  # to WCS
424        m *= ocs.matrix
425    return m
426