1# Created: 06.2020
2# Copyright (c) 2020-2021, Matthew Broadway
3# License: MIT License
4import enum
5from math import radians
6from typing import Union, Tuple, Dict, Iterable, List, Optional, Callable
7
8import ezdxf.lldxf.const as DXFConstants
9from ezdxf.addons.drawing.backend import Backend
10from ezdxf.addons.drawing.debug_utils import draw_rect
11from ezdxf.tools import fonts
12from ezdxf.entities import MText, Text, Attrib, AttDef
13from ezdxf.math import Matrix44, Vec3, sign
14from ezdxf.tools.text import plain_text, text_wrap
15from ezdxf.tools.fonts import FontMeasurements
16
17"""
18Search google for 'typography' or 'font anatomy' for explanations of terms like
19'baseline' and 'x-height'
20
21A Visual Guide to the Anatomy of Typography: https://visme.co/blog/type-anatomy/
22Anatomy of a Character: https://www.fonts.com/content/learning/fontology/level-1/type-anatomy/anatomy
23"""
24
25
26@enum.unique
27class HAlignment(enum.Enum):
28    LEFT = 0
29    CENTER = 1
30    RIGHT = 2
31
32
33@enum.unique
34class VAlignment(enum.Enum):
35    TOP = 0  # the top of capital letters or letters with ascenders (like 'b')
36    LOWER_CASE_CENTER = 1  # the midpoint between the baseline and the x-height
37    BASELINE = 2  # the line which text rests on, characters with descenders (like 'p') are partially below this line
38    BOTTOM = 3  # the lowest point on a character with a descender (like 'p')
39    UPPER_CASE_CENTER = 4  # the midpoint between the baseline and the cap-height
40
41
42Alignment = Tuple[HAlignment, VAlignment]
43AnyText = Union[Text, MText, Attrib, AttDef]
44
45# multiple of cap_height between the baseline of the previous line and the
46# baseline of the next line
47DEFAULT_LINE_SPACING = 5 / 3
48
49DXF_TEXT_ALIGNMENT_TO_ALIGNMENT: Dict[str, Alignment] = {
50    'LEFT': (HAlignment.LEFT, VAlignment.BASELINE),
51    'CENTER': (HAlignment.CENTER, VAlignment.BASELINE),
52    'RIGHT': (HAlignment.RIGHT, VAlignment.BASELINE),
53    'ALIGNED': (HAlignment.CENTER, VAlignment.BASELINE),
54    'MIDDLE': (HAlignment.CENTER, VAlignment.LOWER_CASE_CENTER),
55    'FIT': (HAlignment.CENTER, VAlignment.BASELINE),
56    'BOTTOM_LEFT': (HAlignment.LEFT, VAlignment.BOTTOM),
57    'BOTTOM_CENTER': (HAlignment.CENTER, VAlignment.BOTTOM),
58    'BOTTOM_RIGHT': (HAlignment.RIGHT, VAlignment.BOTTOM),
59    'MIDDLE_LEFT': (HAlignment.LEFT, VAlignment.UPPER_CASE_CENTER),
60    'MIDDLE_CENTER': (HAlignment.CENTER, VAlignment.UPPER_CASE_CENTER),
61    'MIDDLE_RIGHT': (HAlignment.RIGHT, VAlignment.UPPER_CASE_CENTER),
62    'TOP_LEFT': (HAlignment.LEFT, VAlignment.TOP),
63    'TOP_CENTER': (HAlignment.CENTER, VAlignment.TOP),
64    'TOP_RIGHT': (HAlignment.RIGHT, VAlignment.TOP),
65}
66assert DXF_TEXT_ALIGNMENT_TO_ALIGNMENT.keys() == DXFConstants.TEXT_ALIGN_FLAGS.keys()
67
68DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT: Dict[int, Alignment] = {
69    DXFConstants.MTEXT_TOP_LEFT:
70        (HAlignment.LEFT, VAlignment.TOP),
71    DXFConstants.MTEXT_TOP_CENTER:
72        (HAlignment.CENTER, VAlignment.TOP),
73    DXFConstants.MTEXT_TOP_RIGHT:
74        (HAlignment.RIGHT, VAlignment.TOP),
75    DXFConstants.MTEXT_MIDDLE_LEFT:
76        (HAlignment.LEFT, VAlignment.LOWER_CASE_CENTER),
77    DXFConstants.MTEXT_MIDDLE_CENTER:
78        (HAlignment.CENTER, VAlignment.LOWER_CASE_CENTER),
79    DXFConstants.MTEXT_MIDDLE_RIGHT:
80        (HAlignment.RIGHT, VAlignment.LOWER_CASE_CENTER),
81    DXFConstants.MTEXT_BOTTOM_LEFT:
82        (HAlignment.LEFT, VAlignment.BOTTOM),
83    DXFConstants.MTEXT_BOTTOM_CENTER:
84        (HAlignment.CENTER, VAlignment.BOTTOM),
85    DXFConstants.MTEXT_BOTTOM_RIGHT:
86        (HAlignment.RIGHT, VAlignment.BOTTOM)
87}
88assert len(DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT) == len(
89    DXFConstants.MTEXT_ALIGN_FLAGS)
90
91
92def _calc_aligned_rotation(text: Text) -> float:
93    p1: Vec3 = text.dxf.insert
94    p2: Vec3 = text.dxf.align_point
95    if not p1.isclose(p2):
96        return (p2 - p1).angle
97    else:
98        return radians(text.dxf.rotation)
99
100
101def _get_rotation(text: AnyText) -> Matrix44:
102    if isinstance(text, Text):  # Attrib and AttDef are sub-classes of Text
103        if text.get_align() in ("FIT", "ALIGNED"):
104            rotation = _calc_aligned_rotation(text)
105        else:
106            rotation = radians(text.dxf.rotation)
107        return Matrix44.axis_rotate(text.dxf.extrusion, rotation)
108    elif isinstance(text, MText):
109        return Matrix44.axis_rotate(Vec3(0, 0, 1), radians(text.get_rotation()))
110    else:
111        raise TypeError(type(text))
112
113
114def _get_alignment(text: AnyText) -> Alignment:
115    if isinstance(text, Text):  # Attrib and AttDef are sub-classes of Text
116        return DXF_TEXT_ALIGNMENT_TO_ALIGNMENT[text.get_align()]
117    elif isinstance(text, MText):
118        return DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT[text.dxf.attachment_point]
119    else:
120        raise TypeError(type(text))
121
122
123def _get_cap_height(text: AnyText) -> float:
124    if isinstance(text, (Text, Attrib, AttDef)):
125        return text.dxf.height
126    elif isinstance(text, MText):
127        return text.dxf.char_height
128    else:
129        raise TypeError(type(text))
130
131
132def _get_line_spacing(text: AnyText, cap_height: float) -> float:
133    if isinstance(text, (Attrib, AttDef, Text)):
134        return 0.0
135    elif isinstance(text, MText):
136        return cap_height * DEFAULT_LINE_SPACING * text.dxf.line_spacing_factor
137    else:
138        raise TypeError(type(text))
139
140
141def _split_into_lines(entity: AnyText, box_width: Optional[float],
142                      get_text_width: Callable[[str], float]) -> List[str]:
143    if isinstance(entity, AttDef):
144        # ATTDEF outside of an Insert renders the tag rather than the value
145        text = plain_text(entity.dxf.tag)
146    else:
147        text = entity.plain_text()
148    if isinstance(entity, (Text, Attrib, AttDef)):
149        assert '\n' not in text
150        return [text]
151    else:
152        return text_wrap(text, box_width, get_text_width)
153
154
155def _get_text_width(text: AnyText) -> Optional[float]:
156    if isinstance(text, Text):  # Attrib and AttDef are sub-classes of Text
157        return None
158    elif isinstance(text, MText):
159        width = text.dxf.width
160        return None if width == 0.0 else width
161    else:
162        raise TypeError(type(text))
163
164
165def _get_extra_transform(text: AnyText, line_width: float) -> Matrix44:
166    extra_transform = Matrix44()
167    if isinstance(text, Text):  # Attrib and AttDef are sub-classes of Text
168        # 'width' is the width *scale factor* so 1.0 by default:
169        scale_x = text.dxf.width
170        scale_y = 1
171
172        # Calculate text stretching for FIT and ALIGNED:
173        alignment = text.get_align()
174        line_width = abs(line_width)
175        if alignment in ("FIT", "ALIGNED") and line_width > 1e-9:
176            defined_length = (text.dxf.align_point - text.dxf.insert).magnitude
177            stretch_factor = defined_length / line_width
178            scale_x = stretch_factor
179            if alignment == "ALIGNED":
180                scale_y = stretch_factor
181
182        if text.dxf.text_generation_flag & DXFConstants.MIRROR_X:
183            scale_x *= -1
184        if text.dxf.text_generation_flag & DXFConstants.MIRROR_Y:
185            scale_y *= -1
186
187        # Magnitude of extrusion does not have any effect.
188        # An extrusion of (0, 0, 0) acts like (0, 0, 1)
189        scale_x *= sign(text.dxf.extrusion.z)
190
191        if scale_x != 1 or scale_y != 1:
192            extra_transform = Matrix44.scale(scale_x, scale_y)
193
194    elif isinstance(text, MText):
195        # Not sure about the rationale behind this but it does match AutoCAD
196        # behavior...
197        scale_y = sign(text.dxf.extrusion.z)
198        if scale_y != 1:
199            extra_transform = Matrix44.scale(1, scale_y)
200
201    return extra_transform
202
203
204def _apply_alignment(alignment: Alignment,
205                     line_widths: List[float],
206                     line_spacing: float,
207                     box_width: Optional[float],
208                     font_measurements: FontMeasurements
209                     ) -> Tuple[Tuple[float, float], List[float], List[float]]:
210    if not line_widths:
211        return (0, 0), [], []
212
213    halign, valign = alignment
214    line_ys = [-font_measurements.baseline -
215               (font_measurements.cap_height + i * line_spacing)
216               for i in range(len(line_widths))]
217
218    if box_width is None:
219        box_width = max(line_widths)
220
221    last_baseline = line_ys[-1]
222
223    if halign == HAlignment.LEFT:
224        anchor_x = 0
225        line_xs = [0] * len(line_widths)
226    elif halign == HAlignment.CENTER:
227        anchor_x = box_width / 2
228        line_xs = [anchor_x - w / 2 for w in line_widths]
229    elif halign == HAlignment.RIGHT:
230        anchor_x = box_width
231        line_xs = [anchor_x - w for w in line_widths]
232    else:
233        raise ValueError(halign)
234
235    if valign == VAlignment.TOP:
236        anchor_y = 0
237    elif valign == VAlignment.LOWER_CASE_CENTER:
238        first_line_lower_case_top = line_ys[0] + font_measurements.x_height
239        anchor_y = (first_line_lower_case_top + last_baseline) / 2
240    elif valign == VAlignment.UPPER_CASE_CENTER:
241        first_line_upper_case_top = line_ys[0] + font_measurements.cap_height
242        anchor_y = (first_line_upper_case_top + last_baseline) / 2
243    elif valign == VAlignment.BASELINE:
244        anchor_y = last_baseline
245    elif valign == VAlignment.BOTTOM:
246        anchor_y = last_baseline - font_measurements.descender_height
247    else:
248        raise ValueError(valign)
249
250    return (anchor_x, anchor_y), line_xs, line_ys
251
252
253def _get_wcs_insert(text: AnyText) -> Vec3:
254    if isinstance(text, Text):  # Attrib and AttDef are sub-classes of Text
255        insert: Vec3 = text.dxf.insert
256        align_point: Vec3 = text.dxf.align_point
257        alignment: str = text.get_align()
258        if alignment == "LEFT":
259            # LEFT/BASELINE is always located at the insert point.
260            pass
261        elif alignment in ("FIT", "ALIGNED"):
262            # Interpolate insertion location between insert and align point:
263            insert = insert.lerp(align_point, factor=0.5)
264        else:
265            # Everything else is located at the align point:
266            insert = align_point
267        return text.ocs().to_wcs(insert)
268    else:
269        return text.dxf.insert
270
271
272def simplified_text_chunks(text: AnyText, out: Backend,
273                           *,
274                           font: fonts.FontFace = None,
275                           debug_draw_rect: bool = False
276                           ) -> Iterable[Tuple[str, Matrix44, float]]:
277    """ Splits a complex text entity into simple chunks of text which can all be
278    rendered the same way:
279    render the string (which will not contain any newlines) with the given
280    cap_height with (left, baseline) at (0, 0) then transform it with the given
281    matrix to move it into place.
282    """
283    # TODO: if MTEXT has its own renderer, this function can be simplified to
284    #  render just a single line for TEXT, ATTRIB, ATTDEF.
285    #  MTEXT rendering will slower, but rendering of single-line entities
286    #  will be faster.
287    alignment = _get_alignment(text)
288    box_width = _get_text_width(text)
289
290    cap_height = _get_cap_height(text)
291    lines = _split_into_lines(text, box_width,
292                              lambda s: out.get_text_line_width(s, cap_height,
293                                                                font=font))
294    line_spacing = _get_line_spacing(text, cap_height)
295    line_widths = [out.get_text_line_width(line, cap_height, font=font) for line
296                   in lines]
297    font_measurements = out.get_font_measurements(cap_height, font=font)
298    anchor, line_xs, line_ys = \
299        _apply_alignment(alignment, line_widths, line_spacing, box_width,
300                         font_measurements)
301    rotation = _get_rotation(text)
302
303    # first_line_width is used for TEXT, ATTRIB and ATTDEF stretching
304    if line_widths:
305        first_line_width = line_widths[0]
306    else:  # no text lines -> no output, value is not important
307        first_line_width = 1.0
308
309    extra_transform = _get_extra_transform(text, first_line_width)
310    insert = _get_wcs_insert(text)
311
312    whole_text_transform = (
313            Matrix44.translate(-anchor[0], -anchor[1], 0) @
314            extra_transform @
315            rotation @
316            Matrix44.translate(*insert.xyz)
317    )
318    for i, (line, line_x, line_y) in enumerate(zip(lines, line_xs, line_ys)):
319        transform = Matrix44.translate(line_x, line_y, 0) @ whole_text_transform
320        yield line, transform, cap_height
321
322        if debug_draw_rect:
323            width = out.get_text_line_width(line, cap_height, font)
324            ps = list(transform.transform_vertices(
325                [Vec3(0, 0, 0), Vec3(width, 0, 0), Vec3(width, cap_height, 0),
326                 Vec3(0, cap_height, 0), Vec3(0, 0, 0)]))
327            draw_rect(ps, '#ff0000', out)
328