Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/tools/text.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

1872 lines
63 KiB
Python

# Copyright (c) 2021-2024, Manfred Moitzi
# License: MIT License
# pylint: disable=consider-using-in
"""
Tools in this module should be as independent of DXF entities as possible!
"""
from __future__ import annotations
from typing import (
Iterable,
Iterator,
TYPE_CHECKING,
Union,
Optional,
Callable,
NamedTuple,
Any,
)
import enum
import re
import math
from ezdxf.lldxf import validator, const
from ezdxf.enums import (
TextEntityAlignment,
TextHAlign,
MTextParagraphAlignment,
MTextLineAlignment,
MTextStroke,
MAP_MTEXT_ALIGN_TO_FLAGS,
)
from ezdxf.lldxf.const import (
LEFT,
CENTER,
RIGHT,
BASELINE,
MIDDLE,
TOP,
MAX_STR_LEN,
)
from ezdxf.math import Vec3, Vec2, UVec
from ezdxf.colors import rgb2int, RGB, int2rgb
if TYPE_CHECKING:
from ezdxf.entities import Text, MText, DXFEntity
from ezdxf.lldxf.tags import Tags
from ezdxf.fonts import fonts
X_MIDDLE = 4 # special case for overall alignment "MIDDLE"
class TextLine:
"""Helper class which represents a single line text entity
(e.g. :class:`~ezdxf.entities.Text`).
Args:
text: content string
font: ezdxf font definition like :class:`~ezdxf.fonts.fonts.MonospaceFont`
or :class:`~ezdxf.fonts.fonts.TrueTypeFont`
"""
def __init__(self, text: str, font: fonts.AbstractFont):
self._font = font
self._text_width: float = font.text_width(text)
self._stretch_x: float = 1.0
self._stretch_y: float = 1.0
def stretch(self, alignment: TextEntityAlignment, p1: Vec3, p2: Vec3) -> None:
"""Set stretch factors for FIT and ALIGNED alignments to fit the
text between `p1` and `p2`, only the distance between these points is
important. Other given `alignment` values are ignore.
"""
assert isinstance(alignment, TextEntityAlignment)
sx: float = 1.0
sy: float = 1.0
if alignment in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED):
defined_length: float = (p2 - p1).magnitude
if self._text_width > 1e-9:
sx = defined_length / self._text_width
if alignment == TextEntityAlignment.ALIGNED:
sy = sx
self._stretch_x = sx
self._stretch_y = sy
@property
def width(self) -> float:
"""Returns the final (stretched) text width."""
return self._text_width * self._stretch_x
@property
def height(self) -> float:
"""Returns the final (stretched) text height."""
return self._font.measurements.total_height * self._stretch_y
def font_measurements(self) -> fonts.FontMeasurements:
"""Returns the scaled font measurements."""
return self._font.measurements.scale(self._stretch_y)
def baseline_vertices(
self,
insert: UVec,
halign: int = 0,
valign: int = 0,
angle: float = 0,
scale: tuple[float, float] = (1, 1),
) -> list[Vec3]:
"""Returns the left and the right baseline vertex of the text line.
Args:
insert: insertion location
halign: horizontal alignment left=0, center=1, right=2
valign: vertical alignment baseline=0, bottom=1, middle=2, top=3
angle: text rotation in radians
scale: scale in x- and y-axis as 2-tuple of float
"""
fm = self.font_measurements()
vertices = [
Vec2(0, fm.baseline),
Vec2(self.width, fm.baseline),
]
shift = self._shift_vector(halign, valign, fm)
# Oblique angle is deliberately not supported, the baseline should be
# (near) the y-coordinate=0.
return TextLine.transform_2d(vertices, insert, shift, angle, scale)
def corner_vertices(
self,
insert: UVec,
halign: int = 0,
valign: int = 0,
angle: float = 0,
scale: tuple[float, float] = (1, 1),
oblique: float = 0,
) -> list[Vec3]:
"""Returns the corner vertices of the text line in the order
bottom left, bottom right, top right, top left.
Args:
insert: insertion location
halign: horizontal alignment left=0, center=1, right=2
valign: vertical alignment baseline=0, bottom=1, middle=2, top=3
angle: text rotation in radians
scale: scale in x- and y-axis as 2-tuple of float
oblique: shear angle (slanting) in x-direction in radians
"""
fm = self.font_measurements()
vertices = [
Vec2(0, fm.bottom),
Vec2(self.width, fm.bottom),
Vec2(self.width, fm.cap_top),
Vec2(0, fm.cap_top),
]
shift = self._shift_vector(halign, valign, fm)
return TextLine.transform_2d(vertices, insert, shift, angle, scale, oblique)
def _shift_vector(
self, halign: int, valign: int, fm: fonts.FontMeasurements
) -> tuple[float, float]:
return _shift_x(self.width, halign), _shift_y(fm, valign)
@staticmethod
def transform_2d(
vertices: Iterable[UVec],
insert: UVec = Vec3(0, 0, 0),
shift: tuple[float, float] = (0, 0),
rotation: float = 0,
scale: tuple[float, float] = (1, 1),
oblique: float = 0,
) -> list[Vec3]:
"""Transform any vertices from the text line located at the base
location at (0, 0) and alignment LEFT.
Args:
vertices: iterable of vertices
insert: insertion location
shift: (shift-x, shift-y) as 2-tuple of float
rotation: text rotation in radians
scale: (scale-x, scale-y) as 2-tuple of float
oblique: shear angle (slanting) in x-direction in radians
"""
# Building a transformation matrix vs. applying transformations in
# individual steps:
# Most text is horizontal, because people like to read horizontal text!
# Operating in 2D is faster than building a full 3D transformation
# matrix and a pure 2D transformation matrix is not implemented!
# This function doesn't transform many vertices at the same time,
# mostly only 4 vertices, therefore the matrix multiplication overhead
# does not pay off.
# The most expensive rotation transformation is the least frequently
# used transformation.
# IMPORTANT: these assumptions are not verified by profiling!
# Use 2D vectors:
vertices_: Iterable[Vec2] = Vec2.generate(vertices)
# 1. slanting at the original location (very rare):
if oblique:
slant_x = math.tan(oblique)
vertices_ = (Vec2(v.x + v.y * slant_x, v.y) for v in vertices_)
# 2. apply alignment shifting (frequently):
shift_vector = Vec2(shift)
if shift_vector:
vertices_ = (v + shift_vector for v in vertices_)
# 3. scale (and mirror) at the aligned location (more often):
scale_x, scale_y = scale
if scale_x != 1 or scale_y != 1:
vertices_ = (Vec2(v.x * scale_x, v.y * scale_y) for v in vertices_)
# 4. apply rotation (rare):
if rotation:
vertices_ = (v.rotate(rotation) for v in vertices_)
# 5. move to insert location in OCS/3D! (every time)
insert = Vec3(insert)
return [insert + v for v in vertices_]
def _shift_x(total_width: float, halign: int) -> float:
if halign == CENTER:
return -total_width / 2.0
if halign == RIGHT:
return -total_width
return 0.0 # LEFT
def _shift_y(fm: fonts.FontMeasurements, valign: int) -> float:
if valign == BASELINE:
return fm.baseline
if valign == MIDDLE:
return -fm.cap_top + fm.cap_height / 2
if valign == X_MIDDLE:
return -fm.cap_top + fm.total_height / 2
if valign == TOP:
return -fm.cap_top
return -fm.bottom
def unified_alignment(entity: Union[Text, MText]) -> tuple[int, int]:
"""Return unified horizontal and vertical alignment.
horizontal alignment: left=0, center=1, right=2
vertical alignment: baseline=0, bottom=1, middle=2, top=3
Returns:
tuple(halign, valign)
"""
dxftype = entity.dxftype()
if dxftype in ("TEXT", "ATTRIB", "ATTDEF"):
halign = entity.dxf.halign
valign = entity.dxf.valign
if halign in (TextHAlign.ALIGNED, TextHAlign.FIT):
# For the alignments ALIGNED and FIT the text stretching has to be
# handles separately.
halign = CENTER
valign = BASELINE
elif halign == TextHAlign.MIDDLE: # MIDDLE is different to MIDDLE/CENTER
halign = CENTER
valign = X_MIDDLE
return halign, valign
if dxftype == "MTEXT":
return MAP_MTEXT_ALIGN_TO_FLAGS.get(entity.dxf.attachment_point, (LEFT, TOP))
raise TypeError(f"invalid DXF {dxftype}")
def plain_text(text: str) -> str:
"""Returns the plain text for :class:`~ezdxf.entities.Text`,
:class:`~ezdxf.entities.Attrib` and :class:`~ezdxf.entities.Attdef` content.
"""
# TEXT, ATTRIB and ATTDEF are short strings <= 255 in R12.
# R2000 allows 2049 chars, but this limit is not often used in real world
# applications.
result = ""
scanner = TextScanner(validator.fix_one_line_text(caret_decode(text)))
while scanner.has_data:
char = scanner.peek()
if char == "%": # special characters
if scanner.peek(1) == "%":
code = scanner.peek(2).lower()
letter = const.SPECIAL_CHAR_ENCODING.get(code)
if letter:
scanner.consume(3) # %%?
result += letter
continue
if code in "kou":
# formatting codes (%%k, %%o, %%u) will be ignored in
# TEXT, ATTRIB and ATTDEF:
scanner.consume(3)
continue
scanner.consume(1)
# slightly faster then "".join(chars)
result += char
return result
ONE_CHAR_COMMANDS = "PNLlOoKkX"
##################################################
# MTEXT inline codes
# \L Start underline
# \l Stop underline
# \O Start overline
# \o Stop overline
# \K Start strike-through
# \k Stop strike-through
# \P New paragraph (new line)
# \N New column
# \~ None breaking space
# ^I Tabulator
# \ Escape character - e.g. \\ = "\", \{ = "{"
#
# \p start paragraph properties until next ";"
# \pi#,l#,r#; paragraph indent
# i# indent first line left, relative to (l)!
# l# indent paragraph left
# r# indent paragraph right
# q? alignments:
# ql align text in paragraph: left
# qr align text in paragraph: right
# qc align text in paragraph: center
# qj align text in paragraph: justified
# qd align text in paragraph: distributed
# x unknown meaning
# t#[,c#,r#...] define absolute tabulator stops 1,c2,r3...
# without prefix is a left adjusted tab stop
# prefix 'c' for center adjusted tab stop
# prefix 'r' for right adjusted tab stop
# ?* reset command to default value
#
# Examples:
# \pi1,t[5,20,...]; define tab stops as comma separated list
# \pxt4,c8,r12,16,c20,r24; left, centered and right adjusted tab stops
# \pi*,l*,r*,q*,t; reset to default values
# \pi2,l0; = first line 2 & paragraph left 0
# \pi-2,l2; = first line -2 & paragraph left 2
# \pi0,l2; = first line 0 & paragraph left 2
#
# \X Paragraph wrap on the dimension line (only in dimensions)
# \Q Slanting (oblique) text by angle - e.g. \Q30;
# \H Text height relative - e.g. \H3x;
# \H Text height absolute - e.g. \H3;
# \W Text width factor relative e.g. \W0.8x;
# \W Text width factor absolute e.g. \W0.8;
# \F Font selection
# \f Font selection
#
# e.g. \Fgdt;o - GDT-tolerance
# e.g. \fArial|b0|i0|c238|p10; - font Arial, non-bold, non-italic,
# codepage 238, pitch 10
# codepage 0 = no change
# pitch 0 = no change
#
# \S Stacking, fractions
#
# e.g. \SA^ B;
# A
# B
# e.g. \SX/ Y;
# X
# -
# Y
# e.g. \S1# 4;
# 1/4
#
# \A Alignment relative to current line
#
# \A0; = bottom
# \A1; = center
# \A2; = top
#
# \C Color change
#
# \C1; = red
# \C2; = yellow
# \C3; = green
# \C4; = cyan
# \C5; = blue
# \C6; = magenta
# \C7; = white
# \c255; = RED (255, 0, 0)
# \c65280; = GREEN (0, 255, 0)
# \c16711680; = BLUE (0, 0, 255)
# RGB color = \c7528479; = 31,224,114
# ezdxf.rgb2int((31,224,114)) = 2089074 (r,g,b) wrong!
# ezdxf.rgb2int((114,224,31)) = 7528479 (b,g,r) reversed order is correct!
#
# \T Tracking, char spacing factor as absolute value e.g. \T2;
# \T Tracking, char spacing factor as relative value e.g. \T2x;
# {} Braces - define the text area influenced by the code
# Multiple codes after the opening brace are valid until the closing
# brace. e.g. {\H0.4x;\A1;small centered text}
#
# Codes and braces can be nested up to 8 levels deep
#
# Column types in BricsCAD:
# - dynamic auto height: all columns have the same height
# - dynamic manual height: each column has an individual height
# - no columns
# - static: all columns have the same height, like dynamic auto height,
# difference is only important for user interaction in CAD applications
#
# - All columns have the same width and gutter.
# - Paragraphs do overflow into the next column if required.
# pylint: disable-next=too-many-branches
def fast_plain_mtext(text: str, split=False) -> Union[list[str], str]:
"""Returns the plain MTEXT content as a single string or a list of
strings if `split` is ``True``. Replaces ``\\P`` by ``\\n`` and removes
other controls chars and inline codes.
This function is more than 4x faster than :func:`plain_mtext`, but does not
remove single letter inline commands with arguments without a terminating
semicolon like this ``"\\C1red text"``.
.. note::
Well behaved CAD applications and libraries always create inline codes
for commands with arguments with a terminating semicolon like this
``"\\C1;red text"``!
Args:
text: MTEXT content string
split: split content at line endings ``\\P``
"""
chars = []
# split text into chars, in reversed order for efficient pop()
raw_chars = list(reversed(caret_decode(text)))
# pylint: disable=too-many-nested-blocks
while raw_chars:
char = raw_chars.pop()
if char == "\\": # is a formatting command
try:
char = raw_chars.pop()
except IndexError:
break # premature end of text - just ignore
if char in "\\{}":
chars.append(char)
elif char in ONE_CHAR_COMMANDS:
if char == "P": # new line
chars.append("\n")
elif char == "N": # new column
# until columns are supported, better to at least remove the
# escape character
chars.append(" ")
# else: discard other commands
else: # multiple character commands are terminated by ';'
stacking = char == "S" # stacking command surrounds user data
first_char = char
search_chars = raw_chars.copy()
try:
while char != ";": # end of format marker
char = search_chars.pop()
if stacking and char != ";":
# append user data of stacking command
chars.append(char)
raw_chars = search_chars
except IndexError:
# premature end of text - just ignore
chars.append("\\")
chars.append(first_char)
elif char in "{}": # grouping
pass # discard group markers
elif char == "%": # special characters
if raw_chars and raw_chars[-1] == "%":
raw_chars.pop() # discard next '%'
if raw_chars:
code = raw_chars.pop()
letter = const.SPECIAL_CHAR_ENCODING.get(code.lower())
if letter:
chars.append(letter)
else:
chars.extend(("%", "%", code))
else: # char is just a single '%'
chars.append(char)
else: # char is what it is, a character
chars.append(char)
result = "".join(chars)
return result.split("\n") if split else result
def caret_decode(text: str) -> str:
"""DXF stores some special characters using caret notation. This function
decodes this notation to normalize the representation of special characters
in the string.
see: https://en.wikipedia.org/wiki/Caret_notation
"""
def replace_match(match: re.Match) -> str:
c = ord(match.group(1))
return chr((c - 64) % 126)
return re.sub(r"\^(.)", replace_match, text)
def split_mtext_string(s: str, size: int = 250) -> list[str]:
"""Split the MTEXT content string into chunks of max `size`."""
chunks = []
pos = 0
while True:
chunk = s[pos : pos + size]
if len(chunk):
if len(chunk) < size:
chunks.append(chunk)
return chunks
pos += size
# do not split chunks at '^'
if chunk[-1] == "^":
chunk = chunk[:-1]
pos -= 1
chunks.append(chunk)
else:
return chunks
def plain_mtext(
text: str,
split=False,
tabsize: int = 4,
) -> Union[list[str], str]:
"""Returns the plain MTEXT content as a single string or a list of
strings if `split` is ``True``. Replaces ``\\P`` by ``\\n`` and removes
other controls chars and inline codes.
This function is much slower than :func:`fast_plain_mtext`, but removes all
inline codes.
Args:
text: MTEXT content string
split: split content at line endings ``\\P``
tabsize: count of replacement spaces for tabulators ``^I``
"""
content: list[str] = []
paragraph: list[str] = []
# localize enum to speed up inner loop
(
_,
word,
stack,
space,
nbsp,
tabulator,
new_paragraph,
new_column,
*_,
) = iter(TokenType)
tab_replacement = " " * tabsize
# pylint: disable=consider-using-in
for token in MTextParser(text):
t = token.type
if t == word:
paragraph.append(token.data)
elif t == space or t == nbsp:
paragraph.append(" ")
elif t == new_paragraph or t == new_column:
content.append("".join(paragraph))
paragraph.clear()
elif t == tabulator:
paragraph.append(tab_replacement)
elif t == stack:
upr, lwr, divider = token.data
paragraph.append(upr + divider + lwr)
if paragraph:
content.append("".join(paragraph))
if split:
return content
return "\n".join(content)
def escape_dxf_line_endings(text: str) -> str:
# replacing '\r\n' and '\n' by '\P' is required when exporting, else an
# invalid DXF file would be created.
return text.replace("\r", "").replace("\n", "\\P")
def replace_non_printable_characters(text: str, replacement: str = "") -> str:
return "".join(replacement if is_non_printable_char(c) else c for c in text)
def is_non_printable_char(char: str) -> bool:
return 0 <= ord(char) < 32 and char != "\t"
def text_wrap(
text: str,
box_width: Optional[float],
get_text_width: Callable[[str], float],
) -> list[str]:
"""Wrap text at ``\\n`` and given `box_width`. This tool was developed for
usage with the MTEXT entity. This isn't the most straightforward word
wrapping algorithm, but it aims to match the behavior of AutoCAD.
Args:
text: text to wrap, included ``\\n`` are handled as manual line breaks
box_width: wrapping length, ``None`` to just wrap at ``\\n``
get_text_width: callable which returns the width of the given string
"""
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
if not text or text.isspace():
return []
manual_lines = re.split(r"(\n)", text) # includes \n as its own token
tokens = [t for line in manual_lines for t in re.split(r"(\s+)", line) if t]
lines: list[str] = []
current_line: str = ""
line_just_wrapped = False
for t in tokens:
on_first_line = not lines
if t == "\n" and line_just_wrapped:
continue
line_just_wrapped = False
if t == "\n":
lines.append(current_line.rstrip())
current_line = ""
elif t.isspace():
if current_line or on_first_line:
current_line += t
else:
if box_width is not None and get_text_width(current_line + t) > box_width:
if not current_line:
current_line += t
else:
lines.append(current_line.rstrip())
current_line = t
line_just_wrapped = True
else:
current_line += t
if current_line and not current_line.isspace():
lines.append(current_line.rstrip())
return lines
def is_text_vertical_stacked(text: DXFEntity) -> bool:
"""Returns ``True`` if the associated text :class:`~ezdxf.entities.Textstyle`
is vertical stacked.
"""
if not text.is_supported_dxf_attrib("style"):
raise TypeError(f"{text.dxftype()} does not support the style attribute.")
if text.doc:
style = text.doc.styles.get(text.dxf.style)
if style:
return style.is_vertical_stacked
return False
_alignment_char = {
MTextParagraphAlignment.DEFAULT: "",
MTextParagraphAlignment.LEFT: "l",
MTextParagraphAlignment.RIGHT: "r",
MTextParagraphAlignment.CENTER: "c",
MTextParagraphAlignment.JUSTIFIED: "j",
MTextParagraphAlignment.DISTRIBUTED: "d",
}
COMMA = ","
DIGITS = "01234567890"
def rstrip0(s: str) -> str:
if isinstance(s, (int, float)):
return f"{s:g}"
return s
class ParagraphProperties(NamedTuple):
"""Stores all known MTEXT paragraph properties in a :class:`NamedTuple`.
Indentations and tab stops are multiples of the default text height
:attr:`MText.dxf.char_height`. E.g. :attr:`char_height` is 0.25 and
:attr:`indent` is 4, the real indentation is 4 x 0.25 = 1 drawing unit.
The default tabulator stops are 4, 8, 12, ... if no tabulator stops are
explicit defined.
Args:
indent (float): left indentation of the first line, relative to :attr:`left`,
which means an :attr:`indent` of 0 has always the same indentation
as :attr:`left`
left (float): left indentation of the paragraph except for the first line
right (float): left indentation of the paragraph
align: :class:`~ezdxf.lldxf.const.MTextParagraphAlignment` enum
tab_stops: tuple of tabulator stops, as ``float`` or as ``str``,
``float`` values are left aligned tab stops, strings with prefix
``"c"`` are center aligned tab stops and strings with prefix ``"r"``
are right aligned tab stops
"""
# Reset: \pi*,l*,r*,q*,t;
indent: float = 0 # relative to left!
left: float = 0
right: float = 0
align: MTextParagraphAlignment = MTextParagraphAlignment.DEFAULT
# tab stops without prefix or numbers are left adjusted
# tab stops, e.g 2 or '2'
# prefix 'c' defines a center adjusted tab stop e.g. 'c3.5'
# prefix 'r' defines a right adjusted tab stop e.g. 'r2.7'
# The tab stop in drawing units = n x char_height
tab_stops: tuple = tuple()
def tostring(self) -> str:
"""Returns the MTEXT paragraph properties as MTEXT inline code
e.g. ``"\\pxi-2,l2;"``.
"""
args = []
if self.indent:
args.append(f"i{self.indent:g}")
args.append(COMMA)
if self.left:
args.append(f"l{self.left:g}")
args.append(COMMA)
if self.right:
args.append(f"r{self.right:g}")
args.append(COMMA)
if self.align:
args.append(f"q{_alignment_char[self.align]}")
args.append(COMMA)
if self.tab_stops:
args.append(f"t{COMMA.join(map(rstrip0, self.tab_stops))}")
args.append(COMMA)
if args:
if args[-1] == COMMA:
args.pop()
# exporting always "x" as second letter seems to be safe
return "\\px" + "".join(args) + ";"
return ""
# IMPORTANT for parsing MTEXT inline codes: "\\H0.1\\A1\\C1rot"
# Inline commands with a single argument, don't need a trailing ";"!
class MTextEditor:
"""The :class:`MTextEditor` is a helper class to build MTEXT content
strings with support for inline codes to change color, font or
paragraph properties. The result is always accessible by the :attr:`text`
attribute or the magic :func:`__str__` function as
:code:`str(MTextEditor("text"))`.
All text building methods return `self` to implement a floating interface::
e = MTextEditor("This example ").color("red").append("switches color to red.")
mtext = msp.add_mtext(str(e))
The initial text height, color, text style and so on is determined by the
DXF attributes of the :class:`~ezdxf.entities.MText` entity.
.. warning::
The :class:`MTextEditor` assembles just the inline code, which has to be
parsed and rendered by the target CAD application, `ezdxf` has no influence
to that result.
Keep inline formatting as simple as possible, don't test the limits of its
capabilities, this will not work across different CAD applications and keep
the formatting in a logic manner like, do not change paragraph properties
in the middle of a paragraph.
**There is no official documentation for the inline codes!**
Args:
text: init value of the MTEXT content string.
"""
def __init__(self, text: str = ""):
self.text = str(text)
NEW_LINE = r"\P"
NEW_PARAGRAPH = r"\P"
NEW_COLUMN = r"\N"
UNDERLINE_START = r"\L"
UNDERLINE_STOP = r"\l"
OVERSTRIKE_START = r"\O"
OVERSTRIKE_STOP = r"\o"
STRIKE_START = r"\K"
STRIKE_STOP = r"\k"
GROUP_START = "{"
GROUP_END = "}"
ALIGN_BOTTOM = r"\A0;"
ALIGN_MIDDLE = r"\A1;"
ALIGN_TOP = r"\A2;"
NBSP = r"\~" # non-breaking space
TAB = "^I"
def append(self, text: str) -> MTextEditor:
"""Append `text`."""
self.text += text
return self
def __iadd__(self, text: str) -> MTextEditor:
r"""
Append `text`::
e = MTextEditor("First paragraph.\P")
e += "Second paragraph.\P")
"""
self.text += text
return self
def __str__(self) -> str:
"""Returns the MTEXT content attribute :attr:`text`."""
return self.text
def clear(self):
"""Reset the content to an empty string."""
self.text = ""
def font(self, name: str, bold: bool = False, italic: bool = False) -> MTextEditor:
"""Set the text font by the font family name. Changing the font height
should be done by the :meth:`height` or the :meth:`scale_height` method.
The font family name is the name shown in font selection widgets in
desktop applications: "Arial", "Times New Roman", "Comic Sans MS".
Switching the codepage is not supported.
Args:
name: font family name
bold: flag
italic: flag
"""
# c0 = current codepage
# The current implementation of ezdxf writes everything in one
# encoding, defined by $DWGCODEPAGE < DXF R2007 or utf8 for DXF R2007+
# Switching codepage makes no sense!
# p0 = current text size;
# Text size should be changed by \H<factor>x;
return self.append(rf"\f{name}|b{int(bold)}|i{int(italic)};")
def scale_height(self, factor: float) -> MTextEditor:
"""Scale the text height by a `factor`. This scaling will accumulate,
which means starting at height 2.5 and scaling by 2 and again by 3 will
set the text height to 2.5 x 2 x 3 = 15. The current text height is not
stored in the :class:`MTextEditor`, you have to track the text height by
yourself! The initial text height is stored in the
:class:`~ezdxf.entities.MText` entity as DXF attribute
:class:`~ezdxf.entities.MText.dxf.char_height`.
"""
return self.append(rf"\H{round(factor, 3)}x;")
def height(self, height: float) -> MTextEditor:
"""Set the absolute text height in drawing units."""
return self.append(rf"\H{round(height, 3)};")
def width_factor(self, factor: float) -> MTextEditor:
"""Set the absolute text width factor."""
return self.append(rf"\W{round(factor, 3)};")
def char_tracking_factor(self, factor: float) -> MTextEditor:
"""Set the absolute character tracking factor."""
return self.append(rf"\T{round(factor, 3)};")
def oblique(self, angle: int) -> MTextEditor:
"""Set the text oblique angle in degrees, vertical is 0, a value of 15
will lean the text 15 degree to the right.
"""
return self.append(rf"\Q{int(angle)};")
def color(self, name: str) -> MTextEditor:
"""Set the text color by color name: "red", "yellow", "green", "cyan",
"blue", "magenta" or "white".
"""
return self.aci(const.MTEXT_COLOR_INDEX[name.lower()])
def aci(self, aci: int) -> MTextEditor:
"""Set the text color by :ref:`ACI` in range [0, 256]."""
if 0 <= aci <= 256:
return self.append(rf"\C{aci};")
raise ValueError("aci not in range [0, 256]")
def rgb(self, rgb: RGB) -> MTextEditor:
"""Set the text color as RGB value."""
r, g, b = rgb
return self.append(rf"\c{rgb2int((b, g, r))};")
def stack(self, upr: str, lwr: str, t: str = "^") -> MTextEditor:
r"""Append stacked text `upr` over `lwr`, argument `t` defines the
kind of stacking, the space " " after the "^" will be added
automatically to avoid caret decoding:
.. code-block:: none
"^": vertical stacked without divider line, e.g. \SA^ B:
A
B
"/": vertical stacked with divider line, e.g. \SX/Y:
X
-
Y
"#": diagonal stacked, with slanting divider line, e.g. \S1#4:
1/4
"""
if t not in "^/#":
raise ValueError(f"invalid type symbol: {t}")
# space " " after "^" is required to avoid caret decoding
if t == "^":
t += " "
return self.append(rf"\S{upr}{t}{lwr};")
def group(self, text: str) -> MTextEditor:
"""Group `text`, all properties changed inside a group are reverted at
the end of the group. AutoCAD supports grouping up to 8 levels.
"""
return self.append(f"{{{text}}}")
def underline(self, text: str) -> MTextEditor:
"""Append `text` with a line below the text."""
return self.append(rf"\L{text}\l")
def overline(self, text: str) -> MTextEditor:
"""Append `text` with a line above the text."""
return self.append(rf"\O{text}\o")
def strike_through(self, text: str) -> MTextEditor:
"""Append `text` with a line through the text."""
return self.append(rf"\K{text}\k")
def paragraph(self, props: ParagraphProperties) -> MTextEditor:
"""Set paragraph properties by a :class:`ParagraphProperties` object."""
return self.append(props.tostring())
def bullet_list(
self, indent: float, bullets: Iterable[str], content: Iterable[str]
) -> MTextEditor:
"""Build bulleted lists by utilizing paragraph indentation and a
tabulator stop. Any string can be used as bullet. Indentation is
a multiple of the initial MTEXT char height (see also docs about
:class:`ParagraphProperties`), which means indentation in
drawing units is :attr:`MText.dxf.char_height` x `indent`.
Useful UTF bullets:
- "bull" U+2022 = • (Alt Numpad 7)
- "circle" U+25CB = ○ (Alt Numpad 9)
For numbered lists just use numbers as bullets::
MTextEditor.bullet_list(
indent=2,
bullets=["1.", "2."],
content=["first", "second"],
)
Args:
indent: content indentation as multiple of the initial MTEXT char height
bullets: iterable of bullet strings, e.g. :code:`["-"] * 3`,
for 3 dashes as bullet strings
content: iterable of list item strings, one string per list item,
list items should not contain new line or new paragraph commands.
"""
items = MTextEditor().paragraph(
ParagraphProperties(
indent=-indent * 0.75, # like BricsCAD
left=indent,
tab_stops=(indent,),
)
)
items.append(
"".join(
b + self.TAB + c + self.NEW_PARAGRAPH for b, c in zip(bullets, content)
)
)
return self.group(str(items))
class UnknownCommand(Exception):
pass
class MTextContext:
"""Internal class to store the MTEXT context state."""
def __init__(self) -> None:
from ezdxf.fonts import fonts
self._stroke: int = 0
self.continue_stroke: bool = False
self._aci = 7 # used if rgb is None
self.rgb: Optional[RGB] = None # overrules aci
self.align = MTextLineAlignment.BOTTOM
self.font_face = fonts.FontFace() # is immutable
self.cap_height: float = 1.0
self.width_factor: float = 1.0
self.char_tracking_factor: float = 1.0
self.oblique: float = 0.0 # in degrees, where 0 is vertical (TEXT entity)
self.paragraph = ParagraphProperties()
def __copy__(self) -> MTextContext:
p = MTextContext()
p._stroke = self._stroke
p.continue_stroke = self.continue_stroke
p._aci = self._aci
p.rgb = self.rgb
p.align = self.align
p.font_face = self.font_face # is immutable
p.cap_height = self.cap_height
p.width_factor = self.width_factor
p.char_tracking_factor = self.char_tracking_factor
p.oblique = self.oblique
p.paragraph = self.paragraph # is immutable
return p
copy = __copy__
def __hash__(self):
return hash(
(
self._stroke,
self.continue_stroke,
self._aci,
self.rgb,
self.align,
self.font_face,
self.cap_height,
self.width_factor,
self.char_tracking_factor,
self.oblique,
self.paragraph,
)
)
def __eq__(self, other) -> bool:
return hash(self) == hash(other)
@property
def aci(self) -> int:
return self._aci
@aci.setter
def aci(self, aci: int):
if 0 <= aci <= 256:
self._aci = aci
self.rgb = None # clear rgb
else:
raise ValueError("aci not in range[0,256]")
def _set_stroke_state(self, stroke: MTextStroke, state: bool = True) -> None:
"""Set/clear binary `stroke` flag in `self._stroke`.
Args:
stroke: set/clear stroke flag
state: ``True`` for setting, ``False`` for clearing
"""
if state:
self._stroke |= stroke
else:
self._stroke &= ~stroke
@property
def underline(self) -> bool:
return bool(self._stroke & MTextStroke.UNDERLINE)
@underline.setter
def underline(self, value: bool) -> None:
self._set_stroke_state(MTextStroke.UNDERLINE, value)
@property
def strike_through(self) -> bool:
return bool(self._stroke & MTextStroke.STRIKE_THROUGH)
@strike_through.setter
def strike_through(self, value: bool) -> None:
self._set_stroke_state(MTextStroke.STRIKE_THROUGH, value)
@property
def overline(self) -> bool:
return bool(self._stroke & MTextStroke.OVERLINE)
@overline.setter
def overline(self, value: bool) -> None:
self._set_stroke_state(MTextStroke.OVERLINE, value)
@property
def has_any_stroke(self) -> bool:
return bool(self._stroke)
class TextScanner:
__slots__ = ("_text", "_text_len", "_index")
def __init__(self, text: str):
self._text = str(text)
self._text_len = len(self._text)
self._index = 0
@property
def is_empty(self) -> bool:
return self._index >= self._text_len
@property
def has_data(self) -> bool:
return self._index < self._text_len
def get(self) -> str:
try:
char = self._text[self._index]
self._index += 1
except IndexError:
return ""
return char
def consume(self, count: int = 1) -> None:
if count < 1:
raise ValueError(count)
self._index += count
def fast_consume(self, count: int = 1) -> None:
"""consume() without safety check"""
self._index += count
def peek(self, offset: int = 0) -> str:
if offset < 0:
raise ValueError(offset)
try:
return self._text[self._index + offset]
except IndexError:
return ""
def fast_peek(self, offset: int = 0) -> str:
"""peek() without safety check"""
try:
return self._text[self._index + offset]
except IndexError:
return ""
def find(self, char: str, escape=False) -> int:
"""Return the index of the next `char`. If `escape` is True, backslash
escaped `chars` will be ignored e.g. "\\;;" index of the next ";" is 1
if `escape` is False and 2 if `escape` is True. Returns -1 if `char`
was not found.
Args:
char: single letter as string
escape: ignore backslash escaped chars if True.
"""
scanner = self.__class__(self._text[self._index :])
while scanner.has_data:
c = scanner.peek()
if escape and c == "\\" and scanner.peek(1) == char:
scanner.consume(2)
continue
if c == char:
return self._index + scanner._index # pylint: disable=w0212
scanner.consume(1)
return -1
def substr(self, stop: int) -> str:
"""Returns the substring from the current location until index < stop."""
if stop < self._index:
raise IndexError(stop)
return self._text[self._index : stop]
def tail(self) -> str:
"""Returns the unprocessed part of the content."""
return self._text[self._index :]
def index(self) -> int:
return self._index
def substr2(self, start: int, stop: int) -> str:
return self._text[start:stop]
class TokenType(enum.IntEnum):
NONE = 0
WORD = 1 # data = str
STACK = 2 # data = tuple[upr: str, lwr:str, type:str]
SPACE = 3 # data = None
NBSP = 4 # data = None
TABULATOR = 5 # data = None
NEW_PARAGRAPH = 6 # data = None
NEW_COLUMN = 7 # data = None
WRAP_AT_DIMLINE = 8 # data = None
PROPERTIES_CHANGED = 9 # data = full command string e.g. "\H150;"
class MTextToken:
__slots__ = ("type", "ctx", "data")
def __init__(self, t: TokenType, ctx: MTextContext, data=None):
self.type: TokenType = t
self.ctx: MTextContext = ctx
self.data = data
RE_FLOAT = re.compile(r"[+-]?\d+(:?\.\d*)?(:?[eE][+-]?\d+)?")
RE_FLOAT_X = re.compile(r"[+-]?\d+(:?\.\d*)?(:?[eE][+-]?\d+)?([x]?)")
CHAR_TO_ALIGN = {
"l": MTextParagraphAlignment.LEFT,
"r": MTextParagraphAlignment.RIGHT,
"c": MTextParagraphAlignment.CENTER,
"j": MTextParagraphAlignment.JUSTIFIED,
"d": MTextParagraphAlignment.DISTRIBUTED,
}
class MTextParser:
"""Parses the MText content string and yields the content as tokens and
the current MText properties as MTextContext object. The context object is
treated internally as immutable object and should be treated by the client
the same way.
The parser works as iterator and yields MTextToken objects.
Args:
content: MText content string
ctx: initial MText context
yield_property_commands: yield commands that change properties or context,
default is ``False``
"""
def __init__(
self,
content: str,
ctx: Optional[MTextContext] = None,
yield_property_commands=False,
):
if ctx is None:
ctx = MTextContext()
self.ctx = ctx
self.scanner = TextScanner(caret_decode(content))
self._ctx_stack: list[MTextContext] = []
self._continue_stroke = False
self._yield_property_commands = bool(yield_property_commands)
def __iter__(self) -> Iterator[MTextToken]:
return self.parse()
def push_ctx(self) -> None:
self._ctx_stack.append(self.ctx)
def pop_ctx(self) -> None:
if self._ctx_stack:
self.ctx = self._ctx_stack.pop()
def parse(self) -> Iterator[MTextToken]:
# pylint: disable=too-many-statements
# localize method calls
scanner = self.scanner
consume = scanner.fast_consume
peek = scanner.fast_peek
followup_token: Optional[TokenType] = None
space_token = TokenType.SPACE
word_token = TokenType.WORD
def word_and_token(word, token):
nonlocal followup_token
consume()
if word:
followup_token = token
return word_token, word
return token, None
def next_token() -> tuple[TokenType, Any]:
# pylint: disable=too-many-return-statements,too-many-branches,too-many-nested-blocks
word: str = ""
while scanner.has_data:
escape = False
letter = peek()
cmd_start_index = scanner.index()
if letter == "\\":
# known escape sequences: "\\", "\{", "\}"
if peek(1) in "\\{}":
escape = True
consume() # leading backslash
letter = peek()
else:
# A non escaped backslash is always the end of a word.
if word:
# Do not consume backslash!
return word_token, word
consume() # leading backslash
cmd = scanner.get()
if cmd == "~":
return TokenType.NBSP, None
if cmd == "P":
return TokenType.NEW_PARAGRAPH, None
if cmd == "N":
return TokenType.NEW_COLUMN, None
if cmd == "X":
return TokenType.WRAP_AT_DIMLINE, None
if cmd == "S":
return self.parse_stacking()
if cmd:
try:
self.parse_properties(cmd)
except UnknownCommand:
# print invalid escaped letters verbatim
word += letter + cmd
else:
if self._yield_property_commands:
return (
TokenType.PROPERTIES_CHANGED,
scanner.substr2(
cmd_start_index, scanner.index()
),
)
continue
# process control chars, caret decoding is already done!
if letter < " ":
if letter == "\t":
return word_and_token(word, TokenType.TABULATOR)
if letter == "\n": # LF
return word_and_token(word, TokenType.NEW_PARAGRAPH)
# replace other control chars by a space
letter = " "
elif letter == "%" and peek(1) == "%":
code = peek(2).lower()
special_char = const.SPECIAL_CHAR_ENCODING.get(code)
if special_char:
consume(2) # %%
letter = special_char
if letter == " ":
return word_and_token(word, space_token)
if not escape:
if letter == "{":
if word:
return word_token, word
else:
consume(1)
self.push_ctx()
continue
elif letter == "}":
if word:
return word_token, word
else:
consume(1)
self.pop_ctx()
continue
# any unparsed unicode letter can be used in a word
consume()
if letter >= " ":
word += letter
if word:
return word_token, word
else:
return TokenType.NONE, None
while True:
type_, data = next_token()
if type_:
yield MTextToken(type_, self.ctx, data)
if followup_token:
yield MTextToken(followup_token, self.ctx, None)
followup_token = None
else:
break
def parse_stacking(self) -> tuple[TokenType, Any]:
"""Returns a tuple of strings: (numerator, denominator, type).
The numerator and denominator is always a single and can contain spaces,
which are not decoded as separate tokens. The type string is "^" for
a limit style fraction without a line, "/" for a horizontal fraction
and "#" for a diagonal fraction. If the expression does not contain
any stacking type char, the type and denominator string are empty "".
"""
def peek_char():
c = stacking_scanner.peek()
if ord(c) < 32: # replace all control chars by space
c = " "
return c
def get_next_char():
escape = False
c = peek_char()
# escape sequences: remove backslash and return next char
if c == "\\":
escape = True
stacking_scanner.consume(1)
c = peek_char()
stacking_scanner.consume(1)
return c, escape
def parse_numerator() -> tuple[str, str]:
word = ""
while stacking_scanner.has_data:
c, escape = get_next_char()
if not escape and c in "^/#": # scan until stacking type char
return word, c
word += c
return word, ""
def parse_denominator() -> str:
word = ""
while stacking_scanner.has_data:
word += get_next_char()[0]
return word
stacking_scanner = TextScanner(self.extract_expression(escape=True))
numerator, stacking_type = parse_numerator()
denominator = parse_denominator() if stacking_type else ""
return TokenType.STACK, (numerator, denominator, stacking_type)
def parse_properties(self, cmd: str) -> None:
# pylint: disable=too-many-branches
# Treat the existing context as immutable, create a new one:
new_ctx = self.ctx.copy()
if cmd == "L":
new_ctx.underline = True
self._continue_stroke = True
elif cmd == "l":
new_ctx.underline = False
if not new_ctx.has_any_stroke:
self._continue_stroke = False
elif cmd == "O":
new_ctx.overline = True
self._continue_stroke = True
elif cmd == "o":
new_ctx.overline = False
if not new_ctx.has_any_stroke:
self._continue_stroke = False
elif cmd == "K":
new_ctx.strike_through = True
self._continue_stroke = True
elif cmd == "k":
new_ctx.strike_through = False
if not new_ctx.has_any_stroke:
self._continue_stroke = False
elif cmd == "A":
self.parse_align(new_ctx)
elif cmd == "C":
self.parse_aci_color(new_ctx)
elif cmd == "c":
self.parse_rgb_color(new_ctx)
elif cmd == "H":
self.parse_height(new_ctx)
elif cmd == "W":
self.parse_width(new_ctx)
elif cmd == "Q":
self.parse_oblique(new_ctx)
elif cmd == "T":
self.parse_char_tracking(new_ctx)
elif cmd == "p":
self.parse_paragraph_properties(new_ctx)
elif cmd == "f" or cmd == "F":
self.parse_font_properties(new_ctx)
else:
raise UnknownCommand(f"unknown command: {cmd}")
new_ctx.continue_stroke = self._continue_stroke
self.ctx = new_ctx
def parse_align(self, ctx: MTextContext):
char = self.scanner.get() # always consume next char
if char in "012":
ctx.align = MTextLineAlignment(int(char))
else:
ctx.align = MTextLineAlignment.BOTTOM
self.consume_optional_terminator()
def parse_height(self, ctx: MTextContext):
ctx.cap_height = self.parse_float_value_or_factor(ctx.cap_height)
self.consume_optional_terminator()
def parse_width(self, ctx: MTextContext):
ctx.width_factor = self.parse_float_value_or_factor(ctx.width_factor)
self.consume_optional_terminator()
def parse_char_tracking(self, ctx: MTextContext):
ctx.char_tracking_factor = self.parse_float_value_or_factor(
ctx.char_tracking_factor
)
self.consume_optional_terminator()
def parse_float_value_or_factor(self, value) -> float:
expr = self.extract_float_expression(relative=True)
if expr:
if expr.endswith("x"):
factor = float(expr[:-1])
value *= abs(factor)
else:
value = abs(float(expr))
return value
def parse_oblique(self, ctx: MTextContext):
oblique_expr = self.extract_float_expression(relative=False)
if oblique_expr:
ctx.oblique = float(oblique_expr)
self.consume_optional_terminator()
def parse_aci_color(self, ctx: MTextContext):
aci_expr = self.extract_int_expression()
if aci_expr:
aci = int(aci_expr)
if aci < 257:
ctx.aci = aci
ctx.rgb = None
self.consume_optional_terminator()
def parse_rgb_color(self, ctx: MTextContext):
rgb_expr = self.extract_int_expression()
if rgb_expr:
# in reversed order!
b, g, r = int2rgb(int(rgb_expr) & 0xFFFFFF)
ctx.rgb = RGB(r, g, b)
self.consume_optional_terminator()
def extract_float_expression(self, relative=False) -> str:
result = ""
tail = self.scanner.tail()
pattern = RE_FLOAT_X if relative else RE_FLOAT
match = re.match(pattern, tail)
if match:
start, end = match.span()
result = tail[start:end]
self.scanner.consume(end)
return result
def extract_int_expression(self) -> str:
result = ""
tail = self.scanner.tail()
match = re.match(r"\d+", tail)
if match:
start, end = match.span()
result = tail[start:end]
self.scanner.consume(end)
return result
def extract_expression(self, escape=False) -> str:
"""Returns the next expression from the current location until
the terminating ";". The terminating semicolon is not included.
Skips escaped "\\;" semicolons if `escape` is True.
"""
stop = self.scanner.find(";", escape=escape)
if stop < 0: # ";" not found
expr = self.scanner.tail() # scan until end of content
else:
expr = self.scanner.substr(stop) # exclude ";"
# skip the expression in the main scanner
self.scanner.consume(len(expr) + 1) # include ";"
return expr
def parse_paragraph_properties(self, ctx: MTextContext):
def parse_float() -> float:
value = 0.0
expr = parse_float_expr()
if expr:
value = float(expr)
return value
def parse_float_expr() -> str:
expr = ""
tail = paragraph_scanner.tail()
match = re.match(RE_FLOAT, tail)
if match:
start, end = match.span()
expr = tail[start:end]
paragraph_scanner.consume(end)
skip_commas()
return expr
def skip_commas():
while paragraph_scanner.peek() == ",":
paragraph_scanner.consume(1)
paragraph_scanner = TextScanner(self.extract_expression())
indent, left, right, align, tab_stops = ctx.paragraph # NamedTuple
while paragraph_scanner.has_data:
cmd = paragraph_scanner.get()
if cmd == "i":
indent = parse_float()
elif cmd == "l":
left = parse_float()
elif cmd == "r":
right = parse_float()
elif cmd == "x":
pass # ignore
elif cmd == "q":
adjustment = paragraph_scanner.get()
align = CHAR_TO_ALIGN.get(adjustment, MTextParagraphAlignment.DEFAULT)
skip_commas()
elif cmd == "t":
tab_stops = [] # type: ignore
while paragraph_scanner.has_data: # parse to end
type_ = paragraph_scanner.peek()
if type_ == "r" or type_ == "c":
paragraph_scanner.consume()
tab_stops.append(type_ + parse_float_expr()) # type: ignore
else:
float_expr = parse_float_expr()
if float_expr:
tab_stops.append(float(float_expr)) # type: ignore
else:
# invalid float expression, consume invalid letter
# and try again:
paragraph_scanner.consume()
ctx.paragraph = ParagraphProperties(
indent, left, right, align, tuple(tab_stops)
)
def parse_font_properties(self, ctx: MTextContext):
from ezdxf.fonts import fonts
parts = self.extract_expression().split("|")
# an empty font family name does not change the font properties
if parts and parts[0]:
name = parts[0]
style = "Regular"
weight = 400
# ignore codepage and pitch - it seems not to be used in newer
# CAD applications.
for part in parts[1:]:
if part.startswith("b1"):
weight = 700
elif part.startswith("i1"):
style = "Italic"
ctx.font_face = fonts.FontFace(family=name, style=style, weight=weight)
def consume_optional_terminator(self):
if self.scanner.peek() == ";":
self.scanner.consume(1)
def load_mtext_content(tags: Tags) -> str:
tail = ""
content = ""
for code, value in tags:
if code == 1:
tail = value
elif code == 3:
content += value
return escape_dxf_line_endings(content + tail)
def has_inline_formatting_codes(text: str) -> bool:
"""Returns `True` if `text` contains any MTEXT inline formatting codes."""
# Each inline formatting code starts with a backslash "\".
# Remove all special chars starting with a "\" and test if any backslashes
# remain. Escaped backslashes "\\" may return false positive,
# but they are rare.
# Replacing multiple strings at once by "re" is much slower,
# see profiling/string_replace.py
return "\\" in text.replace( # line breaks
r"\P", ""
).replace( # non breaking spaces
r"\~", ""
)
def is_upside_down_text_angle(angle: float, tol: float = 3.0) -> bool:
"""Returns ``True`` if the given text `angle` in degrees causes an upside
down text in the :ref:`WCS`. The strict flip range is 90° < `angle` < 270°,
the tolerance angle `tol` extends this range to: 90+tol < `angle` < 270-tol.
The angle is normalized to [0, 360).
Args:
angle: text angle in degrees
tol: tolerance range in which text flipping will be avoided
"""
angle %= 360.0
return 90.0 + tol < angle < 270.0 - tol
def upright_text_angle(angle: float, tol: float = 3.0) -> float:
"""Returns a readable (upright) text angle in the range `angle` <= 90+tol or
`angle` >= 270-tol. The angle is normalized to [0, 360).
Args:
angle: text angle in degrees
tol: tolerance range in which text flipping will be avoided
"""
if is_upside_down_text_angle(angle, tol):
angle += 180.0
return angle % 360.0
def leading(cap_height: float, line_spacing: float = 1.0) -> float:
"""Returns the distance from baseline to baseline.
Args:
cap_height: cap height of the line
line_spacing: line spacing factor as percentage of 3-on-5 spacing
"""
# method "exact": 3-on-5 line spacing = 5/3 = 1.667
# method "at least" is not supported
return cap_height * 1.667 * line_spacing
def estimate_mtext_extents(mtext: MText) -> tuple[float, float]:
"""Estimate the width and height of a single column
:class:`~ezdxf.entities.MText` entity.
This function is faster than the :func:`~ezdxf.tools.text_size.mtext_size`
function, but the result is very inaccurate if inline codes are used or
line wrapping at the column border is involved!
Returns:
Tuple[width, height]
"""
def _make_font() -> fonts.AbstractFont:
from ezdxf.fonts import fonts
cap_height: float = mtext.dxf.get_default("char_height")
doc = mtext.doc
if doc:
style = doc.styles.get(mtext.dxf.get_default("style"))
if style is not None:
return style.make_font(cap_height)
return fonts.make_font(const.DEFAULT_TTF, cap_height=cap_height)
return estimate_mtext_content_extents(
content=mtext.text,
font=_make_font(),
column_width=mtext.dxf.get("width", 0.0),
line_spacing_factor=mtext.dxf.get_default("line_spacing_factor"),
)
_SAFETY_FACTOR = 1.01
def set_estimation_safety_factor(factor: float) -> None:
"""Set the global safety factor for MTEXT size estimation."""
global _SAFETY_FACTOR
_SAFETY_FACTOR = factor
def reset_estimation_safety_factor() -> None:
"""Reset the global safety factor for MTEXT size estimation to the hard coded
default value.
"""
global _SAFETY_FACTOR
_SAFETY_FACTOR = 1.01
def estimate_mtext_content_extents(
content: str,
font: fonts.AbstractFont,
column_width: float = 0.0,
line_spacing_factor: float = 1.0,
) -> tuple[float, float]:
"""Estimate the width and height of the :class:`~ezdxf.entities.MText`
content string. The result is very inaccurate if inline codes are used or
line wrapping at the column border is involved!
Column breaks ``\\N`` will be ignored.
Args:
content: the :class:`~ezdxf.entities.MText` content string
font: font abstraction based on :class:`ezdxf.tools.fonts.AbstractFont`
column_width: :attr:`MText.dxf.width` or 0.0 for an unrestricted column
width
line_spacing_factor: :attr:`MText.dxf.line_spacing_factor`
Returns:
tuple[width, height]
"""
max_width: float = 0.0
height: float = 0.0
cap_height: float = font.measurements.cap_height
has_column_width: bool = column_width > 0.0
lines: list[str] = fast_plain_mtext(content, split=True) # type: ignore
if any(lines): # has any non-empty lines
line_count: int = 0
for line in lines:
line_width = font.text_width(line)
if line_width == 0 and line:
# line contains only white space and text_width() returns 0
# - MatplotlibFont returns 0 as text width
# - MonospaceFont returns the correct width
line_width = len(line) * font.space_width()
if has_column_width:
# naive line wrapping, does not care about line content
line_count += math.ceil(line_width / column_width)
line_width = min(line_width, column_width)
else:
line_count += 1
# Note: max_width can be smaller than the column_width, if all lines
# are shorter than column_width!
max_width = max(max_width, line_width)
spacing = leading(cap_height, line_spacing_factor) - cap_height
height = cap_height * line_count + spacing * (line_count - 1)
return max_width * _SAFETY_FACTOR, height
def safe_string(s: Optional[str], max_len: int = MAX_STR_LEN) -> str:
"""Returns a string with line breaks ``\\n`` replaced by ``\\P`` and the
length limited to `max_len`.
"""
if isinstance(s, str):
return escape_dxf_line_endings(s)[:max_len]
return ""
VALID_HEIGHT_CHARS = set("0123456789.")
def scale_mtext_inline_commands(content: str, factor: float) -> str:
"""Scale all inline commands which define an absolute value by a `factor`."""
def _scale_leading_number(substr: str, prefix: str) -> str:
index: int = 0
try:
while substr[index] in VALID_HEIGHT_CHARS:
index += 1
if substr[index] == "x": # relative factor
return f"{prefix}{substr}"
except IndexError: # end of string
pass
try:
new_size = float(substr[:index]) * factor
value = f"{new_size:.3g}"
except ValueError:
value = "" # return a valid construct
return rf"{prefix}{value}{substr[index:]}"
# So far only the "\H<value>;" command will be scaled.
# Fast check if scaling is required:
if r"\H" not in content:
return content
factor = abs(factor)
old_parts = content.split(r"\H")
new_parts: list[str] = [old_parts[0]]
for part in old_parts[1:]:
new_parts.append(_scale_leading_number(part, r"\H"))
return "".join(new_parts)