Source code for smufolib.utils.rulers

"""Measurement utilities for calculating glyph geometry.

This module provides functions to retrieve, measure and inspect various parts of glyphs.
It also defines:

- :data:`ENGRAVING_DEFAULTS_MAPPING`, the default association between ruler functions
  and glyphs used for calculating :class:`.EngravingDefaults` attribute values.
- :data:`DISPATCHER`, for dynamically executing measurement operations by function name.

To import the module:

    >>> from smufolib import rulers

"""

from __future__ import annotations
from typing import TYPE_CHECKING, Generator
from collections.abc import Callable
from math import dist

from smufolib.utils import converters

if TYPE_CHECKING:  # pragma: no cover
    from fontParts.fontshell.point import RPoint
    from fontParts.fontshell.segment import RSegment
    from fontParts.fontshell.contour import RContour
    from smufolib.objects.glyph import Glyph

Bounds = tuple[int | float, int | float, int | float, int | float]
Remapping = dict[str, dict[str, str]]
RulerType = Callable[["Glyph"], int | float | None]

TOLERANCE: int | float = 6
TYPES: str | tuple[str, ...] = ("line", "curve", "qcurve")

#: Default mapping of rulers and glyphs to :class:`EngravingDefaults` attributes.
ENGRAVING_DEFAULTS_MAPPING: Remapping = {
    "arrowShaftThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniEB60",
    },
    "barlineSeparation": {
        "ruler": "xDistanceBetweenContours",
        "glyph": "uniE031",
    },
    "beamSpacing": {
        "ruler": "yDistanceBetweenContours",
        "glyph": "uniE1F9",
    },
    "beamThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE1F7",
    },
    "bracketThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE003",
    },
    "dashedBarlineDashLength": {
        "ruler": "yStrokeWidthAtMinimum",
        "glyph": "uniE036",
    },
    "dashedBarlineGapLength": {
        "ruler": "yDistanceBetweenContours",
        "glyph": "uniE036",
    },
    "dashedBarlineThickness": {
        "ruler": "glyphBoundsWidth",
        "glyph": "uniE036",
    },
    "hairpinThickness": {
        "ruler": "wedgeArmStrokeWidth",
        "glyph": "uniE53E",
    },
    "hBarThickness": {
        "ruler": "yStrokeWidthAtMinimum",
        "glyph": "uniE4F0",
    },
    "legerLineExtension": {
        "ruler": "glyphBoundsXMinAbs",
        "glyph": "uniE022",
    },
    "legerLineThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE022",
    },
    "lyricLineThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE010",
    },
    "octaveLineThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE010",
    },
    "pedalLineThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE010",
    },
    "repeatBarlineDotSeparation": {
        "ruler": "xDistanceStemToDot",
        "glyph": "uniE040",
    },
    "repeatEndingLineThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE030",
    },
    "slurEndpointThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE1FD",
    },
    "slurMidpointThickness": {
        "ruler": "yStrokeWidthAtMinimum",
        "glyph": "uniE1FD",
    },
    "staffLineThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE010",
    },
    "stemThickness": {
        "ruler": "glyphBoundsWidth",
        "glyph": "uniE210",
    },
    "subBracketThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE030",
    },
    "textEnclosureThickness": {
        "ruler": "glyphBoundsHeight",
        "glyph": "uniE010",
    },
    "textFontFamily": {},
    "thickBarlineThickness": {
        "ruler": "glyphBoundsWidth",
        "glyph": "uniE034",
    },
    "thinBarlineThickness": {
        "ruler": "glyphBoundsWidth",
        "glyph": "uniE030",
    },
    "thinThickBarlineSeparation": {
        "ruler": "xDistanceBetweenContours",
        "glyph": "uniE032",
    },
    "tieEndpointThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE1FD",
    },
    "tieMidpointThickness": {
        "ruler": "yStrokeWidthAtMinimum",
        "glyph": "uniE1FD",
    },
    "tupletBracketThickness": {
        "ruler": "xStrokeWidthAtOrigin",
        "glyph": "uniE1FE",
    },
}

# ------
# Rulers
# ------


[docs] def glyphBoundsHeight(glyph: Glyph) -> int | float | None: """Return height of the glyph's bounding box. :param glyph: Source :class:`.Glyph` of contours to measure. Example: >>> glyph = font["uniE050"] >>> rulers.glyphBoundsHeight(glyph) 1801 """ boundsList = [c.bounds for c in getGlyphContours(glyph)] bounds = combineBounds(boundsList) if not bounds: return None return converters.toIntIfWhole(bounds[3] - bounds[1])
[docs] def glyphBoundsWidth(glyph: Glyph) -> int | float | None: """Return width of the glyph's bounding box. :param glyph: Source :class:`.Glyph` of contours to measure. Example: >>> glyph = font["uniE050"] >>> rulers.glyphBoundsWidth(glyph) 648 """ boundsList = [c.bounds for c in getGlyphContours(glyph)] bounds = combineBounds(boundsList) if not bounds: return None return converters.toIntIfWhole(bounds[2] - bounds[0])
[docs] def glyphBoundsXMinAbs(glyph: Glyph) -> int | float | None: """Return absolute value of glyph's *xMin* bound. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE022"] >>> rulers.glyphBoundsXMinAbs(glyph) 80 """ boundsList = [c.bounds for c in getGlyphContours(glyph)] bounds = combineBounds(boundsList) if not bounds: return None return converters.toIntIfWhole(abs(bounds[0]))
[docs] def xDistanceStemToDot(glyph: Glyph) -> int | float | None: """Measure horizontal distance between a stem and a dot contour. The contours may be placed on either side of each other. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE040"] >>> rulers.glyphBoundsXMinAbs(glyph) 63 """ dotPoints = [ p for p in getGlyphPoints(glyph) if all(s.type != "line" for s in p.contour) ] stemPoints = list(getGlyphPoints(glyph, "line")) if not stemPoints or not dotPoints: return None # reference point should be the point in the dot contour facing the stem. referencePoint = min(dotPoints, key=lambda p: p.x) closestStemPoint = max(stemPoints, key=lambda p: p.x) if referencePoint.x < closestStemPoint.x: referencePoint = max(dotPoints, key=lambda p: p.x) closestStemPoint = min(stemPoints, key=lambda p: p.x) return converters.toIntIfWhole(abs(closestStemPoint.x - referencePoint.x))
[docs] def xDistanceBetweenContours(glyph: Glyph) -> int | float | None: """Measure horizontal distance between two adjacent contours in a glyph. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE031"] >>> rulers.xDistanceBetweenContours(glyph) 85 """ points = sorted(getGlyphPoints(glyph), key=lambda p: p.x) if not points: return None # Reference point should be the right point of the left contour. referencePoint = max(points[0].contour.points, key=lambda p: p.x) for point in points: if point.contour.index == referencePoint.contour.index: continue return converters.toIntIfWhole(abs(point.x - referencePoint.x)) return None
[docs] def yDistanceBetweenContours(glyph: Glyph) -> int | float | None: """Measure vertical distance between two adjacent contours in a glyph. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE036"] >>> rulers.yDistanceBetweenContours(glyph) 119 """ points = sorted(getGlyphPoints(glyph), key=lambda p: p.y) if not points: return None # Reference point should be the top point of the bottom contour. referencePoint = max(points[0].contour.points, key=lambda p: p.y) for point in points: if point.contour.index == referencePoint.contour.index: continue return converters.toIntIfWhole(abs(point.y - referencePoint.y)) return None
[docs] def xStrokeWidthAtOrigin(glyph: Glyph) -> int | float | None: """Measure horizontal distance between aligned points closest to origin. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE1FE"] >>> rulers.xStrokeWidthAtOrigin(glyph) 32 """ points = sorted(getGlyphPoints(glyph), key=lambda p: abs(p.x) + abs(p.y)) if not points: return None referencePoint, *rest = points for point in rest: if point.contour.index != referencePoint.contour.index: continue if point.x == referencePoint.x: continue if not areAlligned((referencePoint, point), axis="y"): continue return converters.toIntIfWhole(abs(point.x - referencePoint.x)) return None
[docs] def yStrokeWidthAtMinimum(glyph: Glyph) -> int | float | None: """Measure vertical distance between aligned low-points in the glyph. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE1FD"] >>> rulers.yStrokeWidthAtMinimum(glyph) 50 """ points = sorted(getGlyphPoints(glyph), key=lambda p: p.y) if not points: return None referencePoint, *rest = points for point in rest: if point.contour.index != referencePoint.contour.index: continue if point.y == referencePoint.y: continue if not areAlligned((referencePoint, point), axis="x"): continue return converters.toIntIfWhole(abs(point.y - referencePoint.y)) return None
[docs] def wedgeArmStrokeWidth(glyph: Glyph): """Measure thickness of arm in a wedge-shaped glyph. :param glyph: Source :class:`.Glyph` of contours to measure. Example:: >>> glyph = font["uniE1FD"] >>> rulers.yStrokeWidthAtMinimum(glyph) 50 """ def euclidean(p1, p2): return dist((p1.x, p1.y), (p2.x, p2.y)) points = sorted(getGlyphPoints(glyph, "line"), key=lambda p: p.y) referencePoint = max(points, key=lambda p: p.x) candidates = [p for p in points if p is not referencePoint] nearest = min(candidates, key=lambda p: euclidean(p, referencePoint)) return round(euclidean(referencePoint, nearest))
[docs] def areAlligned( points: tuple[RPoint, ...], axis: str, tolerance: int | float = TOLERANCE ) -> bool: """Check if the specified `points` are aligned on `axis`. :param points: The points to check for alignment. :param axis: The axis (``"x"`` or ``"y"``) accross which to check for alignment. :param tolerance: The tolerance for misalignment to apply in font units. Defaults to ``6``. Example: >>> glyph = font["uniE050"] >>> point1, point2 = glyph[0].points[:2] >>> rulers.areAlligned((point1, point2), axis="x", tolerance=3) False """ def withinRange(value: int | float, reference: int | float) -> bool: # Check if values are within range of each other +/- the tolerance level. return value - tolerance <= reference <= value + tolerance first, *rest = points reference = getattr(first, axis) return all(withinRange(getattr(v, axis), reference) for v in rest)
[docs] def getGlyphContours( glyph: Glyph, includeComponents: bool = True ) -> Generator[RContour]: """Return all contours in `glyph`, including component references. :param glyph: The :class:`.Glyph` containing the contours to retrieve. :param includeComponents: Whether to include any referenced contours in the glyph. Defaults to :obj:`True`. Example: >>> glyph = font["uniE050"] >>> rulers.getGlyphContours(glyph) # doctest: +ELLIPSIS <generator object getGlyphContours.<locals>.<genexpr> at 0x...> """ if glyph.components and includeComponents: tempGlyph = glyph.copy() font = glyph.font tempName = "com.smufolib.temp" font.insertGlyph(tempGlyph, name=tempName) tempAssigned = font[tempName] tempAssigned.decompose() contours = (c for c in tempAssigned) font.removeGlyph(tempName) return contours return (c for c in glyph)
[docs] def getGlyphSegments( glyph, types: str | tuple[str, ...] = TYPES, includeComponents: bool = True ) -> Generator[RSegment]: """Return all segments in `glyph`, matching given `types`. Any segments referenced by components may be included. :param glyph: The :class:`.Glyph` containing the segments to retrieve. :param types: The :attr:`~fontParts.base.BaseSegment.type` values to include. Defaults to ``("line", "curve", "qcurve")``. :param includeComponents: Whether to include any referenced contours in the glyph. Example: >>> glyph = font["uniE050"] >>> rulers.getGlyphSegments(glyph) # doctest: +ELLIPSIS <generator object getGlyphSegments.<locals>.<genexpr> at 0x...> """ contours = getGlyphContours(glyph, includeComponents=includeComponents) return (s for c in contours for s in c if s.type in types or s.type == types)
[docs] def getGlyphPoints( glyph, types: str | tuple[str, ...] = TYPES, includeComponents: bool = True ) -> Generator[RPoint]: """Return all points in `glyph`, matching given `types`. Any points referenced by components may be included. :param glyph: The :class:`.Glyph` containing the points to retrieve. :param types: The :attr:`~fontParts.base.BasePoint.type` values to include. Defaults to ``("line", "curve", "qcurve")``. :param includeComponents: Whether to include any referenced contours in the glyph. Example: >>> glyph = font["uniE050"] >>> rulers.getGlyphPoints(glyph) # doctest: +ELLIPSIS <generator object getGlyphPoints.<locals>.<genexpr> at 0x...> """ segments = getGlyphSegments(glyph, includeComponents=includeComponents, types=types) return (p for s in segments for p in s if p.type in types or p.type == types)
[docs] def hasHorizontalOffCurve(point: RPoint) -> bool: """Check if the given point has a predominantly horizontal off-curve. An off-curve control point is considered horizontal if its *x-difference* from the on-curve point is greater than its *y-difference*. :param point: The on-curve point to check. Example: >>> glyph = font["uniE260"] >>> point = glyph[0].points[0] >>> rulers.hasHorizontalOffCurve(point) True """ segment = getParentSegment(point) if segment and point.type in ("curve", "qcurve"): for offCurve in segment.offCurve: onX, onY = point.position offX, offY = offCurve.position xDiff = abs(onX - offX) yDiff = abs(onY - offY) if xDiff > yDiff: return True return False
[docs] def hasVerticalOffCurve(point: RPoint) -> bool: """Check if the given point has a predominantly vertical off-curve. An off-curve control point is considered vertical if its *y-difference* from the on-curve point is greater than its *x-difference*. :param point: The on-curve point to check. Example: >>> glyph = font["uniE260"] >>> point = glyph[0].points[0] >>> rulers.hasVerticalOffCurve(point) False """ segment = getParentSegment(point) if segment and point.type in ("curve", "qcurve"): for offCurve in segment.offCurve: onX, onY = point.position offX, offY = offCurve.position xDiff = abs(onX - offX) yDiff = abs(onY - offY) if xDiff < yDiff: return True return False
[docs] def getParentSegment(point: RPoint) -> RSegment | None: """Get the segment which the given point belongs to. :param point: The point to find the parent segment for. Example: >>> glyph = font["uniE050"] >>> point = glyph[0].points[0] >>> rulers.getParentSegment(point) # doctest: +ELLIPSIS <RSegment line index='3' at ...> """ match = next( (s for s in point.contour.segments if point.contour and point in s), None ) return match
[docs] def combineBounds(boundsList: list[Bounds]) -> Bounds | None: """Combine a list of bounds into one bounding box. Example: >>> glyph1, glyph2 = font["uniE050"], font["uniE260"] >>> boundsList = [glyph1.bounds, glyph2.bounds] >>> rulers.combineBounds(boundsList) (-50, -634, 648, 1167) """ if not boundsList: return None xMins, yMins, xMaxs, yMaxs = zip(*boundsList) return (min(xMins), min(yMins), max(xMaxs), max(yMaxs))
# ---------- # Dispatcher # ---------- #: Dispatch the different ruler functions by their :class:`str` name. DISPATCHER: dict[str, RulerType] = { "glyphBoundsHeight": glyphBoundsHeight, "glyphBoundsWidth": glyphBoundsWidth, "glyphBoundsXMinAbs": glyphBoundsXMinAbs, "xDistanceStemToDot": xDistanceStemToDot, "xDistanceBetweenContours": xDistanceBetweenContours, "xStrokeWidthAtOrigin": xStrokeWidthAtOrigin, "yDistanceBetweenContours": yDistanceBetweenContours, "yStrokeWidthAtMinimum": yStrokeWidthAtMinimum, "wedgeArmStrokeWidth": wedgeArmStrokeWidth, }