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:
@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])
lines: List[BlockLine]
def
getLineByName( self, name: Union[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], Literal['minY', 'maxY']]) -> BlockLine | 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']
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)
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).
offsets: Dict[Literal['ascender', 'capHeight', 'midCapHeight', 'xHeight', 'midXHeight', 'baseline', 'descender'], float]
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]: