diff --git a/requirements.txt b/requirements.txt index d69c7037a0..f5074bdfb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ fabio >= 0.9 pyopencl; platform_machine in "i386, x86_64, AMD64" # For silx.opencl Mako # For pyopencl reduction qtconsole # For silx.gui.console -matplotlib >= 1.2.0 # For silx.gui.plot +matplotlib >= 3.1.0 # For silx.gui.plot PyOpenGL # For silx.gui.plot3d python-dateutil # For silx.gui.plot scipy # For silx.math.fit demo, silx.image.sift demo, silx.image.sift.test diff --git a/setup.py b/setup.py index 7fa1b50dd5..75d72bc5fa 100644 --- a/setup.py +++ b/setup.py @@ -183,7 +183,7 @@ def get_project_configuration(): "Mako", # gui "qtconsole", - "matplotlib>=1.2.0", + "matplotlib>=3.1.0", "PyOpenGL", "python-dateutil", "PyQt5", diff --git a/src/silx/gui/_glutils/font.py b/src/silx/gui/_glutils/font.py index 5f761a8d78..4c0268e30b 100644 --- a/src/silx/gui/_glutils/font.py +++ b/src/silx/gui/_glutils/font.py @@ -23,178 +23,17 @@ # ###########################################################################*/ """Text rasterisation feature leveraging Qt font and text layout support.""" -from __future__ import annotations - __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "13/10/2016" -import logging -import numpy - from .. import qt -from ..utils.image import convertQImageToArray -try: - from ..utils.matplotlib import rasterMathText -except ImportError: - rasterMathText = None - -_logger = logging.getLogger(__name__) +# Expose rasterMathText as part of this module +from ..utils.matplotlib import rasterMathText as rasterText # noqa def getDefaultFontFamily() -> str: """Returns the default font family of the application""" return qt.QApplication.instance().font().family() - - -# Font weights -ULTRA_LIGHT = 0 -"""Lightest characters: Minimum font weight""" - -LIGHT = 25 -"""Light characters""" - -NORMAL = 50 -"""Normal characters""" - -SEMI_BOLD = 63 -"""Between normal and bold characters""" - -BOLD = 74 -"""Thicker characters""" - -BLACK = 87 -"""Really thick characters""" - -ULTRA_BLACK = 99 -"""Thickest characters: Maximum font weight""" - - -def rasterTextQt( - text: str, - font: str | qt.QFont, - size: int = -1, - weight: int = -1, - italic: bool = False, - devicePixelRatio: float = 1.0, -) -> tuple[numpy.ndarray, int]: - """Raster text using Qt. - - It supports multiple lines. - - :param text: The text to raster - :param font: Font name or QFont to use - :param size: - Font size in points - Used only if font is given as name. - :param weight: - Font weight in [0, 99], see QFont.Weight. - Used only if font is given as name. - :param italic: - True for italic font (default: False). - Used only if font is given as name. - :param devicePixelRatio: - The current ratio between device and device-independent pixel - (default: 1.0) - :return: Corresponding image in gray scale and baseline offset from top - :rtype: (HxW numpy.ndarray of uint8, int) - """ - if not text: - _logger.info("Trying to raster empty text, replaced by white space") - text = " " # Replace empty text by white space to produce an image - - if not isinstance(font, qt.QFont): - font = qt.QFont(font, size, weight, italic) - - # get text size - image = qt.QImage(1, 1, qt.QImage.Format_Grayscale8) - painter = qt.QPainter() - painter.begin(image) - painter.setPen(qt.Qt.white) - painter.setFont(font) - bounds = painter.boundingRect( - qt.QRect(0, 0, 4096, 4096), qt.Qt.TextExpandTabs, text - ) - painter.end() - - metrics = qt.QFontMetrics(font) - - # This does not provide the correct text bbox on macOS - # size = metrics.size(qt.Qt.TextExpandTabs, text) - # bounds = metrics.boundingRect( - # qt.QRect(0, 0, size.width(), size.height()), - # qt.Qt.TextExpandTabs, - # text) - - # Add extra border and handle devicePixelRatio - width = bounds.width() * devicePixelRatio + 2 - # align line size to 32 bits to ease conversion to numpy array - width = 4 * ((width + 3) // 4) - image = qt.QImage( - int(width), - int(bounds.height() * devicePixelRatio + 2), - qt.QImage.Format_Grayscale8, - ) - image.setDevicePixelRatio(devicePixelRatio) - image.fill(0) - - # Raster text - painter = qt.QPainter() - painter.begin(image) - painter.setPen(qt.Qt.white) - painter.setFont(font) - painter.drawText(bounds, qt.Qt.TextExpandTabs, text) - painter.end() - - array = convertQImageToArray(image) - - # Remove leading and trailing empty columns/rows but one on each side - filled_rows = numpy.nonzero(numpy.sum(array, axis=1))[0] - filled_columns = numpy.nonzero(numpy.sum(array, axis=0))[0] - if len(filled_rows) == 0 or len(filled_columns) == 0: - return array, metrics.ascent() - - min_row = max(0, filled_rows[0] - 1) - array = array[ - min_row : filled_rows[-1] + 2, - max(0, filled_columns[0] - 1) : filled_columns[-1] + 2, - ] - - return array, metrics.ascent() - min_row - - -def rasterText( - text: str, - font: str | qt.QFont, - size: int = -1, - weight=-1, - italic: bool = False, - devicePixelRatio=1.0, -) -> tuple[numpy.ndarray, int]: - """Raster text using Qt or matplotlib if there may be math syntax. - - It supports multiple lines. - - :param text: The text to raster - :param font: Font name or QFont to use - :param size: - Font size in points - Used only if font is given as name. - :param weight: - Font weight in [0, 99], see QFont.Weight. - Used only if font is given as name. - :param italic: - True for italic font (default: False). - Used only if font is given as name. - :param devicePixelRatio: - The current ratio between device and device-independent pixel - (default: 1.0) - :return: Corresponding image in gray scale and baseline offset from top - :rtype: (HxW numpy.ndarray of uint8, int) - """ - if rasterMathText is not None and text.count("$") >= 2: - return rasterMathText(text, font, size, weight, italic, devicePixelRatio) - else: - return rasterTextQt(text, font, size, weight, italic, devicePixelRatio) diff --git a/src/silx/gui/plot/backends/BackendOpenGL.py b/src/silx/gui/plot/backends/BackendOpenGL.py index 7bdba1b80d..370f14bca6 100755 --- a/src/silx/gui/plot/backends/BackendOpenGL.py +++ b/src/silx/gui/plot/backends/BackendOpenGL.py @@ -749,7 +749,7 @@ def _renderItems(self, overlay=False): # Render marker labels gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) for label in labels: - label.render(self.matScreenProj) + label.render(self.matScreenProj, self._plotFrame.dotsPerInch) def _renderOverlayGL(self): """Render overlay layer: overlay items and crosshair.""" diff --git a/src/silx/gui/plot/backends/glutils/GLPlotFrame.py b/src/silx/gui/plot/backends/glutils/GLPlotFrame.py index 530f079442..42cfa50918 100644 --- a/src/silx/gui/plot/backends/glutils/GLPlotFrame.py +++ b/src/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -25,6 +25,8 @@ This modules provides the rendering of plot titles, axes and grid. """ +from __future__ import annotations + __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "03/04/2017" @@ -83,6 +85,7 @@ def __init__( orderOffsetVAlign=CENTER, titleRotate=0, titleOffset=(0.0, 0.0), + font: qt.QFont | None = None, ): self._tickFormatter = DefaultTickFormatter() self._ticks = None @@ -108,6 +111,7 @@ def __init__( self._titleVAlign = titleVAlign self._titleRotate = titleRotate self._titleOffset = titleOffset + self._font = font @property def dataRange(self): @@ -115,6 +119,12 @@ def dataRange(self): of 2 floats: (min, max).""" return self._dataRange + @property + def font(self) -> qt.QFont: + if self._font is None: + return qt.QApplication.instance().font() + return self._font + @dataRange.setter def dataRange(self, dataRange): assert len(dataRange) == 2 @@ -252,9 +262,6 @@ def getVerticesAndLabels(self): """ vertices = list(self.displayCoords) # Add start and end points labels = [] - tickLabelsSize = [0.0, 0.0] - - font = qt.QApplication.instance().font() xTickLength, yTickLength = self._tickLength xTickLength *= self.devicePixelRatio @@ -267,7 +274,7 @@ def getVerticesAndLabels(self): label = Text2D( text=text, - font=font, + font=self.font, color=self._foregroundColor, x=xPixel - xTickLength, y=yPixel - yTickLength, @@ -275,13 +282,6 @@ def getVerticesAndLabels(self): valign=self._labelVAlign, devicePixelRatio=self.devicePixelRatio, ) - - width, height = label.size - if width > tickLabelsSize[0]: - tickLabelsSize[0] = width - if height > tickLabelsSize[1]: - tickLabelsSize[1] = height - labels.append(label) vertices.append((xPixel, yPixel)) @@ -304,7 +304,7 @@ def getVerticesAndLabels(self): axisTitle = Text2D( text=self.title, - font=font, + font=self.font, color=self._foregroundColor, x=xAxisCenter + xOffset, y=yAxisCenter + yOffset, @@ -320,7 +320,7 @@ def getVerticesAndLabels(self): labels.append( Text2D( text=self._orderAndOffsetText, - font=font, + font=self.font, color=self._foregroundColor, x=xOrderOffset, y=yOrderOffet, @@ -781,7 +781,7 @@ def render(self): gl.glDrawArrays(gl.GL_LINES, 0, len(vertices)) for label in labels: - label.render(matProj) + label.render(matProj, self.dotsPerInch) def renderGrid(self): if self._grid == self.GRID_NONE: @@ -829,7 +829,11 @@ def __init__(self, marginRatios, foregroundColor, gridColor, font: qt.QFont): :type gridColor: tuple RGBA with RGBA values ranging from 0.0 to 1.0 :param font: Font used by the axes label """ - super(GLPlotFrame2D, self).__init__(marginRatios, foregroundColor, gridColor, font) + super(GLPlotFrame2D, self).__init__( + marginRatios, foregroundColor, gridColor, font + ) + self._font = font + self.axes.append( PlotAxis( self, @@ -842,6 +846,7 @@ def __init__(self, marginRatios, foregroundColor, gridColor, font: qt.QFont): titleAlign=CENTER, titleVAlign=TOP, titleRotate=0, + font=self._font, ) ) @@ -859,6 +864,7 @@ def __init__(self, marginRatios, foregroundColor, gridColor, font: qt.QFont): titleAlign=CENTER, titleVAlign=BOTTOM, titleRotate=ROTATE_270, + font=self._font, ) ) @@ -873,6 +879,7 @@ def __init__(self, marginRatios, foregroundColor, gridColor, font: qt.QFont): titleAlign=CENTER, titleVAlign=TOP, titleRotate=ROTATE_270, + font=self._font, ) self._isYAxisInverted = False @@ -1330,10 +1337,9 @@ def _buildVerticesAndLabels(self): self._x2AxisCoords = ((xCoords[0], yCoords[1]), (xCoords[1], yCoords[1])) # Set order&offset anchor **before** handling Y axis inversion - font = qt.QApplication.instance().font() - fontPixelSize = font.pixelSize() + fontPixelSize = self._font.pixelSize() if fontPixelSize == -1: - fontPixelSize = font.pointSizeF() / 72.0 * self.dotsPerInch + fontPixelSize = self._font.pointSizeF() / 72.0 * self.dotsPerInch self.axes[0].orderOffetAnchor = ( xCoords[1], diff --git a/src/silx/gui/plot/backends/glutils/GLText.py b/src/silx/gui/plot/backends/glutils/GLText.py index 11f088d720..15d7a70892 100644 --- a/src/silx/gui/plot/backends/glutils/GLText.py +++ b/src/silx/gui/plot/backends/glutils/GLText.py @@ -131,9 +131,6 @@ class Text2D: _textures = weakref.WeakKeyDictionary() """Cache already created textures""" - _sizes = _Cache() - """Cache already computed sizes""" - def __init__( self, text: str, @@ -168,13 +165,9 @@ def __init__( self._rotate = numpy.radians(rotate) - def _textureKey(self) -> tuple[str, str, float]: - """Returns the current texture key""" - return self.text, self.font.key(), self.devicePixelRatio - - def _getTexture(self) -> tuple[Texture, int]: + def _getTexture(self, dotsPerInch: float) -> tuple[Texture, int]: # Retrieve/initialize texture cache for current context - textureKey = self._textureKey() + key = self.text, self.font.key(), dotsPerInch context = Context.getCurrent() if context not in self._textures: @@ -183,12 +176,8 @@ def _getTexture(self) -> tuple[Texture, int]: ) textures = self._textures[context] - if textureKey not in textures: - image, offset = font.rasterText( - self.text, self.font, devicePixelRatio=self.devicePixelRatio - ) - if textureKey not in self._sizes: - self._sizes[textureKey] = image.shape[1], image.shape[0] + if key not in textures: + image, offset = font.rasterText(self.text, self.font, dotsPerInch) texture = Texture( gl.GL_RED, @@ -198,9 +187,9 @@ def _getTexture(self) -> tuple[Texture, int]: wrap=(gl.GL_CLAMP_TO_EDGE, gl.GL_CLAMP_TO_EDGE), ) texture.prepare() - textures[textureKey] = texture, offset + textures[key] = texture, offset - return textures[textureKey] + return textures[key] @property def text(self) -> str: @@ -210,16 +199,6 @@ def text(self) -> str: def padding(self) -> int: return self._padding - @property - def size(self) -> tuple[int, int]: - textureKey = self._textureKey() - if textureKey not in self._sizes: - image, offset = font.rasterText( - self.text, self.font, devicePixelRatio=self.devicePixelRatio - ) - self._sizes[textureKey] = image.shape[1], image.shape[0] - return self._sizes[textureKey] - def getVertices(self, offset: int, shape: tuple[int, int]) -> numpy.ndarray: height, width = shape @@ -264,7 +243,7 @@ def getVertices(self, offset: int, shape: tuple[int, int]) -> numpy.ndarray: return vertices - def render(self, matrix: numpy.ndarray): + def render(self, matrix: numpy.ndarray, dotsPerInch: float): if not self.text.strip(): return @@ -272,7 +251,7 @@ def render(self, matrix: numpy.ndarray): prog.use() texUnit = 0 - texture, offset = self._getTexture() + texture, offset = self._getTexture(dotsPerInch) gl.glUniform1i(prog.uniforms["texText"], texUnit) diff --git a/src/silx/gui/plot3d/Plot3DWidget.py b/src/silx/gui/plot3d/Plot3DWidget.py index 09c866230c..9a88fe3cb0 100644 --- a/src/silx/gui/plot3d/Plot3DWidget.py +++ b/src/silx/gui/plot3d/Plot3DWidget.py @@ -350,7 +350,9 @@ def paintGL(self): if self.viewport.dirty: self.viewport.adjustCameraDepthExtent() - self._window.render(self.context(), self.getDevicePixelRatio()) + self._window.render( + self.context(), self.getDotsPerInch(), self.getDevicePixelRatio() + ) if self._firstRender: # TODO remove this ugly hack self._firstRender = False diff --git a/src/silx/gui/plot3d/scene/axes.py b/src/silx/gui/plot3d/scene/axes.py index 128867da50..91027327a7 100644 --- a/src/silx/gui/plot3d/scene/axes.py +++ b/src/silx/gui/plot3d/scene/axes.py @@ -46,7 +46,7 @@ def __init__(self): super(LabelledAxes, self).__init__() self._ticksForBounds = None - self._font = text.Font() + self._font = text.Font(size=10) self._boxVisibility = True @@ -225,6 +225,7 @@ def _updateTicks(self): for tick, label in zip(xticks, xlabels): text2d = text.Text2D(text=label, font=self.font) text2d.align = "center" + text2d.valign = "center" text2d.foreground = color text2d.transforms = [ transform.Translate(tx=tick, ty=offsets[1], tz=offsets[2]) @@ -234,6 +235,7 @@ def _updateTicks(self): for tick, label in zip(yticks, ylabels): text2d = text.Text2D(text=label, font=self.font) text2d.align = "center" + text2d.valign = "center" text2d.foreground = color text2d.transforms = [ transform.Translate(tx=offsets[0], ty=tick, tz=offsets[2]) @@ -243,6 +245,7 @@ def _updateTicks(self): for tick, label in zip(zticks, zlabels): text2d = text.Text2D(text=label, font=self.font) text2d.align = "center" + text2d.valign = "center" text2d.foreground = color text2d.transforms = [ transform.Translate(tx=offsets[0], ty=offsets[1], tz=tick) diff --git a/src/silx/gui/plot3d/scene/text.py b/src/silx/gui/plot3d/scene/text.py index c85c939c2f..79cdb130fb 100644 --- a/src/silx/gui/plot3d/scene/text.py +++ b/src/silx/gui/plot3d/scene/text.py @@ -33,7 +33,7 @@ from silx.gui.colors import rgba -from ... import _glutils +from ... import _glutils, qt from ..._glutils import gl from ..._glutils import font as _font @@ -69,9 +69,7 @@ def __init__(self, name=None, size=-1, weight=-1, italic=False): "_size", doc="""Font size in points (int)""", converter=int ) - weight = event.notifyProperty( - "_weight", doc="""Font size in points (int)""", converter=int - ) + weight = event.notifyProperty("_weight", doc="""Font weight (int)""", converter=int) italic = event.notifyProperty( "_italic", doc="""True for italic (bool)""", converter=bool @@ -113,7 +111,7 @@ def __init__(self, text="", font=None): self._overlay = False self._align = "left" self._valign = "baseline" - self._devicePixelRatio = 1.0 # Store it to check for changes + self._dotsPerInch = 96.0 # Store it to check for changes self._texture = None self._textureDirty = True @@ -204,37 +202,42 @@ def _setVAlign(self, valign): Either 'top', 'baseline' (default), 'center' or 'bottom'""", ) - def _raster(self, devicePixelRatio): + def _raster(self, dotsPerInch: float): """Raster current primitive to a bitmap - :param float devicePixelRatio: - The ratio between device and device-independent pixels + :param dotsPerInch: Screen resolution in pixels per inch :return: Corresponding image in grayscale and baseline offset from top :rtype: (HxW numpy.ndarray of uint8, int) """ - params = ( + key = ( self.text, self.font.name, self.font.size, self.font.weight, self.font.italic, - devicePixelRatio, + dotsPerInch, ) - if params not in self._rasterTextCache: # Add to cache - self._rasterTextCache[params] = _font.rasterText(*params) + if key not in self._rasterTextCache: # Add to cache + font = qt.QFont( + self.font.name, + self.font.size, + self.font.weight, + self.font.italic, + ) + self._rasterTextCache[key] = _font.rasterText(self.text, font, dotsPerInch) - array, offset = self._rasterTextCache[params] + array, offset = self._rasterTextCache[key] return array.copy(), offset def _bounds(self, dataBounds=False): return None def prepareGL2(self, context): - # Check if devicePixelRatio has changed since last rendering - devicePixelRatio = context.glCtx.devicePixelRatio - if self._devicePixelRatio != devicePixelRatio: - self._devicePixelRatio = devicePixelRatio + # Check if dotsPerInch has changed since last rendering + dotsPerInch = context.glCtx.dotsPerInch + if self._dotsPerInch != dotsPerInch: + self._dotsPerInch = dotsPerInch self._dirtyTexture = True if self._dirtyTexture: @@ -246,7 +249,7 @@ def prepareGL2(self, context): self._baselineOffset = 0 if self.text: - image, self._baselineOffset = self._raster(self._devicePixelRatio) + image, self._baselineOffset = self._raster(dotsPerInch) self._texture = _glutils.Texture( gl.GL_R8, image, diff --git a/src/silx/gui/plot3d/scene/window.py b/src/silx/gui/plot3d/scene/window.py index 902aba5448..2a6d93b10e 100644 --- a/src/silx/gui/plot3d/scene/window.py +++ b/src/silx/gui/plot3d/scene/window.py @@ -58,6 +58,7 @@ def __init__(self, glContextHandle): self._context = glContextHandle self._isCurrent = False self._devicePixelRatio = 1.0 + self._dotsPerInch = 96.0 @property def isCurrent(self): @@ -74,6 +75,16 @@ def setCurrent(self, isCurrent=True): """ self._isCurrent = bool(isCurrent) + @property + def dotsPerInch(self) -> float: + """Number of physical dots per inch on the screen""" + return self._dotsPerInch + + @dotsPerInch.setter + def dotsPerInch(self, dpi: float): + assert dpi > 0.0 + self._dotsPerInch = float(dpi) + @property def devicePixelRatio(self): """Ratio between device and device independent pixels (float) @@ -350,11 +361,12 @@ def grab(self, glcontext): return numpy.array(image, copy=False, order="C") - def render(self, glcontext, devicePixelRatio): + def render(self, glcontext, dotsPerInch: float, devicePixelRatio: float): """Perform the rendering of attached viewports :param glcontext: System identifier of the OpenGL context - :param float devicePixelRatio: + :param dotsPerInch: Screen physical resolution in pixels per inch + :param devicePixelRatio: Ratio between device and device-independent pixels """ if self.size == (0, 0): @@ -364,6 +376,7 @@ def render(self, glcontext, devicePixelRatio): self._contexts[glcontext] = ContextGL2(glcontext) # New context with self._contexts[glcontext] as context: + context.dotsPerInch = dotsPerInch context.devicePixelRatio = devicePixelRatio if self._isframebuffer: self._renderWithOffscreenFramebuffer(context) diff --git a/src/silx/gui/utils/matplotlib.py b/src/silx/gui/utils/matplotlib.py index 76ee5e7eec..c51ccd273f 100644 --- a/src/silx/gui/utils/matplotlib.py +++ b/src/silx/gui/utils/matplotlib.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2016-2023 European Synchrotron Radiation Facility +# Copyright (c) 2016-2024 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -88,7 +88,7 @@ def qFontToFontProperties(font: qt.QFont): weightFactor = 10 if qt.BINDING == "PyQt5" else 1 families = [font.family(), font.defaultFamily()] if _MATPLOTLIB_VERSION >= Version("3.6.0"): - # Prevent 'Font family not found warnings' + # Prevent 'Font family not found' warnings availableNames = font_manager.get_font_names() families = [f for f in families if f in availableNames] families.append(font_manager.fontManager.defaultFamily["ttf"]) @@ -106,83 +106,80 @@ def qFontToFontProperties(font: qt.QFont): def rasterMathText( text: str, - font: str | qt.QFont, - size: int = -1, - weight: int = -1, - italic: bool = False, - devicePixelRatio: float = 1.0, -) -> tuple[numpy.ndarray, int]: + font: qt.QFont, + dotsPerInch: float = 96.0, +) -> tuple[numpy.ndarray, float]: """Raster text using matplotlib supporting latex-like math syntax. It supports multiple lines. :param text: The text to raster - :param font: Font name or QFont to use - :param size: - Font size in points - Used only if font is given as name. - :param weight: - Font weight in [0, 99], see QFont.Weight. - Used only if font is given as name. - :param italic: - True for italic font (default: False). - Used only if font is given as name. - :param devicePixelRatio: - The current ratio between device and device-independent pixel - (default: 1.0) + :param font: Font to use + :param dotsPerInch: The DPI resolution of the created image :return: Corresponding image in gray scale and baseline offset from top - :rtype: (HxW numpy.ndarray of uint8, int) """ # Implementation adapted from: # https://github.com/matplotlib/matplotlib/blob/d624571a19aec7c7d4a24123643288fc27db17e7/lib/matplotlib/mathtext.py#L264 - dpi = 96 # default - qapp = qt.QApplication.instance() - if qapp: - screen = qapp.primaryScreen() - if screen: - dpi = screen.logicalDotsPerInchY() - - # Make sure dpi is even, it causes issues with array reshape otherwise - dpi = ((dpi * devicePixelRatio) // 2) * 2 stripped_text = text.strip("\n") + font_prop = qFontToFontProperties(font) parser = MathTextParser("path") - width, height, depth, _, _ = parser.parse(stripped_text, dpi=dpi) - width *= 2 - height *= 2 * (stripped_text.count("\n") + 1) + lines_info = [ + parser.parse(line, prop=font_prop, dpi=dotsPerInch) + for line in stripped_text.split("\n") + ] + max_line_width = max(info[0] for info in lines_info) + # Use lp string as minimum height/ascent + ref_info = parser.parse("lp", prop=font_prop, dpi=dotsPerInch) + line_height = max( + ref_info[1], + *(info[1] for info in lines_info), + ) + first_line_ascent = max( + ref_info[1] - ref_info[2], lines_info[0][1] - lines_info[0][2] + ) - if not isinstance(font, qt.QFont): - font = qt.QFont(font, size, weight, italic) + linespacing = 1.2 - fig = figure.Figure(figsize=(width / dpi, height / dpi)) - fig.text( + figure_height = numpy.ceil(line_height * len(lines_info) * linespacing) + 2 + fig = figure.Figure( + figsize=( + (max_line_width + 1) / dotsPerInch, + figure_height / dotsPerInch, + ) + ) + fig.set_dpi(dotsPerInch) + text = fig.text( 0, - depth / height, + 1, stripped_text, - fontproperties=qFontToFontProperties(font), + fontproperties=font_prop, + verticalalignment="top", ) + text.set_linespacing(linespacing) with io.BytesIO() as buffer: - fig.savefig(buffer, dpi=dpi, format="raw") + fig.savefig(buffer, dpi=dotsPerInch, format="raw") + canvas_width, canvas_height = fig.get_window_extent().max buffer.seek(0) image = numpy.frombuffer(buffer.read(), dtype=numpy.uint8).reshape( - int(height), int(width), 4 + int(canvas_height), int(canvas_width), 4 ) # RGB to inverted R channel array = 255 - image[:, :, 0] - # Remove leading and trailing empty columns/rows but one on each side + # Remove leading/trailing empty columns and trailing rows but one on each side filled_rows = numpy.nonzero(numpy.sum(array, axis=1))[0] filled_columns = numpy.nonzero(numpy.sum(array, axis=0))[0] if len(filled_rows) == 0 or len(filled_columns) == 0: - return array, image.shape[0] - 1 - - clipped_array = numpy.ascontiguousarray( - array[ - max(0, filled_rows[0] - 1) : filled_rows[-1] + 2, - max(0, filled_columns[0] - 1) : filled_columns[-1] + 2, - ] + return array, first_line_ascent + return ( + numpy.ascontiguousarray( + array[ + 0 : filled_rows[-1] + 2, + max(0, filled_columns[0] - 1) : filled_columns[-1] + 2, + ] + ), + first_line_ascent, ) - - return clipped_array, image.shape[0] - 1 # baseline not available