1# Copyright (c) 2020, Matthew Broadway
2# License: MIT License
3from abc import ABC, abstractmethod
4from typing import Optional, Tuple, TYPE_CHECKING, Iterable, List, Dict
5
6from ezdxf.addons.drawing.properties import Properties
7from ezdxf.addons.drawing.type_hints import Color
8from ezdxf.entities import DXFGraphic
9from ezdxf.tools.text import replace_non_printable_characters
10from ezdxf.math import Vec3, Matrix44
11from ezdxf.path import Path
12
13if TYPE_CHECKING:
14    from ezdxf.tools.fonts import FontFace, FontMeasurements
15
16# Some params are also used by the Frontend() which has access to the backend
17# attributes:
18# show_defpoints: frontend filters defpoints if option is 0
19# show_hatch: frontend filters all HATCH entities if option is 0
20DEFAULT_PARAMS = {
21    # Updated by Frontend() class, if not set by user:
22    "pdsize": None,
23    # 0   5% of draw area height
24    # <0  Specifies a percentage of the viewport size
25    # >0  Specifies an absolute size
26
27    # See POINT docs:
28    "pdmode": None,
29
30    # Do not show defpoints by default.
31    # Filtering is handled by the Frontend().
32    "show_defpoints": 0,
33
34    # linetype render:
35    # "internal" or "ezdxf"
36    "linetype_renderer": "internal",
37
38    # overall linetype scaling: None as default is important!
39    # 0.0 = disable line types at all, only supported by PyQt backend yet!
40    "linetype_scaling": 1.0,
41
42    # lineweight_scaling: 0.0 to disable lineweights at all - the current
43    # result is correct, in SVG the line width is 0.7 points for 0.25mm as
44    # required, but it often looks too thick
45    "lineweight_scaling": 1.0,
46    "min_lineweight": 0.24,  # 1/300 inch
47    "min_dash_length": 0.1,  # just guessing
48    "max_flattening_distance": 0.01,  # just guessing
49
50    # 0 = disable HATCH entities
51    # 1 = show HATCH entities
52    # Filtering is  handled by the Frontend().
53    "show_hatch": 1,
54
55    # 0 = disable hatch pattern
56    # 1 = use predefined matplotlib pattern by pattern-name matching
57    # 2 = draw as solid fillings
58    "hatch_pattern": 1,
59}
60
61
62class Backend(ABC):
63    def __init__(self, params: Dict = None):
64        params_ = dict(DEFAULT_PARAMS)
65        if params:
66            err = set(params.keys()) - set(DEFAULT_PARAMS.keys())
67            if err:
68                raise ValueError(f'Invalid parameter(s): {str(err)}')
69            params_.update(params)
70        self.entity_stack: List[Tuple[DXFGraphic, Properties]] = []
71        self.pdsize = params_['pdsize']
72        self.pdmode = params_['pdmode']
73        self.show_defpoints = params_['show_defpoints']
74        self.show_hatch = params_['show_hatch']
75        self.hatch_pattern = params_['hatch_pattern']
76        self.linetype_renderer = params_['linetype_renderer'].lower()
77        self.linetype_scaling = params_['linetype_scaling']
78        self.lineweight_scaling = params_['lineweight_scaling']
79        self.min_lineweight = params_['min_lineweight']
80        self.min_dash_length = params_['min_dash_length']
81
82        # Real document measurement value will be updated by the Frontend():
83        # 0=Imperial (in, ft, yd, ...); 1=ISO meters
84        self.measurement = 0
85
86        # Deprecated: instead use Path.flattening() for approximation
87        self.bezier_approximation_count: int = 32
88
89        # Max flattening distance in drawing units: the backend implementation
90        # should calculate an appropriate value, like 1 screen- or paper pixel
91        # on the output medium, but converted into drawing units.
92        # Set Path() approximation accuracy:
93        self.max_flattening_distance = params_['max_flattening_distance']
94
95    def enter_entity(self, entity: DXFGraphic, properties: Properties) -> None:
96        self.entity_stack.append((entity, properties))
97
98    def exit_entity(self, entity: DXFGraphic) -> None:
99        e, p = self.entity_stack.pop()
100        assert e is entity, 'entity stack mismatch'
101
102    @property
103    def current_entity(self) -> Optional[DXFGraphic]:
104        """ Obtain the current entity being drawn """
105        return self.entity_stack[-1][0] if self.entity_stack else None
106
107    @abstractmethod
108    def set_background(self, color: Color) -> None:
109        raise NotImplementedError
110
111    @abstractmethod
112    def draw_point(self, pos: Vec3, properties: Properties) -> None:
113        """ Draw a real dimensionless point, because not all backends support
114        zero-length lines!
115        """
116        raise NotImplementedError
117
118    @abstractmethod
119    def draw_line(self, start: Vec3, end: Vec3,
120                  properties: Properties) -> None:
121        raise NotImplementedError
122
123    def draw_path(self, path: Path, properties: Properties) -> None:
124        """ Draw an outline path (connected string of line segments and Bezier
125        curves).
126
127        The :meth:`draw_path` implementation is a fall-back implementation
128        which approximates Bezier curves by flattening as line segments.
129        Backends can override this method if better path drawing functionality
130        is available for that backend.
131
132        """
133        if len(path):
134            vertices = iter(
135                path.flattening(distance=self.max_flattening_distance)
136            )
137            prev = next(vertices)
138            for vertex in vertices:
139                self.draw_line(prev, vertex, properties)
140                prev = vertex
141
142    def draw_filled_paths(self, paths: Iterable[Path], holes: Iterable[Path],
143                          properties: Properties) -> None:
144        """ Draw multiple filled paths (connected string of line segments and
145        Bezier curves) with holes.
146
147        The strategy to draw multiple paths at once was chosen, because a HATCH
148        entity can contain multiple unconnected areas and the holes are not easy
149        to assign to an external path.
150
151        The idea is to put all filled areas into `paths` (counter-clockwise
152        winding) and all holes into `holes` (clockwise winding) and look what
153        the backend does with this information.
154
155        The HATCH fill strategies ("ignore", "outermost", "ignore") are resolved
156        by the frontend e.g. the holes sequence is empty for the "ignore"
157        strategy and for the "outermost" strategy, holes do not contain nested
158        holes.
159
160        The default implementation draws all paths as filled polygon without
161        holes by the :meth:`draw_filled_polygon` method. Backends can override
162        this method if filled polygon with hole support is available.
163
164        Args:
165            paths: sequence of exterior paths (counter-clockwise winding)
166            holes: sequence of holes (clockwise winding)
167            properties: HATCH properties
168
169        """
170        for path in paths:
171            self.draw_filled_polygon(
172                path.flattening(distance=self.max_flattening_distance),
173                properties
174            )
175
176    @abstractmethod
177    def draw_filled_polygon(self, points: Iterable[Vec3],
178                            properties: Properties) -> None:
179        """ Fill a polygon whose outline is defined by the given points.
180        Used to draw entities with simple outlines where :meth:`draw_path` may
181        be an inefficient way to draw such a polygon.
182        """
183        raise NotImplementedError
184
185    @abstractmethod
186    def draw_text(self, text: str, transform: Matrix44, properties: Properties,
187                  cap_height: float) -> None:
188        """ Draw a single line of text with the anchor point at the baseline
189        left point.
190        """
191        raise NotImplementedError
192
193    @abstractmethod
194    def get_font_measurements(self, cap_height: float,
195                              font: 'FontFace' = None) -> 'FontMeasurements':
196        """ Note: backends might want to cache the results of these calls """
197        raise NotImplementedError
198
199    @abstractmethod
200    def get_text_line_width(self, text: str, cap_height: float,
201                            font: 'FontFace' = None) -> float:
202        """ Get the width of a single line of text. """
203        # https://stackoverflow.com/questions/32555015/how-to-get-the-visual-length-of-a-text-string-in-python
204        # https://stackoverflow.com/questions/4190667/how-to-get-width-of-a-truetype-font-character-in-1200ths-of-an-inch-with-python
205        raise NotImplementedError
206
207    @abstractmethod
208    def clear(self) -> None:
209        """ Clear the canvas. Does not reset the internal state of the backend.
210        Make sure that the previous drawing is finished before clearing.
211
212        """
213        raise NotImplementedError
214
215    def finalize(self) -> None:
216        pass
217
218
219def prepare_string_for_rendering(text: str, dxftype: str) -> str:
220    assert '\n' not in text, 'not a single line of text'
221    if dxftype in {'TEXT', 'ATTRIB', 'ATTDEF'}:
222        text = replace_non_printable_characters(text, replacement='?')
223        text = text.replace('\t', '?')
224    elif dxftype == 'MTEXT':
225        text = replace_non_printable_characters(text, replacement='▯')
226        text = text.replace('\t', '        ')
227    else:
228        raise TypeError(dxftype)
229    return text
230