classes.c14_metrics

  1import drawBot
  2from drawBot import _drawBotDrawingTool as drawBot
  3from collections import OrderedDict
  4from fontTools.ttLib import TTFont
  5from fontTools.pens.boundsPen import BoundsPen
  6from typing import List, Literal, Dict, Protocol, Optional
  7from dataclasses import dataclass
  8from lib import fonts, layout
  9from icecream import ic
 10
 11MetricName = Literal[
 12    "ascender",
 13    "capHeight",
 14    "midCapHeight",
 15    "xHeight",
 16    "midXHeight",
 17    "baseline",
 18    "descender",
 19]
 20
 21BlockMetricName = MetricName | Literal["minY", "maxY"]
 22
 23DEBUG = 0
 24DISTINCT_THRESHOLD = layout.mm(2)
 25
 26
 27def isDistinctFrom(offsetA: float, offsetB: float) -> bool:
 28    return abs(offsetA - offsetB) > DISTINCT_THRESHOLD
 29
 30
 31@dataclass
 32class MetricBlock:
 33    text: str
 34    coords: tuple[float, float, float, float]
 35    lines: List["BlockLine"]
 36
 37    def getLineByName(self, name: BlockMetricName) -> "BlockLine | None":
 38        for line in self.lines:
 39            if line.name == name:
 40                ic(line)
 41                return line
 42        return None
 43
 44
 45@dataclass
 46class BlockLine:
 47    name: MetricName
 48    y: float
 49    offset: float
 50    isDistinct: bool
 51
 52    def isIn(self, names: List[BlockMetricName]) -> bool:
 53        return self.name in names
 54
 55    def isDistinctFrom(self, other: "BlockLine") -> bool:
 56        if self.offset is None or other.offset is None:
 57            return False
 58        return isDistinctFrom(self.offset, other.offset)
 59
 60
 61# Declare external DB class shape for type checking
 62class DBBoundsItem(Protocol):
 63    """Protocol for DrawBot `textBoxCharacterBounds()` item."""
 64
 65    formattedSubString: str
 66    bounds: tuple[float, float, float, float]
 67    baselineOffset: float
 68
 69
 70class KMetrics:
 71    def __init__(self, fontPath: str, threshold: Optional[float] = None):
 72        """
 73        A class to extract and calculate key vertical metrics from a font.
 74
 75        Args:
 76            fontPath (str): Path to the font file.
 77            threshold (float): Minimum distance in points to consider two metrics as distinct, higher number means more aggressive simplification (default: 2mm).
 78        """
 79        self.fontPath = fontPath
 80        self.fontName = fonts.getFontName(fontPath)
 81        self.font = TTFont(fontPath)
 82
 83        if threshold is not None:
 84            globals()["DISTINCT_THRESHOLD"] = threshold
 85
 86        self.glyphSet = self.font.getGlyphSet()
 87        self.cmap = self.font.getBestCmap()
 88        self.unitsPerEm: float = self.font["head"].unitsPerEm
 89
 90        self.os2 = self.font["OS/2"]
 91        self.hhea = self.font["hhea"]
 92
 93        self.offsets: Dict[MetricName, float] = dict(
 94            ascender=self.os2.sTypoAscender,
 95            capHeight=self.os2.sCapHeight,
 96            midCapHeight=self.os2.sCapHeight / 2,
 97            xHeight=self.os2.sxHeight,
 98            midXHeight=self.os2.sxHeight / 2,
 99            baseline=0,
100            descender=self.os2.sTypoDescender,
101        )
102
103    def printOS2Table(self) -> None:
104        for attr in dir(self.os2):
105            if not attr.startswith("_"):
106                print(f"{attr}: {getattr(self.os2, attr)}")
107
108    @property
109    def scale(self) -> float:
110        if not hasattr(self, "_scale"):
111            raise ValueError("Scale not set. Call setScale(fontSize) first.")
112        return self._scale
113
114    @scale.setter
115    def scale(self, fontSize: float):
116        self._scale = fontSize / self.unitsPerEm
117
118    def setScale(self, fontSize: float) -> "KMetrics":
119        self.scale = fontSize
120        return self
121
122    # ? Naming distinction: offset = relative, position = absolute
123    def getOffsetYByName(self, name: MetricName) -> float:
124        """Get the scaled relative offset Y position for a given metric name."""
125        try:
126            return self.offsets[name] * self.scale
127        except KeyError:
128            raise ValueError(f"Invalid metric name: {name}")
129
130    def getOffsetExtremaForText(self, text: str) -> tuple[float | None, float | None]:
131        """Get the minimum and maximum scaled relative Y positions for the given text."""
132        min_y, max_y = None, None
133        for char in text:
134            glyph_name = self.cmap.get(ord(char))
135            if glyph_name is None:
136                if DEBUG:
137                    print(f"No {self.fontName} glyph for character '{char}'")
138                continue
139            pen = BoundsPen(self.glyphSet)
140            self.glyphSet[glyph_name].draw(pen)
141            if pen.bounds:
142                _, yMin, _, yMax = pen.bounds
143                if min_y is None or yMin < min_y:
144                    min_y = yMin
145                if max_y is None or yMax > max_y:
146                    max_y = yMax
147
148        scale = lambda v: v * self.scale if v is not None else None
149        min_y, max_y = [scale(v) for v in (min_y, max_y)]
150        return min_y, max_y
151
152    def splitIntoBlocks(self, text: str, coords: tuple):
153        def _createBlock(item: DBBoundsItem) -> MetricBlock:
154            x, blockY, w, h = item.bounds
155            blockY += item.baselineOffset
156            coords = (x, blockY, w, h)
157
158            relativeOffsets = {
159                name: self.getOffsetYByName(name) for name in self.offsets.keys()
160            }
161            minY, maxY = self.getOffsetExtremaForText(item.formattedSubString)
162            relativeOffsets["minY"] = minY
163            relativeOffsets["maxY"] = maxY
164
165            relativeOffsets = OrderedDict(
166                sorted(
167                    relativeOffsets.items(), key=lambda pair: pair[1] or 0, reverse=True
168                )
169            )
170
171            def createMetricPosition(
172                item: tuple[MetricName, float | None],
173            ) -> BlockLine:
174                name, offset = item
175
176                offsetsExceptThis = [
177                    o for n, o in relativeOffsets.items() if n != name and o is not None
178                ]
179
180                isDistinctFromOther = all(
181                    isDistinctFrom(offset, otherOffset)
182                    for otherOffset in offsetsExceptThis
183                    if offset is not None
184                )
185
186                y = blockY + offset if offset is not None else None
187
188                return BlockLine(name, y, offset, isDistinct=isDistinctFromOther)
189
190            lines: List[BlockLine] = map(createMetricPosition, relativeOffsets.items())
191
192            return MetricBlock(
193                item.formattedSubString,
194                coords,
195                lines,
196            )
197
198        DBBoundsList: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords)
199        return [_createBlock(DBBoundsItem) for DBBoundsItem in DBBoundsList]
200
201    def sketchLines(
202        self, block: MetricBlock, names: List[BlockMetricName]
203    ) -> Dict[BlockMetricName, BlockLine]:
204        return {
205            line.name: line
206            for line in block.lines
207            if line.name in names and line.y is not None
208        }
MetricName = typing.Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender']
BlockMetricName = typing.Union[typing.Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], typing.Literal['minY', 'maxY']]
DEBUG = 0
DISTINCT_THRESHOLD = 5.669291338582678
def isDistinctFrom(offsetA: float, offsetB: float) -> bool:
28def isDistinctFrom(offsetA: float, offsetB: float) -> bool:
29    return abs(offsetA - offsetB) > DISTINCT_THRESHOLD
@dataclass
class MetricBlock:
32@dataclass
33class MetricBlock:
34    text: str
35    coords: tuple[float, float, float, float]
36    lines: List["BlockLine"]
37
38    def getLineByName(self, name: BlockMetricName) -> "BlockLine | None":
39        for line in self.lines:
40            if line.name == name:
41                ic(line)
42                return line
43        return None
MetricBlock( text: str, coords: tuple[float, float, float, float], lines: List[BlockLine])
text: str
coords: tuple[float, float, float, float]
lines: List[BlockLine]
def getLineByName( self, name: Union[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], Literal['minY', 'maxY']]) -> BlockLine | None:
38    def getLineByName(self, name: BlockMetricName) -> "BlockLine | None":
39        for line in self.lines:
40            if line.name == name:
41                ic(line)
42                return line
43        return None
@dataclass
class BlockLine:
46@dataclass
47class BlockLine:
48    name: MetricName
49    y: float
50    offset: float
51    isDistinct: bool
52
53    def isIn(self, names: List[BlockMetricName]) -> bool:
54        return self.name in names
55
56    def isDistinctFrom(self, other: "BlockLine") -> bool:
57        if self.offset is None or other.offset is None:
58            return False
59        return isDistinctFrom(self.offset, other.offset)
BlockLine( name: Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], y: float, offset: float, isDistinct: bool)
name: Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender']
y: float
offset: float
isDistinct: bool
def isIn( self, names: List[Union[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], Literal['minY', 'maxY']]]) -> bool:
53    def isIn(self, names: List[BlockMetricName]) -> bool:
54        return self.name in names
def isDistinctFrom(self, other: BlockLine) -> bool:
56    def isDistinctFrom(self, other: "BlockLine") -> bool:
57        if self.offset is None or other.offset is None:
58            return False
59        return isDistinctFrom(self.offset, other.offset)
class DBBoundsItem(typing.Protocol):
63class DBBoundsItem(Protocol):
64    """Protocol for DrawBot `textBoxCharacterBounds()` item."""
65
66    formattedSubString: str
67    bounds: tuple[float, float, float, float]
68    baselineOffset: float

Protocol for DrawBot textBoxCharacterBounds() item.

DBBoundsItem(*args, **kwargs)
1739def _no_init_or_replace_init(self, *args, **kwargs):
1740    cls = type(self)
1741
1742    if cls._is_protocol:
1743        raise TypeError('Protocols cannot be instantiated')
1744
1745    # Already using a custom `__init__`. No need to calculate correct
1746    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1747    if cls.__init__ is not _no_init_or_replace_init:
1748        return
1749
1750    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1751    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1752    # searches for a proper new `__init__` in the MRO. The new `__init__`
1753    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1754    # instantiation of the protocol subclass will thus use the new
1755    # `__init__` and no longer call `_no_init_or_replace_init`.
1756    for base in cls.__mro__:
1757        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1758        if init is not _no_init_or_replace_init:
1759            cls.__init__ = init
1760            break
1761    else:
1762        # should not happen
1763        cls.__init__ = object.__init__
1764
1765    cls.__init__(self, *args, **kwargs)
formattedSubString: str
bounds: tuple[float, float, float, float]
baselineOffset: float
class KMetrics:
 71class KMetrics:
 72    def __init__(self, fontPath: str, threshold: Optional[float] = None):
 73        """
 74        A class to extract and calculate key vertical metrics from a font.
 75
 76        Args:
 77            fontPath (str): Path to the font file.
 78            threshold (float): Minimum distance in points to consider two metrics as distinct, higher number means more aggressive simplification (default: 2mm).
 79        """
 80        self.fontPath = fontPath
 81        self.fontName = fonts.getFontName(fontPath)
 82        self.font = TTFont(fontPath)
 83
 84        if threshold is not None:
 85            globals()["DISTINCT_THRESHOLD"] = threshold
 86
 87        self.glyphSet = self.font.getGlyphSet()
 88        self.cmap = self.font.getBestCmap()
 89        self.unitsPerEm: float = self.font["head"].unitsPerEm
 90
 91        self.os2 = self.font["OS/2"]
 92        self.hhea = self.font["hhea"]
 93
 94        self.offsets: Dict[MetricName, float] = dict(
 95            ascender=self.os2.sTypoAscender,
 96            capHeight=self.os2.sCapHeight,
 97            midCapHeight=self.os2.sCapHeight / 2,
 98            xHeight=self.os2.sxHeight,
 99            midXHeight=self.os2.sxHeight / 2,
100            baseline=0,
101            descender=self.os2.sTypoDescender,
102        )
103
104    def printOS2Table(self) -> None:
105        for attr in dir(self.os2):
106            if not attr.startswith("_"):
107                print(f"{attr}: {getattr(self.os2, attr)}")
108
109    @property
110    def scale(self) -> float:
111        if not hasattr(self, "_scale"):
112            raise ValueError("Scale not set. Call setScale(fontSize) first.")
113        return self._scale
114
115    @scale.setter
116    def scale(self, fontSize: float):
117        self._scale = fontSize / self.unitsPerEm
118
119    def setScale(self, fontSize: float) -> "KMetrics":
120        self.scale = fontSize
121        return self
122
123    # ? Naming distinction: offset = relative, position = absolute
124    def getOffsetYByName(self, name: MetricName) -> float:
125        """Get the scaled relative offset Y position for a given metric name."""
126        try:
127            return self.offsets[name] * self.scale
128        except KeyError:
129            raise ValueError(f"Invalid metric name: {name}")
130
131    def getOffsetExtremaForText(self, text: str) -> tuple[float | None, float | None]:
132        """Get the minimum and maximum scaled relative Y positions for the given text."""
133        min_y, max_y = None, None
134        for char in text:
135            glyph_name = self.cmap.get(ord(char))
136            if glyph_name is None:
137                if DEBUG:
138                    print(f"No {self.fontName} glyph for character '{char}'")
139                continue
140            pen = BoundsPen(self.glyphSet)
141            self.glyphSet[glyph_name].draw(pen)
142            if pen.bounds:
143                _, yMin, _, yMax = pen.bounds
144                if min_y is None or yMin < min_y:
145                    min_y = yMin
146                if max_y is None or yMax > max_y:
147                    max_y = yMax
148
149        scale = lambda v: v * self.scale if v is not None else None
150        min_y, max_y = [scale(v) for v in (min_y, max_y)]
151        return min_y, max_y
152
153    def splitIntoBlocks(self, text: str, coords: tuple):
154        def _createBlock(item: DBBoundsItem) -> MetricBlock:
155            x, blockY, w, h = item.bounds
156            blockY += item.baselineOffset
157            coords = (x, blockY, w, h)
158
159            relativeOffsets = {
160                name: self.getOffsetYByName(name) for name in self.offsets.keys()
161            }
162            minY, maxY = self.getOffsetExtremaForText(item.formattedSubString)
163            relativeOffsets["minY"] = minY
164            relativeOffsets["maxY"] = maxY
165
166            relativeOffsets = OrderedDict(
167                sorted(
168                    relativeOffsets.items(), key=lambda pair: pair[1] or 0, reverse=True
169                )
170            )
171
172            def createMetricPosition(
173                item: tuple[MetricName, float | None],
174            ) -> BlockLine:
175                name, offset = item
176
177                offsetsExceptThis = [
178                    o for n, o in relativeOffsets.items() if n != name and o is not None
179                ]
180
181                isDistinctFromOther = all(
182                    isDistinctFrom(offset, otherOffset)
183                    for otherOffset in offsetsExceptThis
184                    if offset is not None
185                )
186
187                y = blockY + offset if offset is not None else None
188
189                return BlockLine(name, y, offset, isDistinct=isDistinctFromOther)
190
191            lines: List[BlockLine] = map(createMetricPosition, relativeOffsets.items())
192
193            return MetricBlock(
194                item.formattedSubString,
195                coords,
196                lines,
197            )
198
199        DBBoundsList: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords)
200        return [_createBlock(DBBoundsItem) for DBBoundsItem in DBBoundsList]
201
202    def sketchLines(
203        self, block: MetricBlock, names: List[BlockMetricName]
204    ) -> Dict[BlockMetricName, BlockLine]:
205        return {
206            line.name: line
207            for line in block.lines
208            if line.name in names and line.y is not None
209        }
KMetrics(fontPath: str, threshold: Optional[float] = None)
 72    def __init__(self, fontPath: str, threshold: Optional[float] = None):
 73        """
 74        A class to extract and calculate key vertical metrics from a font.
 75
 76        Args:
 77            fontPath (str): Path to the font file.
 78            threshold (float): Minimum distance in points to consider two metrics as distinct, higher number means more aggressive simplification (default: 2mm).
 79        """
 80        self.fontPath = fontPath
 81        self.fontName = fonts.getFontName(fontPath)
 82        self.font = TTFont(fontPath)
 83
 84        if threshold is not None:
 85            globals()["DISTINCT_THRESHOLD"] = threshold
 86
 87        self.glyphSet = self.font.getGlyphSet()
 88        self.cmap = self.font.getBestCmap()
 89        self.unitsPerEm: float = self.font["head"].unitsPerEm
 90
 91        self.os2 = self.font["OS/2"]
 92        self.hhea = self.font["hhea"]
 93
 94        self.offsets: Dict[MetricName, float] = dict(
 95            ascender=self.os2.sTypoAscender,
 96            capHeight=self.os2.sCapHeight,
 97            midCapHeight=self.os2.sCapHeight / 2,
 98            xHeight=self.os2.sxHeight,
 99            midXHeight=self.os2.sxHeight / 2,
100            baseline=0,
101            descender=self.os2.sTypoDescender,
102        )

A class to extract and calculate key vertical metrics from a font.

Arguments:
  • fontPath (str): Path to the font file.
  • threshold (float): Minimum distance in points to consider two metrics as distinct, higher number means more aggressive simplification (default: 2mm).
fontPath
fontName
font
glyphSet
cmap
unitsPerEm: float
os2
hhea
offsets: Dict[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], float]
def printOS2Table(self) -> None:
104    def printOS2Table(self) -> None:
105        for attr in dir(self.os2):
106            if not attr.startswith("_"):
107                print(f"{attr}: {getattr(self.os2, attr)}")
scale: float
109    @property
110    def scale(self) -> float:
111        if not hasattr(self, "_scale"):
112            raise ValueError("Scale not set. Call setScale(fontSize) first.")
113        return self._scale
def setScale(self, fontSize: float) -> KMetrics:
119    def setScale(self, fontSize: float) -> "KMetrics":
120        self.scale = fontSize
121        return self
def getOffsetYByName( self, name: Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender']) -> float:
124    def getOffsetYByName(self, name: MetricName) -> float:
125        """Get the scaled relative offset Y position for a given metric name."""
126        try:
127            return self.offsets[name] * self.scale
128        except KeyError:
129            raise ValueError(f"Invalid metric name: {name}")

Get the scaled relative offset Y position for a given metric name.

def getOffsetExtremaForText(self, text: str) -> tuple[float | None, float | None]:
131    def getOffsetExtremaForText(self, text: str) -> tuple[float | None, float | None]:
132        """Get the minimum and maximum scaled relative Y positions for the given text."""
133        min_y, max_y = None, None
134        for char in text:
135            glyph_name = self.cmap.get(ord(char))
136            if glyph_name is None:
137                if DEBUG:
138                    print(f"No {self.fontName} glyph for character '{char}'")
139                continue
140            pen = BoundsPen(self.glyphSet)
141            self.glyphSet[glyph_name].draw(pen)
142            if pen.bounds:
143                _, yMin, _, yMax = pen.bounds
144                if min_y is None or yMin < min_y:
145                    min_y = yMin
146                if max_y is None or yMax > max_y:
147                    max_y = yMax
148
149        scale = lambda v: v * self.scale if v is not None else None
150        min_y, max_y = [scale(v) for v in (min_y, max_y)]
151        return min_y, max_y

Get the minimum and maximum scaled relative Y positions for the given text.

def splitIntoBlocks(self, text: str, coords: tuple):
153    def splitIntoBlocks(self, text: str, coords: tuple):
154        def _createBlock(item: DBBoundsItem) -> MetricBlock:
155            x, blockY, w, h = item.bounds
156            blockY += item.baselineOffset
157            coords = (x, blockY, w, h)
158
159            relativeOffsets = {
160                name: self.getOffsetYByName(name) for name in self.offsets.keys()
161            }
162            minY, maxY = self.getOffsetExtremaForText(item.formattedSubString)
163            relativeOffsets["minY"] = minY
164            relativeOffsets["maxY"] = maxY
165
166            relativeOffsets = OrderedDict(
167                sorted(
168                    relativeOffsets.items(), key=lambda pair: pair[1] or 0, reverse=True
169                )
170            )
171
172            def createMetricPosition(
173                item: tuple[MetricName, float | None],
174            ) -> BlockLine:
175                name, offset = item
176
177                offsetsExceptThis = [
178                    o for n, o in relativeOffsets.items() if n != name and o is not None
179                ]
180
181                isDistinctFromOther = all(
182                    isDistinctFrom(offset, otherOffset)
183                    for otherOffset in offsetsExceptThis
184                    if offset is not None
185                )
186
187                y = blockY + offset if offset is not None else None
188
189                return BlockLine(name, y, offset, isDistinct=isDistinctFromOther)
190
191            lines: List[BlockLine] = map(createMetricPosition, relativeOffsets.items())
192
193            return MetricBlock(
194                item.formattedSubString,
195                coords,
196                lines,
197            )
198
199        DBBoundsList: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords)
200        return [_createBlock(DBBoundsItem) for DBBoundsItem in DBBoundsList]
def sketchLines( self, block: MetricBlock, names: List[Union[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], Literal['minY', 'maxY']]]) -> Dict[Union[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], Literal['minY', 'maxY']], BlockLine]:
202    def sketchLines(
203        self, block: MetricBlock, names: List[BlockMetricName]
204    ) -> Dict[BlockMetricName, BlockLine]:
205        return {
206            line.name: line
207            for line in block.lines
208            if line.name in names and line.y is not None
209        }