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