Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add native text rendering to muPDF backend #1159

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/ezdxf/addons/drawing/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from ezdxf.addons.drawing.properties import Properties, BackendProperties
from ezdxf.addons.drawing.type_hints import Color
from ezdxf.entities import DXFGraphic
from ezdxf.fonts.fonts import AbstractFont
from ezdxf.math import Vec2, Matrix44
from ezdxf.math.bbox import BoundingBox2d
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, single_paths

BkPath2d: TypeAlias = NumpyPath2d
Expand Down Expand Up @@ -117,6 +119,20 @@ def draw_filled_polygon(
def draw_image(self, image_data: ImageData, properties: BackendProperties) -> None:
raise NotImplementedError

def draw_text(
self,
text: str,
bbox: BoundingBox2d,
transform: Matrix44,
properties: BackendProperties,
font: AbstractFont,
cap_height: float,
) -> None:
"""Handle rendering of text with `TextPolicy.NATIVE`. If the backend does not support
text data then this method can do nothing.
"""
pass

@abstractmethod
def clear(self) -> None:
raise NotImplementedError
Expand Down
3 changes: 3 additions & 0 deletions src/ezdxf/addons/drawing/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ class TextPolicy(Enum):
OUTLINE: text is rendered as outline paths
REPLACE_RECT: replace text by a rectangle
REPLACE_FILL: replace text by a filled rectangle
NATIVE: for supported backends, output as text instead of rendering to shapes.
For unsupported backends, text will be ignored.
IGNORE: ignore text entirely

"""
Expand All @@ -162,6 +164,7 @@ class TextPolicy(Enum):
OUTLINE = auto()
REPLACE_RECT = auto()
REPLACE_FILL = auto()
NATIVE = auto()
IGNORE = auto()


Expand Down
41 changes: 28 additions & 13 deletions src/ezdxf/addons/drawing/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,26 +334,33 @@ def draw_text(
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:
bbox = self._text_bbox(transformed_paths)
if bbox is None:
return
rect = BkPath2d.from_vertices(BoundingBox2d(points).rect_vertices())
pipeline.draw_path(rect, properties)
pipeline.draw_path(BkPath2d.from_vertices(bbox.rect_vertices()), properties)
return
if text_policy == TextPolicy.REPLACE_FILL:
points = []
for p in transformed_paths:
points.extend(p.extents())
if len(points) < 2:
bbox = self._text_bbox(transformed_paths)
if bbox is None:
return
polygon = BkPoints2d(BoundingBox2d(points).rect_vertices())
if properties.filling is None:
properties.filling = Filling()
pipeline.draw_filled_polygon(polygon, properties)
pipeline.draw_filled_polygon(BkPoints2d(bbox.rect_vertices()), properties)
return
if text_policy == TextPolicy.NATIVE:
bbox = self._text_bbox(transformed_paths)
if bbox is None:
return
abstract_font = self.text_engine.get_font(font_face)
self.backend.draw_text(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to bypass the clipping stage, so text in viewports and clipped INSERTs will be draw at any time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is clipping done in the pipeline? In which case I think you are correct, I didn't really handle clipping so I can't comment on if that would be difficult to add or not

text,
bbox,
transform,
self.get_backend_properties(properties),
abstract_font,
cap_height,
)
return

if (
Expand All @@ -368,6 +375,14 @@ def draw_text(
properties.filling = Filling()
pipeline.draw_filled_paths(transformed_paths, properties)

def _text_bbox(self, path: list[BkPath2d]) -> BoundingBox2d | None:
points: list[Vec2] = []
for p in path:
points.extend(p.extents())
if len(points) < 2:
return None
return BoundingBox2d(points)

def finalize(self) -> None:
self.backend.finalize()

Expand Down
47 changes: 46 additions & 1 deletion src/ezdxf/addons/drawing/pymupdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

import PIL.Image
import numpy as np
import logging

from ezdxf.math import Vec2, BoundingBox2d
from ezdxf.fonts.fonts import AbstractFont, font_manager
from ezdxf.math import Vec2, BoundingBox2d, Matrix44
from ezdxf.colors import RGB
from ezdxf.path import Command
from ezdxf.version import __version__
Expand Down Expand Up @@ -39,6 +41,8 @@
# psd does not work in PyMuPDF v1.22.3
SUPPORTED_IMAGE_FORMATS = ("png", "ppm", "pbm")

logger = logging.getLogger("ezdxf")


class PyMuPdfBackend(recorder.Recorder):
"""This backend uses the `PyMuPdf`_ package to create PDF, PNG, PPM and PBM output.
Expand Down Expand Up @@ -204,6 +208,7 @@ def __init__(self, page: layout.Page, settings: layout.Settings) -> None:
)
self.settings = settings
self._optional_content_groups: dict[str, int] = {}
self._fonts: 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)
Expand Down Expand Up @@ -424,6 +429,46 @@ def draw_image(self, image_data: ImageData, properties: BackendProperties) -> No
oc=self.get_optional_content_group(properties.layer),
)

def register_font(self, font: AbstractFont) -> int:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this implementation cannot handle SHX fonts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assume so. I wonder what autocad / other cad programs do when exporting if non-ttf fonts are used?

If both exact rendering and selectable/interpretable text was desirable then invisible text could be layered on top of the baked glyphs hypothetically

if font.name not in self._fonts:
logger.info("registering font %s", font.name)
path = font_manager.get_font_path(font.name)
self._fonts[font.name] = self.page.insert_font(
fontname=font.name, fontfile=path
)
return self._fonts[font.name]

def draw_text(
self,
text: str,
bbox: BoundingBox2d,
transform: Matrix44,
properties: BackendProperties,
font: AbstractFont,
cap_height: float,
) -> None:
self.register_font(font)
origin = transform.origin
transform = copy.deepcopy(transform)
transform *= Matrix44.translate(-origin.x, -origin.y, -origin.z)
transform *= Matrix44.scale(1, -1, 1)
(a, b, _, c, d, _, _, _, _) = transform.get_2d_transformation()
# last two entries must be 0 as translation is not allowed
m = pymupdf.Matrix(a, b, c, d, 0, 0)
p = pymupdf.Point(origin.x, origin.y)
self.page.insert_text(
origin.vec2,
text,
fontname=font.name,
# scaling factor is empirically derived by rendering with this method
# as well as the frontend at the same time.
fontsize=cap_height * 1.375,
render_mode=0,
morph=(p, m),
color=self.resolve_color(properties.color),
oc=self.get_optional_content_group(properties.layer),
)

def configure(self, config: Configuration) -> None:
self.lineweight_policy = config.lineweight_policy
if config.min_lineweight:
Expand Down
53 changes: 49 additions & 4 deletions src/ezdxf/addons/drawing/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import copy
import abc

from ezdxf.fonts.fonts import AbstractFont
from ezdxf.math import BoundingBox2d, Matrix44, Vec2, UVec
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d, EmptyShapeError
from ezdxf.tools import take2
Expand All @@ -30,12 +31,10 @@ def __init__(self) -> None:
self.handle: str = ""

@abc.abstractmethod
def bbox(self) -> BoundingBox2d:
...
def bbox(self) -> BoundingBox2d: ...

@abc.abstractmethod
def transform_inplace(self, m: Matrix44) -> None:
...
def transform_inplace(self, m: Matrix44) -> None: ...


class PointsRecord(DataRecord):
Expand Down Expand Up @@ -122,6 +121,30 @@ def transform_inplace(self, m: Matrix44) -> None:
self.image_data.transform @= m


class TextRecord(DataRecord):
def __init__(
self,
text: str,
bbox: BoundingBox2d,
transform: Matrix44,
font: AbstractFont,
cap_height: float,
) -> None:
super().__init__()
self.text = text
self._bbox = bbox
self.transform = transform
self.font = font
self.cap_height = cap_height

def bbox(self) -> BoundingBox2d:
return self._bbox

def transform_inplace(self, m: Matrix44) -> None:
self._bbox = BoundingBox2d(m.transform_vertices(self._bbox.rect_vertices()))
self.transform *= m


class Recorder(BackendInterface):
"""Records the output of the Frontend class."""

Expand Down Expand Up @@ -203,6 +226,17 @@ def draw_image(self, image_data: ImageData, properties: BackendProperties) -> No
boundary.transform_inplace(image_data.transform)
self.store(ImageRecord(boundary, image_data), properties)

def draw_text(
self,
text: str,
rect: BoundingBox2d,
transform: Matrix44,
properties: BackendProperties,
font: AbstractFont,
cap_height: float,
) -> None:
self.store(TextRecord(text, rect, transform, font, cap_height), properties)

def enter_entity(self, entity, properties) -> None:
pass

Expand Down Expand Up @@ -304,6 +338,17 @@ def replay(
backend.draw_filled_paths(record.paths, properties)
elif isinstance(record, ImageRecord):
backend.draw_image(record.image_data, properties)
elif isinstance(record, TextRecord):
backend.draw_text(
record.text,
record.bbox(),
record.transform,
properties,
record.font,
record.cap_height,
)
else:
raise TypeError(f"unknown record type {type(record).__name__}")
backend.finalize()

def transform(self, m: Matrix44) -> None:
Expand Down
4 changes: 4 additions & 0 deletions src/ezdxf/fonts/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ def get_lff_glyph_cache(self, font_name: str) -> lff.GlyphCache:
self._loaded_lff_glyph_caches[font_name] = glyph_cache
return glyph_cache

def get_font_path(self, font_name: str) -> Path:
cache_entry = self._font_cache.get(font_name, self.fallback_font_name())
return cache_entry.file_path

def get_font_face(self, font_name: str) -> FontFace:
cache_entry = self._font_cache.get(font_name, self.fallback_font_name())
return cache_entry.font_face
Expand Down