From b00b2508adfb5b15788eb458d870d89449890ff9 Mon Sep 17 00:00:00 2001 From: Matthew Broadway <mattdbway@gmail.com> Date: Fri, 30 Aug 2024 21:54:20 +0100 Subject: [PATCH] optional content groups in muPDF backend --- src/ezdxf/addons/drawing/frontend.py | 16 +++++---- src/ezdxf/addons/drawing/layout.py | 2 ++ src/ezdxf/addons/drawing/properties.py | 16 +++++---- src/ezdxf/addons/drawing/pymupdf.py | 45 +++++++++++++++++++------- src/ezdxf/commands.py | 30 ++++++++--------- 5 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/ezdxf/addons/drawing/frontend.py b/src/ezdxf/addons/drawing/frontend.py index 9f5a5d117..e304fdfc6 100644 --- a/src/ezdxf/addons/drawing/frontend.py +++ b/src/ezdxf/addons/drawing/frontend.py @@ -133,7 +133,7 @@ def __init__( # RenderContext contains all information to resolve resources for a # specific DXF document. self.ctx = ctx - + # the render pipeline is the connection between frontend and backend self.pipeline = pipeline pipeline.set_draw_entities_callback(self.draw_entities_callback) @@ -227,8 +227,8 @@ def override_properties(self, entity: DXFGraphic, properties: Properties) -> Non .. versionchanged:: 1.3.0 - This method is the first function in the stack of new property override - functions. It is possible to push additional override functions onto this + This method is the first function in the stack of new property override + functions. It is possible to push additional override functions onto this stack, see also :meth:`push_property_override_function`. """ @@ -241,7 +241,7 @@ def push_property_override_function(self, override_fn: TEntityFunc) -> None: function, because the DXF entities are not copies - except for virtual entities. The override functions are called after resolving the DXF attributes of an entity - and before the :meth:`Frontend.draw_entity` method in the order from first to + and before the :meth:`Frontend.draw_entity` method in the order from first to last. .. versionadded:: 1.3.0 @@ -566,8 +566,9 @@ def timeout() -> bool: line_pattern = baseline.pattern_renderer(line.distance) for s, e in line_pattern.render(line.start, line.end): if ocs.transform: - s, e = ocs.to_wcs((s.x, s.y, elevation)), ocs.to_wcs( - (e.x, e.y, elevation) + s, e = ( + ocs.to_wcs((s.x, s.y, elevation)), + ocs.to_wcs((e.x, e.y, elevation)), ) lines.append((s, e)) self.pipeline.draw_solid_lines(lines, properties) @@ -750,7 +751,8 @@ def draw_image_entity(self, entity: DXFGraphic, properties: Properties) -> None: if image.transparency != 0.0: loaded_image = _multiply_alpha( - loaded_image, 1.0 - image.transparency # type: ignore + loaded_image, + 1.0 - image.transparency, # type: ignore ) image_data = ImageData( image=np.array(loaded_image), diff --git a/src/ezdxf/addons/drawing/layout.py b/src/ezdxf/addons/drawing/layout.py index 7614a36a3..3a813dfff 100644 --- a/src/ezdxf/addons/drawing/layout.py +++ b/src/ezdxf/addons/drawing/layout.py @@ -298,6 +298,7 @@ class Settings: 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 """ @@ -321,6 +322,7 @@ class Settings: # 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): diff --git a/src/ezdxf/addons/drawing/properties.py b/src/ezdxf/addons/drawing/properties.py index cd05823b8..054c121c0 100644 --- a/src/ezdxf/addons/drawing/properties.py +++ b/src/ezdxf/addons/drawing/properties.py @@ -302,16 +302,16 @@ def set_colors(self, bg: Color, fg: Optional[Color] = None) -> None: class RenderContext: - """The render context for the given DXF document. The :class:`RenderContext` - resolves the properties of DXF entities from the context they reside in to actual + """The render context for the given DXF document. The :class:`RenderContext` + resolves the properties of DXF entities from the context they reside in to actual values like RGB colors, transparency, linewidth and so on. - A given `ctb` file (plot style file) overrides the default properties for all + A given `ctb` file (plot style file) overrides the default properties for all layouts, which means the plot style table stored in the layout is always ignored. Args: doc: DXF document - ctb: path to a plot style table or a :class:`~ezdxf.addons.acadctb.ColorDependentPlotStyles` + ctb: path to a plot style table or a :class:`~ezdxf.addons.acadctb.ColorDependentPlotStyles` instance export_mode: Whether to render the document as it would look when exported (plotted) by a CAD application to a file such as pdf, @@ -389,10 +389,10 @@ def _override_layer_properties(self, layers: Sequence[LayerProperties]): def set_current_layout(self, layout: Layout, ctb: str | CTB = ""): """Set the current layout and update layout specific properties. - + Args: layout: modelspace or a paperspace layout - ctb: path to a plot style table or a :class:`~ezdxf.addons.acadctb.ColorDependentPlotStyles` + ctb: path to a plot style table or a :class:`~ezdxf.addons.acadctb.ColorDependentPlotStyles` instance """ @@ -405,7 +405,9 @@ def set_current_layout(self, layout: Layout, ctb: str | CTB = ""): # last is the ctb stored in the layout ctb = layout.get_plot_style_filename() elif not isinstance(ctb, CTB): - raise TypeError(f"expected argument ctb of type str or {CTB.__name__}, got {type(ctb)}") + raise TypeError( + f"expected argument ctb of type str or {CTB.__name__}, got {type(ctb)}" + ) self.current_layout_properties = LayoutProperties.from_layout(layout) self.plot_styles = self._load_plot_style_table(ctb) self.layers = dict() diff --git a/src/ezdxf/addons/drawing/pymupdf.py b/src/ezdxf/addons/drawing/pymupdf.py index 45f6b2c45..6b4dc9ae4 100644 --- a/src/ezdxf/addons/drawing/pymupdf.py +++ b/src/ezdxf/addons/drawing/pymupdf.py @@ -3,16 +3,17 @@ from __future__ import annotations import math -from typing import Iterable, no_type_check +from typing import Iterable, no_type_check, Any import copy import PIL.Image import numpy as np -from ezdxf.math import Vec2, BoundingBox2d, Matrix44 +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 @@ -21,13 +22,13 @@ from . import layout, recorder is_pymupdf_installed = True +pymupdf: Any = None try: - import fitz + import pymupdf # type: ignore[import-untyped, no-redef] except ImportError: print( "Python module PyMuPDF (AGPL!) is required: https://pypi.org/project/PyMuPDF/" ) - fitz = None is_pymupdf_installed = False # PyMuPDF docs: https://pymupdf.readthedocs.io/en/latest/ @@ -194,16 +195,17 @@ 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 = fitz.open() + self.doc = pymupdf.open() self.doc.set_metadata( { - "producer": f"PyMuPDF {fitz.version[0]}", + "producer": f"PyMuPDF {pymupdf.version[0]}", "creator": f"ezdxf {__version__}", } ) self.settings = settings - self._stroke_width_cache: dict[float, float] = dict() - self._color_cache: dict[str, tuple[float, float, float]] = dict() + 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: @@ -255,6 +257,18 @@ def set_background(self, color: Color) -> None: def new_shape(self): return self.page.new_shape() + 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) @@ -266,6 +280,7 @@ def finish_line(self, shape, properties: BackendProperties, close: bool) -> None 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: @@ -278,6 +293,7 @@ def finish_filling(self, shape, properties: BackendProperties) -> None: 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]: @@ -373,7 +389,7 @@ def draw_image(self, image_data: ImageData, properties: BackendProperties) -> No ) xs = [p.x for p in corners] ys = [p.y for p in corners] - r = fitz.Rect((min(xs), min(ys)), (max(xs), max(ys))) + 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 @@ -396,12 +412,17 @@ def draw_image(self, image_data: ImageData, properties: BackendProperties) -> No image = np.asarray(pil_image) height, width, depth = image.shape - pixmap = fitz.Pixmap( - fitz.Colorspace(fitz.CS_RGB), width, height, bytes(image.data), True + 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) + 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 diff --git a/src/ezdxf/commands.py b/src/ezdxf/commands.py index 5e50b610e..4183963f7 100644 --- a/src/ezdxf/commands.py +++ b/src/ezdxf/commands.py @@ -4,7 +4,7 @@ import pathlib import tempfile -from typing import Callable, Optional, TYPE_CHECKING, Type +from typing import Callable, Optional, TYPE_CHECKING, Type, Sequence import abc import sys import os @@ -30,7 +30,7 @@ if TYPE_CHECKING: from ezdxf.entities import DXFGraphic - from ezdxf.addons.drawing.properties import Properties + from ezdxf.addons.drawing.properties import Properties, LayerProperties __all__ = ["get", "add_parsers"] @@ -331,12 +331,12 @@ def run(args): print(str(e)) sys.exit(1) - if args.backend == "matplotlib": + if args.backend == "matplotlib": try: file_output = MatplotlibFileOutput(args.dpi) except ImportError as e: print(str(e)) - sys.exit(1) + sys.exit(1) elif args.backend == "qt": try: file_output = PyQtFileOutput(args.dpi) @@ -356,6 +356,8 @@ def run(args): raise ValueError(args.backend) verbose = args.verbose + if verbose: + logging.basicConfig(level=logging.INFO) if args.formats: print(f"formats supported by {args.backend}:") @@ -388,20 +390,18 @@ def run(args): out = file_output.backend() if args.all_layers_visible: - for layer_properties in ctx.layers.values(): - layer_properties.is_visible = True + def override_layer_properties(layer_properties: Sequence[LayerProperties]) -> None: + for properties in layer_properties: + properties.is_visible = True - if args.all_entities_visible: + ctx.set_layer_properties_override(override_layer_properties) - class AllVisibleFrontend(Frontend): - def override_properties( - self, entity: DXFGraphic, properties: Properties - ) -> None: - properties.is_visible = True + frontend = Frontend(ctx, out, config=config) - frontend = AllVisibleFrontend(ctx, out, config=config) - else: - frontend = Frontend(ctx, out, config=config) + if args.all_entities_visible: + def override_entity_properties(entity: DXFGraphic, properties: Properties) -> None: + properties.is_visible = True + frontend.push_property_override_function(override_entity_properties) t0 = time.perf_counter() if verbose: