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: