refactor: excel parse
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user