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