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

1046 lines
34 KiB
Python

# Copyright (c) 2022-2023, Manfred Moitzi
# License: MIT License
"""
This module provides support for fonts stored as SHX and SHP files.
(SHP is not the GIS file format!)
The documentation about the SHP file format can be found at Autodesk:
https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-DE941DB5-7044-433C-AA68-2A9AE98A5713
Using bytes for strings because no encoding is defined in shape-files.
"""
from __future__ import annotations
from typing import (
Sequence,
Iterable,
Iterator,
Callable,
Optional,
Any,
Set,
no_type_check,
)
import dataclasses
import enum
import math
import pathlib
from ezdxf.math import (
UVec,
Vec2,
ConstructionEllipse,
bulge_to_arc,
BoundingBox2d,
Matrix44,
)
from ezdxf import path
from .font_measurements import FontMeasurements
from .glyphs import GlyphPath, Glyphs
class ShapeFileException(Exception):
pass
class UnsupportedShapeFile(ShapeFileException):
pass
class UnsupportedShapeNumber(ShapeFileException):
pass
class InvalidFontDefinition(ShapeFileException):
pass
class InvalidFontParameters(ShapeFileException):
pass
class InvalidShapeRecord(ShapeFileException):
pass
class FileStructureError(ShapeFileException):
pass
class StackUnderflow(ShapeFileException):
pass
class FontEmbedding(enum.IntEnum):
ALLOWED = 0
DISALLOWED = 1
READONLY = 2
class FontEncoding(enum.IntEnum):
UNICODE = 0
PACKED_MULTIBYTE_1 = 1
SHAPE_FILE = 2
class FontMode(enum.IntEnum):
HORIZONTAL = 0
BIDIRECT = 2
NO_DATA: Sequence[int] = tuple()
DEBUG = False
DEBUG_CODES: Set[int] = set()
DEBUG_SHAPE_NUMBERS = set()
ORD_NULL = ord("0")
ORD_MINUS = ord("-")
# slots=True - Python 3.10+
@dataclasses.dataclass
class Symbol:
number: int
byte_count: int
name: bytes
data: Sequence[int] = NO_DATA
def export_str(self, as_num: int = 0) -> list[bytes]:
num = as_num if as_num else self.number
export = [f"*{num:05X},{self.byte_count},".encode() + self.name]
export.extend(format_shape_data_string(self.data))
return export
def format_shape_data_string(data: Sequence[int]) -> list[bytes]:
export = []
s = b""
for num in data:
s2 = s + f"{num},".encode()
if len(s2) < 80:
s = s2
else:
export.append(s[:-1])
s = f",{num},".encode()
if s:
export.append(s[:-1])
return export
class ShapeFile:
"""Low level representation of a SHX/SHP file."""
def __init__(
self,
name: bytes,
above: int,
below: int,
mode=FontMode.HORIZONTAL,
encoding=FontEncoding.UNICODE,
embed=FontEmbedding.ALLOWED,
):
self.shapes: dict[int, Symbol] = dict()
self.name = name
self.above = above
self.below = below
self.mode = mode
self.encoding = encoding
self.embed = embed
@property
def cap_height(self) -> float:
return float(self.above)
@property
def descender(self) -> float:
return float(self.below)
@property
def is_font(self) -> bool:
return self.encoding != FontEncoding.SHAPE_FILE
@property
def is_shape_file(self) -> bool:
return self.encoding == FontEncoding.SHAPE_FILE
def find(self, name: bytes) -> Optional[Symbol]:
for symbol in self.shapes.values():
if name == symbol.name:
return symbol
return None
def __len__(self):
return len(self.shapes)
def __getitem__(self, item):
return self.shapes[item]
@staticmethod
def from_ascii_records(records: Sequence[bytes]):
if len(records) == 2:
encoding = FontEncoding.UNICODE
above = 0
below = 0
embed = FontEmbedding.ALLOWED
mode = FontMode.HORIZONTAL
end = 0
header, params = records
try:
# ignore second value: defbytes
spec, _, name = header.split(b",", maxsplit=2)
except ValueError:
raise InvalidFontDefinition()
if spec == b"*UNIFONT":
try:
above, below, mode, encoding, embed, end = params.split(b",") # type: ignore
except ValueError:
raise InvalidFontParameters(params)
elif spec == b"*0":
try:
above, below, mode, *rest = params.split(b",") # type: ignore
end = rest[-1] # type: ignore
except ValueError:
raise InvalidFontParameters(params)
else: # it's a simple shape file
encoding = FontEncoding.SHAPE_FILE
assert int(end) == 0
return ShapeFile(
name.strip(),
int(above),
int(below),
FontMode(int(mode)),
FontEncoding(int(encoding)),
FontEmbedding(int(embed)),
)
def parse_ascii_records(self, records: Iterable[Sequence[bytes]]) -> None:
for record in records:
if len(record) < 2:
raise InvalidShapeRecord(record)
try:
number, byte_count, name = split_def_record(record[0])
except ValueError:
raise FileStructureError(record[0])
assert len(number) > 1
if number[1] == ORD_NULL: # hex if first char is "0" like "*0000A"
int_num = int(number[1:], 16)
else: # like "*130"
int_num = int(number[1:], 10)
symbol = Symbol(int_num, int(byte_count), name)
data = b"".join(record[1:])
symbol.data = tuple(parse_codes(split_record(data)))
if symbol.data[-1] == 0:
self.shapes[int_num] = symbol
else:
s = record[0].decode(errors="ignore")
raise FileStructureError(f"file structure error at symbol <{s}>")
def get_codes(self, number: int) -> Sequence[int]:
symbol = self.shapes.get(number)
if symbol is None:
raise UnsupportedShapeNumber(number)
return symbol.data
def render_shape(self, number: int, stacked=False) -> GlyphPath:
return render_shapes([number], self.get_codes, stacked=stacked)
def render_shapes(self, numbers: Sequence[int], stacked=False) -> GlyphPath:
return render_shapes(numbers, self.get_codes, stacked=stacked)
def render_text(self, text: str, stacked=False) -> GlyphPath:
numbers = [ord(char) for char in text]
return render_shapes(
numbers, self.get_codes, stacked=stacked, reset_to_baseline=True
)
def shape_string(self, shape_number: int, as_num: int = 0) -> list[bytes]:
return self.shapes[shape_number].export_str(as_num)
def readfile(filename: str) -> ShapeFile:
"""Load shape-file `filename`, the file type is detected by the file
name extension and should be ".shp" or ".shx" otherwise rises an
exception :class:`UnsupportedShapeFile`.
"""
data = pathlib.Path(filename).read_bytes()
filename = filename.lower()
if filename.endswith(".shp"):
return shp_load(data)
elif filename.endswith(".shx"):
return shx_load(data)
else:
raise UnsupportedShapeFile("unknown filetype")
def split_def_record(record: bytes) -> Sequence[bytes]:
return tuple(s.strip() for s in record.split(b",", maxsplit=2))
def split_record(record: bytes) -> Sequence[bytes]:
return tuple(s.strip() for s in record.split(b","))
def parse_codes(codes: Iterable[bytes]) -> Iterator[int]:
for code in codes:
code = code.strip(b"()")
if code == b"":
continue
if code[0] == ORD_NULL:
yield int(code, 16)
elif code[0] == ORD_MINUS and code[1] == ORD_NULL:
yield int(code, 16)
else:
yield int(code, 10)
def shp_load(data: bytes) -> ShapeFile:
records = parse_string_records(merge_lines(filter_noise(data.split(b"\n"))))
if b"*UNIFONT" in records:
font_definition = records.pop(b"*UNIFONT")
elif b"*0" in records:
font_definition = records.pop(b"*0")
else:
# a common shape file without a name
# symbol numbers are decimal!!!
font_definition = (b"_,_,_", b"")
shp = ShapeFile.from_ascii_records(font_definition)
shp.parse_ascii_records(records.values())
return shp
def shx_load(data: bytes) -> ShapeFile:
if data.startswith(b"AutoCAD-86 shapes 1.0"):
return load_shx_shape_file_1_0(data)
elif data.startswith(b"AutoCAD-86 shapes 1.1"):
return load_shx_shape_file_1_1(data)
elif data.startswith(b"AutoCAD-86 unifont 1.0"):
return load_shx_unifont_file_1_0(data)
elif data.startswith(b"AutoCAD-86 bigfont 1.0"):
raise UnsupportedShapeFile("BIGFONT shapes are not supported yet")
raise UnsupportedShapeFile("unknown shape file format")
def load_shx_shape_file_1_0(data: bytes) -> ShapeFile:
above = 0
below = 0
mode = FontMode.HORIZONTAL
name = b""
shapes = parse_shx_shapes(data)
font_definition = shapes.get(0)
if font_definition:
shapes.pop(0)
shape_data = font_definition.data
above = shape_data[0]
below = shape_data[1]
mode = FontMode(shape_data[2])
shape_file = ShapeFile(name, above=above, below=below, mode=mode)
shape_file.shapes = shapes
return shape_file
def load_shx_shape_file_1_1(data: bytes) -> ShapeFile:
# seems to be the same as v1.0:
return load_shx_shape_file_1_0(data)
class DataReader:
def __init__(self, data: bytes, index=0):
self.data = data
self.index = index
@property
def has_data(self) -> bool:
return self.index < len(self.data)
def skip(self, n: int) -> None:
self.index += n
def u8(self) -> int:
index = self.index
self.index += 1
return self.data[index]
def i8(self) -> int:
value = self.data[self.index]
self.index += 1
if value > 127:
return (value & 127) - 128 # ???
return value
def octant(self) -> int:
value = self.data[self.index]
self.index += 1
if value & 128:
return -(value & 127)
else:
return value
def read_str(self) -> bytes:
data = bytearray()
while True:
char = self.u8()
if char:
data.append(char)
else:
return data
def read_bytes(self, n: int) -> bytes:
index = self.index
self.index += n
return self.data[index : index + n]
def u16(self) -> int:
# little ending
index = self.index
self.index += 2
return self.data[index] + (self.data[index + 1] << 8)
# the old 'AutoCAD-86 shapes 1.0' format
SHX_SHAPES_START_INDEX = 0x17
def parse_shx_shapes(data: bytes) -> dict[int, Symbol]:
"""SHX/SHP shape parser."""
shapes: dict[int, Symbol] = dict()
reader = DataReader(data, SHX_SHAPES_START_INDEX)
if reader.u8() != 0x1A:
raise FileStructureError("signature byte 0x1A not found")
first_number = reader.u16()
last_number = reader.u16()
shape_count = reader.u16()
index_table: list[tuple[int, int]] = []
for _ in range(shape_count):
shape_number = reader.u16()
data_size = reader.u16()
index_table.append((shape_number, data_size))
if index_table[0][0] != first_number:
raise FileStructureError("invalid first entry in index table")
if index_table[-1][0] != last_number:
raise FileStructureError("invalid last entry in index table")
for shape_number, length in index_table:
record = reader.read_bytes(length)
try:
name, shape_data = parse_shx_data_record(record)
except IndexError:
raise FileStructureError(
f"SHX parsing error shape *{shape_number:05X}: {str(record)}"
)
byte_count = length - len(name) - 1
shapes[shape_number] = Symbol(shape_number, byte_count, name, shape_data)
if reader.read_bytes(3) != b"EOF":
raise FileStructureError("EOF marker not found")
return shapes
def parse_shx_data_record(data: bytes) -> tuple[bytes, Sequence[int]]:
"""Low level SHX/SHP shape record parser."""
reader = DataReader(data)
name = reader.read_str()
codes = parse_shape_codes(reader)
return name, codes
# the new 'AutoCAD-86 unifont 1.0' format
SHX_UNIFONT_START_INDEX = 0x18
def load_shx_unifont_file_1_0(data: bytes) -> ShapeFile:
reader = DataReader(data, SHX_UNIFONT_START_INDEX)
name, above, below, mode, encoding, embed = parse_shx_unifont_definition(reader)
shape_file = ShapeFile(
name,
above=above,
below=below,
mode=mode,
encoding=encoding,
embed=embed,
)
try:
shape_file.shapes = parse_shx_unifont_shapes(reader)
except IndexError:
raise FileStructureError("pre-mature end of file")
return shape_file
def parse_shx_unifont_definition(reader: DataReader) -> Sequence[Any]:
if reader.u8() != 0x1A:
raise FileStructureError("signature byte 0x1A not found")
reader.skip(6)
# u16 record count: in isocp.shx = 238 (0xEE 0x00)
# = 1 definition record + 237 character records
# u16 isocp.shx: 0 (0x00 0x00), always 0???
# u16 font definition record size; isocp.shx: 52 (0x34 0x00)
name = reader.read_str()
above = reader.u8()
below = reader.u8()
mode = FontMode(reader.u8())
encoding = FontEncoding(reader.u8())
embed = FontEmbedding(reader.u8())
reader.skip(1) # <00> byte - end of font definition
return name, above, below, mode, encoding, embed
def parse_shx_unifont_shapes(reader: DataReader) -> dict[int, Symbol]:
shapes: dict[int, Symbol] = dict()
while reader.has_data:
shape_number = reader.u16()
byte_count = reader.u16()
name = reader.read_str()
byte_count = byte_count - len(name) - 1
data_record = reader.read_bytes(byte_count)
codes = parse_shape_codes(DataReader(data_record), unifont=True)
shapes[shape_number] = Symbol(shape_number, byte_count, name, codes)
return shapes
SINGLE_CODES = {1, 2, 5, 6, 14}
def parse_shape_codes(reader: DataReader, unifont=False) -> Sequence[int]:
codes: list[int] = []
while True:
code = reader.u8()
codes.append(code)
if code == 0:
return tuple(codes)
elif code in SINGLE_CODES or code > 14:
continue
elif code == 3 or code == 4: # size control
codes.append(reader.u8())
elif code == 7: # sub shape
if unifont:
codes.append(reader.u16())
else:
codes.append(reader.u8())
elif code == 8: # displacement
codes.append(reader.i8())
codes.append(reader.i8())
elif code == 9: # multiple displacement
x, y = 1, 1
while x or y:
x = reader.i8()
y = reader.i8()
codes.append(x)
codes.append(y)
elif code == 10: # octant arc
codes.append(reader.u8()) # radius
codes.append(reader.octant()) # octant specs
elif code == 11: # fractional arc
codes.append(reader.u8()) # start offset
codes.append(reader.u8()) # end offset
codes.append(reader.u8()) # radius hi
codes.append(reader.u8()) # radius lo
codes.append(reader.octant()) # octant specs
elif code == 12: # bulge arcs
codes.append(reader.i8()) # x
codes.append(reader.i8()) # y
codes.append(reader.i8()) # bulge
elif code == 13: # multiple bulge arcs
x, y = 1, 1
while x or y:
x = reader.i8()
y = reader.i8()
codes.append(x)
codes.append(y)
if x or y:
codes.append(reader.i8())
def filter_noise(lines: Iterable[bytes]) -> Iterator[bytes]:
for line in lines:
line.rstrip(b"\r\n")
line = line.strip()
if line:
line = line.split(b";")[0]
line = line.strip()
if line:
yield line
def merge_lines(lines: Iterable[bytes]) -> Iterator[bytes]:
current = b""
for next_line in lines:
if not current:
current = next_line
elif current.startswith(b"*"):
if next_line.startswith(b","): # wrapped specification line?
current += next_line
else:
yield current
current = next_line
elif current.endswith(b","):
current += next_line
elif next_line.startswith(b","):
current += next_line
else:
yield current
current = next_line
if current:
yield current
def parse_string_records(
lines: Iterable[bytes],
) -> dict[bytes, Sequence[bytes]]:
records: dict[bytes, Sequence[bytes]] = dict()
name = None
record = []
for line in lines:
if line.startswith(b"*BIGFONT"):
raise UnsupportedShapeFile("BIGFONT shape files are not supported yet")
if line.startswith(b"*"):
if name is not None:
records[name] = tuple(record)
name = line.split(b",")[0].strip()
record = [line]
else:
record.append(line)
if name is not None:
records[name] = tuple(record)
return records
def render_shapes(
shape_numbers: Sequence[int],
get_codes: Callable[[int], Sequence[int]],
stacked: bool,
start: UVec = (0, 0),
reset_to_baseline=False,
) -> GlyphPath:
"""Renders multiple shapes into a single glyph path."""
ctx = ShapeRenderer(
path.Path(start),
pen_down=True,
stacked=stacked,
get_codes=get_codes,
)
for shape_number in shape_numbers:
try:
ctx.render(shape_number, reset_to_baseline=reset_to_baseline)
except UnsupportedShapeNumber:
pass
except StackUnderflow:
raise StackUnderflow(
f"stack underflow while rendering shape number {shape_number}"
)
# move cursor to the start of the next char???
return GlyphPath(ctx.p)
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F
VEC_X = [1, 1, 1, 0.5, 0, -0.5, -1, -1, -1, -1, -1, -0.5, 0, 0.5, 1, 1]
# 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F
VEC_Y = [0, 0.5, 1, 1, 1, 1, 1, 0.5, 0, -0.5, -1, -1, -1, -1, -1, -0.5]
class ShapeRenderer:
"""Low level glyph renderer for SHX/SHP fonts."""
def __init__(
self,
p: path.Path,
get_codes: Callable[[int], Sequence[int]],
*,
vector_length: float = 1.0,
pen_down: bool = True,
stacked: bool = False,
):
self.p = p
self.vector_length = float(vector_length) # initial vector length
self.pen_down = pen_down
self.stacked = stacked # vertical stacked text
self._location_stack: list[Vec2] = []
self._get_codes = get_codes
self._baseline_y = self.p.start.y
@property
def current_location(self) -> Vec2:
return Vec2(self.p.end)
def push(self) -> None:
self._location_stack.append(self.current_location)
def pop(self) -> None:
self.p.move_to(self._location_stack.pop())
def render(
self,
shape_number: int,
reset_to_baseline=False,
) -> None:
self.pen_down = True
codes = self._get_codes(shape_number)
index = 0
skip_next = False
while index < len(codes):
code = codes[index]
if DEBUG and (code in DEBUG_CODES) and shape_number:
DEBUG_SHAPE_NUMBERS.add(shape_number)
index += 1
if code > 15 and not skip_next:
self.draw_vector(code)
elif code == 0:
break
elif code == 1 and not skip_next: # pen down
self.pen_down = True
elif code == 2 and not skip_next: # pen up
self.pen_down = False
elif code == 3 or code == 4: # scale size
factor = codes[index]
index += 1
if not skip_next:
if code == 3:
self.vector_length /= factor
elif code == 4:
self.vector_length *= factor
continue
elif code == 5 and not skip_next: # push location state
self.push()
elif code == 6 and not skip_next: # pop location state
try:
self.pop()
except IndexError:
raise StackUnderflow()
elif code == 7: # sub-shape:
sub_shape_number = codes[index]
index += 1
if not skip_next:
# Use current state of pen and location!
self.render(sub_shape_number)
# resume with current state of pen and location!
elif code == 8: # displacement vector
x = codes[index]
y = codes[index + 1]
index += 2
if not skip_next:
self.draw_displacement(x, y)
elif code == 9: # multiple displacements vectors
while True:
x = codes[index]
y = codes[index + 1]
index += 2
if x == 0 and y == 0:
break
if not skip_next:
self.draw_displacement(x, y)
elif code == 10: # 10 octant arc
radius = codes[index]
start_octant, octant_span, ccw = decode_octant_specs(codes[index + 1])
if octant_span == 0: # full circle
octant_span = 8
index += 2
if not skip_next:
self.draw_arc_span(
radius * self.vector_length,
math.radians(start_octant * 45),
math.radians(octant_span * 45),
ccw,
)
elif code == 11: # fractional arc
# TODO: this still seems not 100% correct, see vertical placing
# of characters "9" and "&" for font isocp.shx.
# This is solved by placing the end point on the baseline after
# each character rendering, but only for text rendering.
# The remaining problems can possibly be due to a loss of
# precision when converting floats to ints.
start_offset = codes[index]
end_offset = codes[index + 1]
radius = (codes[index + 2] << 8) + codes[index + 3]
start_octant, octant_span, ccw = decode_octant_specs(codes[index + 4])
index += 5
if end_offset == 0:
end_offset = 256
binary_deg = 45.0 / 256.0
start_offset_angle = start_offset * binary_deg
end_offset_angle = end_offset * binary_deg
if ccw:
end_octant = start_octant + octant_span - 1
start_angle = start_octant * 45.0 + start_offset_angle
end_angle = end_octant * 45.0 + end_offset_angle
else:
end_octant = start_octant - octant_span + 1
start_angle = start_octant * 45.0 - start_offset_angle
end_angle = end_octant * 45.0 - end_offset_angle
if not skip_next:
self.draw_arc_start_to_end(
radius * self.vector_length,
math.radians(start_angle),
math.radians(end_angle),
ccw,
)
elif code == 12: # bulge arc
x = codes[index]
y = codes[index + 1]
bulge = codes[index + 2]
index += 3
if not skip_next:
self.draw_bulge(x, y, bulge)
elif code == 13: # multiple bulge arcs
while True:
x = codes[index]
y = codes[index + 1]
if x == 0 and y == 0:
index += 2
break
bulge = codes[index + 2]
index += 3
if not skip_next:
self.draw_bulge(x, y, bulge)
elif code == 14: # flag vertical text
if not self.stacked:
skip_next = True
continue
skip_next = False
if reset_to_baseline:
# HACK: because of invalid fractional arc rendering!
if not math.isclose(self.p.end.y, self._baseline_y):
self.p.move_to((self.p.end.x, self._baseline_y))
def draw_vector(self, code: int) -> None:
angle: int = code & 0xF
length: int = (code >> 4) & 0xF
self.draw_displacement(VEC_X[angle] * length, VEC_Y[angle] * length)
def draw_displacement(self, x: float, y: float):
scale = self.vector_length
target = self.current_location + Vec2(x * scale, y * scale)
if self.pen_down:
self.p.line_to(target)
else:
self.p.move_to(target)
def draw_arc_span(
self, radius: float, start_angle: float, span_angle: float, ccw: bool
):
# IMPORTANT: radius has to be scaled by self.vector_length!
end_angle = start_angle + (span_angle if ccw else -span_angle)
self.draw_arc_start_to_end(radius, start_angle, end_angle, ccw)
def draw_arc_start_to_end(
self, radius: float, start_angle: float, end_angle: float, ccw: bool
):
# IMPORTANT: radius has to be scaled by self.vector_length!
assert radius > 0.0
arc = ConstructionEllipse(
major_axis=(radius, 0),
start_param=start_angle,
end_param=end_angle,
ccw=ccw,
)
# arc goes start -> end if ccw otherwise end -> start
# move arc start-point to the end-point of current path
arc.center += self.current_location - Vec2(
arc.start_point if ccw else arc.end_point
)
if self.pen_down:
path.add_ellipse(self.p, arc, reset=False)
else:
self.p.move_to(arc.end_point if ccw else arc.start_point)
def draw_arc(
self,
center: Vec2,
radius: float,
start_param: float,
end_param: float,
ccw: bool,
):
# IMPORTANT: radius has to be scaled by self.vector_length!
arc = ConstructionEllipse(
center=center,
major_axis=(radius, 0),
start_param=start_param,
end_param=end_param,
ccw=ccw,
)
if self.pen_down:
path.add_ellipse(self.p, arc, reset=False)
else:
self.p.move_to(arc.end_point if ccw else arc.start_point)
def draw_bulge(self, x: float, y: float, bulge: float):
if self.pen_down and bulge:
start_point = self.current_location
scale = self.vector_length
ccw = bulge > 0
end_point = start_point + Vec2(x * scale, y * scale)
bulge = abs(bulge) / 127.0
if ccw: # counter-clockwise
center, start_angle, end_angle, radius = bulge_to_arc(
start_point, end_point, bulge
)
else: # clockwise
center, start_angle, end_angle, radius = bulge_to_arc(
end_point, start_point, bulge
)
self.draw_arc(center, radius, start_angle, end_angle, ccw=True)
else:
self.draw_displacement(x, y)
def decode_octant_specs(specs: int) -> tuple[int, int, bool]:
ccw = True
if specs < 0:
ccw = False
specs = -specs
start_octant = (specs >> 4) & 0xF
octant_span = specs & 0xF
return start_octant, octant_span, ccw
def find_shape_number(filename: str, name: bytes) -> int:
"""Returns the shape number for shape `name` from the shape-file, returns -1 if not
found.
The filename can be an absolute path or the shape-file will be searched in the
current directory and all directories stored in :attr:`ezdxf.options.support_dirs`.
Supports shape files of type ".shx" and ".shp".
"""
from ezdxf.filemanagement import find_support_file
from ezdxf import options
shape_file_name = find_support_file(filename, options.support_dirs)
index = -1
try:
shapes = readfile(shape_file_name)
except (IOError, ShapeFileException):
return index
shx_symbol = shapes.find(name)
if shx_symbol is not None:
return shx_symbol.number
return index
class GlyphCache(Glyphs):
def __init__(self, font: ShapeFile) -> None:
"""Text render engine for SHX/SHP fonts with integrated glyph caching."""
self.font: ShapeFile = font
self._glyph_cache: dict[int, GlyphPath] = dict()
self._advance_width_cache: dict[int, float] = dict()
self.space_width: float = self.detect_space_width()
self.empty_box: GlyphPath = self.get_empty_box()
self.font_measurements: FontMeasurements = self._get_font_measurements()
def get_scaling_factor(self, cap_height: float) -> float:
try:
return cap_height / self.font_measurements.cap_height
except ZeroDivisionError:
return 1.0
def detect_space_width(self) -> float:
if 32 not in self.font.shapes:
return self.get_shape(65).end.x # width of "A"
space = self.get_shape(32)
return space.end.x
def get_empty_box(self) -> GlyphPath:
glyph_A = self.get_shape(65)
box = BoundingBox2d(glyph_A.control_vertices())
height = box.size.y
width = box.size.x
start = glyph_A.start
p = path.Path(start)
p.line_to(start + Vec2(width, 0))
p.line_to(start + Vec2(width, height))
p.line_to(start + Vec2(0, height))
p.close()
p.move_to(glyph_A.end)
return GlyphPath(p)
def _render_shape(self, shape_number) -> GlyphPath:
ctx = ShapeRenderer(
path.Path(),
pen_down=True,
stacked=False,
get_codes=self.font.get_codes,
)
try:
ctx.render(shape_number, reset_to_baseline=False)
except StackUnderflow:
pass
return GlyphPath(ctx.p)
def get_shape(self, shape_number: int) -> GlyphPath:
try:
return self._glyph_cache[shape_number].clone()
except KeyError:
pass
try:
glyph = self._render_shape(shape_number)
except UnsupportedShapeNumber:
if shape_number < 32:
glyph = GlyphPath(None)
else:
glyph = self.empty_box
self._glyph_cache[shape_number] = glyph
try:
width = glyph.end.x
except IndexError:
width = self.space_width
self._advance_width_cache[shape_number] = width
return glyph.clone()
def get_advance_width(self, shape_number: int) -> float:
if shape_number == 32:
return self.space_width
try:
return self._advance_width_cache[shape_number]
except KeyError:
pass
return self.get_shape(shape_number).end.x
@no_type_check
def _get_font_measurements(self) -> FontMeasurements:
# ignore last move_to command, which places the pen at the start of the
# following glyph
bbox = BoundingBox2d(self.get_shape(ord("x")).control_vertices()[:-1])
baseline = bbox.extmin.y
x_height = bbox.extmax.y - baseline
cap_height = self.font.above
if cap_height == 0:
bbox = BoundingBox2d(self.get_shape(ord("A")).control_vertices()[:-1])
cap_height = bbox.extmax.y - baseline
descender_height = self.font.below
if descender_height == 0:
bbox = BoundingBox2d(self.get_shape(ord("p")).control_vertices()[:-1])
descender_height = baseline - bbox.extmin.y
return FontMeasurements(
baseline=baseline,
cap_height=cap_height,
x_height=x_height,
descender_height=descender_height,
)
def get_text_length(
self, text: str, cap_height: float, width_factor: float = 1.0
) -> float:
scaling_factor = self.get_scaling_factor(cap_height) * width_factor
return sum(self.get_advance_width(ord(c)) for c in text) * scaling_factor
def get_text_glyph_paths(
self, text: str, cap_height: float = 1.0, width_factor: float = 1.0
) -> list[GlyphPath]:
"""Returns the glyph paths of string `s` as a list, scaled to cap height."""
glyph_paths: list[GlyphPath] = []
sy = self.get_scaling_factor(cap_height)
sx = sy * width_factor
m = Matrix44.scale(sx, sy, 1)
current_location = 0.0
for c in text:
shape_number = ord(c)
if shape_number > 32:
glyph = self.get_shape(shape_number)
m[3, 0] = current_location
glyph.transform_inplace(m)
glyph_paths.append(glyph)
current_location += self.get_advance_width(shape_number) * sx
return glyph_paths