refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -0,0 +1,5 @@
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
from .frontend import Frontend
from .properties import Properties, RenderContext, LayerProperties
@@ -0,0 +1,265 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from abc import ABC, abstractmethod, ABCMeta
from typing import Optional, Iterable
import numpy as np
from typing_extensions import TypeAlias
import dataclasses
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.properties import Properties, BackendProperties
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.entities import DXFGraphic
from ezdxf.math import Vec2, Matrix44
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, single_paths
BkPath2d: TypeAlias = NumpyPath2d
BkPoints2d: TypeAlias = NumpyPoints2d
# fmt: off
_IMAGE_FLIP_MATRIX = [
1.0, 0.0, 0.0, 0.0,
0.0, -1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 999, 0.0, 1.0 # index 13: 999 = image height
]
# fmt: on
@dataclasses.dataclass
class ImageData:
"""Image data.
Attributes:
image: an array of RGBA pixels
transform: the transformation to apply to the image when drawing
(the transform from pixel coordinates to wcs)
pixel_boundary_path: boundary path vertices in pixel coordinates, the image
coordinate system has an inverted y-axis and the top-left corner is (0, 0)
remove_outside: remove image outside the clipping boundary if ``True`` otherwise
remove image inside the clipping boundary
"""
image: np.ndarray
transform: Matrix44
pixel_boundary_path: NumpyPoints2d
use_clipping_boundary: bool = False
remove_outside: bool = True
def image_size(self) -> tuple[int, int]:
"""Returns the image size as tuple (width, height)."""
image_height, image_width, *_ = self.image.shape
return image_width, image_height
def flip_matrix(self) -> Matrix44:
"""Returns the transformation matrix to align the image coordinate system with
the WCS.
"""
_, image_height = self.image_size()
_IMAGE_FLIP_MATRIX[13] = image_height
return Matrix44(_IMAGE_FLIP_MATRIX)
class BackendInterface(ABC):
"""Public interface for 2D rendering backends."""
@abstractmethod
def configure(self, config: Configuration) -> None:
raise NotImplementedError
@abstractmethod
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
raise NotImplementedError
@abstractmethod
def exit_entity(self, entity: DXFGraphic) -> None:
raise NotImplementedError
@abstractmethod
def set_background(self, color: Color) -> None:
raise NotImplementedError
@abstractmethod
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
raise NotImplementedError
@abstractmethod
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
raise NotImplementedError
@abstractmethod
def clear(self) -> None:
raise NotImplementedError
@abstractmethod
def finalize(self) -> None:
raise NotImplementedError
class Backend(BackendInterface, metaclass=ABCMeta):
def __init__(self) -> None:
self.entity_stack: list[tuple[DXFGraphic, Properties]] = []
self.config: Configuration = Configuration()
def configure(self, config: Configuration) -> None:
self.config = config
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
self.entity_stack.append((entity, properties))
def exit_entity(self, entity: DXFGraphic) -> None:
e, p = self.entity_stack.pop()
assert e is entity, "entity stack mismatch"
@property
def current_entity(self) -> Optional[DXFGraphic]:
"""Obtain the current entity being drawn"""
return self.entity_stack[-1][0] if self.entity_stack else None
@abstractmethod
def set_background(self, color: Color) -> None:
raise NotImplementedError
@abstractmethod
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
"""Draw a real dimensionless point, because not all backends support
zero-length lines!
"""
raise NotImplementedError
@abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
raise NotImplementedError
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
"""Fast method to draw a bunch of solid lines with the same properties."""
# Must be overridden by the backend to gain a performance benefit.
# This is the default implementation to ensure compatibility with
# existing backends.
for s, e in lines:
if e.isclose(s):
self.draw_point(s, properties)
else:
self.draw_line(s, e, properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
"""Draw an outline path (connected string of line segments and Bezier
curves).
The :meth:`draw_path` implementation is a fall-back implementation
which approximates Bezier curves by flattening as line segments.
Backends can override this method if better path drawing functionality
is available for that backend.
"""
if len(path):
vertices = iter(
path.flattening(distance=self.config.max_flattening_distance)
)
prev = next(vertices)
for vertex in vertices:
self.draw_line(prev, vertex, properties)
prev = vertex
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
"""Draw multiple filled paths (connected string of line segments and
Bezier curves).
The current implementation passes these paths to the backend, all backends
included in ezdxf handle holes by the even-odd method. If a backend requires
oriented paths (exterior paths in counter-clockwise and holes in clockwise
orientation) use the function :func:`oriented_paths` to separate and orient the
input paths.
The default implementation draws all paths as filled polygons.
Args:
paths: sequence of paths
properties: HATCH properties
"""
for path in paths:
self.draw_filled_polygon(
BkPoints2d(
path.flattening(distance=self.config.max_flattening_distance)
),
properties,
)
@abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
"""Fill a polygon whose outline is defined by the given points.
Used to draw entities with simple outlines where :meth:`draw_path` may
be an inefficient way to draw such a polygon.
"""
raise NotImplementedError
@abstractmethod
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
"""Draw an image with the given pixels."""
raise NotImplementedError
@abstractmethod
def clear(self) -> None:
"""Clear the canvas. Does not reset the internal state of the backend.
Make sure that the previous drawing is finished before clearing.
"""
raise NotImplementedError
def finalize(self) -> None:
pass
def oriented_paths(paths: Iterable[BkPath2d]) -> tuple[list[BkPath2d], list[BkPath2d]]:
"""Separate paths into exterior paths and holes. Exterior paths are oriented
counter-clockwise, holes are oriented clockwise.
"""
from ezdxf.path import winding_deconstruction, make_polygon_structure
polygons = make_polygon_structure(single_paths(paths))
external_paths: list[BkPath2d]
holes: list[BkPath2d]
external_paths, holes = winding_deconstruction(polygons)
for p in external_paths:
p.counter_clockwise()
for p in holes:
p.clockwise()
return external_paths, holes
@@ -0,0 +1,292 @@
# Copyright (c) 2021-2024, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Optional
import warnings
import dataclasses
from dataclasses import dataclass
from enum import Enum, auto
from ezdxf import disassemble
from ezdxf.enums import Measurement
from .type_hints import Color
class LinePolicy(Enum):
"""This enum is used to define how to render linetypes.
.. note::
Text and shapes in linetypes are not supported.
Attributes:
SOLID: draw all lines as solid regardless of the linetype style
ACCURATE: render styled lines as accurately as possible
APPROXIMATE: ignored since v0.18.1 - uses always ACCURATE by default
"""
SOLID = auto()
APPROXIMATE = auto() # ignored since v0.18.1
ACCURATE = auto()
class ProxyGraphicPolicy(Enum):
"""The action to take when an entity with a proxy graphic is encountered
.. note::
To get proxy graphics support proxy graphics have to be loaded:
Set the global option :attr:`ezdxf.options.load_proxy_graphics` to
``True``, which is the default value.
This can not prevent drawing proxy graphic inside of blocks,
because this is beyond the domain of the drawing add-on!
Attributes:
IGNORE: do not display proxy graphics (skip_entity will be called instead)
SHOW: if the entity cannot be rendered directly (e.g. if not implemented)
but a proxy is present: display the proxy
PREFER: display proxy graphics even for entities where direct rendering
is available
"""
IGNORE = auto()
SHOW = auto()
PREFER = auto()
class HatchPolicy(Enum):
"""The action to take when a HATCH entity is encountered
Attributes:
NORMAL: render pattern and solid fillings
IGNORE: do not show HATCH entities at all
SHOW_OUTLINE: show only the outline of HATCH entities
SHOW_SOLID: show HATCH entities as solid filling regardless of the pattern
"""
NORMAL = auto()
IGNORE = auto()
SHOW_OUTLINE = auto()
SHOW_SOLID = auto()
SHOW_APPROXIMATE_PATTERN = auto() # ignored since v0.18.1 == NORMAL
class LineweightPolicy(Enum):
"""This enum is used to define how to determine the lineweight.
Attributes:
ABSOLUTE: in mm as resolved by the :class:`Frontend` class
RELATIVE: lineweight is relative to page size
RELATIVE_FIXED: fixed lineweight relative to page size for all strokes
"""
ABSOLUTE = auto()
# set fixed lineweight for all strokes in absolute mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# The RELATIVE policy is a backend feature and is not supported by all backends!
RELATIVE = auto()
RELATIVE_FIXED = auto()
class ColorPolicy(Enum):
"""This enum is used to define how to determine the line/fill color.
Attributes:
COLOR: as resolved by the :class:`Frontend` class
COLOR_SWAP_BW: as resolved by the :class:`Frontend` class but swaps black and white
COLOR_NEGATIVE: invert all colors
MONOCHROME: maps all colors to gray scale in range [0%, 100%]
MONOCHROME_DARK_BG: maps all colors to gray scale in range [30%, 100%], brightens
colors for dark backgrounds
MONOCHROME_LIGHT_BG: maps all colors to gray scale in range [0%, 70%], darkens
colors for light backgrounds
BLACK: maps all colors to black
WHITE: maps all colors to white
CUSTOM: maps all colors to custom color :attr:`Configuration.custom_fg_color`
"""
COLOR = auto()
COLOR_SWAP_BW = auto()
COLOR_NEGATIVE = auto()
MONOCHROME = auto()
MONOCHROME_DARK_BG = auto()
MONOCHROME_LIGHT_BG = auto()
BLACK = auto()
WHITE = auto()
CUSTOM = auto()
class BackgroundPolicy(Enum):
"""This enum is used to define the background color.
Attributes:
DEFAULT: as resolved by the :class:`Frontend` class
WHITE: white background
BLACK: black background
PAPERSPACE: default paperspace background
MODELSPACE: default modelspace background
OFF: fully transparent background
CUSTOM: custom background color by :attr:`Configuration.custom_bg_color`
"""
DEFAULT = auto()
WHITE = auto()
BLACK = auto()
PAPERSPACE = auto()
MODELSPACE = auto()
OFF = auto()
CUSTOM = auto()
class TextPolicy(Enum):
"""This enum is used to define the text rendering.
Attributes:
FILLING: text is rendered as solid filling (default)
OUTLINE: text is rendered as outline paths
REPLACE_RECT: replace text by a rectangle
REPLACE_FILL: replace text by a filled rectangle
IGNORE: ignore text entirely
"""
FILLING = auto()
OUTLINE = auto()
REPLACE_RECT = auto()
REPLACE_FILL = auto()
IGNORE = auto()
class ImagePolicy(Enum):
"""This enum is used to define the image rendering.
Attributes:
DISPLAY: display images as they would appear in a regular CAD application
RECT: display images as rectangles
MISSING: images are always rendered as-if they are missing (rectangle + path text)
PROXY: images are rendered using their proxy representations (rectangle)
IGNORE: ignore images entirely
"""
DISPLAY = auto()
RECT = auto()
MISSING = auto()
PROXY = auto()
IGNORE = auto()
@dataclass(frozen=True)
class Configuration:
"""Configuration options for the :mod:`drawing` add-on.
Attributes:
pdsize: the size to draw POINT entities (in drawing units)
set to None to use the $PDSIZE value from the dxf document header
======= ====================================================
0 5% of draw area height
<0 Specifies a percentage of the viewport size
>0 Specifies an absolute size
None use the $PDMODE value from the dxf document header
======= ====================================================
pdmode: point styling mode (see POINT documentation)
see :class:`~ezdxf.entities.Point` class documentation
measurement: whether to use metric or imperial units as enum :class:`ezdxf.enums.Measurement`
======= ======================================================
0 use imperial units (in, ft, yd, ...)
1 use metric units (ISO meters)
None use the $MEASUREMENT value from the dxf document header
======= ======================================================
show_defpoints: whether to show or filter out POINT entities on the defpoints layer
proxy_graphic_policy: the action to take when a proxy graphic is encountered
line_policy: the method to use when drawing styled lines (eg dashed,
dotted etc)
hatch_policy: the method to use when drawing HATCH entities
infinite_line_length: the length to use when drawing infinite lines
lineweight_scaling:
multiplies every lineweight by this factor; set this factor to 0.0 for a
constant minimum line width defined by the :attr:`min_lineweight` setting
for all lineweights;
the correct DXF lineweight often looks too thick in SVG, so setting a
factor < 1 can improve the visual appearance
min_lineweight: the minimum line width in 1/300 inch; set to ``None`` for
let the backend choose.
min_dash_length: the minimum length for a dash when drawing a styled line
(default value is arbitrary)
max_flattening_distance: Max flattening distance in drawing units
see Path.flattening documentation.
The backend implementation should calculate an appropriate value,
like 1 screen- or paper pixel on the output medium, but converted
into drawing units. Sets Path() approximation accuracy
circle_approximation_count: Approximate a full circle by `n` segments, arcs
have proportional less segments. Only used for approximation of arcs
in banded polylines.
hatching_timeout: hatching timeout for a single entity, very dense
hatching patterns can cause a very long execution time, the default
timeout for a single entity is 30 seconds.
min_hatch_line_distance: minimum hatch line distance to render, narrower pattern
lines are rendered as solid filling
color_policy:
custom_fg_color: Used for :class:`ColorPolicy.custom` policy, custom foreground
color as "#RRGGBBAA" color string (RGB+alpha)
background_policy:
custom_bg_color: Used for :class:`BackgroundPolicy.custom` policy, custom
background color as "#RRGGBBAA" color string (RGB+alpha)
lineweight_policy:
text_policy:
image_policy: the method for drawing IMAGE entities
"""
pdsize: Optional[int] = None # use $PDSIZE from HEADER section
pdmode: Optional[int] = None # use $PDMODE from HEADER section
measurement: Optional[Measurement] = None
show_defpoints: bool = False
proxy_graphic_policy: ProxyGraphicPolicy = ProxyGraphicPolicy.SHOW
line_policy: LinePolicy = LinePolicy.ACCURATE
hatch_policy: HatchPolicy = HatchPolicy.NORMAL
infinite_line_length: float = 20
lineweight_scaling: float = 1.0
min_lineweight: Optional[float] = None
min_dash_length: float = 0.1
max_flattening_distance: float = disassemble.Primitive.max_flattening_distance
circle_approximation_count: int = 128
hatching_timeout: float = 30.0
# Keep value in sync with ezdxf.render.hatching.MIN_HATCH_LINE_DISTANCE
min_hatch_line_distance: float = 1e-4
color_policy: ColorPolicy = ColorPolicy.COLOR
custom_fg_color: Color = "#000000"
background_policy: BackgroundPolicy = BackgroundPolicy.DEFAULT
custom_bg_color: Color = "#ffffff"
lineweight_policy: LineweightPolicy = LineweightPolicy.ABSOLUTE
text_policy: TextPolicy = TextPolicy.FILLING
image_policy: ImagePolicy = ImagePolicy.DISPLAY
@staticmethod
def defaults() -> Configuration:
warnings.warn(
"use Configuration() instead of Configuration.defaults()",
DeprecationWarning,
)
return Configuration()
def with_changes(self, **kwargs) -> Configuration:
"""Returns a new frozen :class:`Configuration` object with modified values."""
params = dataclasses.asdict(self)
for k, v in kwargs.items():
params[k] = v
return Configuration(**params)
@@ -0,0 +1,52 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable
from ezdxf.math import Vec2
from .properties import BackendProperties
from .backend import Backend, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
class BasicBackend(Backend):
"""The basic backend has no draw_path() support and approximates all curves
by lines.
"""
def __init__(self):
super().__init__()
self.collector = []
self.configure(Configuration())
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.collector.append(("point", pos, properties))
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.collector.append(("line", start, end, properties))
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
self.collector.append(("filled_polygon", points, properties))
def draw_image(
self, image_data: ImageData, properties: BackendProperties
) -> None:
self.collector.append(("image", image_data, properties))
def set_background(self, color: str) -> None:
self.collector.append(("bgcolor", color))
def clear(self) -> None:
self.collector = []
class PathBackend(BasicBackend):
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.collector.append(("path", path, properties))
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
self.collector.append(("filled_path", tuple(paths), properties))
@@ -0,0 +1,15 @@
# Copyright (c) 2020-2021, Matthew Broadway
# License: MIT License
from __future__ import annotations
from ezdxf.addons.drawing.backend import BackendInterface
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.math import Vec3
def draw_rect(points: list[Vec3], color: Color, out: BackendInterface):
from ezdxf.addons.drawing.properties import BackendProperties
props = BackendProperties(color=color)
for a, b in zip(points, points[1:]):
out.draw_line(a, b, props)
@@ -0,0 +1,223 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING, no_type_check
from functools import lru_cache
import enum
import numpy as np
from ezdxf import colors
from ezdxf.lldxf.const import VALID_DXF_LINEWEIGHTS
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import to_splines_and_polylines, to_hatches
from ezdxf.layouts import BaseLayout
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
from .properties import BackendProperties
if TYPE_CHECKING:
from ezdxf.document import Drawing
from ezdxf.entities import Solid
class ColorMode(enum.Enum):
"""This enum is used to define the color output mode of the :class:`DXFBackend`.
Attributes:
ACI: the color is set as :ref:`ACI` and assigned by layer
RGB: the color is set as RGB true color value
"""
# Use color index as primary color
ACI = enum.auto()
# Use always the RGB value
RGB = enum.auto()
DARK_COLOR_THRESHOLD = 0.2
RGB_BLACK = colors.RGB(0, 0, 0)
BYLAYER = 256
class DXFBackend(BackendInterface):
"""The :class:`DXFBackend` creates simple DXF files of POINT, LINE, LWPOLYLINE and
HATCH entities. This backend does ot need any additional packages.
Args:
layout: a DXF :class:`~ezdxf.layouts.BaseLayout`
color_mode: see :class:`ColorMode`
"""
def __init__(
self, layout: BaseLayout, color_mode: ColorMode = ColorMode.RGB
) -> None:
assert layout.doc is not None, "valid DXF document required"
super().__init__()
self.layout = layout
self.doc = layout.doc
self.color_mode = color_mode
self.bg_color = RGB_BLACK
self.is_dark_bg = True
self._layers: dict[int, str] = dict()
self._dxfattribs: dict[int, dict] = dict()
def set_background(self, color: Color) -> None:
self.bg_color = colors.RGB.from_hex(color)
self.is_dark_bg = self.bg_color.luminance < DARK_COLOR_THRESHOLD
def get_layer_name(self, pen: int) -> str:
try:
return self._layers[pen]
except KeyError:
pass
layer_name = f"PEN_{pen:03d}"
self._layers[pen] = layer_name
if not self.doc.layers.has_entry(layer_name):
self.doc.layers.add(layer_name, color=pen)
return layer_name
def resolve_properties(self, properties: BackendProperties) -> dict:
key = hash(properties)
try:
return self._dxfattribs[key]
except KeyError:
pass
rgb = properties.rgb
pen = properties.pen
if pen < 1 or pen > 255:
pen = 7
aci = pen
if self.color_mode == ColorMode.ACI:
aci = BYLAYER
attribs = {
"color": aci,
"layer": self.get_layer_name(pen),
"lineweight": make_lineweight(properties.lineweight),
}
if self.color_mode == ColorMode.RGB:
attribs["true_color"] = colors.rgb2int(rgb)
alpha = properties.color[7:9]
if alpha:
try:
f = int(alpha, 16) / 255
except ValueError:
pass
else:
attribs["transparency"] = colors.float2transparency(f)
self._dxfattribs[key] = attribs
return attribs
def set_solid_fill(self, hatch, properties: BackendProperties) -> None:
rgb: colors.RGB | None = None
aci = BYLAYER
if self.color_mode == ColorMode.RGB:
rgb = properties.rgb
aci = properties.pen
hatch.set_solid_fill(color=aci, style=0, rgb=rgb)
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.layout.add_point(pos, dxfattribs=self.resolve_properties(properties))
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.layout.add_line(start, end, dxfattribs=self.resolve_properties(properties))
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
attribs = self.resolve_properties(properties)
for start, end in lines:
self.layout.add_line(start, end, dxfattribs=attribs)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
attribs = self.resolve_properties(properties)
if path.has_curves:
for entity in to_splines_and_polylines(path, dxfattribs=attribs): # type: ignore
self.layout.add_entity(entity)
else:
self.layout.add_lwpolyline(path.control_vertices(), dxfattribs=attribs)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
attribs = self.resolve_properties(properties)
py_paths = [p.to_path() for p in paths]
for hatch in to_hatches(py_paths, dxfattribs=attribs):
self.layout.add_entity(hatch)
self.set_solid_fill(hatch, properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
hatch = self.layout.add_hatch(dxfattribs=self.resolve_properties(properties))
hatch.paths.add_polyline_path(points.vertices(), is_closed=True)
self.set_solid_fill(hatch, properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
def configure(self, config: Configuration) -> None:
pass
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_transparency(alpha: int) -> float:
return colors.float2transparency(alpha / 255)
@lru_cache(maxsize=None)
def make_lineweight(width: float) -> int:
width_int = int(width * 100)
for lw in VALID_DXF_LINEWEIGHTS:
if width_int <= lw:
return lw
return VALID_DXF_LINEWEIGHTS[-1]
@no_type_check
def update_extents(doc: Drawing, bbox: BoundingBox2d) -> None:
doc.header["$EXTMIN"] = (bbox.extmin.x, bbox.extmin.y, 0)
doc.header["$EXTMAX"] = (bbox.extmax.x, bbox.extmax.y, 0)
def setup_paperspace(doc: Drawing, bbox: BoundingBox2d):
psp_size = bbox.size / 40.0 # plu to mm
psp_center = psp_size * 0.5
psp = doc.paperspace()
psp.page_setup(size=(psp_size.x, psp_size.y), margins=(0, 0, 0, 0), units="mm")
psp.add_viewport(
center=psp_center,
size=(psp_size.x, psp_size.y),
view_center_point=bbox.center,
view_height=bbox.size.y,
status=2,
)
def add_background(msp: BaseLayout, bbox: BoundingBox2d, color: colors.RGB) -> Solid:
v = bbox.rect_vertices()
bg = msp.add_solid(
[v[0], v[1], v[3], v[2]], dxfattribs={"true_color": colors.rgb2int(color)}
)
return bg
@@ -0,0 +1,228 @@
import pathlib
import sys
from abc import ABC, abstractmethod
import subprocess
import os
import platform
from ezdxf.addons.drawing.backend import BackendInterface
class FileOutputRenderBackend(ABC):
def __init__(self, dpi: float) -> None:
self._dpi = dpi
@abstractmethod
def supported_formats(self) -> list[tuple[str, str]]:
raise NotImplementedError
@abstractmethod
def default_format(self) -> str:
raise NotImplementedError
@abstractmethod
def backend(self) -> BackendInterface:
raise NotImplementedError
@abstractmethod
def save(self, output: pathlib.Path) -> None:
raise NotImplementedError
class MatplotlibFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
try:
import matplotlib.pyplot as plt
except ImportError:
raise ImportError("Matplotlib not found") from None
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
self._plt = plt
self._fig = plt.figure()
self._ax = self._fig.add_axes((0, 0, 1, 1))
self._backend = MatplotlibBackend(self._ax)
def supported_formats(self) -> list[tuple[str, str]]:
return list(self._fig.canvas.get_supported_filetypes().items())
def default_format(self) -> str:
return "png"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
self._fig.savefig(output, dpi=self._dpi)
self._plt.close(self._fig)
class PyQtFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
try:
from ezdxf.addons.xqt import QtCore, QtGui, QtWidgets
from ezdxf.addons.drawing.pyqt import PyQtBackend
except ImportError:
raise ImportError("PyQt not found") from None
self._qc = QtCore
self._qg = QtGui
self._qw = QtWidgets
self._app = QtWidgets.QApplication(sys.argv)
self._scene = QtWidgets.QGraphicsScene()
self._backend = PyQtBackend()
self._backend.set_scene(self._scene)
def supported_formats(self) -> list[tuple[str, str]]:
# https://doc.qt.io/qt-6/qimage.html#reading-and-writing-image-files
return [
("bmp", "Windows Bitmap"),
("jpg", "Joint Photographic Experts Group"),
("jpeg", "Joint Photographic Experts Group"),
("png", "Portable Network Graphics"),
("ppm", "Portable Pixmap"),
("xbm", "X11 Bitmap"),
("xpm", "X11 Pixmap"),
("svg", "Scalable Vector Graphics"),
]
def default_format(self) -> str:
return "png"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
if output.suffix.lower() == ".svg":
from PySide6.QtSvg import QSvgGenerator
generator = QSvgGenerator()
generator.setFileName(str(output))
generator.setResolution(int(self._dpi))
scene_rect = self._scene.sceneRect()
output_size = self._qc.QSize(
round(scene_rect.size().width()), round(scene_rect.size().height())
)
generator.setSize(output_size)
generator.setViewBox(
self._qc.QRect(0, 0, output_size.width(), output_size.height())
)
painter = self._qg.QPainter()
transform = self._qg.QTransform()
transform.scale(1, -1)
transform.translate(0, -output_size.height())
painter.begin(generator)
painter.setWorldTransform(transform, combine=True)
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
self._scene.render(painter)
painter.end()
else:
view = self._qw.QGraphicsView(self._scene)
view.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
sizef: QRectF = self._scene.sceneRect() * self._dpi / 92 # type: ignore
image = self._qg.QImage(
self._qc.QSize(round(sizef.width()), round(sizef.height())),
self._qg.QImage.Format.Format_ARGB32,
)
painter = self._qg.QPainter(image)
painter.setRenderHint(self._qg.QPainter.RenderHint.Antialiasing)
painter.fillRect(image.rect(), self._scene.backgroundBrush())
self._scene.render(painter)
painter.end()
image.mirror(False, True)
image.save(str(output))
class MuPDFFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
from ezdxf.addons.drawing.pymupdf import PyMuPdfBackend, is_pymupdf_installed
if not is_pymupdf_installed:
raise ImportError("PyMuPDF not found")
self._backend = PyMuPdfBackend()
def supported_formats(self) -> list[tuple[str, str]]:
# https://pymupdf.readthedocs.io/en/latest/pixmap.html#pixmapoutput
return [
("pdf", "Portable Document Format"),
("svg", "Scalable Vector Graphics"),
("jpg", "Joint Photographic Experts Group"),
("jpeg", "Joint Photographic Experts Group"),
("pam", "Portable Arbitrary Map"),
("pbm", "Portable Bitmap"),
("pgm", "Portable Graymap"),
("png", "Portable Network Graphics"),
("pnm", "Portable Anymap"),
("ppm", "Portable Pixmap (no alpha channel)"),
("ps", "Adobe PostScript Image"),
("psd", "Adobe Photoshop Document"),
]
def default_format(self) -> str:
return "pdf"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
from ezdxf.addons.drawing import layout
backend = self._backend.get_replay(layout.Page(0, 0))
if output.suffix == ".pdf":
output.write_bytes(backend.get_pdf_bytes())
elif output.suffix == ".svg":
output.write_text(backend.get_svg_image())
else:
pixmap = backend.get_pixmap(int(self._dpi), alpha=True)
pixmap.save(str(output))
class SvgFileOutput(FileOutputRenderBackend):
def __init__(self, dpi: float) -> None:
super().__init__(dpi)
from ezdxf.addons.drawing.svg import SVGBackend
self._backend = SVGBackend()
def supported_formats(self) -> list[tuple[str, str]]:
return [("svg", "Scalable Vector Graphics")]
def default_format(self) -> str:
return "svg"
def backend(self) -> BackendInterface:
return self._backend
def save(self, output: pathlib.Path) -> None:
from ezdxf.addons.drawing import layout
output.write_text(self._backend.get_string(layout.Page(0, 0)))
def open_file(path: pathlib.Path) -> None:
"""open the given path in the default application"""
system = platform.system()
if system == "Darwin":
subprocess.call(
["open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
elif system == "Windows":
os.startfile(str(path)) # type: ignore
else:
subprocess.call(
["xdg-open", str(path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Iterator
from ezdxf.entities import DXFGraphic, DXFEntity
from ezdxf.lldxf import const
from ezdxf.lldxf.tagwriter import AbstractTagWriter
from ezdxf.protocols import SupportsVirtualEntities
from ezdxf.entities.copy import default_copy, CopyNotSupported
class DXFGraphicProxy(DXFGraphic):
"""DO NOT USE THIS WRAPPER AS REAL DXF ENTITY OUTSIDE THE DRAWING ADD-ON!"""
def __init__(self, entity: DXFEntity):
super().__init__()
self.entity = entity
self.dxf = self._setup_dxf_namespace(entity)
def _setup_dxf_namespace(self, entity):
# copy DXF namespace - modifications do not effect the wrapped entity
dxf = entity.dxf.copy(self)
# setup mandatory DXF attributes without default values like layer:
for k, v in self.DEFAULT_ATTRIBS.items():
if not dxf.hasattr(k):
dxf.set(k, v)
return dxf
def dxftype(self) -> str:
return self.entity.dxftype()
def __virtual_entities__(self) -> Iterator[DXFGraphic]:
"""Implements the SupportsVirtualEntities protocol."""
if isinstance(self.entity, SupportsVirtualEntities):
return self.entity.__virtual_entities__()
if hasattr(self.entity, "virtual_entities"):
return self.entity.virtual_entities()
return iter([])
def virtual_entities(self) -> Iterable[DXFGraphic]:
return self.__virtual_entities__()
def copy(self, copy_strategy=default_copy) -> DXFGraphicProxy:
raise CopyNotSupported(f"Copying of DXFGraphicProxy() not supported.")
def preprocess_export(self, tagwriter: AbstractTagWriter) -> bool:
# prevent dxf export
return False
@@ -0,0 +1,549 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check
import copy
import numpy as np
from ezdxf import colors
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import Command
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
__all__ = ["PlotterBackend"]
SEMICOLON = ord(";")
PRELUDE = b"%0B;IN;BP;"
EPILOG = b"PU;PA0,0;"
FLATTEN_MAX = 10 # plot units
MM_TO_PLU = 40 # 40 plu = 1mm
DEFAULT_PEN = 0
WHITE = colors.RGB(255, 255, 255)
BLACK = colors.RGB(0, 0, 0)
MAX_FLATTEN = 10
# comparing Command.<attrib> to ints is very slow
CMD_MOVE_TO = int(Command.MOVE_TO)
CMD_LINE_TO = int(Command.LINE_TO)
CMD_CURVE3_TO = int(Command.CURVE3_TO)
CMD_CURVE4_TO = int(Command.CURVE4_TO)
class PlotterBackend(recorder.Recorder):
"""The :class:`PlotterBackend` creates HPGL/2 plot files for output on raster
plotters. This backend does not need any additional packages. This backend support
content cropping at page margins.
The plot files are tested by the plot file viewer `ViewCompanion Standard`_
but not on real hardware - please use with care and give feedback.
.. _ViewCompanion Standard: http://www.softwarecompanions.com/
"""
def __init__(self) -> None:
super().__init__()
def get_bytes(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
curves=True,
decimal_places: int = 1,
base=64,
) -> bytes:
"""Returns the HPGL/2 data as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
curves: use Bèzier curves for HPGL/2 output
decimal_places: HPGL/2 output precision, less decimal places creates smaller
files but for the price of imprecise curves (text)
base: base for polyline encoding, 32 for 7 bit encoding or 64 for 8 bit encoding
"""
top_origin = False
settings = copy.copy(settings)
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the bottom-left corner.
output_layout = layout.Layout(render_box, flip_y=False)
page = output_layout.get_final_page(page, settings)
if page.width == 0 or page.height == 0:
return b"" # empty page
# DXF coordinates are mapped to integer coordinates (plu) in the first
# quadrant: 40 plu = 1mm
settings.output_coordinate_space = (
max(page.width_in_mm, page.height_in_mm) * MM_TO_PLU
)
# transform content to the output coordinates space:
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * MM_TO_PLU # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
backend = _RenderBackend(
page,
settings=settings,
curves=curves,
decimal_places=decimal_places,
base=base,
)
player.replay(backend)
return backend.get_bytes()
def compatible(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 7-bit encoded bytes curves as approximated
polylines and coordinates are rounded to integer values.
Has often the smallest file size and should be compatible to all output devices
but has a low quality text rendering.
"""
return self.get_bytes(
page, settings=settings, curves=False, decimal_places=0, base=32
)
def low_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
curves and coordinates are rounded to integer values.
Has a smaller file size than normal quality and the output device must support
8-bit encoding and Bèzier curves.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=0, base=64
)
def normal_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes, curves as Bézier
curves and coordinates are floats rounded to one decimal place.
Has a smaller file size than high quality and the output device must support
8-bit encoding, Bèzier curves and fractional coordinates.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=1, base=64
)
def high_quality(
self, page: layout.Page, settings: layout.Settings = layout.Settings()
) -> bytes:
"""Returns the HPGL/2 data as 8-bit encoded bytes and all curves as Bézier
curves and coordinates are floats rounded to two decimal places.
Has the largest file size and the output device must support 8-bit encoding,
Bèzier curves and fractional coordinates.
"""
return self.get_bytes(
page, settings=settings, curves=True, decimal_places=2, base=64
)
class PenTable:
def __init__(self, max_pens: int = 64) -> None:
self.pens: dict[int, colors.RGB] = dict()
self.max_pens = int(max_pens)
def __contains__(self, index: int) -> bool:
return index in self.pens
def __getitem__(self, index: int) -> colors.RGB:
return self.pens[index]
def add_pen(self, index: int, color: colors.RGB):
self.pens[index] = color
def to_bytes(self) -> bytes:
command: list[bytes] = [f"NP{self.max_pens-1};".encode()]
pens: list[tuple[int, colors.RGB]] = [
(index, rgb) for index, rgb in self.pens.items()
]
pens.sort()
for index, rgb in pens:
command.append(make_pc(index, rgb))
return b"".join(command)
def make_pc(pen: int, rgb: colors.RGB) -> bytes:
# pen color
return f"PC{pen},{rgb.r},{rgb.g},{rgb.b};".encode()
class _RenderBackend(BackendInterface):
"""Creates the HPGL/2 output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Move content in the first quadrant of the coordinate system.
- The output coordinates are integer values, scale the content appropriately:
- 1 plot unit (plu) = 0.025mm
- 40 plu = 1mm
- 1016 plu = 1 inch
- 3.39 plu = 1 dot @300 dpi
- Replay the recorded output on this backend.
"""
def __init__(
self,
page: layout.Page,
*,
settings: layout.Settings,
curves=True,
decimal_places: int = 2,
base: int = 64,
) -> None:
self.settings = settings
self.curves = curves
self.factional_bits = round(decimal_places * 3.33)
self.decimal_places: int | None = (
int(decimal_places) if decimal_places else None
)
self.base = base
self.header: list[bytes] = []
self.data: list[bytes] = []
self.pen_table = PenTable(max_pens=256)
self.current_pen: int = 0
self.current_pen_width: float = 0.0
self.setup(page)
self._stroke_width_cache: dict[float, float] = dict()
# StrokeWidthPolicy.absolute:
# pen width in mm as resolved by the frontend
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# fixed lineweight for all strokes in ABSOLUTE mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of max(width, height)
max_size = max(page.width_in_mm, page.height_in_mm)
self.max_stroke_width: float = round(max_size * settings.max_stroke_width, 2)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: float = round(
self.max_stroke_width * settings.min_stroke_width, 2
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: float = round(
self.max_stroke_width * settings.fixed_stroke_width, 2
)
def setup(self, page: layout.Page) -> None:
cmd = f"PS{page.width_in_mm*MM_TO_PLU:.0f},{page.height_in_mm*MM_TO_PLU:.0f};"
self.header.append(cmd.encode())
self.header.append(b"FT1;PA;") # solid fill; plot absolute;
def get_bytes(self) -> bytes:
header: list[bytes] = list(self.header)
header.append(self.pen_table.to_bytes())
return compile_hpgl2(header, self.data)
def switch_current_pen(self, pen_number: int, rgb: colors.RGB) -> int:
if pen_number in self.pen_table:
pen_color = self.pen_table[pen_number]
if rgb != pen_color:
self.data.append(make_pc(DEFAULT_PEN, rgb))
pen_number = DEFAULT_PEN
else:
self.pen_table.add_pen(pen_number, rgb)
return pen_number
def set_pen(self, pen_number: int) -> None:
if self.current_pen == pen_number:
return
self.data.append(f"SP{pen_number};".encode())
self.current_pen = pen_number
def set_pen_width(self, width: float) -> None:
if self.current_pen_width == width:
return
self.data.append(f"PW{width:g};".encode()) # pen width in mm
self.current_pen_width = width
def enter_polygon_mode(self, start_point: Vec2) -> None:
x = round(start_point.x, self.decimal_places)
y = round(start_point.y, self.decimal_places)
self.data.append(f"PA;PU{x},{y};PM;".encode())
def close_current_polygon(self) -> None:
self.data.append(b"PM1;")
def fill_polygon(self) -> None:
self.data.append(b"PM2;FP;") # even/odd fill method
def set_properties(self, properties: BackendProperties) -> None:
pen_number = properties.pen
pen_color, opacity = self.resolve_pen_color(properties.color)
pen_width = self.resolve_pen_width(properties.lineweight)
pen_number = self.switch_current_pen(pen_number, pen_color)
self.set_pen(pen_number)
self.set_pen_width(pen_width)
def add_polyline_encoded(
self, vertices: Iterable[Vec2], properties: BackendProperties
):
self.set_properties(properties)
self.data.append(polyline_encoder(vertices, self.factional_bits, self.base))
def add_path(self, path: BkPath2d, properties: BackendProperties):
if self.curves and path.has_curves:
self.set_properties(properties)
self.data.append(path_encoder(path, self.decimal_places))
else:
points = list(path.flattening(MAX_FLATTEN, segments=4))
self.add_polyline_encoded(points, properties)
@staticmethod
def resolve_pen_color(color: Color) -> tuple[colors.RGB, float]:
rgb = colors.RGB.from_hex(color)
if rgb == WHITE:
rgb = BLACK
return rgb, alpha_to_opacity(color[7:9])
def resolve_pen_width(self, width: float) -> float:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.fixed_stroke_width
policy = self.lineweight_policy
if policy == LineweightPolicy.ABSOLUTE:
if self.lineweight_scaling:
width = max(self.min_lineweight, width) * self.lineweight_scaling
else:
width = self.min_lineweight
stroke_width = round(width, 2) # in mm
elif policy == LineweightPolicy.RELATIVE:
stroke_width = round(
map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
),
2,
)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def set_background(self, color: Color) -> None:
# background is always a white paper
pass
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_polyline_encoded([pos], properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_polyline_encoded([start, end], properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
for line in lines:
self.add_polyline_encoded(line, properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
for sub_path in path.sub_paths():
if len(sub_path) == 0:
continue
self.add_path(sub_path, properties)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
self.enter_polygon_mode(paths[0].start)
for p in paths:
for sub_path in p.sub_paths():
if len(sub_path) == 0:
continue
self.add_path(sub_path, properties)
self.close_current_polygon()
self.fill_polygon()
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
points2d: list[Vec2] = points.vertices()
if points2d:
self.enter_polygon_mode(points2d[0])
self.add_polyline_encoded(points2d, properties)
self.fill_polygon()
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: float,
max_stroke_width: float,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> float:
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return round(min_stroke_width + round(lineweight * factor), 2)
def flatten_path(path: BkPath2d) -> Sequence[Vec2]:
points = list(path.flattening(distance=FLATTEN_MAX))
return points
def compile_hpgl2(header: Sequence[bytes], commands: Sequence[bytes]) -> bytes:
output = bytearray(PRELUDE)
output.extend(b"".join(header))
output.extend(b"".join(commands))
output.extend(EPILOG)
return bytes(output)
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
if frac_bits:
value *= 1 << frac_bits
x = round(value)
if x >= 0:
x *= 2
else:
x = abs(x) * 2 + 1
chars = bytearray()
while x >= base:
x, r = divmod(x, base)
chars.append(63 + r)
if base == 64:
chars.append(191 + x)
else:
chars.append(95 + x)
return bytes(chars)
def polyline_encoder(vertices: Iterable[Vec2], frac_bits: int, base: int) -> bytes:
cmd = b"PE"
if base == 32:
cmd = b"PE7"
if frac_bits:
cmd += b">" + pe_encode(frac_bits, 0, base)
data = [cmd + b"<="]
vertices = list(vertices)
# first point as absolute coordinates
current = vertices[0]
data.append(pe_encode(current.x, frac_bits, base))
data.append(pe_encode(current.y, frac_bits, base))
for vertex in vertices[1:]:
# remaining points as relative coordinates
delta = vertex - current
data.append(pe_encode(delta.x, frac_bits, base))
data.append(pe_encode(delta.y, frac_bits, base))
current = vertex
data.append(b";")
return b"".join(data)
@no_type_check
def path_encoder(path: BkPath2d, decimal_places: int | None) -> bytes:
# first point as absolute coordinates
current = path.start
x = round(current.x, decimal_places)
y = round(current.y, decimal_places)
data = [f"PU;PA{x:g},{y:g};PD;".encode()]
prev_command = Command.MOVE_TO
if len(path):
commands: list[bytes] = []
for cmd in path.commands():
delta = cmd.end - current
xe = round(delta.x, decimal_places)
ye = round(delta.y, decimal_places)
if cmd.type == Command.LINE_TO:
coords = f"{xe:g},{ye:g};".encode()
if prev_command == Command.LINE_TO:
# extend previous PR command
commands[-1] = commands[-1][:-1] + b"," + coords
else:
commands.append(b"PR" + coords)
prev_command = Command.LINE_TO
else:
if cmd.type == Command.CURVE3_TO:
control = cmd.ctrl - current
end = cmd.end - current
control_1 = 2.0 * control / 3.0
control_2 = end + 2.0 * (control - end) / 3.0
elif cmd.type == Command.CURVE4_TO:
control_1 = cmd.ctrl1 - current
control_2 = cmd.ctrl2 - current
else:
raise ValueError("internal error: MOVE_TO command is illegal here")
x1 = round(control_1.x, decimal_places)
y1 = round(control_1.y, decimal_places)
x2 = round(control_2.x, decimal_places)
y2 = round(control_2.y, decimal_places)
coords = f"{x1:g},{y1:g},{x2:g},{y2:g},{xe:g},{ye:g};".encode()
if prev_command == Command.CURVE4_TO:
# extend previous BR command
commands[-1] = commands[-1][:-1] + b"," + coords
else:
commands.append(b"BR" + coords)
prev_command = Command.CURVE4_TO
current = cmd.end
data.append(b"".join(commands))
data.append(b"PU;")
return b"".join(data)
@@ -0,0 +1,590 @@
# Copyright (c) 2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check, Any, Callable, Dict, List, Tuple
from typing_extensions import TypeAlias, override
import abc
import json
from ezdxf.math import Vec2, world_mercator_to_gps
from ezdxf.path import Command, nesting
from ezdxf.npshapes import orient_paths, single_paths
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration
from .properties import BackendProperties
__all__ = ["CustomJSONBackend", "GeoJSONBackend"]
CUSTOM_JSON_SPECS = """
JSON content = [entity, entity, ...]
entity = {
"type": point | lines | path | filled-paths | filled-polygon,
"properties": {
"color": "#RRGGBBAA",
"stroke-width": 0.25, # in mm
"layer": "name"
},
"geometry": depends on "type"
}
DXF linetypes (DASH, DOT, ...) are resolved into solid lines.
A single point:
point = {
"type": "point",
"properties": {...},
"geometry": [x, y]
}
Multiple lines with common properties:
lines = {
"type": "lines",
"properties": {...},
"geometry": [
(x0, y0, x1, y1), # 1. line
(x0, y0, x1, y1), # 2. line
....
]
}
Lines can contain points where x0 == x1 and y0 == y1!
A single linear path without filling:
path = {
"type": "path",
"properties": {...},
"geometry": [path-command, ...]
}
SVG-like path structure:
- The first path-command is always an absolute move to "M"
- The "M" command does not appear inside a path, each path is a continuouse geometry
(no multi-paths).
path-command =
("M", x, y) = absolute move to
("L", x, y) = absolute line to
("Q", x0, y0, x1, y1) = absolute quadratice Bezier curve to
- (x0, y0) = control point
- (x1, y1) = end point
("C", x0, y0, x1, y1, x2, y2) = absolute cubic Bezier curve to
- (x0, y0) = control point 1
- (x1, y1) = control point 2
- (x2, y2) = end point
("Z",) = close path
Multiple filled paths:
Exterior paths and holes are mixed and NOT oriented by default (clockwise or
counter-clockwise) - PyQt and SVG have no problem with that structure but matplotlib
requires oriented paths. When oriented paths are required the CustomJSONBackend can
orient the paths on demand.
filled-paths = {
"type": "filled-paths",
"properties": {...},
"geometry": [
[path-command, ...], # 1. path
[path-command, ...], # 2. path
...
]
}
A single filled polygon:
A polygon is explicitly closed, so first vertex == last vertex is guaranteed.
filled-polygon = {
"type": "filled-polygon",
"properties": {...},
"geometry": [
(x0, y0),
(x1, y1),
(x2, y2),
...
]
}
"""
class _JSONBackend(BackendInterface):
def __init__(self) -> None:
self._entities: list[dict[str, Any]] = []
self.max_sagitta = 0.01 # set by configure()
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
# set fixed lineweight for all strokes:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
self.fixed_lineweight = 0.0
@abc.abstractmethod
def get_json_data(self) -> Any: ...
def get_string(self, *, indent: int | str = 2) -> str:
"""Returns the result as a JSON string."""
return json.dumps(self.get_json_data(), indent=indent)
@override
def configure(self, config: Configuration) -> None:
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
if self.lineweight_scaling == 0.0:
# use a fixed lineweight for all strokes defined by min_lineweight
self.fixed_lineweight = self.min_lineweight
self.max_sagitta = config.max_flattening_distance
@override
def clear(self) -> None:
self._entities.clear()
@override
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass
@override
def set_background(self, color: Color) -> None:
pass
@override
def finalize(self) -> None:
pass
@override
def enter_entity(self, entity, properties) -> None:
pass
@override
def exit_entity(self, entity) -> None:
pass
MOVE_TO_ABS = "M"
LINE_TO_ABS = "L"
QUAD_TO_ABS = "Q"
CUBIC_TO_ABS = "C"
CLOSE_PATH = "Z"
class CustomJSONBackend(_JSONBackend):
"""Creates a JSON-like output with a custom JSON scheme. This scheme supports
curved shapes by a SVG-path like structure and coordinates are not limited in
any way. This backend can be used to send geometries from a web-backend to a
frontend.
The JSON scheme is documented in the source code:
https://github.com/mozman/ezdxf/blob/master/src/ezdxf/addons/drawing/json.py
Args:
orient_paths: orient exterior and hole paths on demand, exterior paths have
counter-clockwise orientation and holes have clockwise orientation.
**Class Methods**
.. automethod:: get_json_data
.. automethod:: get_string
.. versionadded:: 1.3.0
"""
def __init__(self, orient_paths=False) -> None:
super().__init__()
self.orient_paths = orient_paths
@override
def get_json_data(self) -> list[dict[str, Any]]:
"""Returns the result as a JSON-like data structure."""
return self._entities
def add_entity(
self, entity_type: str, geometry: Sequence[Any], properties: BackendProperties
):
if not geometry:
return
self._entities.append(
{
"type": entity_type,
"properties": self.make_properties_dict(properties),
"geometry": geometry,
}
)
def make_properties_dict(self, properties: BackendProperties) -> dict[str, Any]:
if self.fixed_lineweight:
stroke_width = self.fixed_lineweight
else:
stroke_width = max(
self.min_lineweight, properties.lineweight * self.lineweight_scaling
)
return {
"color": properties.color,
"stroke-width": round(stroke_width, 2),
"layer": properties.layer,
}
@override
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_entity("point", [pos.x, pos.y], properties)
@override
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_entity("lines", [(start.x, start.y, end.x, end.y)], properties)
@override
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
self.add_entity("lines", [(s.x, s.y, e.x, e.y) for s, e in lines], properties)
@override
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.add_entity("path", make_json_path(path), properties)
@override
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
if self.orient_paths:
paths = orient_paths(paths) # returns single paths
else:
# Just single paths allowed, no multi paths!
paths = single_paths(paths)
json_paths: list[Any] = []
for path in paths:
if len(path):
json_paths.append(make_json_path(path, close=True))
if json_paths:
self.add_entity("filled-paths", json_paths, properties)
@override
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices: list[Vec2] = points.vertices()
if len(vertices) < 3:
return
if not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
self.add_entity("filled-polygon", [(v.x, v.y) for v in vertices], properties)
@no_type_check
def make_json_path(path: BkPath2d, close=False) -> list[Any]:
if len(path) == 0:
return []
end: Vec2 = path.start
commands: list = [(MOVE_TO_ABS, end.x, end.y)]
for cmd in path.commands():
end = cmd.end
if cmd.type == Command.MOVE_TO:
commands.append((MOVE_TO_ABS, end.x, end.y))
elif cmd.type == Command.LINE_TO:
commands.append((LINE_TO_ABS, end.x, end.y))
elif cmd.type == Command.CURVE3_TO:
c1 = cmd.ctrl
commands.append((QUAD_TO_ABS, c1.x, c1.y, end.x, end.y))
elif cmd.type == Command.CURVE4_TO:
c1 = cmd.ctrl1
c2 = cmd.ctrl2
commands.append((CUBIC_TO_ABS, c1.x, c1.y, c2.x, c2.y, end.x, end.y))
if close:
commands.append(CLOSE_PATH)
return commands
# dict and list not allowed here for Python < 3.10
PropertiesMaker: TypeAlias = Callable[[str, float, str], Dict[str, Any]]
TransformFunc: TypeAlias = Callable[[Vec2], Tuple[float, float]]
# GeoJSON ring
Ring: TypeAlias = List[Tuple[float, float]]
# The first ring is the exterior path followed by optional holes, nested polygons are
# not supported by the GeoJSON specification.
GeoJsonPolygon: TypeAlias = List[Ring]
def properties_maker(color: str, stroke_width: float, layer: str) -> dict[str, Any]:
"""Returns the property dict::
{
"color": color,
"stroke-width": stroke_width,
"layer": layer,
}
Returning an empty dict prevents properties in the GeoJSON output and also avoids
wraping entities into "Feature" objects.
"""
return {
"color": color,
"stroke-width": round(stroke_width, 2),
"layer": layer,
}
def no_transform(location: Vec2) -> tuple[float, float]:
"""Dummy transformation function. Does not apply any transformations and
just returns the input coordinates.
"""
return (location.x, location.y)
def make_world_mercator_to_gps_function(tol: float = 1e-6) -> TransformFunc:
"""Returns a function to transform WGS84 World Mercator `EPSG:3395 <https://epsg.io/3395>`_
location given as cartesian 2D coordinates x, y in meters into WGS84 decimal
degrees as longitude and latitude `EPSG:4326 <https://epsg.io/4326>`_ as
used by GPS.
Args:
tol: accuracy for latitude calculation
"""
def _transform(location: Vec2) -> tuple[float, float]:
"""Transforms WGS84 World Mercator EPSG:3395 coordinates to WGS84 EPSG:4326."""
return world_mercator_to_gps(location.x, location.y, tol)
return _transform
class GeoJSONBackend(_JSONBackend):
"""Creates a JSON-like output according the `GeoJSON`_ scheme.
GeoJSON uses a geographic coordinate reference system, World Geodetic
System 1984 `EPSG:4326 <https://epsg.io/4326>`_, and units of decimal degrees.
- Latitude: -90 to +90 (South/North)
- Longitude: -180 to +180 (East/West)
So most DXF files will produce invalid coordinates and it is the job of the
**package-user** to provide a function to transfrom the input coordinates to
EPSG:4326! The :class:`~ezdxf.addons.drawing.recorder.Recorder` and
:class:`~ezdxf.addons.drawing.recorder.Player` classes can help to detect the
extents of the DXF content.
Default implementation:
.. autofunction:: no_transform
Factory function to make a transform function from WGS84 World Mercator
`EPSG:3395 <https://epsg.io/3395>`_ coordinates to WGS84 (GPS)
`EPSG:4326 <https://epsg.io/4326>`_.
.. autofunction:: make_world_mercator_to_gps_function
The GeoJSON format supports only straight lines so curved shapes are flattened to
polylines and polygons.
The properties are handled as a foreign member feature and is therefore not defined
in the GeoJSON specs. It is possible to provide a custom function to create these
property objects.
Default implementation:
.. autofunction:: properties_maker
Args:
properties_maker: function to create a properties dict.
**Class Methods**
.. automethod:: get_json_data
.. automethod:: get_string
.. versionadded:: 1.3.0
.. _GeoJSON: https://geojson.org/
"""
def __init__(
self,
properties_maker: PropertiesMaker = properties_maker,
transform_func: TransformFunc = no_transform,
) -> None:
super().__init__()
self._properties_dict_maker = properties_maker
self._transform_function = transform_func
@override
def get_json_data(self) -> dict[str, Any]:
"""Returns the result as a JSON-like data structure according the GeoJSON specs."""
if len(self._entities) == 0:
return {}
using_features = self._entities[0]["type"] == "Feature"
if using_features:
return {"type": "FeatureCollection", "features": self._entities}
else:
return {"type": "GeometryCollection", "geometries": self._entities}
def add_entity(self, entity: dict[str, Any], properties: BackendProperties):
if not entity:
return
properties_dict: dict[str, Any] = self._properties_dict_maker(
*self.make_properties(properties)
)
if properties_dict:
self._entities.append(
{
"type": "Feature",
"properties": properties_dict,
"geometry": entity,
}
)
else:
self._entities.append(entity)
def make_properties(self, properties: BackendProperties) -> tuple[str, float, str]:
if self.fixed_lineweight:
stroke_width = self.fixed_lineweight
else:
stroke_width = max(
self.min_lineweight, properties.lineweight * self.lineweight_scaling
)
return (properties.color, round(stroke_width, 2), properties.layer)
@override
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_entity(
geojson_object("Point", list(self._transform_function(pos))), properties
)
@override
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
tf = self._transform_function
self.add_entity(
geojson_object("LineString", [tf(start), tf(end)]),
properties,
)
@override
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
tf = self._transform_function
json_lines = [(tf(s), tf(e)) for s, e in lines]
self.add_entity(geojson_object("MultiLineString", json_lines), properties)
@override
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
tf = self._transform_function
vertices = [tf(v) for v in path.flattening(distance=self.max_sagitta)]
self.add_entity(geojson_object("LineString", vertices), properties)
@override
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
paths = list(paths)
if len(paths) == 0:
return
polygons: list[GeoJsonPolygon] = []
for path in paths:
if len(path):
polygons.extend(
geojson_polygons(
path, max_sagitta=self.max_sagitta, tf=self._transform_function
)
)
if polygons:
self.add_entity(geojson_object("MultiPolygon", polygons), properties)
@override
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices: list[Vec2] = points.vertices()
if len(vertices) < 3:
return
if not vertices[0].isclose(vertices[-1]):
vertices.append(vertices[0])
# exterior ring, without holes
tf = self._transform_function
self.add_entity(
geojson_object("Polygon", [[tf(v) for v in vertices]]), properties
)
def geojson_object(name: str, coordinates: Any) -> dict[str, Any]:
return {"type": name, "coordinates": coordinates}
def geojson_ring(
path: BkPath2d, is_hole: bool, max_sagitta: float, tf: TransformFunc
) -> Ring:
"""Returns a linear ring according to the GeoJSON specs.
- A linear ring is a closed LineString with four or more positions.
- The first and last positions are equivalent, and they MUST contain
identical values; their representation SHOULD also be identical.
- A linear ring is the boundary of a surface or the boundary of a
hole in a surface.
- A linear ring MUST follow the right-hand rule with respect to the
area it bounds, i.e., exterior rings are counterclockwise, and
holes are clockwise.
"""
if path.has_sub_paths:
raise TypeError("multi-paths not allowed")
vertices: Ring = [tf(v) for v in path.flattening(max_sagitta)]
if not path.is_closed:
start = path.start
vertices.append(tf(start))
clockwise = path.has_clockwise_orientation()
if (is_hole and not clockwise) or (not is_hole and clockwise):
vertices.reverse()
return vertices
def geojson_polygons(
path: BkPath2d, max_sagitta: float, tf: TransformFunc
) -> list[GeoJsonPolygon]:
"""Returns a list of polygons, where each polygon is a list of an exterior path and
optional holes e.g. [[ext0, hole0, hole1], [ext1], [ext2, hole0], ...].
"""
sub_paths: list[BkPath2d] = path.sub_paths()
if len(sub_paths) == 0:
return []
if len(sub_paths) == 1:
return [[geojson_ring(sub_paths[0], False, max_sagitta, tf)]]
polygons = nesting.make_polygon_structure(sub_paths)
geojson_polygons: list[GeoJsonPolygon] = []
for polygon in polygons:
geojson_polygon: GeoJsonPolygon = [
geojson_ring(polygon[0], False, max_sagitta, tf)
] # exterior ring
if len(polygon) > 1:
# GeoJSON has no support for nested hole structures, so the sub polygons of
# holes (hole[1]) are ignored yet!
holes = polygon[1]
if isinstance(holes, BkPath2d): # single hole
geojson_polygon.append(geojson_ring(holes, True, max_sagitta, tf))
continue
if isinstance(holes, (tuple, list)): # multiple holes
for hole in holes:
if isinstance(hole, (tuple, list)): # nested polygon
# TODO: add sub polygons of holes as separated polygons
hole = hole[0] # exterior path
geojson_polygon.append(geojson_ring(hole, True, max_sagitta, tf))
geojson_polygons.append(geojson_polygon)
return geojson_polygons
@@ -0,0 +1,561 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple, TYPE_CHECKING
from typing_extensions import Self
import math
import enum
import dataclasses
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
if TYPE_CHECKING:
from ezdxf.layouts.layout import Layout as DXFLayout
class Units(enum.IntEnum):
"""Page units as enum.
Attributes:
inch: 25.4 mm
px: 1/96 inch
pt: 1/72 inch
mm:
cm:
"""
# equivalent to ezdxf.units if possible
inch = 1
px = 2 # no equivalent DXF unit
pt = 3 # no equivalent DXF unit
mm = 4
cm = 5
# all page sizes in landscape orientation
PAGE_SIZES = {
"ISO A0": (1189, 841, Units.mm),
"ISO A1": (841, 594, Units.mm),
"ISO A2": (594, 420, Units.mm),
"ISO A3": (420, 297, Units.mm),
"ISO A4": (297, 210, Units.mm),
"ANSI A": (11, 8.5, Units.inch),
"ANSI B": (17, 11, Units.inch),
"ANSI C": (22, 17, Units.inch),
"ANSI D": (34, 22, Units.inch),
"ANSI E": (44, 34, Units.inch),
"ARCH C": (24, 18, Units.inch),
"ARCH D": (36, 24, Units.inch),
"ARCH E": (48, 36, Units.inch),
"ARCH E1": (42, 30, Units.inch),
"Letter": (11, 8.5, Units.inch),
"Legal": (14, 8.5, Units.inch),
}
UNITS_TO_MM = {
Units.mm: 1.0,
Units.cm: 10.0,
Units.inch: 25.4,
Units.px: 25.4 / 96.0,
Units.pt: 25.4 / 72.0,
}
class PageAlignment(enum.IntEnum):
"""Page alignment of content as enum.
Attributes:
TOP_LEFT:
TOP_CENTER:
TOP_RIGHT:
MIDDLE_LEFT:
MIDDLE_CENTER:
MIDDLE_RIGHT:
BOTTOM_LEFT:
BOTTOM_CENTER:
BOTTOM_RIGHT:
"""
TOP_LEFT = 1
TOP_CENTER = 2
TOP_RIGHT = 3
MIDDLE_LEFT = 4
MIDDLE_CENTER = 5
MIDDLE_RIGHT = 6
BOTTOM_LEFT = 7
BOTTOM_CENTER = 8
BOTTOM_RIGHT = 9
class Margins(NamedTuple):
"""Page margins definition class
Attributes:
top:
left:
bottom:
right:
"""
top: float
right: float
bottom: float
left: float
@classmethod
def all(cls, margin: float) -> Self:
"""Returns a page margins definition class with four equal margins."""
return cls(margin, margin, margin, margin)
@classmethod
def all2(cls, top_bottom: float, left_right: float) -> Self:
"""Returns a page margins definition class with equal top-bottom and
left-right margins.
"""
return cls(top_bottom, left_right, top_bottom, left_right)
# noinspection PyArgumentList
def scale(self, factor: float) -> Self:
return self.__class__(
self.top * factor,
self.right * factor,
self.bottom * factor,
self.left * factor,
)
@dataclasses.dataclass
class Page:
"""Page definition class
Attributes:
width: page width, 0 for auto-detect
height: page height, 0 for auto-detect
units: page units as enum :class:`Units`
margins: page margins in page units
max_width: limit width for auto-detection, 0 for unlimited
max_height: limit height for auto-detection, 0 for unlimited
"""
width: float
height: float
units: Units = Units.mm
margins: Margins = Margins.all(0)
max_width: float = 0.0
max_height: float = 0.0
def __post_init__(self):
assert isinstance(self.units, Units), "units require type <Units>"
assert isinstance(self.margins, Margins), "margins require type <Margins>"
@property
def to_mm_factor(self) -> float:
return UNITS_TO_MM[self.units]
@property
def width_in_mm(self) -> float:
"""Returns the page width in mm."""
return round(self.width * self.to_mm_factor, 1)
@property
def max_width_in_mm(self) -> float:
"""Returns max page width in mm."""
return round(self.max_width * self.to_mm_factor, 1)
@property
def height_in_mm(self) -> float:
"""Returns the page height in mm."""
return round(self.height * self.to_mm_factor, 1)
@property
def max_height_in_mm(self) -> float:
"""Returns max page height in mm."""
return round(self.max_height * self.to_mm_factor, 1)
@property
def margins_in_mm(self) -> Margins:
"""Returns the page margins in mm."""
return self.margins.scale(self.to_mm_factor)
@property
def is_landscape(self) -> bool:
"""Returns ``True`` if the page has landscape orientation."""
return self.width > self.height
@property
def is_portrait(self) -> bool:
"""Returns ``True`` if the page has portrait orientation. (square is portrait)"""
return self.width <= self.height
def to_landscape(self) -> None:
"""Converts the page to landscape orientation."""
if self.is_portrait:
self.width, self.height = self.height, self.width
def to_portrait(self) -> None:
"""Converts the page to portrait orientation."""
if self.is_landscape:
self.width, self.height = self.height, self.width
def get_margin_rect(self, top_origin=True) -> tuple[Vec2, Vec2]:
"""Returns the bottom-left and the top-right corner of the page margins in mm.
The origin (0, 0) is the top-left corner of the page if `top_origin` is
``True`` or in the bottom-left corner otherwise.
"""
margins = self.margins_in_mm
right_margin = self.width_in_mm - margins.right
page_height = self.height_in_mm
if top_origin:
bottom_left = Vec2(margins.left, margins.top)
top_right = Vec2(right_margin, page_height - margins.bottom)
else: # bottom origin
bottom_left = Vec2(margins.left, margins.bottom)
top_right = Vec2(right_margin, page_height - margins.top)
return bottom_left, top_right
@classmethod
def from_dxf_layout(cls, layout: DXFLayout) -> Self:
"""Returns the :class:`Page` based on the DXF attributes stored in the LAYOUT
entity. The modelspace layout often **doesn't** have usable page settings!
Args:
layout: any paperspace layout or the modelspace layout
"""
# all layout measurements in mm
width = round(layout.dxf.paper_width, 1)
height = round(layout.dxf.paper_height, 1)
top = round(layout.dxf.top_margin, 1)
right = round(layout.dxf.right_margin, 1)
bottom = round(layout.dxf.bottom_margin, 1)
left = round(layout.dxf.left_margin, 1)
rotation = layout.dxf.plot_rotation
if rotation == 1: # 90 degrees
return cls(
height,
width,
Units.mm,
margins=Margins(top=right, right=bottom, bottom=left, left=top),
)
elif rotation == 2: # 180 degrees
return cls(
width,
height,
Units.mm,
margins=Margins(top=bottom, right=left, bottom=top, left=right),
)
elif rotation == 3: # 270 degrees
return cls(
height,
width,
Units.mm,
margins=Margins(top=left, right=top, bottom=right, left=bottom),
)
return cls( # 0 degrees
width,
height,
Units.mm,
margins=Margins(top=top, right=right, bottom=bottom, left=left),
)
@dataclasses.dataclass
class Settings:
"""The Layout settings.
Attributes:
content_rotation: Rotate content about 0, 90, 180 or 270 degrees
fit_page: Scale content to fit the page.
page_alignment: Supported by backends that use the :class:`Page` class to define
the size of the output media, default alignment is :attr:`PageAlignment.MIDDLE_CENTER`
crop_at_margins: crops the content at the page margins if ``True``, when
supported by the backend, default is ``False``
scale: Factor to scale the DXF units of model- or paperspace, to represent 1mm
in the rendered output drawing. Only uniform scaling is supported.
e.g. scale 1:100 and DXF units are meters, 1m = 1000mm corresponds 10mm in
the output drawing = 10 / 1000 = 0.01;
e.g. scale 1:1; DXF units are mm = 1 / 1 = 1.0 the default value
The value is ignored if the page size is defined and the content fits the page and
the value is also used to determine missing page sizes (width or height).
max_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
:attr:`max_stroke_width` is defined as percentage of the content extents,
e.g. 0.001 is 0.1% of max(page-width, page-height)
min_stroke_width: Used for :class:`LineweightPolicy.RELATIVE` policy,
:attr:`min_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
e.g. 0.05 is 5% of :attr:`max_stroke_width`
fixed_stroke_width: Used for :class:`LineweightPolicy.RELATIVE_FIXED` policy,
:attr:`fixed_stroke_width` is defined as percentage of :attr:`max_stroke_width`,
e.g. 0.15 is 15% of :attr:`max_stroke_width`
output_coordinate_space: expert feature to map the DXF coordinates to the
output coordinate system [0, output_coordinate_space]
output_layers: For supported backends, separate the entities into 'layers' in the output
"""
content_rotation: int = 0
fit_page: bool = True
scale: float = 1.0
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER
crop_at_margins: bool = False
# for LineweightPolicy.RELATIVE
# max_stroke_width is defined as percentage of the content extents
max_stroke_width: float = 0.001 # 0.1% of max(width, height) in viewBox coords
# min_stroke_width is defined as percentage of max_stroke_width
min_stroke_width: float = 0.05 # 5% of max_stroke_width
# StrokeWidthPolicy.fixed_1
# fixed_stroke_width is defined as percentage of max_stroke_width
fixed_stroke_width: float = 0.15 # 15% of max_stroke_width
# PDF, HPGL expect the coordinates in the first quadrant and SVG has an inverted
# y-axis, so transformation from DXF to the output coordinate system is required.
# The output_coordinate_space defines the space into which the DXF coordinates are
# mapped, the range is [0, output_coordinate_space] for the larger page
# dimension - aspect ratio is always preserved - these are CAD drawings!
# The SVGBackend uses this feature to map all coordinates to integer values:
output_coordinate_space: float = 1_000_000 # e.g. for SVGBackend
output_layers: bool = True
def __post_init__(self) -> None:
if self.content_rotation not in (0, 90, 180, 270):
raise ValueError(
f"invalid content rotation {self.content_rotation}, "
f"expected: 0, 90, 180, 270"
)
def page_output_scale_factor(self, page: Page) -> float:
"""Returns the scaling factor to map page coordinates in mm to output space
coordinates.
"""
try:
return self.output_coordinate_space / max(
page.width_in_mm, page.height_in_mm
)
except ZeroDivisionError:
return 1.0
class Layout:
def __init__(self, render_box: BoundingBox2d, flip_y=False) -> None:
super().__init__()
self.flip_y: float = -1.0 if flip_y else 1.0
self.render_box = render_box
def get_rotation(self, settings: Settings) -> int:
if settings.content_rotation not in (0, 90, 180, 270):
raise ValueError("content rotation must be 0, 90, 180 or 270 degrees")
rotation = settings.content_rotation
if self.flip_y == -1.0:
if rotation == 90:
rotation = 270
elif rotation == 270:
rotation = 90
return rotation
def get_content_size(self, rotation: int) -> Vec2:
content_size = self.render_box.size
if rotation in (90, 270):
# swap x, y to apply rotation to content_size
content_size = Vec2(content_size.y, content_size.x)
return content_size
def get_final_page(self, page: Page, settings: Settings) -> Page:
rotation = self.get_rotation(settings)
content_size = self.get_content_size(rotation)
return final_page_size(content_size, page, settings)
def get_placement_matrix(
self, page: Page, settings=Settings(), top_origin=True
) -> Matrix44:
# Argument `page` has to be the resolved final page size!
rotation = self.get_rotation(settings)
content_size = self.get_content_size(rotation)
content_size_mm = content_size * settings.scale
if settings.fit_page:
content_size_mm *= fit_to_page(content_size_mm, page)
try:
scale_dxf_to_mm = content_size_mm.x / content_size.x
except ZeroDivisionError:
scale_dxf_to_mm = 1.0
# map output coordinates to range [0, output_coordinate_space]
scale_mm_to_output_space = settings.page_output_scale_factor(page)
scale = scale_dxf_to_mm * scale_mm_to_output_space
m = placement_matrix(
self.render_box,
sx=scale,
sy=scale * self.flip_y,
rotation=rotation,
page=page,
output_coordinate_space=settings.output_coordinate_space,
page_alignment=settings.page_alignment,
top_origin=top_origin,
)
return m
def final_page_size(content_size: Vec2, page: Page, settings: Settings) -> Page:
scale = settings.scale
width = page.width_in_mm
height = page.height_in_mm
margins = page.margins_in_mm
if width == 0.0:
width = scale * content_size.x + margins.left + margins.right
if height == 0.0:
height = scale * content_size.y + margins.top + margins.bottom
width, height = limit_page_size(
width, height, page.max_width_in_mm, page.max_height_in_mm
)
return Page(round(width, 1), round(height, 1), Units.mm, margins)
def limit_page_size(
width: float, height: float, max_width: float, max_height: float
) -> tuple[float, float]:
try:
ar = width / height
except ZeroDivisionError:
return width, height
if max_height:
height = min(max_height, height)
width = height * ar
if max_width and width > max_width:
width = min(max_width, width)
height = width / ar
return width, height
def fit_to_page(content_size_mm: Vec2, page: Page) -> float:
margins = page.margins_in_mm
try:
sx = (page.width_in_mm - margins.left - margins.right) / content_size_mm.x
sy = (page.height_in_mm - margins.top - margins.bottom) / content_size_mm.y
except ZeroDivisionError:
return 1.0
return min(sx, sy)
def placement_matrix(
bbox: BoundingBox2d,
sx: float,
sy: float,
rotation: float,
page: Page,
output_coordinate_space: float,
page_alignment: PageAlignment = PageAlignment.MIDDLE_CENTER,
# top_origin True: page origin (0, 0) in top-left corner, +y axis pointing down
# top_origin False: page origin (0, 0) in bottom-left corner, +y axis pointing up
top_origin=True,
) -> Matrix44:
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
system (+x, +y).
"""
try:
scale_mm_to_vb = output_coordinate_space / max(
page.width_in_mm, page.height_in_mm
)
except ZeroDivisionError:
scale_mm_to_vb = 1.0
margins = page.margins_in_mm
# create scaling and rotation matrix:
if abs(sx) < 1e-9:
sx = 1.0
if abs(sy) < 1e-9:
sy = 1.0
m = Matrix44.scale(sx, sy, 1.0)
if rotation:
m @= Matrix44.z_rotate(math.radians(rotation))
# calc bounding box of the final output canvas:
corners = m.transform_vertices(bbox.rect_vertices())
canvas = BoundingBox2d(corners)
# shift content to first quadrant +x/+y
tx, ty = canvas.extmin
# align content within margins
view_box_content_x = (
page.width_in_mm - margins.left - margins.right
) * scale_mm_to_vb
view_box_content_y = (
page.height_in_mm - margins.top - margins.bottom
) * scale_mm_to_vb
dx = view_box_content_x - canvas.size.x
dy = view_box_content_y - canvas.size.y
offset_x = margins.left * scale_mm_to_vb # left
if top_origin:
offset_y = margins.top * scale_mm_to_vb
else:
offset_y = margins.bottom * scale_mm_to_vb
if is_center_aligned(page_alignment):
offset_x += dx / 2
elif is_right_aligned(page_alignment):
offset_x += dx
if is_middle_aligned(page_alignment):
offset_y += dy / 2
elif is_bottom_aligned(page_alignment):
if top_origin:
offset_y += dy
else: # top aligned
if not top_origin:
offset_y += dy
return m @ Matrix44.translate(-tx + offset_x, -ty + offset_y, 0)
def is_left_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_LEFT,
PageAlignment.MIDDLE_LEFT,
PageAlignment.BOTTOM_LEFT,
)
def is_center_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_CENTER,
PageAlignment.MIDDLE_CENTER,
PageAlignment.BOTTOM_CENTER,
)
def is_right_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_RIGHT,
PageAlignment.MIDDLE_RIGHT,
PageAlignment.BOTTOM_RIGHT,
)
def is_top_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.TOP_LEFT,
PageAlignment.TOP_CENTER,
PageAlignment.TOP_RIGHT,
)
def is_middle_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.MIDDLE_LEFT,
PageAlignment.MIDDLE_CENTER,
PageAlignment.MIDDLE_RIGHT,
)
def is_bottom_aligned(align: PageAlignment) -> bool:
return align in (
PageAlignment.BOTTOM_LEFT,
PageAlignment.BOTTOM_CENTER,
PageAlignment.BOTTOM_RIGHT,
)
@@ -0,0 +1,361 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional, Union
import math
import logging
from os import PathLike
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.collections import LineCollection
from matplotlib.image import AxesImage
from matplotlib.lines import Line2D
from matplotlib.patches import PathPatch
from matplotlib.path import Path
from matplotlib.transforms import Affine2D
from ezdxf.npshapes import to_matplotlib_path
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
from ezdxf.addons.drawing.properties import BackendProperties, LayoutProperties
from ezdxf.addons.drawing.type_hints import FilterFunc
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.math import Vec2, Matrix44
from ezdxf.layouts import Layout
from .config import Configuration
logger = logging.getLogger("ezdxf")
# matplotlib docs: https://matplotlib.org/index.html
# line style:
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linestyle
# https://matplotlib.org/gallery/lines_bars_and_markers/linestyles.html
# line width:
# https://matplotlib.org/api/_as_gen/matplotlib.lines.Line2D.html#matplotlib.lines.Line2D.set_linewidth
# points unit (pt), 1pt = 1/72 inch, 1pt = 0.3527mm
POINTS = 1.0 / 0.3527 # mm -> points
CURVE4x3 = (Path.CURVE4, Path.CURVE4, Path.CURVE4)
SCATTER_POINT_SIZE = 0.1
def setup_axes(ax: plt.Axes):
# like set_axis_off, except that the face_color can still be set
ax.xaxis.set_visible(False)
ax.yaxis.set_visible(False)
for s in ax.spines.values():
s.set_visible(False)
ax.autoscale(False)
ax.set_aspect("equal", "datalim")
class MatplotlibBackend(Backend):
"""Backend which uses the :mod:`Matplotlib` package for image export.
Args:
ax: drawing canvas as :class:`matplotlib.pyplot.Axes` object
adjust_figure: automatically adjust the size of the parent
:class:`matplotlib.pyplot.Figure` to display all content
"""
def __init__(
self,
ax: plt.Axes,
*,
adjust_figure: bool = True,
):
super().__init__()
setup_axes(ax)
self.ax = ax
self._adjust_figure = adjust_figure
self._current_z = 0
def configure(self, config: Configuration) -> None:
if config.min_lineweight is None:
# If not set by user, use ~1 pixel
figure = self.ax.get_figure()
if figure:
config = config.with_changes(min_lineweight=72.0 / figure.dpi)
super().configure(config)
# LinePolicy.ACCURATE is handled by the frontend since v0.18.1
def _get_z(self) -> int:
z = self._current_z
self._current_z += 1
return z
def set_background(self, color: Color):
self.ax.set_facecolor(color)
def draw_point(self, pos: Vec2, properties: BackendProperties):
"""Draw a real dimensionless point."""
color = properties.color
self.ax.scatter(
[pos.x],
[pos.y],
s=SCATTER_POINT_SIZE,
c=color,
zorder=self._get_z(),
)
def get_lineweight(self, properties: BackendProperties) -> float:
"""Set lineweight_scaling=0 to use a constant minimal lineweight."""
assert self.config.min_lineweight is not None
return max(
properties.lineweight * self.config.lineweight_scaling,
self.config.min_lineweight,
)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties):
"""Draws a single solid line, line type rendering is done by the
frontend since v0.18.1
"""
if start.isclose(end):
# matplotlib draws nothing for a zero-length line:
self.draw_point(start, properties)
else:
self.ax.add_line(
Line2D(
(start.x, end.x),
(start.y, end.y),
linewidth=self.get_lineweight(properties),
color=properties.color,
zorder=self._get_z(),
)
)
def draw_solid_lines(
self,
lines: Iterable[tuple[Vec2, Vec2]],
properties: BackendProperties,
):
"""Fast method to draw a bunch of solid lines with the same properties."""
color = properties.color
lineweight = self.get_lineweight(properties)
_lines = []
point_x = []
point_y = []
z = self._get_z()
for s, e in lines:
if s.isclose(e):
point_x.append(s.x)
point_y.append(s.y)
else:
_lines.append(((s.x, s.y), (e.x, e.y)))
self.ax.scatter(point_x, point_y, s=SCATTER_POINT_SIZE, c=color, zorder=z)
self.ax.add_collection(
LineCollection(
_lines,
linewidths=lineweight,
color=color,
zorder=z,
capstyle="butt",
)
)
def draw_path(self, path: BkPath2d, properties: BackendProperties):
"""Draw a solid line path, line type rendering is done by the
frontend since v0.18.1
"""
mpl_path = to_matplotlib_path([path])
try:
patch = PathPatch(
mpl_path,
linewidth=self.get_lineweight(properties),
fill=False,
color=properties.color,
zorder=self._get_z(),
)
except ValueError as e:
logger.info(f"ignored matplotlib error: {str(e)}")
else:
self.ax.add_patch(patch)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
):
linewidth = 0
try:
patch = PathPatch(
to_matplotlib_path(paths, detect_holes=True),
color=properties.color,
linewidth=linewidth,
fill=True,
zorder=self._get_z(),
)
except ValueError as e:
logger.info(f"ignored matplotlib error in draw_filled_paths(): {str(e)}")
else:
self.ax.add_patch(patch)
def draw_filled_polygon(self, points: BkPoints2d, properties: BackendProperties):
self.ax.fill(
*zip(*((p.x, p.y) for p in points.vertices())),
color=properties.color,
linewidth=0,
zorder=self._get_z(),
)
def draw_image(
self, image_data: ImageData, properties: BackendProperties
) -> None:
height, width, depth = image_data.image.shape
assert depth == 4
# using AxesImage directly avoids an issue with ax.imshow where the data limits
# are updated to include the un-transformed image because the transform is applied
# afterward. We can use a slight hack which is that the outlines of images are drawn
# as well as the image itself, so we don't have to adjust the data limits at all here
# as the outline will take care of that
handle = AxesImage(self.ax, interpolation="antialiased")
handle.set_data(np.flip(image_data.image, axis=0))
handle.set_zorder(self._get_z())
(
m11,
m12,
m13,
m14,
m21,
m22,
m23,
m24,
m31,
m32,
m33,
m34,
m41,
m42,
m43,
m44,
) = image_data.transform
matplotlib_transform = Affine2D(
matrix=np.array(
[
[m11, m21, m41],
[m12, m22, m42],
[0, 0, 1],
]
)
)
handle.set_transform(matplotlib_transform + self.ax.transData)
self.ax.add_image(handle)
def clear(self):
self.ax.clear()
def finalize(self):
super().finalize()
self.ax.autoscale(True)
if self._adjust_figure:
minx, maxx = self.ax.get_xlim()
miny, maxy = self.ax.get_ylim()
data_width, data_height = maxx - minx, maxy - miny
if not math.isclose(data_width, 0):
width, height = plt.figaspect(data_height / data_width)
self.ax.get_figure().set_size_inches(width, height, forward=True)
def _get_aspect_ratio(ax: plt.Axes) -> float:
minx, maxx = ax.get_xlim()
miny, maxy = ax.get_ylim()
data_width, data_height = maxx - minx, maxy - miny
if abs(data_height) > 1e-9:
return data_width / data_height
return 1.0
def _get_width_height(ratio: float, width: float, height: float) -> tuple[float, float]:
if width == 0.0 and height == 0.0:
raise ValueError("invalid (width, height) values")
if width == 0.0:
width = height * ratio
elif height == 0.0:
height = width / ratio
return width, height
def qsave(
layout: Layout,
filename: Union[str, PathLike],
*,
bg: Optional[Color] = None,
fg: Optional[Color] = None,
dpi: int = 300,
backend: str = "agg",
config: Optional[Configuration] = None,
filter_func: Optional[FilterFunc] = None,
size_inches: Optional[tuple[float, float]] = None,
) -> None:
"""Quick and simplified render export by matplotlib.
Args:
layout: modelspace or paperspace layout to export
filename: export filename, file extension determines the format e.g.
"image.png" to save in PNG format.
bg: override default background color in hex format #RRGGBB or #RRGGBBAA,
e.g. use bg="#FFFFFF00" to get a transparent background and a black
foreground color (ACI=7), because a white background #FFFFFF gets a
black foreground color or vice versa bg="#00000000" for a transparent
(black) background and a white foreground color.
fg: override default foreground color in hex format #RRGGBB or #RRGGBBAA,
requires also `bg` argument. There is no explicit foreground color
in DXF defined (also not a background color), but the ACI color 7
has already a variable color value, black on a light background and
white on a dark background, this argument overrides this (ACI=7)
default color value.
dpi: image resolution (dots per inches).
size_inches: paper size in inch as `(width, height)` tuple, which also
defines the size in pixels = (`width` * `dpi`) x (`height` * `dpi`).
If `width` or `height` is 0.0 the value is calculated by the aspect
ratio of the drawing.
backend: the matplotlib rendering backend to use (agg, cairo, svg etc)
(see documentation for `matplotlib.use() <https://matplotlib.org/3.1.1/api/matplotlib_configuration_api.html?highlight=matplotlib%20use#matplotlib.use>`_
for a complete list of backends)
config: drawing parameters
filter_func: filter function which takes a DXFGraphic object as input
and returns ``True`` if the entity should be drawn or ``False`` if
the entity should be ignored
"""
from .properties import RenderContext
from .frontend import Frontend
import matplotlib
# Set the backend to prevent warnings about GUIs being opened from a thread
# other than the main thread.
old_backend = matplotlib.get_backend()
matplotlib.use(backend)
if config is None:
config = Configuration()
try:
fig: plt.Figure = plt.figure(dpi=dpi)
ax: plt.Axes = fig.add_axes((0, 0, 1, 1))
ctx = RenderContext(layout.doc)
layout_properties = LayoutProperties.from_layout(layout)
if bg is not None:
layout_properties.set_colors(bg, fg)
out = MatplotlibBackend(ax)
Frontend(ctx, out, config).draw_layout(
layout,
finalize=True,
filter_func=filter_func,
layout_properties=layout_properties,
)
# transparent=True sets the axes color to fully transparent
# facecolor sets the figure color
# (semi-)transparent axes colors do not produce transparent outputs
# but (semi-)transparent figure colors do.
if size_inches is not None:
ratio = _get_aspect_ratio(ax)
w, h = _get_width_height(ratio, size_inches[0], size_inches[1])
fig.set_size_inches(w, h, True)
fig.savefig(filename, dpi=dpi, facecolor=ax.get_facecolor(), transparent=True)
plt.close(fig)
finally:
matplotlib.use(old_backend)
@@ -0,0 +1,310 @@
# Copyright (c) 2021-2022, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional
from typing_extensions import Protocol
import copy
import math
from ezdxf import colors
from ezdxf.entities import MText
from ezdxf.lldxf import const
from ezdxf.math import Matrix44, Vec3, AnyVec
from ezdxf.render.abstract_mtext_renderer import AbstractMTextRenderer
from ezdxf.fonts import fonts
from ezdxf.tools import text_layout as tl
from ezdxf.tools.text import MTextContext
from .properties import Properties, RenderContext, rgb_to_hex
from .type_hints import Color
__all__ = ["complex_mtext_renderer"]
def corner_vertices(
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> Iterable[Vec3]:
corners = [ # closed polygon: fist vertex == last vertex
(left, top),
(right, top),
(right, bottom),
(left, bottom),
(left, top),
]
if m is None:
return Vec3.generate(corners)
else:
return m.transform_vertices(corners)
class DrawInterface(Protocol):
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties) -> None:
...
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None:
...
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
) -> None:
...
class FrameRenderer(tl.ContentRenderer):
def __init__(self, properties: Properties, backend: DrawInterface):
self.properties = properties
self.backend = backend
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> None:
self._render_outline(list(corner_vertices(left, bottom, right, top, m)))
def _render_outline(self, vertices: list[Vec3]) -> None:
backend = self.backend
properties = self.properties
prev = vertices.pop(0)
for vertex in vertices:
backend.draw_line(prev, vertex, properties)
prev = vertex
def line(
self, x1: float, y1: float, x2: float, y2: float, m: Matrix44 = None
) -> None:
points = [(x1, y1), (x2, y2)]
if m is not None:
p1, p2 = m.transform_vertices(points)
else:
p1, p2 = Vec3.generate(points)
self.backend.draw_line(p1, p2, self.properties)
class ColumnBackgroundRenderer(FrameRenderer):
def __init__(
self,
properties: Properties,
backend: DrawInterface,
bg_properties: Optional[Properties] = None,
offset: float = 0,
text_frame: bool = False,
):
super().__init__(properties, backend)
self.bg_properties = bg_properties
self.offset = offset # background border offset
self.has_text_frame = text_frame
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
) -> None:
# Important: this is not a clipping box, it is possible to
# render anything outside of the given borders!
offset = self.offset
vertices = list(
corner_vertices(
left - offset, bottom - offset, right + offset, top + offset, m
)
)
if self.bg_properties is not None:
self.backend.draw_filled_polygon(vertices, self.bg_properties)
if self.has_text_frame:
self._render_outline(vertices)
class TextRenderer(FrameRenderer):
"""Text content renderer."""
def __init__(
self,
text: str,
cap_height: float,
width_factor: float,
oblique: float, # angle in degrees
properties: Properties,
backend: DrawInterface,
):
super().__init__(properties, backend)
self.text = text
self.cap_height = cap_height
self.width_factor = width_factor
self.oblique = oblique # angle in degrees
def render(
self,
left: float,
bottom: float,
right: float,
top: float,
m: Matrix44 = None,
):
"""Create/render the text content"""
sx = 1.0
tx = 0.0
if not math.isclose(self.width_factor, 1.0, rel_tol=1e-6):
sx = self.width_factor
if abs(self.oblique) > 1e-3: # degrees
tx = math.tan(math.radians(self.oblique))
# fmt: off
t = Matrix44((
sx, 0.0, 0.0, 0.0,
tx, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
left, bottom, 0.0, 1.0
))
# fmt: on
if m is not None:
t *= m
self.backend.draw_text(self.text, t, self.properties, self.cap_height)
def complex_mtext_renderer(
ctx: RenderContext,
backend: DrawInterface,
mtext: MText,
properties: Properties,
) -> None:
cmr = ComplexMTextRenderer(ctx, backend, properties)
align = tl.LayoutAlignment(mtext.dxf.attachment_point)
layout_engine = cmr.layout_engine(mtext)
layout_engine.place(align=align)
layout_engine.render(mtext.ucs().matrix)
class ComplexMTextRenderer(AbstractMTextRenderer):
def __init__(
self,
ctx: RenderContext,
backend: DrawInterface,
properties: Properties,
):
super().__init__()
self._render_ctx = ctx
self._backend = backend
self._properties = properties
# Implementation of required AbstractMTextRenderer methods:
def word(self, text: str, ctx: MTextContext) -> tl.ContentCell:
return tl.Text(
width=self.get_font(ctx).text_width(text),
height=ctx.cap_height,
valign=tl.CellAlignment(ctx.align),
stroke=self.get_stroke(ctx),
renderer=TextRenderer(
text,
ctx.cap_height,
ctx.width_factor,
ctx.oblique,
self.new_text_properties(self._properties, ctx),
self._backend,
),
)
def fraction(self, data: tuple[str, str, str], ctx: MTextContext) -> tl.ContentCell:
upr, lwr, type_ = data
if type_:
return tl.Fraction(
top=self.word(upr, ctx),
bottom=self.word(lwr, ctx),
stacking=self.get_stacking(type_),
# renders just the divider line:
renderer=FrameRenderer(self._properties, self._backend),
)
else:
return self.word(upr, ctx)
def get_font_face(self, mtext: MText) -> fonts.FontFace:
return self._properties.font # type: ignore
def make_bg_renderer(self, mtext: MText) -> tl.ContentRenderer:
dxf = mtext.dxf
bg_fill = dxf.get("bg_fill", 0)
bg_aci = None
bg_true_color = None
bg_properties: Optional[Properties] = None
has_text_frame = False
offset = 0
if bg_fill:
# The fill scale is a multiple of the initial char height and
# a scale of 1, fits exact the outer border
# of the column -> offset = 0
offset = dxf.char_height * (dxf.get("box_fill_scale", 1.5) - 1)
if bg_fill & const.MTEXT_BG_COLOR:
if dxf.hasattr("bg_fill_color"):
bg_aci = dxf.bg_fill_color
if dxf.hasattr("bg_fill_true_color"):
bg_aci = None
bg_true_color = dxf.bg_fill_true_color
if (bg_fill & 3) == 3: # canvas color = bit 0 and 1 set
# can not detect canvas color from DXF document!
# do not draw any background:
bg_aci = None
bg_true_color = None
if bg_fill & const.MTEXT_TEXT_FRAME:
has_text_frame = True
bg_properties = self.new_bg_properties(bg_aci, bg_true_color)
return ColumnBackgroundRenderer(
self._properties,
self._backend,
bg_properties,
offset=offset,
text_frame=has_text_frame,
)
# Implementation details of ComplexMTextRenderer:
@property
def backend(self) -> DrawInterface:
return self._backend
def resolve_aci_color(self, aci: int) -> Color:
return self._render_ctx.resolve_aci_color(aci, self._properties.layer)
def new_text_properties(
self, properties: Properties, ctx: MTextContext
) -> Properties:
new_properties = copy.copy(properties)
if ctx.rgb is None:
new_properties.color = self.resolve_aci_color(ctx.aci)
else:
new_properties.color = rgb_to_hex(ctx.rgb)
new_properties.font = ctx.font_face
return new_properties
def new_bg_properties(
self, aci: Optional[int], true_color: Optional[int]
) -> Properties:
new_properties = copy.copy(self._properties)
new_properties.color = ( # canvas background color
self._render_ctx.current_layout_properties.background_color
)
if true_color is None:
if aci is not None:
new_properties.color = self.resolve_aci_color(aci)
# else canvas background color
else:
new_properties.color = rgb_to_hex(colors.int2rgb(true_color))
return new_properties
@@ -0,0 +1,886 @@
# Copyright (c) 2023-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Sequence,
Optional,
Iterable,
Tuple,
Iterator,
Callable,
)
from typing_extensions import TypeAlias
import abc
import numpy as np
import PIL.Image
import PIL.ImageDraw
import PIL.ImageOps
from ezdxf.colors import RGB
import ezdxf.bbox
from ezdxf.fonts import fonts
from ezdxf.math import Vec2, Matrix44, BoundingBox2d, AnyVec
from ezdxf.path import make_path, Path
from ezdxf.render import linetypes
from ezdxf.entities import DXFGraphic, Viewport
from ezdxf.tools.text import replace_non_printable_characters
from ezdxf.tools.clipping_portal import (
ClippingPortal,
ClippingShape,
find_best_clipping_shape,
)
from ezdxf.layouts import Layout
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import LinePolicy, TextPolicy, ColorPolicy, Configuration
from .properties import BackendProperties, Filling
from .properties import Properties, RenderContext
from .type_hints import Color
from .unified_text_renderer import UnifiedTextRenderer
PatternKey: TypeAlias = Tuple[str, float]
DrawEntitiesCallback: TypeAlias = Callable[[RenderContext, Iterable[DXFGraphic]], None]
__all__ = ["AbstractPipeline", "RenderPipeline2d"]
class AbstractPipeline(abc.ABC):
"""This drawing pipeline separates the frontend from the backend and implements
these features:
- automatically linetype rendering
- font rendering
- VIEWPORT rendering
- foreground color mapping according Frontend.config.color_policy
The pipeline is organized as concatenated render stages.
"""
text_engine = UnifiedTextRenderer()
default_font_face = fonts.FontFace()
draw_entities: DrawEntitiesCallback
@abc.abstractmethod
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None: ...
@abc.abstractmethod
def set_config(self, config: Configuration) -> None: ...
@abc.abstractmethod
def set_current_entity_handle(self, handle: str) -> None: ...
@abc.abstractmethod
def push_clipping_shape(
self, shape: ClippingShape, transform: Matrix44 | None
) -> None: ...
@abc.abstractmethod
def pop_clipping_shape(self) -> None: ...
@abc.abstractmethod
def draw_viewport(
self,
vp: Viewport,
layout_ctx: RenderContext,
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> None:
"""Draw the content of the given viewport current viewport."""
...
@abc.abstractmethod
def draw_point(self, pos: AnyVec, properties: Properties) -> None: ...
@abc.abstractmethod
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties): ...
@abc.abstractmethod
def draw_solid_lines(
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_path(self, path: Path, properties: Properties): ...
@abc.abstractmethod
def draw_filled_paths(
self,
paths: Iterable[Path],
properties: Properties,
) -> None: ...
@abc.abstractmethod
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
dxftype: str = "TEXT",
) -> None: ...
@abc.abstractmethod
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
@abc.abstractmethod
def finalize(self) -> None: ...
@abc.abstractmethod
def set_background(self, color: Color) -> None: ...
@abc.abstractmethod
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
# gets the full DXF properties information
...
@abc.abstractmethod
def exit_entity(self, entity: DXFGraphic) -> None: ...
class RenderStage2d(abc.ABC):
next_stage: RenderStage2d
def set_config(self, config: Configuration) -> None:
pass
@abc.abstractmethod
def draw_point(self, pos: Vec2, properties: Properties) -> None: ...
@abc.abstractmethod
def draw_line(self, start: Vec2, end: Vec2, properties: Properties): ...
@abc.abstractmethod
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_path(self, path: BkPath2d, properties: Properties): ...
@abc.abstractmethod
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None: ...
@abc.abstractmethod
def draw_filled_polygon(
self, points: BkPoints2d, properties: Properties
) -> None: ...
@abc.abstractmethod
def draw_image(self, image_data: ImageData, properties: Properties) -> None: ...
class RenderPipeline2d(AbstractPipeline):
"""Render pipeline for 2D backends."""
def __init__(self, backend: BackendInterface):
self.backend = backend
self.config = Configuration()
try: # request default font face
self.default_font_face = fonts.font_manager.get_font_face("")
except fonts.FontNotFoundError: # no default font found
# last resort MonospaceFont which renders only "tofu"
pass
self.clipping_portal = ClippingPortal()
self.current_vp_scale = 1.0
self._current_entity_handle: str = ""
self._color_mapping: dict[str, str] = dict()
self._pipeline = self.build_render_pipeline()
def build_render_pipeline(self) -> RenderStage2d:
backend_stage = BackendStage2d(
self.backend, converter=self.get_backend_properties
)
linetype_stage = LinetypeStage2d(
self.config,
get_ltype_scale=self.get_vp_ltype_scale,
next_stage=backend_stage,
)
clipping_stage = ClippingStage2d(
self.config, self.clipping_portal, next_stage=linetype_stage
)
return clipping_stage
def get_vp_ltype_scale(self) -> float:
"""The linetype pattern should look the same in all viewports
regardless of the viewport scale.
"""
return 1.0 / max(self.current_vp_scale, 0.0001) # max out at 1:10000
def get_backend_properties(self, properties: Properties) -> BackendProperties:
try:
color = self._color_mapping[properties.color]
except KeyError:
color = apply_color_policy(
properties.color, self.config.color_policy, self.config.custom_fg_color
)
self._color_mapping[properties.color] = color
return BackendProperties(
color,
properties.lineweight,
properties.layer,
properties.pen,
self._current_entity_handle,
)
def set_draw_entities_callback(self, callback: DrawEntitiesCallback) -> None:
self.draw_entities = callback
def set_config(self, config: Configuration) -> None:
self.backend.configure(config)
self.config = config
stage = self._pipeline
while True:
stage.set_config(config)
if not hasattr(stage, "next_stage"): # BackendStage2d
return
stage = stage.next_stage
def set_current_entity_handle(self, handle: str) -> None:
assert handle is not None
self._current_entity_handle = handle
def push_clipping_shape(
self, shape: ClippingShape, transform: Matrix44 | None
) -> None:
self.clipping_portal.push(shape, transform)
def pop_clipping_shape(self) -> None:
self.clipping_portal.pop()
def draw_viewport(
self,
vp: Viewport,
layout_ctx: RenderContext,
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> None:
"""Draw the content of the given viewport current viewport."""
if vp.doc is None:
return
try:
msp_limits = vp.get_modelspace_limits()
except ValueError: # modelspace limits not detectable
return
if self.enter_viewport(vp):
self.draw_entities(
layout_ctx.from_viewport(vp),
filter_vp_entities(vp.doc.modelspace(), msp_limits, bbox_cache),
)
self.exit_viewport()
def enter_viewport(self, vp: Viewport) -> bool:
"""Set current viewport, returns ``True`` for valid viewports."""
self.current_vp_scale = vp.get_scale()
m = vp.get_transformation_matrix()
clipping_path = make_path(vp)
if len(clipping_path):
vertices = clipping_path.control_vertices()
if clipping_path.has_curves:
layout = vp.get_layout()
if isinstance(layout, Layout):
# plot paper units:
# 0: inches, max sagitta = 1/254 = 0.1 mm
# 1: millimeters, max sagitta = 0.1 mm
# 2: pixels, max sagitta = 0.1 pixel
units = layout.dxf.get("plot_paper_units", 1)
max_sagitta = 1.0 / 254.0 if units == 0 else 0.1
vertices = list(clipping_path.flattening(max_sagitta))
clipping_shape = find_best_clipping_shape(vertices)
self.clipping_portal.push(clipping_shape, m)
return True
return False
def exit_viewport(self):
self.clipping_portal.pop()
# Reset viewport scaling: viewports cannot be nested!
self.current_vp_scale = 1.0
def draw_text(
self,
text: str,
transform: Matrix44,
properties: Properties,
cap_height: float,
dxftype: str = "TEXT",
) -> None:
"""Render text as filled paths."""
text_policy = self.config.text_policy
pipeline = self._pipeline
if not text.strip() or text_policy == TextPolicy.IGNORE:
return # no point rendering empty strings
text = prepare_string_for_rendering(text, dxftype)
font_face = properties.font
if font_face is None:
font_face = self.default_font_face
try:
glyph_paths = self.text_engine.get_text_glyph_paths(
text, font_face, cap_height
)
except (RuntimeError, ValueError):
return
for p in glyph_paths:
p.transform_inplace(transform)
transformed_paths: list[BkPath2d] = glyph_paths
points: list[Vec2]
if text_policy == TextPolicy.REPLACE_RECT:
points = []
for p in transformed_paths:
points.extend(p.extents())
if len(points) < 2:
return
rect = BkPath2d.from_vertices(BoundingBox2d(points).rect_vertices())
pipeline.draw_path(rect, properties)
return
if text_policy == TextPolicy.REPLACE_FILL:
points = []
for p in transformed_paths:
points.extend(p.extents())
if len(points) < 2:
return
polygon = BkPoints2d(BoundingBox2d(points).rect_vertices())
if properties.filling is None:
properties.filling = Filling()
pipeline.draw_filled_polygon(polygon, properties)
return
if (
self.text_engine.is_stroke_font(font_face)
or text_policy == TextPolicy.OUTLINE
):
for text_path in transformed_paths:
pipeline.draw_path(text_path, properties)
return
if properties.filling is None:
properties.filling = Filling()
pipeline.draw_filled_paths(transformed_paths, properties)
def finalize(self) -> None:
self.backend.finalize()
def set_background(self, color: Color) -> None:
self.backend.set_background(color)
def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
self.backend.enter_entity(entity, properties)
def exit_entity(self, entity: DXFGraphic) -> None:
self.backend.exit_entity(entity)
# Enter render pipeline:
def draw_point(self, pos: AnyVec, properties: Properties) -> None:
self._pipeline.draw_point(Vec2(pos), properties)
def draw_line(self, start: AnyVec, end: AnyVec, properties: Properties):
self._pipeline.draw_line(Vec2(start), Vec2(end), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[AnyVec, AnyVec]], properties: Properties
) -> None:
self._pipeline.draw_solid_lines(
[(Vec2(s), Vec2(e)) for s, e in lines], properties
)
def draw_path(self, path: Path, properties: Properties):
self._pipeline.draw_path(BkPath2d(path), properties)
def draw_filled_paths(
self,
paths: Iterable[Path],
properties: Properties,
) -> None:
self._pipeline.draw_filled_paths(list(map(BkPath2d, paths)), properties)
def draw_filled_polygon(
self, points: Iterable[AnyVec], properties: Properties
) -> None:
self._pipeline.draw_filled_polygon(BkPoints2d(points), properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self._pipeline.draw_image(image_data, properties)
class ClippingStage2d(RenderStage2d):
def __init__(
self,
config: Configuration,
clipping_portal: ClippingPortal,
next_stage: RenderStage2d,
):
self.clipping_portal = clipping_portal
self.config = config
self.next_stage = next_stage
def set_config(self, config: Configuration) -> None:
self.config = config
def draw_point(self, pos: Vec2, properties: Properties) -> None:
if self.clipping_portal.is_active:
pos = self.clipping_portal.clip_point(pos)
if pos is None:
return
self.next_stage.draw_point(pos, properties)
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
next_stage = self.next_stage
clipping_portal = self.clipping_portal
if clipping_portal.is_active:
for segment in clipping_portal.clip_line(start, end):
next_stage.draw_line(segment[0], segment[1], properties)
return
next_stage.draw_line(start, end, properties)
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
clipping_portal = self.clipping_portal
if clipping_portal.is_active:
cropped_lines: list[tuple[Vec2, Vec2]] = []
for start, end in lines:
cropped_lines.extend(clipping_portal.clip_line(start, end))
lines = cropped_lines
self.next_stage.draw_solid_lines(lines, properties)
def draw_path(self, path: BkPath2d, properties: Properties):
clipping_portal = self.clipping_portal
next_stage = self.next_stage
max_sagitta = self.config.max_flattening_distance
if clipping_portal.is_active:
for clipped_path in clipping_portal.clip_paths([path], max_sagitta):
next_stage.draw_path(clipped_path, properties)
return
next_stage.draw_path(path, properties)
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
clipping_portal = self.clipping_portal
max_sagitta = self.config.max_flattening_distance
if clipping_portal.is_active:
paths = clipping_portal.clip_filled_paths(paths, max_sagitta)
if len(paths) == 0:
return
self.next_stage.draw_filled_paths(paths, properties)
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
clipping_portal = self.clipping_portal
next_stage = self.next_stage
if clipping_portal.is_active:
for points in clipping_portal.clip_polygon(points):
if len(points) > 0:
next_stage.draw_filled_polygon(points, properties)
return
if len(points) > 0:
next_stage.draw_filled_polygon(points, properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
# the outer bounds contain the visible parts of the image for the
# clip mode "remove inside"
outer_bounds: list[BkPoints2d] = []
clipping_portal = self.clipping_portal
if not clipping_portal.is_active:
self._draw_image(image_data, outer_bounds, properties)
return
# the pixel boundary path can be split into multiple paths
transform = image_data.flip_matrix() * image_data.transform
pixel_boundary_path = image_data.pixel_boundary_path
clipping_paths = _clip_image_polygon(
clipping_portal, pixel_boundary_path, transform
)
if not image_data.remove_outside:
# remove inside:
# detect the visible parts of the image which are not removed by
# clipping through viewports or block references
width, height = image_data.image_size()
outer_boundary = BkPoints2d(
Vec2.generate([(0, 0), (width, 0), (width, height), (0, height)])
)
outer_bounds = _clip_image_polygon(
clipping_portal, outer_boundary, transform
)
image_data.transform = clipping_portal.transform_matrix(image_data.transform)
if len(clipping_paths) == 1:
new_clipping_path = clipping_paths[0]
if new_clipping_path is not image_data.pixel_boundary_path:
image_data.pixel_boundary_path = new_clipping_path
# forced clipping triggered by viewport- or block reference clipping:
image_data.use_clipping_boundary = True
self._draw_image(image_data, outer_bounds, properties)
else:
for clipping_path in clipping_paths:
# when clipping path is split into multiple parts:
# copy image for each part, not efficient but works
# this should be a rare usecase so optimization is not required
self._draw_image(
ImageData(
image=image_data.image.copy(),
transform=image_data.transform,
pixel_boundary_path=clipping_path,
use_clipping_boundary=True,
),
outer_bounds,
properties,
)
def _draw_image(
self,
image_data: ImageData,
outer_bounds: list[BkPoints2d],
properties: Properties,
) -> None:
if image_data.use_clipping_boundary:
_mask_image(image_data, outer_bounds)
self.next_stage.draw_image(image_data, properties)
class LinetypeStage2d(RenderStage2d):
def __init__(
self,
config: Configuration,
get_ltype_scale: Callable[[], float],
next_stage: RenderStage2d,
):
self.config = config
self.solid_lines_only = False
self.next_stage = next_stage
self.get_ltype_scale = get_ltype_scale
self.pattern_cache: dict[PatternKey, Sequence[float]] = dict()
self.set_config(config)
def set_config(self, config: Configuration) -> None:
self.config = config
self.solid_lines_only = config.line_policy == LinePolicy.SOLID
def pattern(self, properties: Properties) -> Sequence[float]:
"""Returns simplified linetype tuple: on-off sequence"""
if self.solid_lines_only:
scale = 0.0
else:
scale = properties.linetype_scale * self.get_ltype_scale()
key: PatternKey = (properties.linetype_name, scale)
pattern_ = self.pattern_cache.get(key)
if pattern_ is None:
pattern_ = self._create_pattern(properties, scale)
self.pattern_cache[key] = pattern_
return pattern_
def _create_pattern(self, properties: Properties, scale: float) -> Sequence[float]:
if len(properties.linetype_pattern) < 2:
# Do not return None -> None indicates: "not cached"
return tuple()
min_dash_length = self.config.min_dash_length * self.get_ltype_scale()
pattern = [max(e * scale, min_dash_length) for e in properties.linetype_pattern]
if len(pattern) % 2:
pattern.pop()
return pattern
def draw_point(self, pos: Vec2, properties: Properties) -> None:
self.next_stage.draw_point(pos, properties)
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
s = Vec2(start)
e = Vec2(end)
next_stage = self.next_stage
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
next_stage.draw_line(s, e, properties)
return
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
next_stage.draw_solid_lines(
[(s, e) for s, e in renderer.line_segment(s, e)],
properties,
)
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
self.next_stage.draw_solid_lines(lines, properties)
def draw_path(self, path: BkPath2d, properties: Properties):
next_stage = self.next_stage
if self.solid_lines_only or len(properties.linetype_pattern) < 2: # CONTINUOUS
next_stage.draw_path(path, properties)
return
renderer = linetypes.LineTypeRenderer(self.pattern(properties))
vertices = path.flattening(self.config.max_flattening_distance, segments=16)
next_stage.draw_solid_lines(
[(Vec2(s), Vec2(e)) for s, e in renderer.line_segments(vertices)],
properties,
)
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
self.next_stage.draw_filled_paths(paths, properties)
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
self.next_stage.draw_filled_polygon(points, properties)
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self.next_stage.draw_image(image_data, properties)
class BackendStage2d(RenderStage2d):
"""Send data to the output backend."""
def __init__(
self,
backend: BackendInterface,
converter: Callable[[Properties], BackendProperties],
):
self.backend = backend
self.converter = converter
assert not hasattr(self, "next_stage"), "has to be the last render stage"
def draw_point(self, pos: Vec2, properties: Properties) -> None:
self.backend.draw_point(pos, self.converter(properties))
def draw_line(self, start: Vec2, end: Vec2, properties: Properties):
self.backend.draw_line(start, end, self.converter(properties))
def draw_solid_lines(
self, lines: list[tuple[Vec2, Vec2]], properties: Properties
) -> None:
self.backend.draw_solid_lines(lines, self.converter(properties))
def draw_path(self, path: BkPath2d, properties: Properties):
self.backend.draw_path(path, self.converter(properties))
def draw_filled_paths(
self,
paths: list[BkPath2d],
properties: Properties,
) -> None:
self.backend.draw_filled_paths(paths, self.converter(properties))
def draw_filled_polygon(self, points: BkPoints2d, properties: Properties) -> None:
self.backend.draw_filled_polygon(points, self.converter(properties))
def draw_image(self, image_data: ImageData, properties: Properties) -> None:
self.backend.draw_image(image_data, self.converter(properties))
def _mask_image(image_data: ImageData, outer_bounds: list[BkPoints2d]) -> None:
"""Mask away the clipped parts of the image. The argument `outer_bounds` is only
used for clip mode "remove_inside". The outer bounds can be composed of multiple
parts. If `outer_bounds` is empty the image has no removed parts and is fully
visible before applying the image clipping path.
Args:
image_data:
image_data.pixel_boundary: path contains the image clipping path
image_data.remove_outside: defines the clipping mode (inside/outside)
outer_bounds: countain the parts of the image which are __not__ removed by
clipping through viewports or clipped block references
e.g. an image without any removed parts has the outer bounds
[(0, 0) (width, 0), (width, height), (0, height)]
"""
clip_polygon = [(p.x, p.y) for p in image_data.pixel_boundary_path.vertices()]
# create an empty image
clipping_image = PIL.Image.new("L", image_data.image_size(), 0)
# paint in the clipping path
PIL.ImageDraw.ImageDraw(clipping_image).polygon(
clip_polygon, outline=None, width=0, fill=1
)
clipping_mask = np.asarray(clipping_image)
if not image_data.remove_outside: # clip mode "remove_inside"
if outer_bounds:
# create a new empty image
visible_image = PIL.Image.new("L", image_data.image_size(), 0)
# paint in parts of the image which are still visible
for boundary in outer_bounds:
clip_polygon = [(p.x, p.y) for p in boundary.vertices()]
PIL.ImageDraw.ImageDraw(visible_image).polygon(
clip_polygon, outline=None, width=0, fill=1
)
# remove the clipping path
clipping_mask = np.asarray(visible_image) - clipping_mask
else:
# create mask for fully visible image
fully_visible_image_mask = np.full(
clipping_mask.shape, fill_value=1, dtype=clipping_mask.dtype
)
# remove the clipping path
clipping_mask = fully_visible_image_mask - clipping_mask
image_data.image[:, :, 3] *= clipping_mask
def _clip_image_polygon(
clipping_portal: ClippingPortal, polygon_px: BkPoints2d, m: Matrix44
) -> list[BkPoints2d]:
original = [polygon_px]
# inverse matrix includes the transformation applied by the clipping portal
inverse = clipping_portal.transform_matrix(m)
try:
inverse.inverse()
except ZeroDivisionError:
# inverse transformation from WCS to pixel coordinates is not possible
return original
# transform image coordinates to WCS coordinates
polygon = polygon_px.clone()
polygon.transform_inplace(m)
clipped_polygons = clipping_portal.clip_polygon(polygon)
if (len(clipped_polygons) == 1) and (clipped_polygons[0] is polygon):
# this shows the caller that the image boundary path wasn't clipped
return original
# transform WCS coordinates to image coordinates
for polygon in clipped_polygons:
polygon.transform_inplace(inverse)
return clipped_polygons # in image coordinates!
def invert_color(color: Color) -> Color:
r, g, b = RGB.from_hex(color)
return RGB(255 - r, 255 - g, 255 - b).to_hex()
def swap_bw(color: str) -> Color:
color = color.lower()
if color == "#000000":
return "#ffffff"
if color == "#ffffff":
return "#000000"
return color
def color_to_monochrome(color: Color, scale: float = 1.0, offset: float = 0.0) -> Color:
lum = RGB.from_hex(color).luminance * scale + offset
if lum < 0.0:
lum = 0.0
elif lum > 1.0:
lum = 1.0
gray = round(lum * 255)
return RGB(gray, gray, gray).to_hex()
def apply_color_policy(color: Color, policy: ColorPolicy, custom_color: Color) -> Color:
alpha = color[7:9]
color = color[:7]
if policy == ColorPolicy.COLOR_SWAP_BW:
color = swap_bw(color)
elif policy == ColorPolicy.COLOR_NEGATIVE:
color = invert_color(color)
elif policy == ColorPolicy.MONOCHROME_DARK_BG: # [0.3, 1.0]
color = color_to_monochrome(color, scale=0.7, offset=0.3)
elif policy == ColorPolicy.MONOCHROME_LIGHT_BG: # [0.0, 0.7]
color = color_to_monochrome(color, scale=0.7, offset=0.0)
elif policy == ColorPolicy.MONOCHROME: # [0.0, 1.0]
color = color_to_monochrome(color)
elif policy == ColorPolicy.BLACK:
color = "#000000"
elif policy == ColorPolicy.WHITE:
color = "#ffffff"
elif policy == ColorPolicy.CUSTOM:
fg = custom_color
color = fg[:7]
alpha = fg[7:9]
return color + alpha
def filter_vp_entities(
msp: Layout,
limits: Sequence[float],
bbox_cache: Optional[ezdxf.bbox.Cache] = None,
) -> Iterator[DXFGraphic]:
"""Yields all DXF entities that need to be processed by the given viewport
`limits`. The entities may be partially of even complete outside the viewport.
By passing the bounding box cache of the modelspace entities,
the function can filter entities outside the viewport to speed up rendering
time.
There are two processing modes for the `bbox_cache`:
1. The `bbox_cache` is``None``: all entities must be processed,
pass through mode
2. If the `bbox_cache` is given but does not contain an entity,
the bounding box is computed and added to the cache.
Even passing in an empty cache can speed up rendering time when
multiple viewports need to be processed.
Args:
msp: modelspace layout
limits: modelspace limits of the viewport, as tuple (min_x, min_y, max_x, max_y)
bbox_cache: the bounding box cache of the modelspace entities
"""
# WARNING: this works only with top-view viewports
# The current state of the drawing add-on supports only top-view viewports!
def is_visible(e):
entity_bbox = bbox_cache.get(e)
if entity_bbox is None:
# compute and add bounding box
entity_bbox = ezdxf.bbox.extents((e,), fast=True, cache=bbox_cache)
if not entity_bbox.has_data:
return True
# Check for separating axis:
if min_x >= entity_bbox.extmax.x:
return False
if max_x <= entity_bbox.extmin.x:
return False
if min_y >= entity_bbox.extmax.y:
return False
if max_y <= entity_bbox.extmin.y:
return False
return True
if bbox_cache is None: # pass through all entities
yield from msp
return
min_x, min_y, max_x, max_y = limits
if not bbox_cache.has_data:
# fill cache at once
ezdxf.bbox.extents(msp, fast=True, cache=bbox_cache)
for entity in msp:
if is_visible(entity):
yield entity
def prepare_string_for_rendering(text: str, dxftype: str) -> str:
assert "\n" not in text, "not a single line of text"
if dxftype in {"TEXT", "ATTRIB", "ATTDEF"}:
text = replace_non_printable_characters(text, replacement="?")
text = text.replace("\t", "?")
elif dxftype == "MTEXT":
text = replace_non_printable_characters(text, replacement="")
text = text.replace("\t", " ")
else:
raise TypeError(dxftype)
return text
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,486 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import math
from typing import Iterable, no_type_check, Any
import copy
import PIL.Image
import numpy as np
from ezdxf.math import Vec2, BoundingBox2d
from ezdxf.colors import RGB
from ezdxf.path import Command
from ezdxf.version import __version__
from ezdxf.lldxf.validator import make_table_key as layer_key
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
is_pymupdf_installed = True
pymupdf: Any = None
try:
import pymupdf # type: ignore[import-untyped, no-redef]
except ImportError:
print(
"Python module PyMuPDF (AGPL!) is required: https://pypi.org/project/PyMuPDF/"
)
is_pymupdf_installed = False
# PyMuPDF docs: https://pymupdf.readthedocs.io/en/latest/
__all__ = ["PyMuPdfBackend", "is_pymupdf_installed"]
# PDF units are points (pt), 1 pt is 1/72 of an inch:
MM_TO_POINTS = 72.0 / 25.4 # 25.4 mm = 1 inch / 72
# psd does not work in PyMuPDF v1.22.3
SUPPORTED_IMAGE_FORMATS = ("png", "ppm", "pbm")
class PyMuPdfBackend(recorder.Recorder):
"""This backend uses the `PyMuPdf`_ package to create PDF, PNG, PPM and PBM output.
This backend support content cropping at page margins.
PyMuPDF is licensed under the `AGPL`_. Sorry, but it's the best package for the job
I've found so far.
Install package::
pip install pymupdf
.. _PyMuPdf: https://pypi.org/project/PyMuPDF/
.. _AGPL: https://www.gnu.org/licenses/agpl-3.0.html
"""
def __init__(self) -> None:
super().__init__()
self._init_flip_y = True
def get_replay(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> PyMuPdfRenderBackend:
"""Returns the PDF document as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
"""
top_origin = True
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the top-left corner.
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
page = output_layout.get_final_page(page, settings)
# DXF coordinates are mapped to PDF Units in the first quadrant
settings = copy.copy(settings)
settings.output_coordinate_space = get_coordinate_output_space(page)
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
# transform content to the output coordinates space:
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * MM_TO_POINTS # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
self._init_flip_y = False
backend = self.make_backend(page, settings)
player.replay(backend)
return backend
def get_pdf_bytes(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> bytes:
"""Returns the PDF document as bytes.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
"""
backend = self.get_replay(page, settings=settings, render_box=render_box)
return backend.get_pdf_bytes()
def get_pixmap_bytes(
self,
page: layout.Page,
*,
fmt="png",
settings: layout.Settings = layout.Settings(),
dpi: int = 96,
alpha=False,
render_box: BoundingBox2d | None = None,
) -> bytes:
"""Returns a pixel image as bytes, supported image formats:
=== =========================
png Portable Network Graphics
ppm Portable Pixmap (no alpha channel)
pbm Portable Bitmap (no alpha channel)
=== =========================
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
fmt: image format
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
dpi: output resolution in dots per inch
alpha: add alpha channel (transparency)
render_box: set explicit region to render, default is content bounding box
"""
if fmt not in SUPPORTED_IMAGE_FORMATS:
raise ValueError(f"unsupported image format: '{fmt}'")
backend = self.get_replay(page, settings=settings, render_box=render_box)
try:
pixmap = backend.get_pixmap(dpi=dpi, alpha=alpha)
return pixmap.tobytes(output=fmt)
except RuntimeError as e:
print(f"PyMuPDF Runtime Error: {str(e)}")
return b""
@staticmethod
def make_backend(
page: layout.Page, settings: layout.Settings
) -> PyMuPdfRenderBackend:
"""Override this method to use a customized render backend."""
return PyMuPdfRenderBackend(page, settings)
def get_coordinate_output_space(page: layout.Page) -> int:
page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
return max(page_width_in_pt, page_height_in_pt)
class PyMuPdfRenderBackend(BackendInterface):
"""Creates the PDF/PNG/PSD/SVG output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Move content in the first quadrant of the coordinate system.
- The page is defined by the upper left corner in the origin (0, 0) and
the lower right corner at (page-width, page-height)
- The output coordinates are floats in 1/72 inch, scale the content appropriately
- Replay the recorded output on this backend.
.. important::
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
"""
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
assert (
is_pymupdf_installed
), "Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/"
self.doc = pymupdf.open()
self.doc.set_metadata(
{
"producer": f"PyMuPDF {pymupdf.version[0]}",
"creator": f"ezdxf {__version__}",
}
)
self.settings = settings
self._optional_content_groups: dict[str, int] = {}
self._stroke_width_cache: dict[float, float] = {}
self._color_cache: dict[str, tuple[float, float, float]] = {}
self.page_width_in_pt = int(page.width_in_mm * MM_TO_POINTS)
self.page_height_in_pt = int(page.height_in_mm * MM_TO_POINTS)
# LineweightPolicy.ABSOLUTE:
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# when the stroke width is too thin PDF viewers may get confused;
self.abs_min_stroke_width = 0.1 # pt == 0.03528mm (arbitrary choice)
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
self.max_stroke_width: float = max(
self.abs_min_stroke_width,
int(settings.output_coordinate_space * settings.max_stroke_width),
)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: float = max(
self.abs_min_stroke_width,
int(self.max_stroke_width * settings.min_stroke_width),
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: float = max(
self.abs_min_stroke_width,
int(self.max_stroke_width * settings.fixed_stroke_width),
)
self.page = self.doc.new_page(-1, self.page_width_in_pt, self.page_height_in_pt)
# The page content is stored in a shared shape:
self.content_shape = self.page.new_shape()
# see also: https://github.com/pymupdf/PyMuPDF/issues/3800
def get_pdf_bytes(self) -> bytes:
return self.doc.tobytes()
def get_pixmap(self, dpi: int, alpha=False):
return self.page.get_pixmap(dpi=dpi, alpha=alpha)
def get_svg_image(self) -> str:
return self.page.get_svg_image()
def set_background(self, color: Color) -> None:
rgb = self.resolve_color(color)
opacity = alpha_to_opacity(color[7:9])
if color == (1.0, 1.0, 1.0) or opacity == 0.0:
return
shape = self.content_shape
shape.draw_rect([0, 0, self.page_width_in_pt, self.page_height_in_pt])
shape.finish(width=None, color=None, fill=rgb, fill_opacity=opacity)
shape.commit()
def get_optional_content_group(self, layer_name: str) -> int:
if not self.settings.output_layers:
return 0 # the default value of `oc` when not provided
layer_name = layer_key(layer_name)
if layer_name not in self._optional_content_groups:
self._optional_content_groups[layer_name] = self.doc.add_ocg(
name=layer_name,
config=-1,
on=True,
)
return self._optional_content_groups[layer_name]
def finish_line(self, shape, properties: BackendProperties, close: bool) -> None:
color = self.resolve_color(properties.color)
width = self.resolve_stroke_width(properties.lineweight)
shape.finish(
width=width,
color=color,
fill=None,
lineJoin=1,
lineCap=1,
stroke_opacity=alpha_to_opacity(properties.color[7:9]),
closePath=close,
oc=self.get_optional_content_group(properties.layer),
)
def finish_filling(self, shape, properties: BackendProperties) -> None:
shape.finish(
width=None,
color=None,
fill=self.resolve_color(properties.color),
fill_opacity=alpha_to_opacity(properties.color[7:9]),
lineJoin=1,
lineCap=1,
closePath=True,
even_odd=True,
oc=self.get_optional_content_group(properties.layer),
)
def resolve_color(self, color: Color) -> tuple[float, float, float]:
key = color[:7]
try:
return self._color_cache[key]
except KeyError:
pass
color_floats = RGB.from_hex(color).to_floats()
self._color_cache[key] = color_floats
return color_floats
def resolve_stroke_width(self, width: float) -> float:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.min_stroke_width
if self.lineweight_policy == LineweightPolicy.ABSOLUTE:
stroke_width = ( # in points (pt) = 1/72 inch
max(self.min_lineweight, width) * MM_TO_POINTS * self.lineweight_scaling
)
elif self.lineweight_policy == LineweightPolicy.RELATIVE:
stroke_width = map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
)
stroke_width = max(self.abs_min_stroke_width, stroke_width)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
shape = self.content_shape
pos = Vec2(pos)
shape.draw_line(pos, pos)
self.finish_line(shape, properties, close=False)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
shape = self.content_shape
shape.draw_line(Vec2(start), Vec2(end))
self.finish_line(shape, properties, close=False)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
shape = self.content_shape
for start, end in lines:
shape.draw_line(start, end)
self.finish_line(shape, properties, close=False)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
shape = self.content_shape
add_path_to_shape(shape, path, close=False)
self.finish_line(shape, properties, close=False)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
shape = self.content_shape
for p in paths:
add_path_to_shape(shape, p, close=True)
self.finish_filling(shape, properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
vertices = points.to_list()
if len(vertices) < 3:
return
# pymupdf >= 1.23.19 does not accept Vec2() instances
# input coordinates are page coordinates in pdf units
shape = self.content_shape
shape.draw_polyline(vertices)
self.finish_filling(shape, properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
transform = image_data.transform
image = image_data.image
height, width, depth = image.shape
assert depth == 4
corners = list(
transform.transform_vertices(
[Vec2(0, 0), Vec2(width, 0), Vec2(width, height), Vec2(0, height)]
)
)
xs = [p.x for p in corners]
ys = [p.y for p in corners]
r = pymupdf.Rect((min(xs), min(ys)), (max(xs), max(ys)))
# translation and non-uniform scale are handled by having the image stretch to fill the given rect.
angle = (corners[1] - corners[0]).angle_deg
need_rotate = not math.isclose(angle, 0.0)
# already mirroring once to go from pixels (+y down) to wcs (+y up)
# so a positive determinant means an additional reflection
need_flip = transform.determinant() > 0
if need_rotate or need_flip:
pil_image = PIL.Image.fromarray(image, mode="RGBA")
if need_flip:
pil_image = pil_image.transpose(PIL.Image.Transpose.FLIP_TOP_BOTTOM)
if need_rotate:
pil_image = pil_image.rotate(
-angle,
resample=PIL.Image.Resampling.BICUBIC,
expand=True,
fillcolor=(0, 0, 0, 0),
)
image = np.asarray(pil_image)
height, width, depth = image.shape
pixmap = pymupdf.Pixmap(
pymupdf.Colorspace(pymupdf.CS_RGB), width, height, bytes(image.data), True
)
# TODO: could improve by caching and re-using xrefs. If a document contains many
# identical images redundant copies will be stored for each one
self.page.insert_image(
r,
keep_proportion=False,
pixmap=pixmap,
oc=self.get_optional_content_group(properties.layer),
)
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
self.content_shape.commit()
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
@no_type_check
def add_path_to_shape(shape, path: BkPath2d, close: bool) -> None:
start = path.start
sub_path_start = start
last_point = start
for command in path.commands():
end = command.end
if command.type == Command.MOVE_TO:
if close and not sub_path_start.isclose(end):
shape.draw_line(start, sub_path_start)
sub_path_start = end
elif command.type == Command.LINE_TO:
shape.draw_line(start, end)
elif command.type == Command.CURVE3_TO:
shape.draw_curve(start, command.ctrl, end)
elif command.type == Command.CURVE4_TO:
shape.draw_bezier(start, command.ctrl1, command.ctrl2, end)
start = end
last_point = end
if close and not sub_path_start.isclose(last_point):
shape.draw_line(last_point, sub_path_start)
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: float,
max_stroke_width: float,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> float:
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return min_stroke_width + round(lineweight * factor, 1)
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
@@ -0,0 +1,314 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Optional, Iterable
import abc
import math
import numpy as np
from ezdxf.addons.xqt import QtCore as qc, QtGui as qg, QtWidgets as qw
from ezdxf.addons.drawing.backend import Backend, BkPath2d, BkPoints2d, ImageData
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.addons.drawing.properties import BackendProperties
from ezdxf.math import Vec2, Matrix44
from ezdxf.npshapes import to_qpainter_path
class _Point(qw.QAbstractGraphicsShapeItem):
"""A dimensionless point which is drawn 'cosmetically' (scale depends on
view)
"""
def __init__(self, x: float, y: float, brush: qg.QBrush):
super().__init__()
self.location = qc.QPointF(x, y)
self.radius = 1.0
self.setPen(qg.QPen(qc.Qt.NoPen))
self.setBrush(brush)
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
view_scale = _get_x_scale(painter.transform())
radius = self.radius / view_scale
painter.setBrush(self.brush())
painter.setPen(qc.Qt.NoPen)
painter.drawEllipse(self.location, radius, radius)
def boundingRect(self) -> qc.QRectF:
return qc.QRectF(self.location, qc.QSizeF(1, 1))
# The key used to store the dxf entity corresponding to each graphics element
CorrespondingDXFEntity = qc.Qt.UserRole + 0 # type: ignore
CorrespondingDXFParentStack = qc.Qt.UserRole + 1 # type: ignore
class _PyQtBackend(Backend):
"""
Abstract PyQt backend which uses the :mod:`PySide6` package to implement an
interactive viewer. The :mod:`PyQt5` package can be used as fallback if the
:mod:`PySide6` package is not available.
"""
def __init__(self, scene: qw.QGraphicsScene):
super().__init__()
self._scene = scene
self._color_cache: dict[Color, qg.QColor] = {}
self._no_line = qg.QPen(qc.Qt.NoPen)
self._no_fill = qg.QBrush(qc.Qt.NoBrush)
def configure(self, config: Configuration) -> None:
if config.min_lineweight is None:
config = config.with_changes(min_lineweight=0.24)
super().configure(config)
def set_scene(self, scene: qw.QGraphicsScene) -> None:
self._scene = scene
def _add_item(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
self.set_item_data(item, entity_handle)
self._scene.addItem(item)
@abc.abstractmethod
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
...
def _get_color(self, color: Color) -> qg.QColor:
try:
return self._color_cache[color]
except KeyError:
pass
if len(color) == 7:
qt_color = qg.QColor(color) # '#RRGGBB'
elif len(color) == 9:
rgb = color[1:7]
alpha = color[7:9]
qt_color = qg.QColor(f"#{alpha}{rgb}") # '#AARRGGBB'
else:
raise TypeError(color)
self._color_cache[color] = qt_color
return qt_color
def _get_pen(self, properties: BackendProperties) -> qg.QPen:
"""Returns a cosmetic pen with applied lineweight but without line type
support.
"""
px = properties.lineweight / 0.3527 * self.config.lineweight_scaling
pen = qg.QPen(self._get_color(properties.color), px)
# Use constant width in pixel:
pen.setCosmetic(True)
pen.setJoinStyle(qc.Qt.RoundJoin)
return pen
def _get_fill_brush(self, color: Color) -> qg.QBrush:
return qg.QBrush(self._get_color(color), qc.Qt.SolidPattern) # type: ignore
def set_background(self, color: Color):
self._scene.setBackgroundBrush(qg.QBrush(self._get_color(color)))
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
"""Draw a real dimensionless point."""
brush = self._get_fill_brush(properties.color)
item = _Point(pos.x, pos.y, brush)
self._add_item(item, properties.handle)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
# PyQt draws a long line for a zero-length line:
if start.isclose(end):
self.draw_point(start, properties)
else:
item = qw.QGraphicsLineItem(start.x, start.y, end.x, end.y)
item.setPen(self._get_pen(properties))
self._add_item(item, properties.handle)
def draw_solid_lines(
self,
lines: Iterable[tuple[Vec2, Vec2]],
properties: BackendProperties,
):
"""Fast method to draw a bunch of solid lines with the same properties."""
pen = self._get_pen(properties)
add_line = self._add_item
for s, e in lines:
if s.isclose(e):
self.draw_point(s, properties)
else:
item = qw.QGraphicsLineItem(s.x, s.y, e.x, e.y)
item.setPen(pen)
add_line(item, properties.handle)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
if len(path) == 0:
return
item = qw.QGraphicsPathItem(to_qpainter_path([path]))
item.setPen(self._get_pen(properties))
item.setBrush(self._no_fill)
self._add_item(item, properties.handle)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
# Default fill rule is OddEvenFill! Detecting the path orientation is not
# necessary!
_paths = list(paths)
if len(_paths) == 0:
return
item = _CosmeticPath(to_qpainter_path(_paths))
item.setPen(self._get_pen(properties))
item.setBrush(self._get_fill_brush(properties.color))
self._add_item(item, properties.handle)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
brush = self._get_fill_brush(properties.color)
polygon = qg.QPolygonF()
for p in points.vertices():
polygon.append(qc.QPointF(p.x, p.y))
item = _CosmeticPolygon(polygon)
item.setPen(self._no_line)
item.setBrush(brush)
self._add_item(item, properties.handle)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
image = image_data.image
transform = image_data.transform
height, width, depth = image.shape
assert depth == 4
bytes_per_row = width * depth
image = np.ascontiguousarray(np.flip(image, axis=0))
pixmap = qg.QPixmap(
qg.QImage(
image.data,
width,
height,
bytes_per_row,
qg.QImage.Format.Format_RGBA8888,
)
)
item = qw.QGraphicsPixmapItem()
item.setPixmap(pixmap)
item.setTransformationMode(qc.Qt.TransformationMode.SmoothTransformation)
item.setTransform(_matrix_to_qtransform(transform))
self._add_item(item, properties.handle)
def clear(self) -> None:
self._scene.clear()
def finalize(self) -> None:
super().finalize()
self._scene.setSceneRect(self._scene.itemsBoundingRect())
class PyQtBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# This implementation keeps all virtual entities alive by attaching references
# to entities to the graphic scene items.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
parent_stack = tuple(e for e, props in self.entity_stack[:-1])
current_entity = self.current_entity
item.setData(CorrespondingDXFEntity, current_entity)
item.setData(CorrespondingDXFParentStack, parent_stack)
class PyQtPlaybackBackend(_PyQtBackend):
"""
Backend which uses the :mod:`PySide6` package to implement an interactive
viewer. The :mod:`PyQt5` package can be used as fallback if the :mod:`PySide6`
package is not available.
This backend can be used a playback backend for the :meth:`replay` method of the
:class:`Player` class
Args:
scene: drawing canvas of type :class:`QtWidgets.QGraphicsScene`,
if ``None`` a new canvas will be created
"""
def __init__(
self,
scene: Optional[qw.QGraphicsScene] = None,
):
super().__init__(scene or qw.QGraphicsScene())
# The backend recorder does not record enter_entity() and exit_entity() events.
# This implementation attaches only entity handles (str) to the graphic scene items.
# Each item references the top level entity e.g. all items of a block reference
# references the handle of the INSERT entity.
def set_item_data(self, item: qw.QGraphicsItem, entity_handle: str) -> None:
item.setData(CorrespondingDXFEntity, entity_handle)
class _CosmeticPath(qw.QGraphicsPathItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
class _CosmeticPolygon(qw.QGraphicsPolygonItem):
def paint(
self,
painter: qg.QPainter,
option: qw.QStyleOptionGraphicsItem,
widget: Optional[qw.QWidget] = None,
) -> None:
_set_cosmetic_brush(self, painter)
super().paint(painter, option, widget)
def _set_cosmetic_brush(
item: qw.QAbstractGraphicsShapeItem, painter: qg.QPainter
) -> None:
"""like a cosmetic pen, this sets the brush pattern to appear the same independent of the view"""
brush = item.brush()
# scale by -1 in y because the view is always mirrored in y and undoing the view transformation entirely would make
# the hatch mirrored w.r.t the view
brush.setTransform(painter.transform().inverted()[0].scale(1, -1)) # type: ignore
item.setBrush(brush)
def _get_x_scale(t: qg.QTransform) -> float:
return math.sqrt(t.m11() * t.m11() + t.m21() * t.m21())
def _matrix_to_qtransform(matrix: Matrix44) -> qg.QTransform:
"""Qt also uses row-vectors so the translation elements are placed in the
bottom row.
This is only a simple conversion which assumes that although the
transformation is 4x4,it does not involve the z axis.
A more correct transformation could be implemented like so:
https://stackoverflow.com/questions/10629737/convert-3d-4x4-rotation-matrix-into-2d
"""
return qg.QTransform(*matrix.get_2d_transformation())
@@ -0,0 +1,623 @@
#!/usr/bin/env python3
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
# mypy: ignore_errors=True
from __future__ import annotations
from typing import Iterable, Sequence, Set, Optional
import math
import os
import time
from ezdxf.addons.xqt import QtWidgets as qw, QtCore as qc, QtGui as qg
from ezdxf.addons.xqt import Slot, QAction, Signal
import ezdxf
import ezdxf.bbox
from ezdxf import recover
from ezdxf.addons import odafc
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import Configuration
from ezdxf.addons.drawing.properties import (
is_dark_color,
set_layers_state,
LayerProperties,
)
from ezdxf.addons.drawing.pyqt import (
_get_x_scale,
PyQtBackend,
CorrespondingDXFEntity,
CorrespondingDXFParentStack,
)
from ezdxf.audit import Auditor
from ezdxf.document import Drawing
from ezdxf.entities import DXFGraphic, DXFEntity
from ezdxf.layouts import Layout
from ezdxf.lldxf.const import DXFStructureError
class CADGraphicsView(qw.QGraphicsView):
closing = Signal()
def __init__(
self,
*,
view_buffer: float = 0.2,
zoom_per_scroll_notch: float = 0.2,
loading_overlay: bool = True,
):
super().__init__()
self._zoom = 1.0
self._default_zoom = 1.0
self._zoom_limits = (0.5, 100)
self._zoom_per_scroll_notch = zoom_per_scroll_notch
self._view_buffer = view_buffer
self._loading_overlay = loading_overlay
self._is_loading = False
self.setTransformationAnchor(qw.QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(qw.QGraphicsView.AnchorUnderMouse)
self.setVerticalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(qc.Qt.ScrollBarAlwaysOff)
self.setDragMode(qw.QGraphicsView.ScrollHandDrag)
self.setFrameShape(qw.QFrame.NoFrame)
self.setRenderHints(
qg.QPainter.Antialiasing
| qg.QPainter.TextAntialiasing
| qg.QPainter.SmoothPixmapTransform
)
self.setScene(qw.QGraphicsScene())
self.scale(1, -1) # so that +y is up
def closeEvent(self, event: qg.QCloseEvent) -> None:
super().closeEvent(event)
self.closing.emit()
def clear(self):
pass
def begin_loading(self):
self._is_loading = True
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
qw.QApplication.processEvents()
def end_loading(self, new_scene: qw.QGraphicsScene):
self.setScene(new_scene)
self._is_loading = False
self.buffer_scene_rect()
self.scene().invalidate(qc.QRectF(), qw.QGraphicsScene.AllLayers)
def buffer_scene_rect(self):
scene = self.scene()
r = scene.sceneRect()
bx, by = (
r.width() * self._view_buffer / 2,
r.height() * self._view_buffer / 2,
)
scene.setSceneRect(r.adjusted(-bx, -by, bx, by))
def fit_to_scene(self):
self.fitInView(self.sceneRect(), qc.Qt.KeepAspectRatio)
self._default_zoom = _get_x_scale(self.transform())
self._zoom = 1
def _get_zoom_amount(self) -> float:
return _get_x_scale(self.transform()) / self._default_zoom
def wheelEvent(self, event: qg.QWheelEvent) -> None:
# dividing by 120 gets number of notches on a typical scroll wheel.
# See QWheelEvent documentation
delta_notches = event.angleDelta().y() / 120
direction = math.copysign(1, delta_notches)
factor = (1.0 + self._zoom_per_scroll_notch * direction) ** abs(delta_notches)
resulting_zoom = self._zoom * factor
if resulting_zoom < self._zoom_limits[0]:
factor = self._zoom_limits[0] / self._zoom
elif resulting_zoom > self._zoom_limits[1]:
factor = self._zoom_limits[1] / self._zoom
self.scale(factor, factor)
self._zoom *= factor
def save_view(self) -> SavedView:
return SavedView(
self.transform(),
self._default_zoom,
self._zoom,
self.horizontalScrollBar().value(),
self.verticalScrollBar().value(),
)
def restore_view(self, view: SavedView):
self.setTransform(view.transform)
self._default_zoom = view.default_zoom
self._zoom = view.zoom
self.horizontalScrollBar().setValue(view.x)
self.verticalScrollBar().setValue(view.y)
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
if self._is_loading and self._loading_overlay:
painter.save()
painter.fillRect(rect, qg.QColor("#aa000000"))
painter.setWorldMatrixEnabled(False)
r = self.viewport().rect()
painter.setBrush(qc.Qt.NoBrush)
painter.setPen(qc.Qt.white)
painter.drawText(r.center(), "Loading...")
painter.restore()
class SavedView:
def __init__(
self, transform: qg.QTransform, default_zoom: float, zoom: float, x: int, y: int
):
self.transform = transform
self.default_zoom = default_zoom
self.zoom = zoom
self.x = x
self.y = y
class CADGraphicsViewWithOverlay(CADGraphicsView):
mouse_moved = Signal(qc.QPointF)
element_hovered = Signal(object, int)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._selected_items: list[qw.QGraphicsItem] = []
self._selected_index = None
self._mark_selection = True
@property
def current_hovered_element(self) -> Optional[DXFEntity]:
if self._selected_items:
graphics_item = self._selected_items[self._selected_index]
dxf_entity = graphics_item.data(CorrespondingDXFEntity)
return dxf_entity
else:
return None
def clear(self):
super().clear()
self._selected_items = None
self._selected_index = None
def begin_loading(self):
self.clear()
super().begin_loading()
def drawForeground(self, painter: qg.QPainter, rect: qc.QRectF) -> None:
super().drawForeground(painter, rect)
if self._selected_items and self._mark_selection:
item = self._selected_items[self._selected_index]
r = item.sceneTransform().mapRect(item.boundingRect())
painter.fillRect(r, qg.QColor(0, 255, 0, 100))
def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
super().mouseMoveEvent(event)
pos = self.mapToScene(event.pos())
self.mouse_moved.emit(pos)
selected_items = self.scene().items(pos)
if selected_items != self._selected_items:
self._selected_items = selected_items
self._selected_index = 0 if self._selected_items else None
self._emit_selected()
def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
super().mouseReleaseEvent(event)
if event.button() == qc.Qt.LeftButton and self._selected_items:
self._selected_index = (self._selected_index + 1) % len(
self._selected_items
)
self._emit_selected()
def _emit_selected(self):
self.element_hovered.emit(self._selected_items, self._selected_index)
self.scene().invalidate(self.sceneRect(), qw.QGraphicsScene.ForegroundLayer)
def toggle_selection_marker(self):
self._mark_selection = not self._mark_selection
class CADWidget(qw.QWidget):
def __init__(self, view: CADGraphicsView, config: Configuration = Configuration()):
super().__init__()
layout = qw.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
self.setLayout(layout)
self._view = view
self._view.closing.connect(self.close)
self._config = config
self._bbox_cache = ezdxf.bbox.Cache()
self._doc: Drawing = None # type: ignore
self._render_context: RenderContext = None # type: ignore
self._visible_layers: set[str] = set()
self._current_layout: str = "Model"
self._reset_backend()
def _reset_backend(self):
# clear caches
self._backend = PyQtBackend()
@property
def doc(self) -> Drawing:
return self._doc
@property
def view(self) -> CADGraphicsView:
return self._view
@property
def render_context(self) -> RenderContext:
return self._render_context
@property
def current_layout(self) -> str:
return self._current_layout
def set_document(
self,
document: Drawing,
*,
layout: str = "Model",
draw: bool = True,
):
self._doc = document
# initialize bounding box cache for faste paperspace drawing
self._bbox_cache = ezdxf.bbox.Cache()
self._render_context = self._make_render_context(document)
self._reset_backend()
self._visible_layers = set()
self._current_layout = None
if draw:
self.draw_layout(layout)
def set_visible_layers(self, layers: Set[str]) -> None:
self._visible_layers = layers
self.draw_layout(self._current_layout, reset_view=False)
def _make_render_context(self, doc: Drawing) -> RenderContext:
def update_layers_state(layers: Sequence[LayerProperties]):
if self._visible_layers:
set_layers_state(layers, self._visible_layers, state=True)
render_context = RenderContext(doc)
render_context.set_layer_properties_override(update_layers_state)
return render_context
def draw_layout(
self,
layout_name: str,
reset_view: bool = True,
):
self._current_layout = layout_name
self._view.begin_loading()
new_scene = qw.QGraphicsScene()
self._backend.set_scene(new_scene)
layout = self._doc.layout(layout_name)
self._update_render_context(layout)
try:
self._create_frontend().draw_layout(layout)
finally:
self._backend.finalize()
self._view.end_loading(new_scene)
self._view.buffer_scene_rect()
if reset_view:
self._view.fit_to_scene()
def _create_frontend(self) -> Frontend:
return Frontend(
ctx=self._render_context,
out=self._backend,
config=self._config,
bbox_cache=self._bbox_cache,
)
def _update_render_context(self, layout: Layout) -> None:
assert self._render_context is not None
self._render_context.set_current_layout(layout)
class CADViewer(qw.QMainWindow):
def __init__(self, cad: Optional[CADWidget] = None):
super().__init__()
self._doc: Optional[Drawing] = None
if cad is None:
self._cad = CADWidget(CADGraphicsViewWithOverlay(), config=Configuration())
else:
self._cad = cad
self._view = self._cad.view
if isinstance(self._view, CADGraphicsViewWithOverlay):
self._view.element_hovered.connect(self._on_element_hovered)
self._view.mouse_moved.connect(self._on_mouse_moved)
menu = self.menuBar()
select_doc_action = QAction("Select Document", self)
select_doc_action.triggered.connect(self._select_doc)
menu.addAction(select_doc_action)
self.select_layout_menu = menu.addMenu("Select Layout")
toggle_sidebar_action = QAction("Toggle Sidebar", self)
toggle_sidebar_action.triggered.connect(self._toggle_sidebar)
menu.addAction(toggle_sidebar_action)
toggle_selection_marker_action = QAction("Toggle Entity Marker", self)
toggle_selection_marker_action.triggered.connect(self._toggle_selection_marker)
menu.addAction(toggle_selection_marker_action)
self.reload_menu = menu.addMenu("Reload")
reload_action = QAction("Reload", self)
reload_action.setShortcut(qg.QKeySequence("F5"))
reload_action.triggered.connect(self._reload)
self.reload_menu.addAction(reload_action)
self.keep_view_action = QAction("Keep View", self)
self.keep_view_action.setCheckable(True)
self.keep_view_action.setChecked(True)
self.reload_menu.addAction(self.keep_view_action)
watch_action = QAction("Watch", self)
watch_action.setCheckable(True)
watch_action.toggled.connect(self._toggle_watch)
self.reload_menu.addAction(watch_action)
self._watch_timer = qc.QTimer()
self._watch_timer.setInterval(50)
self._watch_timer.timeout.connect(self._check_watch)
self._watch_mtime = None
self.sidebar = qw.QSplitter(qc.Qt.Vertical)
self.layers = qw.QListWidget()
self.layers.setStyleSheet(
"QListWidget {font-size: 12pt;} "
"QCheckBox {font-size: 12pt; padding-left: 5px;}"
)
self.sidebar.addWidget(self.layers)
info_container = qw.QWidget()
info_layout = qw.QVBoxLayout()
info_layout.setContentsMargins(0, 0, 0, 0)
self.selected_info = qw.QPlainTextEdit()
self.selected_info.setReadOnly(True)
info_layout.addWidget(self.selected_info)
self.mouse_pos = qw.QLabel()
info_layout.addWidget(self.mouse_pos)
info_container.setLayout(info_layout)
self.sidebar.addWidget(info_container)
container = qw.QSplitter()
self.setCentralWidget(container)
container.addWidget(self._cad)
container.addWidget(self.sidebar)
container.setCollapsible(0, False)
container.setCollapsible(1, True)
w = container.width()
container.setSizes([int(3 * w / 4), int(w / 4)])
self.setWindowTitle("CAD Viewer")
self.resize(1600, 900)
self.show()
@staticmethod
def from_config(config: Configuration) -> CADViewer:
return CADViewer(cad=CADWidget(CADGraphicsViewWithOverlay(), config=config))
def _create_cad_widget(self):
self._view = CADGraphicsViewWithOverlay()
self._cad = CADWidget(self._view)
def load_file(self, path: str, layout: str = "Model"):
try:
if os.path.splitext(path)[1].lower() == ".dwg":
doc = odafc.readfile(path)
auditor = doc.audit()
else:
try:
doc = ezdxf.readfile(path)
except ezdxf.DXFError:
doc, auditor = recover.readfile(path)
else:
auditor = doc.audit()
self.set_document(doc, auditor, layout=layout)
except IOError as e:
qw.QMessageBox.critical(self, "Loading Error", str(e))
except DXFStructureError as e:
qw.QMessageBox.critical(
self,
"DXF Structure Error",
f'Invalid DXF file "{path}": {str(e)}',
)
def _select_doc(self):
path, _ = qw.QFileDialog.getOpenFileName(
self,
caption="Select CAD Document",
filter="CAD Documents (*.dxf *.DXF *.dwg *.DWG)",
)
if path:
self.load_file(path)
def set_document(
self,
document: Drawing,
auditor: Auditor,
*,
layout: str = "Model",
draw: bool = True,
):
error_count = len(auditor.errors)
if error_count > 0:
ret = qw.QMessageBox.question(
self,
"Found DXF Errors",
f'Found {error_count} errors in file "{document.filename}"\n'
f"Load file anyway? ",
)
if ret == qw.QMessageBox.No:
auditor.print_error_report(auditor.errors)
return
if document.filename:
try:
self._watch_mtime = os.stat(document.filename).st_mtime
except OSError:
self._watch_mtime = None
else:
self._watch_mtime = None
self._cad.set_document(document, layout=layout, draw=draw)
self._doc = document
self._populate_layouts()
self._populate_layer_list()
self.setWindowTitle("CAD Viewer - " + str(document.filename))
def _populate_layer_list(self):
self.layers.blockSignals(True)
self.layers.clear()
for layer in self._cad.render_context.layers.values():
name = layer.layer
item = qw.QListWidgetItem()
self.layers.addItem(item)
checkbox = qw.QCheckBox(name)
checkbox.setCheckState(
qc.Qt.Checked if layer.is_visible else qc.Qt.Unchecked
)
checkbox.stateChanged.connect(self._layers_updated)
text_color = "#FFFFFF" if is_dark_color(layer.color, 0.4) else "#000000"
checkbox.setStyleSheet(
f"color: {text_color}; background-color: {layer.color}"
)
self.layers.setItemWidget(item, checkbox)
self.layers.blockSignals(False)
def _populate_layouts(self):
def draw_layout(name: str):
def run():
self.draw_layout(name, reset_view=True)
return run
self.select_layout_menu.clear()
for layout_name in self._cad.doc.layout_names_in_taborder():
action = QAction(layout_name, self)
action.triggered.connect(draw_layout(layout_name))
self.select_layout_menu.addAction(action)
def draw_layout(
self,
layout_name: str,
reset_view: bool = True,
):
print(f"drawing {layout_name}")
try:
start = time.perf_counter()
self._cad.draw_layout(layout_name, reset_view=reset_view)
duration = time.perf_counter() - start
print(f"took {duration:.4f} seconds")
except DXFStructureError as e:
qw.QMessageBox.critical(
self,
"DXF Structure Error",
f'Abort rendering of layout "{layout_name}": {str(e)}',
)
def resizeEvent(self, event: qg.QResizeEvent) -> None:
self._view.fit_to_scene()
def _layer_checkboxes(self) -> Iterable[tuple[int, qw.QCheckBox]]:
for i in range(self.layers.count()):
item = self.layers.itemWidget(self.layers.item(i))
yield i, item # type: ignore
@Slot(int) # type: ignore
def _layers_updated(self, item_state: qc.Qt.CheckState):
shift_held = qw.QApplication.keyboardModifiers() & qc.Qt.ShiftModifier
if shift_held:
for i, item in self._layer_checkboxes():
item.blockSignals(True)
item.setCheckState(item_state)
item.blockSignals(False)
visible_layers = set()
for i, layer in self._layer_checkboxes():
if layer.checkState() == qc.Qt.Checked:
visible_layers.add(layer.text())
self._cad.set_visible_layers(visible_layers)
@Slot()
def _toggle_sidebar(self):
self.sidebar.setHidden(not self.sidebar.isHidden())
@Slot()
def _toggle_selection_marker(self):
self._view.toggle_selection_marker()
@Slot()
def _reload(self):
if self._cad.doc is not None and self._cad.doc.filename:
keep_view = self.keep_view_action.isChecked()
view = self._view.save_view() if keep_view else None
self.load_file(self._cad.doc.filename, layout=self._cad.current_layout)
if keep_view:
self._view.restore_view(view)
@Slot()
def _toggle_watch(self):
if self._watch_timer.isActive():
self._watch_timer.stop()
else:
self._watch_timer.start()
@Slot()
def _check_watch(self):
if self._watch_mtime is None or self._cad.doc is None:
return
filename = self._cad.doc.filename
if filename:
try:
mtime = os.stat(filename).st_mtime
except OSError:
return
if mtime != self._watch_mtime:
self._reload()
@Slot(qc.QPointF)
def _on_mouse_moved(self, mouse_pos: qc.QPointF):
self.mouse_pos.setText(
f"mouse position: {mouse_pos.x():.4f}, {mouse_pos.y():.4f}\n"
)
@Slot(object, int)
def _on_element_hovered(self, elements: list[qw.QGraphicsItem], index: int):
if not elements:
text = "No element selected"
else:
text = f"Selected: {index + 1} / {len(elements)} (click to cycle)\n"
element = elements[index]
dxf_entity: DXFGraphic | str | None = element.data(CorrespondingDXFEntity)
if isinstance(dxf_entity, str):
dxf_entity = self.load_dxf_entity(dxf_entity)
if dxf_entity is None:
text += "No data"
else:
text += (
f"Selected Entity: {dxf_entity}\n"
f"Layer: {dxf_entity.dxf.layer}\n\nDXF Attributes:\n"
)
text += _entity_attribs_string(dxf_entity)
dxf_parent_stack = element.data(CorrespondingDXFParentStack)
if dxf_parent_stack:
text += "\nParents:\n"
for entity in reversed(dxf_parent_stack):
text += f"- {entity}\n"
text += _entity_attribs_string(entity, indent=" ")
self.selected_info.setPlainText(text)
def load_dxf_entity(self, entity_handle: str) -> DXFGraphic | None:
if self._doc is not None:
return self._doc.entitydb.get(entity_handle)
return None
def _entity_attribs_string(dxf_entity: DXFGraphic, indent: str = "") -> str:
text = ""
for key, value in dxf_entity.dxf.all_existing_dxf_attribs().items():
text += f"{indent}- {key}: {value}\n"
return text
@@ -0,0 +1,447 @@
# Copyright (c) 2023-2024, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import (
Iterable,
Iterator,
Sequence,
Callable,
Optional,
NamedTuple,
)
from typing_extensions import Self, TypeAlias
import copy
import abc
from ezdxf.math import BoundingBox2d, Matrix44, Vec2, UVec
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, EmptyShapeError
from ezdxf.tools import take2
from ezdxf.tools.clipping_portal import ClippingRect
from .backend import BackendInterface, ImageData
from .config import Configuration
from .properties import BackendProperties
from .type_hints import Color
class DataRecord(abc.ABC):
def __init__(self) -> None:
self.property_hash: int = 0
self.handle: str = ""
@abc.abstractmethod
def bbox(self) -> BoundingBox2d:
...
@abc.abstractmethod
def transform_inplace(self, m: Matrix44) -> None:
...
class PointsRecord(DataRecord):
# n=1 point; n=2 line; n>2 filled polygon
def __init__(self, points: NumpyPoints2d) -> None:
super().__init__()
self.points: NumpyPoints2d = points
def bbox(self) -> BoundingBox2d:
try:
return self.points.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.points.transform_inplace(m)
class SolidLinesRecord(DataRecord):
def __init__(self, lines: NumpyPoints2d) -> None:
super().__init__()
self.lines: NumpyPoints2d = lines
def bbox(self) -> BoundingBox2d:
try:
return self.lines.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.lines.transform_inplace(m)
class PathRecord(DataRecord):
def __init__(self, path: NumpyPath2d) -> None:
super().__init__()
self.path: NumpyPath2d = path
def bbox(self) -> BoundingBox2d:
try:
return self.path.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.path.transform_inplace(m)
class FilledPathsRecord(DataRecord):
def __init__(self, paths: Sequence[NumpyPath2d]) -> None:
super().__init__()
self.paths: Sequence[NumpyPath2d] = paths
def bbox(self) -> BoundingBox2d:
bbox = BoundingBox2d()
for path in self.paths:
if len(path):
bbox.extend(path.extents())
return bbox
def transform_inplace(self, m: Matrix44) -> None:
for path in self.paths:
path.transform_inplace(m)
class ImageRecord(DataRecord):
def __init__(self, boundary: NumpyPoints2d, image_data: ImageData) -> None:
super().__init__()
self.boundary: NumpyPoints2d = boundary
self.image_data: ImageData = image_data
def bbox(self) -> BoundingBox2d:
try:
return self.boundary.bbox()
except EmptyShapeError:
pass
return BoundingBox2d()
def transform_inplace(self, m: Matrix44) -> None:
self.boundary.transform_inplace(m)
self.image_data.transform @= m
class Recorder(BackendInterface):
"""Records the output of the Frontend class."""
def __init__(self) -> None:
self.config = Configuration()
self.background: Color = "#000000"
self.records: list[DataRecord] = []
self.properties: dict[int, BackendProperties] = dict()
def player(self) -> Player:
"""Returns a :class:`Player` instance with the original recordings! Make a copy
of this player to protect the original recordings from being modified::
safe_player = recorder.player().copy()
"""
player = Player()
player.config = self.config
player.background = self.background
player.records = self.records
player.properties = self.properties
player.has_shared_recordings = True
return player
def configure(self, config: Configuration) -> None:
self.config = config
def set_background(self, color: Color) -> None:
self.background = color
def store(self, record: DataRecord, properties: BackendProperties) -> None:
# exclude top-level entity handle to reduce the variance:
# color, lineweight, layer, pen
prop_hash = hash(properties[:4])
record.property_hash = prop_hash
record.handle = properties.handle
self.records.append(record)
self.properties[prop_hash] = properties
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.store(PointsRecord(NumpyPoints2d((pos,))), properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.store(PointsRecord(NumpyPoints2d((start, end))), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
def flatten() -> Iterator[Vec2]:
for s, e in lines:
yield s
yield e
self.store(SolidLinesRecord(NumpyPoints2d(flatten())), properties)
def draw_path(self, path: NumpyPath2d, properties: BackendProperties) -> None:
assert isinstance(path, NumpyPath2d)
self.store(PathRecord(path), properties)
def draw_filled_polygon(
self, points: NumpyPoints2d, properties: BackendProperties
) -> None:
assert isinstance(points, NumpyPoints2d)
self.store(PointsRecord(points), properties)
def draw_filled_paths(
self, paths: Iterable[NumpyPath2d], properties: BackendProperties
) -> None:
paths = tuple(paths)
if len(paths) == 0:
return
assert isinstance(paths[0], NumpyPath2d)
self.store(FilledPathsRecord(paths), properties)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
# preserve the boundary in image_data in pixel coordinates
boundary = copy.deepcopy(image_data.pixel_boundary_path)
boundary.transform_inplace(image_data.transform)
self.store(ImageRecord(boundary, image_data), properties)
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def clear(self) -> None:
raise NotImplementedError()
def finalize(self) -> None:
pass
class Override(NamedTuple):
"""Represents the override state for a data record.
Attributes:
properties: original or modified :class:`BackendProperties`
is_visible: override visibility e.g. switch layers on/off
"""
properties: BackendProperties
is_visible: bool = True
OverrideFunc: TypeAlias = Callable[[BackendProperties], Override]
class Player:
"""Plays the recordings of the :class:`Recorder` backend on another backend."""
def __init__(self) -> None:
self.config = Configuration()
self.background: Color = "#000000"
self.records: list[DataRecord] = []
self.properties: dict[int, BackendProperties] = dict()
self._bbox = BoundingBox2d()
self.has_shared_recordings: bool = False
def __copy__(self) -> Self:
"""Returns a copy of the player with non-shared recordings."""
player = self.__class__()
# config is a frozen dataclass:
player.config = self.config
player.background = self.background
# recordings are mutable: transform and crop inplace
player.records = copy.deepcopy(self.records)
# the properties dict may grow, but entries will never be removed:
player.properties = self.properties
player.has_shared_recordings = False
return player
copy = __copy__
def recordings(self) -> Iterator[tuple[DataRecord, BackendProperties]]:
"""Yields all recordings as `(DataRecord, BackendProperties)` tuples."""
props = self.properties
for record in self.records:
properties = BackendProperties(
*props[record.property_hash][:4], record.handle
)
yield record, properties
def replay(
self, backend: BackendInterface, override: Optional[OverrideFunc] = None
) -> None:
"""Replay the recording on another backend that implements the
:class:`BackendInterface`. The optional `override` function can be used to
override the properties and state of data records, it gets the :class:`BackendProperties`
as input and must return an :class:`Override` instance.
"""
backend.configure(self.config)
backend.set_background(self.background)
for record, properties in self.recordings():
if override:
state = override(properties)
if not state.is_visible:
continue
properties = state.properties
if isinstance(record, PointsRecord):
count = len(record.points)
if count == 0:
continue
if count > 2:
backend.draw_filled_polygon(record.points, properties)
continue
vertices = record.points.vertices()
if len(vertices) == 1:
backend.draw_point(vertices[0], properties)
else:
backend.draw_line(vertices[0], vertices[1], properties)
elif isinstance(record, SolidLinesRecord):
backend.draw_solid_lines(take2(record.lines.vertices()), properties)
elif isinstance(record, PathRecord):
backend.draw_path(record.path, properties)
elif isinstance(record, FilledPathsRecord):
backend.draw_filled_paths(record.paths, properties)
elif isinstance(record, ImageRecord):
backend.draw_image(record.image_data, properties)
backend.finalize()
def transform(self, m: Matrix44) -> None:
"""Transforms the recordings inplace by a transformation matrix `m` of type
:class:`~ezdxf.math.Matrix44`.
"""
for record in self.records:
record.transform_inplace(m)
if self._bbox.has_data:
# works for 90-, 180- and 270-degree rotation
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
def bbox(self) -> BoundingBox2d:
"""Returns the bounding box of all records as :class:`~ezdxf.math.BoundingBox2d`."""
if not self._bbox.has_data:
self.update_bbox()
return self._bbox
def update_bbox(self) -> None:
bbox = BoundingBox2d()
for record in self.records:
bbox.extend(record.bbox())
self._bbox = bbox
def crop_rect(self, p1: UVec, p2: UVec, distance: float) -> None:
"""Crop recorded shapes inplace by a rectangle defined by two points.
The argument `distance` defines the approximation precision for paths which have
to be approximated as polylines for cropping but only paths which are really get
cropped are approximated, paths that are fully inside the crop box will not be
approximated.
Args:
p1: first corner of the clipping rectangle
p2: second corner of the clipping rectangle
distance: maximum distance from the center of the curve to the
center of the line segment between two approximation points to
determine if a segment should be subdivided.
"""
crop_rect = BoundingBox2d([Vec2(p1), Vec2(p2)])
self.records = crop_records_rect(self.records, crop_rect, distance)
self._bbox = BoundingBox2d() # determine new bounding box on demand
def crop_records_rect(
records: list[DataRecord], crop_rect: BoundingBox2d, distance: float
) -> list[DataRecord]:
"""Crop recorded shapes inplace by a rectangle."""
def sort_paths(np_paths: Sequence[NumpyPath2d]):
_inside: list[NumpyPath2d] = []
_crop: list[NumpyPath2d] = []
for np_path in np_paths:
bbox = BoundingBox2d(np_path.extents())
if not crop_rect.has_intersection(bbox):
# path is complete outside the cropping rectangle
pass
elif crop_rect.inside(bbox.extmin) and crop_rect.inside(bbox.extmax):
# path is complete inside the cropping rectangle
_inside.append(np_path)
else:
_crop.append(np_path)
return _crop, _inside
def crop_paths(
np_paths: Sequence[NumpyPath2d],
) -> list[NumpyPath2d]:
return list(clipper.clip_filled_paths(np_paths, distance))
# an undefined crop box crops nothing:
if not crop_rect.has_data:
return records
cropped_records: list[DataRecord] = []
size = crop_rect.size
# a crop box size of zero in any dimension crops everything:
if size.x < 1e-12 or size.y < 1e-12:
return cropped_records
clipper = ClippingRect(crop_rect.rect_vertices())
for record in records:
record_box = record.bbox()
if not crop_rect.has_intersection(record_box):
# record is complete outside the cropping rectangle
continue
if crop_rect.inside(record_box.extmin) and crop_rect.inside(record_box.extmax):
# record is complete inside the cropping rectangle
cropped_records.append(record)
continue
if isinstance(record, FilledPathsRecord):
paths_to_crop, inside = sort_paths(record.paths)
cropped_paths = crop_paths(paths_to_crop) + inside
if cropped_paths:
record.paths = tuple(cropped_paths)
cropped_records.append(record)
elif isinstance(record, PathRecord):
# could be split into multiple parts
for p in clipper.clip_paths([record.path], distance):
path_record = PathRecord(p)
path_record.property_hash = record.property_hash
path_record.handle = record.handle
cropped_records.append(path_record)
elif isinstance(record, PointsRecord):
count = len(record.points)
if count == 1:
# record is inside the clipping shape!
cropped_records.append(record)
elif count == 2:
s, e = record.points.vertices()
for segment in clipper.clip_line(s, e):
if not segment:
continue
_record = copy.copy(record) # shallow copy
_record.points = NumpyPoints2d(segment)
cropped_records.append(_record)
else:
for polygon in clipper.clip_polygon(record.points):
if not polygon:
continue
_record = copy.copy(record) # shallow copy!
_record.points = polygon
cropped_records.append(_record)
elif isinstance(record, SolidLinesRecord):
points: list[Vec2] = []
for s, e in take2(record.lines.vertices()):
for segment in clipper.clip_line(s, e):
points.extend(segment)
record.lines = NumpyPoints2d(points)
cropped_records.append(record)
elif isinstance(record, ImageRecord):
pass
# TODO: Image cropping not supported
# Crop image boundary and apply transparency to cropped
# parts of the image? -> Image boundary is now a polygon!
else:
raise ValueError("invalid record type")
return cropped_records
@@ -0,0 +1,422 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Sequence, no_type_check
import copy
from xml.etree import ElementTree as ET
import numpy as np
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.path import Command
from .type_hints import Color
from .backend import BackendInterface, BkPath2d, BkPoints2d, ImageData
from .config import Configuration, LineweightPolicy
from .properties import BackendProperties
from . import layout, recorder
__all__ = ["SVGBackend"]
class SVGBackend(recorder.Recorder):
"""This is a native SVG rendering backend and does not require any external packages
to render SVG images other than the core dependencies. This backend support content
cropping at page margins.
"""
def __init__(self) -> None:
super().__init__()
self._init_flip_y = True
def get_xml_root_element(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
) -> ET.Element:
top_origin = True
settings = copy.copy(settings)
# DXF coordinates are mapped to integer viewBox coordinates in the first
# quadrant, producing compact SVG files. The larger the coordinate range, the
# more precise and the lager the files.
settings.output_coordinate_space = 1_000_000
# This player changes the original recordings!
player = self.player()
if render_box is None:
render_box = player.bbox()
# the page origin (0, 0) is in the top-left corner.
output_layout = layout.Layout(render_box, flip_y=self._init_flip_y)
page = output_layout.get_final_page(page, settings)
if page.width == 0 or page.height == 0:
return ET.Element("svg") # empty page
m = output_layout.get_placement_matrix(
page, settings=settings, top_origin=top_origin
)
# transform content to the output coordinates space:
player.transform(m)
if settings.crop_at_margins:
p1, p2 = page.get_margin_rect(top_origin=top_origin) # in mm
# scale factor to map page coordinates to output space coordinates:
output_scale = settings.page_output_scale_factor(page)
max_sagitta = 0.1 * output_scale # curve approximation 0.1 mm
# crop content inplace by the margin rect:
player.crop_rect(p1 * output_scale, p2 * output_scale, max_sagitta)
self._init_flip_y = False
backend = self.make_backend(page, settings)
player.replay(backend)
return backend.get_xml_root_element()
def get_string(
self,
page: layout.Page,
*,
settings: layout.Settings = layout.Settings(),
render_box: BoundingBox2d | None = None,
xml_declaration=True,
) -> str:
"""Returns the XML data as unicode string.
Args:
page: page definition, see :class:`~ezdxf.addons.drawing.layout.Page`
settings: layout settings, see :class:`~ezdxf.addons.drawing.layout.Settings`
render_box: set explicit region to render, default is content bounding box
xml_declaration: inserts the "<?xml version='1.0' encoding='utf-8'?>" string
in front of the <svg> element
"""
xml = self.get_xml_root_element(page, settings=settings, render_box=render_box)
return ET.tostring(xml, encoding="unicode", xml_declaration=xml_declaration)
@staticmethod
def make_backend(page: layout.Page, settings: layout.Settings) -> SVGRenderBackend:
"""Override this method to use a customized render backend."""
return SVGRenderBackend(page, settings)
def make_view_box(page: layout.Page, output_coordinate_space: float) -> tuple[int, int]:
size = round(output_coordinate_space)
if page.width > page.height:
return size, round(size * (page.height / page.width))
return round(size * (page.width / page.height)), size
def scale_page_to_view_box(page: layout.Page, output_coordinate_space: float) -> float:
# The viewBox coordinates are integer values in the range of [0, output_coordinate_space]
return min(
output_coordinate_space / page.width,
output_coordinate_space / page.height,
)
class Styles:
def __init__(self, xml: ET.Element) -> None:
self._xml = xml
self._class_names: dict[int, str] = dict()
self._counter = 1
def get_class(
self,
*,
stroke: Color = "none",
stroke_width: int | str = "none",
stroke_opacity: float = 1.0,
fill: Color = "none",
fill_opacity: float = 1.0,
) -> str:
style = (
f"{{stroke: {stroke}; "
f"stroke-width: {stroke_width}; "
f"stroke-opacity: {stroke_opacity:.3f}; "
f"fill: {fill}; "
f"fill-opacity: {fill_opacity:.3f};}}"
)
key = hash(style)
try:
return self._class_names[key]
except KeyError:
pass
name = f"C{self._counter:X}"
self._counter += 1
self._add_class(name, style)
self._class_names[key] = name
return name
def _add_class(self, name, style_str: str) -> None:
style = ET.Element("style")
style.text = f".{name} {style_str}"
self._xml.append(style)
CMD_M_ABS = "M {0.x:.0f} {0.y:.0f}"
CMD_M_REL = "m {0.x:.0f} {0.y:.0f}"
CMD_L_ABS = "L {0.x:.0f} {0.y:.0f}"
CMD_L_REL = "l {0.x:.0f} {0.y:.0f}"
CMD_C3_ABS = "Q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
CMD_C3_REL = "q {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f}"
CMD_C4_ABS = "C {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
CMD_C4_REL = "c {0.x:.0f} {0.y:.0f} {1.x:.0f} {1.y:.0f} {2.x:.0f} {2.y:.0f}"
CMD_CONT = "{0.x:.0f} {0.y:.0f}"
class SVGRenderBackend(BackendInterface):
"""Creates the SVG output.
This backend requires some preliminary work, record the frontend output via the
Recorder backend to accomplish the following requirements:
- Scale the content in y-axis by -1 to invert the y-axis (SVG).
- Move content in the first quadrant of the coordinate system.
- The viewBox is defined by the lower left corner in the origin (0, 0) and
the upper right corner at (view_box_width, view_box_height)
- The output coordinates are integer values, scale the content appropriately.
- Replay the recorded output on this backend.
"""
def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
self.settings = settings
self._stroke_width_cache: dict[float, int] = dict()
view_box_width, view_box_height = make_view_box(
page, settings.output_coordinate_space
)
# StrokeWidthPolicy.absolute:
# stroke-width in mm as resolved by the frontend
self.stroke_width_scale: float = view_box_width / page.width_in_mm
self.min_lineweight = 0.05 # in mm, set by configure()
self.lineweight_scaling = 1.0 # set by configure()
self.lineweight_policy = LineweightPolicy.ABSOLUTE # set by configure()
# fixed lineweight for all strokes in ABSOLUTE mode:
# set Configuration.min_lineweight to the desired lineweight in 1/300 inch!
# set Configuration.lineweight_scaling to 0
# LineweightPolicy.RELATIVE:
# max_stroke_width is determined as a certain percentage of settings.output_coordinate_space
self.max_stroke_width: int = int(
settings.output_coordinate_space * settings.max_stroke_width
)
# min_stroke_width is determined as a certain percentage of max_stroke_width
self.min_stroke_width: int = int(
self.max_stroke_width * settings.min_stroke_width
)
# LineweightPolicy.RELATIVE_FIXED:
# all strokes have a fixed stroke-width as a certain percentage of max_stroke_width
self.fixed_stroke_width: int = int(
self.max_stroke_width * settings.fixed_stroke_width
)
self.root = ET.Element(
"svg",
xmlns="http://www.w3.org/2000/svg",
width=f"{page.width_in_mm:g}mm",
height=f"{page.height_in_mm:g}mm",
viewBox=f"0 0 {view_box_width} {view_box_height}",
)
self.styles = Styles(ET.SubElement(self.root, "defs"))
self.background = ET.SubElement(
self.root,
"rect",
fill="white",
x="0",
y="0",
width=str(view_box_width),
height=str(view_box_height),
)
self.entities = ET.SubElement(self.root, "g")
self.entities.set("stroke-linecap", "round")
self.entities.set("stroke-linejoin", "round")
self.entities.set("fill-rule", "evenodd")
def get_xml_root_element(self) -> ET.Element:
return self.root
def add_strokes(self, d: str, properties: BackendProperties):
if not d:
return
element = ET.SubElement(self.entities, "path", d=d)
stroke_width = self.resolve_stroke_width(properties.lineweight)
stroke_color, stroke_opacity = self.resolve_color(properties.color)
cls = self.styles.get_class(
stroke=stroke_color,
stroke_width=stroke_width,
stroke_opacity=stroke_opacity,
)
element.set("class", cls)
def add_filling(self, d: str, properties: BackendProperties):
if not d:
return
element = ET.SubElement(self.entities, "path", d=d)
fill_color, fill_opacity = self.resolve_color(properties.color)
cls = self.styles.get_class(fill=fill_color, fill_opacity=fill_opacity)
element.set("class", cls)
def resolve_color(self, color: Color) -> tuple[Color, float]:
return color[:7], alpha_to_opacity(color[7:9])
def resolve_stroke_width(self, width: float) -> int:
try:
return self._stroke_width_cache[width]
except KeyError:
pass
stroke_width = self.fixed_stroke_width
policy = self.lineweight_policy
if policy == LineweightPolicy.ABSOLUTE:
if self.lineweight_scaling:
width = max(self.min_lineweight, width) * self.lineweight_scaling
else:
width = self.min_lineweight
stroke_width = round(width * self.stroke_width_scale)
elif policy == LineweightPolicy.RELATIVE:
stroke_width = map_lineweight_to_stroke_width(
width, self.min_stroke_width, self.max_stroke_width
)
self._stroke_width_cache[width] = stroke_width
return stroke_width
def set_background(self, color: Color) -> None:
color_str = color[:7]
opacity = alpha_to_opacity(color[7:9])
self.background.set("fill", color_str)
self.background.set("fill-opacity", str(opacity))
def draw_point(self, pos: Vec2, properties: BackendProperties) -> None:
self.add_strokes(self.make_polyline_str([pos, pos]), properties)
def draw_line(self, start: Vec2, end: Vec2, properties: BackendProperties) -> None:
self.add_strokes(self.make_polyline_str([start, end]), properties)
def draw_solid_lines(
self, lines: Iterable[tuple[Vec2, Vec2]], properties: BackendProperties
) -> None:
lines = list(lines)
if len(lines) == 0:
return
self.add_strokes(self.make_multi_line_str(lines), properties)
def draw_path(self, path: BkPath2d, properties: BackendProperties) -> None:
self.add_strokes(self.make_path_str(path), properties)
def draw_filled_paths(
self, paths: Iterable[BkPath2d], properties: BackendProperties
) -> None:
d = []
for path in paths:
if len(path):
d.append(self.make_path_str(path, close=True))
self.add_filling(" ".join(d), properties)
def draw_filled_polygon(
self, points: BkPoints2d, properties: BackendProperties
) -> None:
self.add_filling(
self.make_polyline_str(points.vertices(), close=True), properties
)
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
pass # TODO: not implemented
@staticmethod
def make_polyline_str(points: Sequence[Vec2], close=False) -> str:
if len(points) < 2:
return ""
current = points[0]
# first move is absolute, consecutive lines are relative:
d: list[str] = [CMD_M_ABS.format(current), "l"]
for point in points[1:]:
relative = point - current
current = point
d.append(CMD_CONT.format(relative))
if close:
d.append("Z")
return " ".join(d)
@staticmethod
def make_multi_line_str(lines: Sequence[tuple[Vec2, Vec2]]) -> str:
assert len(lines) > 0
start, end = lines[0]
d: list[str] = [CMD_M_ABS.format(start), CMD_L_REL.format(end - start)]
current = end
for start, end in lines[1:]:
d.append(CMD_M_REL.format(start - current))
current = start
d.append(CMD_L_REL.format(end - current))
current = end
return " ".join(d)
@staticmethod
@no_type_check
def make_path_str(path: BkPath2d, close=False) -> str:
d: list[str] = [CMD_M_ABS.format(path.start)]
if len(path) == 0:
return ""
current = path.start
for cmd in path.commands():
end = cmd.end
if cmd.type == Command.MOVE_TO:
d.append(CMD_M_REL.format(end - current))
elif cmd.type == Command.LINE_TO:
d.append(CMD_L_REL.format(end - current))
elif cmd.type == Command.CURVE3_TO:
d.append(CMD_C3_REL.format(cmd.ctrl - current, end - current))
elif cmd.type == Command.CURVE4_TO:
d.append(
CMD_C4_REL.format(
cmd.ctrl1 - current, cmd.ctrl2 - current, end - current
)
)
current = end
if close:
d.append("Z")
return " ".join(d)
def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
# config.min_lineweight in 1/300 inch!
min_lineweight_mm = config.min_lineweight * 25.4 / 300
self.min_lineweight = max(0.05, min_lineweight_mm)
self.lineweight_scaling = config.lineweight_scaling
def clear(self) -> None:
pass
def finalize(self) -> None:
pass
def enter_entity(self, entity, properties) -> None:
pass
def exit_entity(self, entity) -> None:
pass
def alpha_to_opacity(alpha: str) -> float:
# stroke-opacity: 0.0 = transparent; 1.0 = opaque
# alpha: "00" = transparent; "ff" = opaque
if len(alpha):
try:
return int(alpha, 16) / 255
except ValueError:
pass
return 1.0
def map_lineweight_to_stroke_width(
lineweight: float,
min_stroke_width: int,
max_stroke_width: int,
min_lineweight=0.05, # defined by DXF
max_lineweight=2.11, # defined by DXF
) -> int:
"""Map the DXF lineweight in mm to stroke-width in viewBox coordinates."""
lineweight = max(min(lineweight, max_lineweight), min_lineweight) - min_lineweight
factor = (max_stroke_width - min_stroke_width) / (max_lineweight - min_lineweight)
return min_stroke_width + round(lineweight * factor)
@@ -0,0 +1,351 @@
# Copyright (c) 2020-2023, Matthew Broadway
# License: MIT License
from __future__ import annotations
from typing import Union, Tuple, Iterable, Optional, Callable
from typing_extensions import TypeAlias
import enum
from math import radians
import ezdxf.lldxf.const as DXFConstants
from ezdxf.enums import (
TextEntityAlignment,
MAP_TEXT_ENUM_TO_ALIGN_FLAGS,
MTextEntityAlignment,
)
from ezdxf.entities import MText, Text, Attrib, AttDef
from ezdxf.math import Matrix44, Vec3, sign
from ezdxf.fonts import fonts
from ezdxf.fonts.font_measurements import FontMeasurements
from ezdxf.tools.text import plain_text, text_wrap
from .text_renderer import TextRenderer
"""
Search google for 'typography' or 'font anatomy' for explanations of terms like
'baseline' and 'x-height'
A Visual Guide to the Anatomy of Typography: https://visme.co/blog/type-anatomy/
Anatomy of a Character: https://www.fonts.com/content/learning/fontology/level-1/type-anatomy/anatomy
"""
@enum.unique
class HAlignment(enum.Enum):
LEFT = 0
CENTER = 1
RIGHT = 2
@enum.unique
class VAlignment(enum.Enum):
TOP = 0 # the top of capital letters or letters with ascenders (like 'b')
LOWER_CASE_CENTER = 1 # the midpoint between the baseline and the x-height
BASELINE = 2 # the line which text rests on, characters with descenders (like 'p') are partially below this line
BOTTOM = 3 # the lowest point on a character with a descender (like 'p')
UPPER_CASE_CENTER = 4 # the midpoint between the baseline and the cap-height
Alignment: TypeAlias = Tuple[HAlignment, VAlignment]
AnyText: TypeAlias = Union[Text, MText, Attrib, AttDef]
# multiple of cap_height between the baseline of the previous line and the
# baseline of the next line
DEFAULT_LINE_SPACING = 5 / 3
DXF_TEXT_ALIGNMENT_TO_ALIGNMENT: dict[TextEntityAlignment, Alignment] = {
TextEntityAlignment.LEFT: (HAlignment.LEFT, VAlignment.BASELINE),
TextEntityAlignment.CENTER: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.RIGHT: (HAlignment.RIGHT, VAlignment.BASELINE),
TextEntityAlignment.ALIGNED: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.MIDDLE: (
HAlignment.CENTER,
VAlignment.LOWER_CASE_CENTER,
),
TextEntityAlignment.FIT: (HAlignment.CENTER, VAlignment.BASELINE),
TextEntityAlignment.BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
TextEntityAlignment.BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
TextEntityAlignment.BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
TextEntityAlignment.MIDDLE_LEFT: (
HAlignment.LEFT,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.MIDDLE_CENTER: (
HAlignment.CENTER,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.MIDDLE_RIGHT: (
HAlignment.RIGHT,
VAlignment.UPPER_CASE_CENTER,
),
TextEntityAlignment.TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
TextEntityAlignment.TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
TextEntityAlignment.TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
}
assert DXF_TEXT_ALIGNMENT_TO_ALIGNMENT.keys() == MAP_TEXT_ENUM_TO_ALIGN_FLAGS.keys()
DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT: dict[int, Alignment] = {
DXFConstants.MTEXT_TOP_LEFT: (HAlignment.LEFT, VAlignment.TOP),
DXFConstants.MTEXT_TOP_CENTER: (HAlignment.CENTER, VAlignment.TOP),
DXFConstants.MTEXT_TOP_RIGHT: (HAlignment.RIGHT, VAlignment.TOP),
DXFConstants.MTEXT_MIDDLE_LEFT: (
HAlignment.LEFT,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_MIDDLE_CENTER: (
HAlignment.CENTER,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_MIDDLE_RIGHT: (
HAlignment.RIGHT,
VAlignment.LOWER_CASE_CENTER,
),
DXFConstants.MTEXT_BOTTOM_LEFT: (HAlignment.LEFT, VAlignment.BOTTOM),
DXFConstants.MTEXT_BOTTOM_CENTER: (HAlignment.CENTER, VAlignment.BOTTOM),
DXFConstants.MTEXT_BOTTOM_RIGHT: (HAlignment.RIGHT, VAlignment.BOTTOM),
}
assert len(DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT) == len(MTextEntityAlignment)
def _calc_aligned_rotation(text: Text) -> float:
p1: Vec3 = text.dxf.insert
p2: Vec3 = text.dxf.align_point
if not p1.isclose(p2):
return (p2 - p1).angle
else:
return radians(text.dxf.rotation)
def _get_rotation(text: AnyText) -> Matrix44:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
if text.get_align_enum() in (
TextEntityAlignment.FIT,
TextEntityAlignment.ALIGNED,
):
rotation = _calc_aligned_rotation(text)
else:
rotation = radians(text.dxf.rotation)
return Matrix44.axis_rotate(text.dxf.extrusion, rotation)
elif isinstance(text, MText):
return Matrix44.axis_rotate(Vec3(0, 0, 1), radians(text.get_rotation()))
else:
raise TypeError(type(text))
def _get_alignment(text: AnyText) -> Alignment:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
return DXF_TEXT_ALIGNMENT_TO_ALIGNMENT[text.get_align_enum()]
elif isinstance(text, MText):
return DXF_MTEXT_ALIGNMENT_TO_ALIGNMENT[text.dxf.attachment_point]
else:
raise TypeError(type(text))
def _get_cap_height(text: AnyText) -> float:
if isinstance(text, (Text, Attrib, AttDef)):
return text.dxf.height
elif isinstance(text, MText):
return text.dxf.char_height
else:
raise TypeError(type(text))
def _get_line_spacing(text: AnyText, cap_height: float) -> float:
if isinstance(text, (Attrib, AttDef, Text)):
return 0.0
elif isinstance(text, MText):
return cap_height * DEFAULT_LINE_SPACING * text.dxf.line_spacing_factor
else:
raise TypeError(type(text))
def _split_into_lines(
entity: AnyText,
box_width: Optional[float],
get_text_width: Callable[[str], float],
) -> list[str]:
if isinstance(entity, AttDef):
# ATTDEF outside of an Insert renders the tag rather than the value
text = plain_text(entity.dxf.tag)
else:
text = entity.plain_text() # type: ignore
if isinstance(entity, (Text, Attrib, AttDef)):
assert "\n" not in text
return [text]
else:
return text_wrap(text, box_width, get_text_width)
def _get_text_width(text: AnyText) -> Optional[float]:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
return None
elif isinstance(text, MText):
width = text.dxf.width
return None if width == 0.0 else width
else:
raise TypeError(type(text))
def _get_extra_transform(text: AnyText, line_width: float) -> Matrix44:
extra_transform = Matrix44()
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
# 'width' is the width *scale factor* so 1.0 by default:
scale_x = text.dxf.width
scale_y = 1.0
# Calculate text stretching for FIT and ALIGNED:
alignment = text.get_align_enum()
line_width = abs(line_width)
if (
alignment in (TextEntityAlignment.FIT, TextEntityAlignment.ALIGNED)
and line_width > 1e-9
):
defined_length = (text.dxf.align_point - text.dxf.insert).magnitude
stretch_factor = defined_length / line_width
scale_x = stretch_factor
if alignment == TextEntityAlignment.ALIGNED:
scale_y = stretch_factor
if text.dxf.text_generation_flag & DXFConstants.MIRROR_X:
scale_x *= -1.0
if text.dxf.text_generation_flag & DXFConstants.MIRROR_Y:
scale_y *= -1.0
# Magnitude of extrusion does not have any effect.
# An extrusion of (0, 0, 0) acts like (0, 0, 1)
scale_x *= sign(text.dxf.extrusion.z)
if scale_x != 1.0 or scale_y != 1.0:
extra_transform = Matrix44.scale(scale_x, scale_y)
elif isinstance(text, MText):
# Not sure about the rationale behind this but it does match AutoCAD
# behavior...
scale_y = sign(text.dxf.extrusion.z)
if scale_y != 1.0:
extra_transform = Matrix44.scale(1.0, scale_y)
return extra_transform
def _apply_alignment(
alignment: Alignment,
line_widths: list[float],
line_spacing: float,
box_width: Optional[float],
font_measurements: FontMeasurements,
) -> tuple[tuple[float, float], list[float], list[float]]:
if not line_widths:
return (0, 0), [], []
halign, valign = alignment
line_ys = [
-font_measurements.baseline - (font_measurements.cap_height + i * line_spacing)
for i in range(len(line_widths))
]
if box_width is None:
box_width = max(line_widths)
last_baseline = line_ys[-1]
if halign == HAlignment.LEFT:
anchor_x = 0.0
line_xs = [0.0] * len(line_widths)
elif halign == HAlignment.CENTER:
anchor_x = box_width / 2
line_xs = [anchor_x - w / 2 for w in line_widths]
elif halign == HAlignment.RIGHT:
anchor_x = box_width
line_xs = [anchor_x - w for w in line_widths]
else:
raise ValueError(halign)
if valign == VAlignment.TOP:
anchor_y = 0.0
elif valign == VAlignment.LOWER_CASE_CENTER:
first_line_lower_case_top = line_ys[0] + font_measurements.x_height
anchor_y = (first_line_lower_case_top + last_baseline) / 2
elif valign == VAlignment.UPPER_CASE_CENTER:
first_line_upper_case_top = line_ys[0] + font_measurements.cap_height
anchor_y = (first_line_upper_case_top + last_baseline) / 2
elif valign == VAlignment.BASELINE:
anchor_y = last_baseline
elif valign == VAlignment.BOTTOM:
anchor_y = last_baseline - font_measurements.descender_height
else:
raise ValueError(valign)
return (anchor_x, anchor_y), line_xs, line_ys
def _get_wcs_insert(text: AnyText) -> Vec3:
if isinstance(text, Text): # Attrib and AttDef are sub-classes of Text
insert: Vec3 = text.dxf.insert
align_point: Vec3 = text.dxf.align_point
alignment: TextEntityAlignment = text.get_align_enum()
if alignment == TextEntityAlignment.LEFT:
# LEFT/BASELINE is always located at the insert point.
pass
elif alignment in (
TextEntityAlignment.FIT,
TextEntityAlignment.ALIGNED,
):
# Interpolate insertion location between insert and align point:
insert = insert.lerp(align_point, factor=0.5)
else:
# Everything else is located at the align point:
insert = align_point
return text.ocs().to_wcs(insert)
else:
return text.dxf.insert
# Simple but fast MTEXT renderer:
def simplified_text_chunks(
text: AnyText,
render_engine: TextRenderer,
*,
font_face: fonts.FontFace,
) -> Iterable[tuple[str, Matrix44, float]]:
"""Splits a complex text entity into simple chunks of text which can all be
rendered the same way:
render the string (which will not contain any newlines) with the given
cap_height with (left, baseline) at (0, 0) then transform it with the given
matrix to move it into place.
"""
alignment = _get_alignment(text)
box_width = _get_text_width(text)
cap_height = _get_cap_height(text)
lines = _split_into_lines(
text,
box_width,
lambda s: render_engine.get_text_line_width(s, font_face, cap_height),
)
line_spacing = _get_line_spacing(text, cap_height)
line_widths = [
render_engine.get_text_line_width(line, font_face, cap_height) for line in lines
]
font_measurements = render_engine.get_font_measurements(font_face, cap_height)
anchor, line_xs, line_ys = _apply_alignment(
alignment, line_widths, line_spacing, box_width, font_measurements
)
rotation = _get_rotation(text)
# first_line_width is used for TEXT, ATTRIB and ATTDEF stretching
if line_widths:
first_line_width = line_widths[0]
else: # no text lines -> no output, value is not important
first_line_width = 1.0
extra_transform = _get_extra_transform(text, first_line_width)
insert = _get_wcs_insert(text)
whole_text_transform = (
Matrix44.translate(-anchor[0], -anchor[1], 0)
@ extra_transform
@ rotation
@ Matrix44.translate(*insert.xyz)
)
for i, (line, line_x, line_y) in enumerate(zip(lines, line_xs, line_ys)):
transform = Matrix44.translate(line_x, line_y, 0) @ whole_text_transform
yield line, transform, cap_height
@@ -0,0 +1,46 @@
# Copyright (c) 2022-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TypeVar, TYPE_CHECKING
import abc
from ezdxf.fonts import fonts
if TYPE_CHECKING:
from ezdxf.npshapes import NumpyPath2d
T = TypeVar("T")
class TextRenderer(abc.ABC):
"""Minimal requirement to be usable as a universal text renderer"""
@abc.abstractmethod
def get_font_measurements(
self, font_face: fonts.FontFace, cap_height: float = 1.0
) -> fonts.FontMeasurements:
...
@abc.abstractmethod
def get_text_line_width(
self,
text: str,
font_face: fonts.FontFace,
cap_height: float = 1.0,
) -> float:
...
@abc.abstractmethod
def get_text_path(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> NumpyPath2d:
...
@abc.abstractmethod
def get_text_glyph_paths(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> list[NumpyPath2d]:
...
@abc.abstractmethod
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
...
@@ -0,0 +1,10 @@
# Copyright (c) 2020-2022, Matthew Broadway
# License: MIT License
from typing import Callable
from typing_extensions import TypeAlias
from ezdxf.entities import DXFGraphic
LayerName: TypeAlias = str
Color: TypeAlias = str
Radians: TypeAlias = float
FilterFunc: TypeAlias = Callable[[DXFGraphic], bool]
@@ -0,0 +1,72 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import TYPE_CHECKING
from ezdxf.fonts import fonts
from ezdxf.fonts.font_measurements import FontMeasurements
from .text_renderer import TextRenderer
if TYPE_CHECKING:
from ezdxf.npshapes import NumpyPath2d
class UnifiedTextRenderer(TextRenderer):
"""This text renderer supports .ttf, .ttc, .otf, .shx, .shp and .lff fonts.
The resolving order for .shx fonts is applied in the RenderContext.add_text_style()
method.
"""
def __init__(self) -> None:
self._font_cache: dict[str, fonts.AbstractFont] = dict()
def get_font(self, font_face: fonts.FontFace) -> fonts.AbstractFont:
if not font_face.filename and font_face.family:
found = fonts.find_best_match(
family=font_face.family,
weight=700 if font_face.is_bold else 400,
italic=font_face.is_italic,
)
if found is not None:
font_face = found
key = font_face.filename.lower()
try:
return self._font_cache[key]
except KeyError:
pass
abstract_font = fonts.make_font(font_face.filename, 1.0)
self._font_cache[key] = abstract_font
return abstract_font
def is_stroke_font(self, font_face: fonts.FontFace) -> bool:
abstract_font = self.get_font(font_face)
return abstract_font.font_render_type == fonts.FontRenderType.STROKE
def get_font_measurements(
self, font_face: fonts.FontFace, cap_height: float = 1.0
) -> FontMeasurements:
abstract_font = self.get_font(font_face)
return abstract_font.measurements.scale(cap_height)
def get_text_path(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> NumpyPath2d:
abstract_font = self.get_font(font_face)
return abstract_font.text_path_ex(text, cap_height)
def get_text_glyph_paths(
self, text: str, font_face: fonts.FontFace, cap_height: float = 1.0
) -> list[NumpyPath2d]:
abstract_font = self.get_font(font_face)
return abstract_font.text_glyph_paths(text, cap_height)
def get_text_line_width(
self,
text: str,
font_face: fonts.FontFace,
cap_height: float = 1.0,
) -> float:
abstract_font = self.get_font(font_face)
return abstract_font.text_width_ex(text, cap_height)