classes.c14_metrics
1import drawBot 2from collections import OrderedDict 3from fontTools.ttLib import TTFont 4from fontTools.pens.boundsPen import BoundsPen 5from typing import TYPE_CHECKING, List, Literal, Dict, Protocol, Optional 6 7if TYPE_CHECKING: 8 from datatypes import DFontProps 9from dataclasses import dataclass 10from lib import fonts, layout 11from loguru import logger 12from icecream import ic 13 14MetricName = Literal[ 15 "ascender", 16 "capHeight", 17 "midCapHeight", 18 "xHeight", 19 "midXHeight", 20 "baseline", 21 "descender", 22] 23"Named font metrics found in OS/2 table." 24 25BlockMetricName = MetricName | Literal["minY", "maxY"] 26"Metric names for lines computed in a visual line block, including computed extrema found in given text." 27DistinctRules = Dict[BlockMetricName, BlockMetricName] 28"Mapping of line names to other line names for distinctness filtering, meaning 'keep lineA only if distinct from lineB'." 29 30DEBUG = 0 31 32 33@dataclass 34class MetricBlock: 35 """Metric information for one rendered text block (typically one visual line). 36 37 Attributes: 38 text: Visible substring represented by this block. 39 coords: Block frame as ``(x, baselineY, width, height)``. 40 lines: Computed metric lines for the block. 41 """ 42 43 text: str 44 coords: tuple[float, float, float, float] 45 lines: List["BlockLine"] 46 47 def line(self, name: BlockMetricName, required: bool = False) -> "BlockLine | None": 48 """Return one metric line by name. 49 50 Args: 51 name: Metric key (for example ``baseline`` or ``capHeight``). 52 required: If ``True``, raise ``ValueError`` when missing. 53 54 Returns: 55 The matching ``BlockLine`` or ``None``. 56 """ 57 for line in self.lines: 58 if line.name == name: 59 return line 60 if required: 61 raise ValueError(f"Metric {name} not found in block with text: {self.text}") 62 return None 63 64 def select( 65 self, 66 names: List[BlockMetricName], 67 distinctFrom: Optional[DistinctRules] = None, 68 ) -> Dict[BlockMetricName, "BlockLine"]: 69 """Return selected metric lines with optional distinctness filtering. 70 71 Args: 72 names: Metric names to include in the initial selection. 73 distinctFrom: Optional mapping ``{lineA: lineB}`` meaning 74 "keep ``lineA`` only if it is distinct from ``lineB``". 75 76 Returns: 77 Mapping from metric name to ``BlockLine`` for visible lines. 78 """ 79 selected = { 80 line.name: line 81 for line in self.lines 82 if line.name in names and line.y is not None 83 } 84 85 if not distinctFrom: 86 return selected 87 88 for lineName, compareTo in distinctFrom.items(): 89 line = selected.get(lineName) 90 if line is None: 91 continue 92 if not line.isDistinctFrom(selected.get(compareTo)): 93 selected.pop(lineName, None) 94 95 return selected 96 97 98@dataclass 99class BlockLine: 100 """One horizontal metric line belonging to a ``MetricBlock``. 101 102 Attributes: 103 name: Metric label. 104 y: Absolute Y coordinate in page space. 105 offset: Relative Y offset from baseline. 106 isDistinct: Whether this line is distinct from all sibling lines in block. 107 threshold: Distinctness threshold in points. 108 value: Raw metric value in font units (unitsPerEm space). 109 """ 110 111 name: BlockMetricName 112 y: float | None 113 offset: float | None 114 isDistinct: bool 115 threshold: float 116 value: float 117 118 def __post_init__(self) -> None: 119 if self.value is None: 120 raise ValueError(f"BlockLine '{self.name}' value cannot be None") 121 122 def isIn(self, names: List[BlockMetricName]) -> bool: 123 """Check whether this line name is present in a name list.""" 124 return self.name in names 125 126 def isDistinctFrom(self, other: "BlockLine | None") -> bool: 127 """Compare this line with another line using the line threshold. 128 129 Args: 130 other: Another ``BlockLine`` (or ``None``). 131 132 Returns: 133 ``True`` if both offsets exist and differ more than ``threshold``. 134 """ 135 if self.offset is None or other is None or other.offset is None: 136 return False 137 return abs(self.offset - other.offset) > self.threshold 138 139 def annotate( 140 self, 141 x: float, 142 fontProps: "DFontProps", 143 *, 144 dy: layout.UnitSource = 0, 145 edge: Literal["top", "bottom"] = "top", 146 text: str | None = None, 147 ) -> None: 148 """Draw a text label for this metric line. 149 150 Args: 151 x: Horizontal position for the label baseline. 152 fontProps: Font properties applied when rendering the label. 153 dy: Additional Y offset (implicit unit: mm). Positive values increase the gap 154 between the label and the metric line. 155 edge: ``"top"`` places the label above the line (text 156 baseline sits at ``self.y + dy``); ``"bottom"`` places 157 the label below (text floats under the line). 158 text: Label string. Defaults to ``self.name``. 159 """ 160 if self.y is None: 161 return 162 label = text if text is not None else self.name 163 dy = layout.parseUnit(dy, implicitUnit="mm") 164 if edge == "top": 165 label_y = self.y + dy 166 else: 167 label_y = self.y - fontProps.lineHeight - dy 168 with drawBot.savedState(): 169 fontProps.apply() 170 drawBot.text(label, (x, label_y)) 171 172 173@dataclass 174class _BoundsSeed: 175 """Internal text-bound seed used before line clustering. 176 177 Attributes: 178 text: Seed substring. 179 bounds: Seed bounds from DrawBot as ``(x, y, w, h)``. 180 baselineY: Absolute baseline Y for this seed. 181 index: Original order index from DrawBot output. 182 """ 183 184 text: str 185 bounds: tuple[float, float, float, float] 186 baselineY: float 187 index: int 188 189 190# Declare external DB class shape for type checking 191class DBBoundsItem(Protocol): 192 """Protocol for DrawBot `textBoxCharacterBounds()` item.""" 193 194 formattedSubString: str 195 bounds: tuple[float, float, float, float] 196 baselineOffset: float 197 198 199class KMetrics: 200 """Compute and expose vertical metric lines for rendered text. 201 202 The class reads OpenType vertical metrics from a font file, scales them to a 203 target font size, and combines them with DrawBot bounds to produce line-based 204 ``MetricBlock`` objects suitable for drawing overlays. 205 """ 206 207 def __init__( 208 self, 209 fontPath: str, 210 fontSize: Optional[float] = None, 211 threshold: float = layout.mm(2), 212 ): 213 """ 214 A class to extract and calculate key vertical metrics from a font. 215 216 Args: 217 fontPath (str): Path to the font file. 218 fontSize (float | None): Optional type size in points. If not provided, 219 call setFontSize() before accessing scaled metrics. 220 threshold (float): Minimum distance in points to consider two metrics 221 as distinct; higher number means more aggressive simplification. 222 """ 223 self._fontName = fonts.getFontName(fontPath) 224 self._font = TTFont(fontPath) 225 self._threshold = threshold 226 227 self._glyphSet = self._font.getGlyphSet() 228 self._cmap = self._font.getBestCmap() 229 self._upm: float = self._font["head"].unitsPerEm 230 231 self._os2 = self._font["OS/2"] 232 self._hhea = self._font["hhea"] 233 234 self._offsets: Dict[MetricName, float] = dict( 235 ascender=self._os2.sTypoAscender, 236 capHeight=self._os2.sCapHeight, 237 midCapHeight=self._os2.sCapHeight / 2, 238 xHeight=self._os2.sxHeight, 239 midXHeight=self._os2.sxHeight / 2, 240 baseline=0, 241 descender=self._os2.sTypoDescender, 242 ) 243 244 if fontSize is not None: 245 self.setFontSize(fontSize) 246 247 def printOS2Table(self) -> None: 248 """Print all public attributes from the font OS/2 table for debugging.""" 249 for attr in dir(self._os2): 250 if not attr.startswith("_"): 251 print(f"{attr}: {getattr(self._os2, attr)}") 252 253 @property 254 def scale(self) -> float: 255 """Return the current font-size scale factor (points per font unit).""" 256 if not hasattr(self, "_scale"): 257 raise ValueError("Font size not set. Call setFontSize(fontSize) first.") 258 return self._scale 259 260 @scale.setter 261 def scale(self, fontSize: float): 262 """Set internal scale from a font size in points.""" 263 self._scale = fontSize / self._upm 264 265 def setFontSize(self, fontSize: float) -> "KMetrics": 266 """Configure font size for all scaled metric calculations. 267 268 Args: 269 fontSize: Target type size in points. 270 271 Returns: 272 ``self`` for fluent usage. 273 """ 274 self.scale = fontSize 275 return self 276 277 # ? Naming distinction: offset = relative, position = absolute 278 def getOffsetYByName(self, name: MetricName) -> float: 279 """Get the scaled relative Y offset for a metric name. 280 281 Args: 282 name: One metric key from ``MetricName``. 283 284 Returns: 285 Relative Y offset in points. 286 """ 287 try: 288 return self._offsets[name] * self.scale 289 except KeyError: 290 raise ValueError(f"Invalid metric name: {name}") 291 292 def getOffsetExtremaForText(self, text: str) -> tuple[float, float]: 293 """Compute glyph-extrema offsets for a text string. 294 295 Args: 296 text: Text to inspect using glyph outlines. 297 298 Returns: 299 Tuple ``(minY, maxY)`` as relative offsets in points. 300 """ 301 min_y, max_y = self.getRawOffsetExtremaForText(text) 302 scale = lambda v: v * self.scale if v is not None else None 303 min_y, max_y = [scale(v) for v in (min_y, max_y)] 304 305 if min_y is None or max_y is None: 306 raise ValueError(f"Could not compute extrema for text: {text!r}") 307 308 return min_y, max_y 309 310 def getRawOffsetExtremaForText(self, text: str) -> tuple[float, float]: 311 """Compute glyph-extrema offsets for a text string in font units. 312 313 Args: 314 text: Text to inspect using glyph outlines. 315 316 Returns: 317 Tuple ``(minY, maxY)`` in font units (unitsPerEm space). 318 """ 319 min_y, max_y = None, None 320 for char in text: 321 glyph_name = self._cmap.get(ord(char)) 322 if glyph_name is None: 323 if DEBUG: 324 print(f"No {self._fontName} glyph for character '{char}'") 325 continue 326 pen = BoundsPen(self._glyphSet) 327 self._glyphSet[glyph_name].draw(pen) 328 if pen.bounds: 329 _, yMin, _, yMax = pen.bounds 330 if min_y is None or yMin < min_y: 331 min_y = yMin 332 if max_y is None or yMax > max_y: 333 max_y = yMax 334 335 if min_y is None or max_y is None: 336 raise ValueError(f"Could not compute extrema for text: {text!r}") 337 338 return min_y, max_y 339 340 def _epsilon(self) -> float: 341 """Return clustering tolerance used to group seeds by baseline. 342 343 Returns: 344 Tolerance in points. 345 """ 346 return max(0.5, self.scale * self._upm * 0.01) 347 348 def _clusterByBaseline( 349 self, seeds: List[_BoundsSeed], epsilon: float 350 ) -> List[List[_BoundsSeed]]: 351 """Group bounds seeds into visual lines by baseline proximity. 352 353 Args: 354 seeds: Flat seed list from DrawBot bounds output. 355 epsilon: Maximum baseline delta allowed in a cluster. 356 357 Returns: 358 List of seed groups, each group representing one visual line. 359 """ 360 sortedSeeds = sorted(seeds, key=lambda s: (-s.baselineY, s.bounds[0], s.index)) 361 clusters: List[List[_BoundsSeed]] = [] 362 363 for seed in sortedSeeds: 364 targetIndex = None 365 for clusterIndex, cluster in enumerate(clusters): 366 clusterBaseline = sum(item.baselineY for item in cluster) / len(cluster) 367 if abs(clusterBaseline - seed.baselineY) <= epsilon: 368 targetIndex = clusterIndex 369 break 370 371 if targetIndex is None: 372 clusters.append([seed]) 373 else: 374 clusters[targetIndex].append(seed) 375 376 return [ 377 sorted(cluster, key=lambda s: (s.bounds[0], s.index)) 378 for cluster in clusters 379 ] 380 381 def _toBlock(self, seeds: List[_BoundsSeed]) -> MetricBlock: 382 """Convert one clustered seed group into a ``MetricBlock``. 383 384 Args: 385 seeds: One line-cluster of bounds seeds. 386 387 Returns: 388 A fully populated ``MetricBlock`` with absolute metric lines. 389 """ 390 text = "".join(seed.text for seed in seeds) 391 392 minX = min(seed.bounds[0] for seed in seeds) 393 minY = min(seed.bounds[1] for seed in seeds) 394 maxX = max(seed.bounds[0] + seed.bounds[2] for seed in seeds) 395 maxY = max(seed.bounds[1] + seed.bounds[3] for seed in seeds) 396 baselineY = sum(seed.baselineY for seed in seeds) / len(seeds) 397 398 coords = (minX, baselineY, maxX - minX, maxY - minY) 399 400 rawValues: Dict[BlockMetricName, float] = { 401 name: value for name, value in self._offsets.items() 402 } 403 textMinYRaw, textMaxYRaw = self.getRawOffsetExtremaForText(text) 404 rawValues["minY"] = textMinYRaw 405 rawValues["maxY"] = textMaxYRaw 406 407 relativeOffsets: Dict[BlockMetricName, float] = { 408 name: (value * self.scale) for name, value in rawValues.items() 409 } 410 411 sortedOffsets = OrderedDict( 412 sorted(relativeOffsets.items(), key=lambda pair: pair[1] or 0, reverse=True) 413 ) 414 415 lines: List[BlockLine] = [] 416 for name, offset in sortedOffsets.items(): 417 rawValue = rawValues.get(name) 418 if rawValue is None: 419 raise ValueError( 420 f"Metric '{name}' has no raw value for text block: {text!r}" 421 ) 422 423 offsetsExceptThis = [ 424 otherOffset 425 for otherName, otherOffset in sortedOffsets.items() 426 if otherName != name and otherOffset is not None 427 ] 428 429 isDistinctFromOther = ( 430 all( 431 abs(offset - otherOffset) > self._threshold 432 for otherOffset in offsetsExceptThis 433 ) 434 if offset is not None 435 else False 436 ) 437 438 y = baselineY + offset if offset is not None else None 439 lines.append( 440 BlockLine( 441 name=name, 442 y=y, 443 offset=offset, 444 isDistinct=isDistinctFromOther, 445 threshold=self._threshold, 446 value=rawValue, 447 ) 448 ) 449 450 return MetricBlock(text=text, coords=coords, lines=lines) 451 452 def getBlocks( 453 self, 454 text: str, 455 coords: tuple, 456 ) -> List[MetricBlock]: 457 """Return metric blocks extracted from rendered text bounds. 458 459 Args: 460 text: Input string rendered in the current DrawBot text context. 461 coords: Text box coordinates ``(x, y, w, h)`` passed to 462 ``drawBot.textBoxCharacterBounds()``. 463 464 Returns: 465 A list of ``MetricBlock`` instances grouped by visual line using 466 baseline clustering. 467 """ 468 boundsItems: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords) 469 seeds = [ 470 _BoundsSeed( 471 text=item.formattedSubString, 472 bounds=item.bounds, 473 baselineY=item.bounds[1] + item.baselineOffset, 474 index=index, 475 ) 476 for index, item in enumerate(boundsItems) 477 if item.formattedSubString.strip() != "" 478 ] 479 480 if len(seeds) == 0: 481 return [] 482 483 groups = self._clusterByBaseline(seeds, epsilon=self._epsilon()) 484 485 return [self._toBlock(group) for group in groups] 486 487 def getBlock( 488 self, 489 text: str, 490 coords: tuple, 491 ) -> MetricBlock | None: 492 """Return the first metric block for a rendered text box. 493 494 Args: 495 text: Input string rendered in DrawBot. 496 coords: Text box coordinates ``(x, y, w, h)``. 497 498 Returns: 499 First ``MetricBlock`` from ``getBlocks()`` or ``None`` if no visible 500 seeds are produced. 501 """ 502 blocks = self.getBlocks(text=text, coords=coords) 503 return blocks[0] if blocks else None 504 505 def selectLines( 506 self, 507 block: MetricBlock, 508 names: List[BlockMetricName], 509 distinctFrom: Optional[DistinctRules] = None, 510 ) -> Dict[BlockMetricName, BlockLine]: 511 """Select a named subset of lines from a block. 512 513 Args: 514 block: Source ``MetricBlock``. 515 names: Metric names to keep. 516 distinctFrom: Optional mapping ``{lineA: lineB}`` to keep ``lineA`` 517 only when distinct from ``lineB``. 518 519 Returns: 520 Mapping of selected names to ``BlockLine``. 521 """ 522 return block.select(names=names, distinctFrom=distinctFrom)
Named font metrics found in OS/2 table.
Metric names for lines computed in a visual line block, including computed extrema found in given text.
Mapping of line names to other line names for distinctness filtering, meaning 'keep lineA only if distinct from lineB'.
34@dataclass 35class MetricBlock: 36 """Metric information for one rendered text block (typically one visual line). 37 38 Attributes: 39 text: Visible substring represented by this block. 40 coords: Block frame as ``(x, baselineY, width, height)``. 41 lines: Computed metric lines for the block. 42 """ 43 44 text: str 45 coords: tuple[float, float, float, float] 46 lines: List["BlockLine"] 47 48 def line(self, name: BlockMetricName, required: bool = False) -> "BlockLine | None": 49 """Return one metric line by name. 50 51 Args: 52 name: Metric key (for example ``baseline`` or ``capHeight``). 53 required: If ``True``, raise ``ValueError`` when missing. 54 55 Returns: 56 The matching ``BlockLine`` or ``None``. 57 """ 58 for line in self.lines: 59 if line.name == name: 60 return line 61 if required: 62 raise ValueError(f"Metric {name} not found in block with text: {self.text}") 63 return None 64 65 def select( 66 self, 67 names: List[BlockMetricName], 68 distinctFrom: Optional[DistinctRules] = None, 69 ) -> Dict[BlockMetricName, "BlockLine"]: 70 """Return selected metric lines with optional distinctness filtering. 71 72 Args: 73 names: Metric names to include in the initial selection. 74 distinctFrom: Optional mapping ``{lineA: lineB}`` meaning 75 "keep ``lineA`` only if it is distinct from ``lineB``". 76 77 Returns: 78 Mapping from metric name to ``BlockLine`` for visible lines. 79 """ 80 selected = { 81 line.name: line 82 for line in self.lines 83 if line.name in names and line.y is not None 84 } 85 86 if not distinctFrom: 87 return selected 88 89 for lineName, compareTo in distinctFrom.items(): 90 line = selected.get(lineName) 91 if line is None: 92 continue 93 if not line.isDistinctFrom(selected.get(compareTo)): 94 selected.pop(lineName, None) 95 96 return selected
Metric information for one rendered text block (typically one visual line).
Attributes:
- text: Visible substring represented by this block.
- coords: Block frame as
(x, baselineY, width, height). - lines: Computed metric lines for the block.
48 def line(self, name: BlockMetricName, required: bool = False) -> "BlockLine | None": 49 """Return one metric line by name. 50 51 Args: 52 name: Metric key (for example ``baseline`` or ``capHeight``). 53 required: If ``True``, raise ``ValueError`` when missing. 54 55 Returns: 56 The matching ``BlockLine`` or ``None``. 57 """ 58 for line in self.lines: 59 if line.name == name: 60 return line 61 if required: 62 raise ValueError(f"Metric {name} not found in block with text: {self.text}") 63 return None
Return one metric line by name.
Arguments:
- name: Metric key (for example
baselineorcapHeight). - required: If
True, raiseValueErrorwhen missing.
Returns:
The matching
BlockLineorNone.
65 def select( 66 self, 67 names: List[BlockMetricName], 68 distinctFrom: Optional[DistinctRules] = None, 69 ) -> Dict[BlockMetricName, "BlockLine"]: 70 """Return selected metric lines with optional distinctness filtering. 71 72 Args: 73 names: Metric names to include in the initial selection. 74 distinctFrom: Optional mapping ``{lineA: lineB}`` meaning 75 "keep ``lineA`` only if it is distinct from ``lineB``". 76 77 Returns: 78 Mapping from metric name to ``BlockLine`` for visible lines. 79 """ 80 selected = { 81 line.name: line 82 for line in self.lines 83 if line.name in names and line.y is not None 84 } 85 86 if not distinctFrom: 87 return selected 88 89 for lineName, compareTo in distinctFrom.items(): 90 line = selected.get(lineName) 91 if line is None: 92 continue 93 if not line.isDistinctFrom(selected.get(compareTo)): 94 selected.pop(lineName, None) 95 96 return selected
Return selected metric lines with optional distinctness filtering.
Arguments:
- names: Metric names to include in the initial selection.
- distinctFrom: Optional mapping
{lineA: lineB}meaning "keeplineAonly if it is distinct fromlineB".
Returns:
Mapping from metric name to
BlockLinefor visible lines.
99@dataclass 100class BlockLine: 101 """One horizontal metric line belonging to a ``MetricBlock``. 102 103 Attributes: 104 name: Metric label. 105 y: Absolute Y coordinate in page space. 106 offset: Relative Y offset from baseline. 107 isDistinct: Whether this line is distinct from all sibling lines in block. 108 threshold: Distinctness threshold in points. 109 value: Raw metric value in font units (unitsPerEm space). 110 """ 111 112 name: BlockMetricName 113 y: float | None 114 offset: float | None 115 isDistinct: bool 116 threshold: float 117 value: float 118 119 def __post_init__(self) -> None: 120 if self.value is None: 121 raise ValueError(f"BlockLine '{self.name}' value cannot be None") 122 123 def isIn(self, names: List[BlockMetricName]) -> bool: 124 """Check whether this line name is present in a name list.""" 125 return self.name in names 126 127 def isDistinctFrom(self, other: "BlockLine | None") -> bool: 128 """Compare this line with another line using the line threshold. 129 130 Args: 131 other: Another ``BlockLine`` (or ``None``). 132 133 Returns: 134 ``True`` if both offsets exist and differ more than ``threshold``. 135 """ 136 if self.offset is None or other is None or other.offset is None: 137 return False 138 return abs(self.offset - other.offset) > self.threshold 139 140 def annotate( 141 self, 142 x: float, 143 fontProps: "DFontProps", 144 *, 145 dy: layout.UnitSource = 0, 146 edge: Literal["top", "bottom"] = "top", 147 text: str | None = None, 148 ) -> None: 149 """Draw a text label for this metric line. 150 151 Args: 152 x: Horizontal position for the label baseline. 153 fontProps: Font properties applied when rendering the label. 154 dy: Additional Y offset (implicit unit: mm). Positive values increase the gap 155 between the label and the metric line. 156 edge: ``"top"`` places the label above the line (text 157 baseline sits at ``self.y + dy``); ``"bottom"`` places 158 the label below (text floats under the line). 159 text: Label string. Defaults to ``self.name``. 160 """ 161 if self.y is None: 162 return 163 label = text if text is not None else self.name 164 dy = layout.parseUnit(dy, implicitUnit="mm") 165 if edge == "top": 166 label_y = self.y + dy 167 else: 168 label_y = self.y - fontProps.lineHeight - dy 169 with drawBot.savedState(): 170 fontProps.apply() 171 drawBot.text(label, (x, label_y))
One horizontal metric line belonging to a MetricBlock.
Attributes:
- name: Metric label.
- y: Absolute Y coordinate in page space.
- offset: Relative Y offset from baseline.
- isDistinct: Whether this line is distinct from all sibling lines in block.
- threshold: Distinctness threshold in points.
- value: Raw metric value in font units (unitsPerEm space).
123 def isIn(self, names: List[BlockMetricName]) -> bool: 124 """Check whether this line name is present in a name list.""" 125 return self.name in names
Check whether this line name is present in a name list.
127 def isDistinctFrom(self, other: "BlockLine | None") -> bool: 128 """Compare this line with another line using the line threshold. 129 130 Args: 131 other: Another ``BlockLine`` (or ``None``). 132 133 Returns: 134 ``True`` if both offsets exist and differ more than ``threshold``. 135 """ 136 if self.offset is None or other is None or other.offset is None: 137 return False 138 return abs(self.offset - other.offset) > self.threshold
140 def annotate( 141 self, 142 x: float, 143 fontProps: "DFontProps", 144 *, 145 dy: layout.UnitSource = 0, 146 edge: Literal["top", "bottom"] = "top", 147 text: str | None = None, 148 ) -> None: 149 """Draw a text label for this metric line. 150 151 Args: 152 x: Horizontal position for the label baseline. 153 fontProps: Font properties applied when rendering the label. 154 dy: Additional Y offset (implicit unit: mm). Positive values increase the gap 155 between the label and the metric line. 156 edge: ``"top"`` places the label above the line (text 157 baseline sits at ``self.y + dy``); ``"bottom"`` places 158 the label below (text floats under the line). 159 text: Label string. Defaults to ``self.name``. 160 """ 161 if self.y is None: 162 return 163 label = text if text is not None else self.name 164 dy = layout.parseUnit(dy, implicitUnit="mm") 165 if edge == "top": 166 label_y = self.y + dy 167 else: 168 label_y = self.y - fontProps.lineHeight - dy 169 with drawBot.savedState(): 170 fontProps.apply() 171 drawBot.text(label, (x, label_y))
Draw a text label for this metric line.
Arguments:
- x: Horizontal position for the label baseline.
- fontProps: Font properties applied when rendering the label.
- dy: Additional Y offset (implicit unit: mm). Positive values increase the gap between the label and the metric line.
- edge:
"top"places the label above the line (text baseline sits atself.y + dy);"bottom"places the label below (text floats under the line). - text: Label string. Defaults to
self.name.
192class DBBoundsItem(Protocol): 193 """Protocol for DrawBot `textBoxCharacterBounds()` item.""" 194 195 formattedSubString: str 196 bounds: tuple[float, float, float, float] 197 baselineOffset: float
Protocol for DrawBot textBoxCharacterBounds() item.
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
200class KMetrics: 201 """Compute and expose vertical metric lines for rendered text. 202 203 The class reads OpenType vertical metrics from a font file, scales them to a 204 target font size, and combines them with DrawBot bounds to produce line-based 205 ``MetricBlock`` objects suitable for drawing overlays. 206 """ 207 208 def __init__( 209 self, 210 fontPath: str, 211 fontSize: Optional[float] = None, 212 threshold: float = layout.mm(2), 213 ): 214 """ 215 A class to extract and calculate key vertical metrics from a font. 216 217 Args: 218 fontPath (str): Path to the font file. 219 fontSize (float | None): Optional type size in points. If not provided, 220 call setFontSize() before accessing scaled metrics. 221 threshold (float): Minimum distance in points to consider two metrics 222 as distinct; higher number means more aggressive simplification. 223 """ 224 self._fontName = fonts.getFontName(fontPath) 225 self._font = TTFont(fontPath) 226 self._threshold = threshold 227 228 self._glyphSet = self._font.getGlyphSet() 229 self._cmap = self._font.getBestCmap() 230 self._upm: float = self._font["head"].unitsPerEm 231 232 self._os2 = self._font["OS/2"] 233 self._hhea = self._font["hhea"] 234 235 self._offsets: Dict[MetricName, float] = dict( 236 ascender=self._os2.sTypoAscender, 237 capHeight=self._os2.sCapHeight, 238 midCapHeight=self._os2.sCapHeight / 2, 239 xHeight=self._os2.sxHeight, 240 midXHeight=self._os2.sxHeight / 2, 241 baseline=0, 242 descender=self._os2.sTypoDescender, 243 ) 244 245 if fontSize is not None: 246 self.setFontSize(fontSize) 247 248 def printOS2Table(self) -> None: 249 """Print all public attributes from the font OS/2 table for debugging.""" 250 for attr in dir(self._os2): 251 if not attr.startswith("_"): 252 print(f"{attr}: {getattr(self._os2, attr)}") 253 254 @property 255 def scale(self) -> float: 256 """Return the current font-size scale factor (points per font unit).""" 257 if not hasattr(self, "_scale"): 258 raise ValueError("Font size not set. Call setFontSize(fontSize) first.") 259 return self._scale 260 261 @scale.setter 262 def scale(self, fontSize: float): 263 """Set internal scale from a font size in points.""" 264 self._scale = fontSize / self._upm 265 266 def setFontSize(self, fontSize: float) -> "KMetrics": 267 """Configure font size for all scaled metric calculations. 268 269 Args: 270 fontSize: Target type size in points. 271 272 Returns: 273 ``self`` for fluent usage. 274 """ 275 self.scale = fontSize 276 return self 277 278 # ? Naming distinction: offset = relative, position = absolute 279 def getOffsetYByName(self, name: MetricName) -> float: 280 """Get the scaled relative Y offset for a metric name. 281 282 Args: 283 name: One metric key from ``MetricName``. 284 285 Returns: 286 Relative Y offset in points. 287 """ 288 try: 289 return self._offsets[name] * self.scale 290 except KeyError: 291 raise ValueError(f"Invalid metric name: {name}") 292 293 def getOffsetExtremaForText(self, text: str) -> tuple[float, float]: 294 """Compute glyph-extrema offsets for a text string. 295 296 Args: 297 text: Text to inspect using glyph outlines. 298 299 Returns: 300 Tuple ``(minY, maxY)`` as relative offsets in points. 301 """ 302 min_y, max_y = self.getRawOffsetExtremaForText(text) 303 scale = lambda v: v * self.scale if v is not None else None 304 min_y, max_y = [scale(v) for v in (min_y, max_y)] 305 306 if min_y is None or max_y is None: 307 raise ValueError(f"Could not compute extrema for text: {text!r}") 308 309 return min_y, max_y 310 311 def getRawOffsetExtremaForText(self, text: str) -> tuple[float, float]: 312 """Compute glyph-extrema offsets for a text string in font units. 313 314 Args: 315 text: Text to inspect using glyph outlines. 316 317 Returns: 318 Tuple ``(minY, maxY)`` in font units (unitsPerEm space). 319 """ 320 min_y, max_y = None, None 321 for char in text: 322 glyph_name = self._cmap.get(ord(char)) 323 if glyph_name is None: 324 if DEBUG: 325 print(f"No {self._fontName} glyph for character '{char}'") 326 continue 327 pen = BoundsPen(self._glyphSet) 328 self._glyphSet[glyph_name].draw(pen) 329 if pen.bounds: 330 _, yMin, _, yMax = pen.bounds 331 if min_y is None or yMin < min_y: 332 min_y = yMin 333 if max_y is None or yMax > max_y: 334 max_y = yMax 335 336 if min_y is None or max_y is None: 337 raise ValueError(f"Could not compute extrema for text: {text!r}") 338 339 return min_y, max_y 340 341 def _epsilon(self) -> float: 342 """Return clustering tolerance used to group seeds by baseline. 343 344 Returns: 345 Tolerance in points. 346 """ 347 return max(0.5, self.scale * self._upm * 0.01) 348 349 def _clusterByBaseline( 350 self, seeds: List[_BoundsSeed], epsilon: float 351 ) -> List[List[_BoundsSeed]]: 352 """Group bounds seeds into visual lines by baseline proximity. 353 354 Args: 355 seeds: Flat seed list from DrawBot bounds output. 356 epsilon: Maximum baseline delta allowed in a cluster. 357 358 Returns: 359 List of seed groups, each group representing one visual line. 360 """ 361 sortedSeeds = sorted(seeds, key=lambda s: (-s.baselineY, s.bounds[0], s.index)) 362 clusters: List[List[_BoundsSeed]] = [] 363 364 for seed in sortedSeeds: 365 targetIndex = None 366 for clusterIndex, cluster in enumerate(clusters): 367 clusterBaseline = sum(item.baselineY for item in cluster) / len(cluster) 368 if abs(clusterBaseline - seed.baselineY) <= epsilon: 369 targetIndex = clusterIndex 370 break 371 372 if targetIndex is None: 373 clusters.append([seed]) 374 else: 375 clusters[targetIndex].append(seed) 376 377 return [ 378 sorted(cluster, key=lambda s: (s.bounds[0], s.index)) 379 for cluster in clusters 380 ] 381 382 def _toBlock(self, seeds: List[_BoundsSeed]) -> MetricBlock: 383 """Convert one clustered seed group into a ``MetricBlock``. 384 385 Args: 386 seeds: One line-cluster of bounds seeds. 387 388 Returns: 389 A fully populated ``MetricBlock`` with absolute metric lines. 390 """ 391 text = "".join(seed.text for seed in seeds) 392 393 minX = min(seed.bounds[0] for seed in seeds) 394 minY = min(seed.bounds[1] for seed in seeds) 395 maxX = max(seed.bounds[0] + seed.bounds[2] for seed in seeds) 396 maxY = max(seed.bounds[1] + seed.bounds[3] for seed in seeds) 397 baselineY = sum(seed.baselineY for seed in seeds) / len(seeds) 398 399 coords = (minX, baselineY, maxX - minX, maxY - minY) 400 401 rawValues: Dict[BlockMetricName, float] = { 402 name: value for name, value in self._offsets.items() 403 } 404 textMinYRaw, textMaxYRaw = self.getRawOffsetExtremaForText(text) 405 rawValues["minY"] = textMinYRaw 406 rawValues["maxY"] = textMaxYRaw 407 408 relativeOffsets: Dict[BlockMetricName, float] = { 409 name: (value * self.scale) for name, value in rawValues.items() 410 } 411 412 sortedOffsets = OrderedDict( 413 sorted(relativeOffsets.items(), key=lambda pair: pair[1] or 0, reverse=True) 414 ) 415 416 lines: List[BlockLine] = [] 417 for name, offset in sortedOffsets.items(): 418 rawValue = rawValues.get(name) 419 if rawValue is None: 420 raise ValueError( 421 f"Metric '{name}' has no raw value for text block: {text!r}" 422 ) 423 424 offsetsExceptThis = [ 425 otherOffset 426 for otherName, otherOffset in sortedOffsets.items() 427 if otherName != name and otherOffset is not None 428 ] 429 430 isDistinctFromOther = ( 431 all( 432 abs(offset - otherOffset) > self._threshold 433 for otherOffset in offsetsExceptThis 434 ) 435 if offset is not None 436 else False 437 ) 438 439 y = baselineY + offset if offset is not None else None 440 lines.append( 441 BlockLine( 442 name=name, 443 y=y, 444 offset=offset, 445 isDistinct=isDistinctFromOther, 446 threshold=self._threshold, 447 value=rawValue, 448 ) 449 ) 450 451 return MetricBlock(text=text, coords=coords, lines=lines) 452 453 def getBlocks( 454 self, 455 text: str, 456 coords: tuple, 457 ) -> List[MetricBlock]: 458 """Return metric blocks extracted from rendered text bounds. 459 460 Args: 461 text: Input string rendered in the current DrawBot text context. 462 coords: Text box coordinates ``(x, y, w, h)`` passed to 463 ``drawBot.textBoxCharacterBounds()``. 464 465 Returns: 466 A list of ``MetricBlock`` instances grouped by visual line using 467 baseline clustering. 468 """ 469 boundsItems: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords) 470 seeds = [ 471 _BoundsSeed( 472 text=item.formattedSubString, 473 bounds=item.bounds, 474 baselineY=item.bounds[1] + item.baselineOffset, 475 index=index, 476 ) 477 for index, item in enumerate(boundsItems) 478 if item.formattedSubString.strip() != "" 479 ] 480 481 if len(seeds) == 0: 482 return [] 483 484 groups = self._clusterByBaseline(seeds, epsilon=self._epsilon()) 485 486 return [self._toBlock(group) for group in groups] 487 488 def getBlock( 489 self, 490 text: str, 491 coords: tuple, 492 ) -> MetricBlock | None: 493 """Return the first metric block for a rendered text box. 494 495 Args: 496 text: Input string rendered in DrawBot. 497 coords: Text box coordinates ``(x, y, w, h)``. 498 499 Returns: 500 First ``MetricBlock`` from ``getBlocks()`` or ``None`` if no visible 501 seeds are produced. 502 """ 503 blocks = self.getBlocks(text=text, coords=coords) 504 return blocks[0] if blocks else None 505 506 def selectLines( 507 self, 508 block: MetricBlock, 509 names: List[BlockMetricName], 510 distinctFrom: Optional[DistinctRules] = None, 511 ) -> Dict[BlockMetricName, BlockLine]: 512 """Select a named subset of lines from a block. 513 514 Args: 515 block: Source ``MetricBlock``. 516 names: Metric names to keep. 517 distinctFrom: Optional mapping ``{lineA: lineB}`` to keep ``lineA`` 518 only when distinct from ``lineB``. 519 520 Returns: 521 Mapping of selected names to ``BlockLine``. 522 """ 523 return block.select(names=names, distinctFrom=distinctFrom)
Compute and expose vertical metric lines for rendered text.
The class reads OpenType vertical metrics from a font file, scales them to a
target font size, and combines them with DrawBot bounds to produce line-based
MetricBlock objects suitable for drawing overlays.
208 def __init__( 209 self, 210 fontPath: str, 211 fontSize: Optional[float] = None, 212 threshold: float = layout.mm(2), 213 ): 214 """ 215 A class to extract and calculate key vertical metrics from a font. 216 217 Args: 218 fontPath (str): Path to the font file. 219 fontSize (float | None): Optional type size in points. If not provided, 220 call setFontSize() before accessing scaled metrics. 221 threshold (float): Minimum distance in points to consider two metrics 222 as distinct; higher number means more aggressive simplification. 223 """ 224 self._fontName = fonts.getFontName(fontPath) 225 self._font = TTFont(fontPath) 226 self._threshold = threshold 227 228 self._glyphSet = self._font.getGlyphSet() 229 self._cmap = self._font.getBestCmap() 230 self._upm: float = self._font["head"].unitsPerEm 231 232 self._os2 = self._font["OS/2"] 233 self._hhea = self._font["hhea"] 234 235 self._offsets: Dict[MetricName, float] = dict( 236 ascender=self._os2.sTypoAscender, 237 capHeight=self._os2.sCapHeight, 238 midCapHeight=self._os2.sCapHeight / 2, 239 xHeight=self._os2.sxHeight, 240 midXHeight=self._os2.sxHeight / 2, 241 baseline=0, 242 descender=self._os2.sTypoDescender, 243 ) 244 245 if fontSize is not None: 246 self.setFontSize(fontSize)
A class to extract and calculate key vertical metrics from a font.
Arguments:
- fontPath (str): Path to the font file.
- fontSize (float | None): Optional type size in points. If not provided, call setFontSize() before accessing scaled metrics.
- threshold (float): Minimum distance in points to consider two metrics as distinct; higher number means more aggressive simplification.
248 def printOS2Table(self) -> None: 249 """Print all public attributes from the font OS/2 table for debugging.""" 250 for attr in dir(self._os2): 251 if not attr.startswith("_"): 252 print(f"{attr}: {getattr(self._os2, attr)}")
Print all public attributes from the font OS/2 table for debugging.
254 @property 255 def scale(self) -> float: 256 """Return the current font-size scale factor (points per font unit).""" 257 if not hasattr(self, "_scale"): 258 raise ValueError("Font size not set. Call setFontSize(fontSize) first.") 259 return self._scale
Return the current font-size scale factor (points per font unit).
266 def setFontSize(self, fontSize: float) -> "KMetrics": 267 """Configure font size for all scaled metric calculations. 268 269 Args: 270 fontSize: Target type size in points. 271 272 Returns: 273 ``self`` for fluent usage. 274 """ 275 self.scale = fontSize 276 return self
Configure font size for all scaled metric calculations.
Arguments:
- fontSize: Target type size in points.
Returns:
selffor fluent usage.
279 def getOffsetYByName(self, name: MetricName) -> float: 280 """Get the scaled relative Y offset for a metric name. 281 282 Args: 283 name: One metric key from ``MetricName``. 284 285 Returns: 286 Relative Y offset in points. 287 """ 288 try: 289 return self._offsets[name] * self.scale 290 except KeyError: 291 raise ValueError(f"Invalid metric name: {name}")
Get the scaled relative Y offset for a metric name.
Arguments:
- name: One metric key from
MetricName.
Returns:
Relative Y offset in points.
293 def getOffsetExtremaForText(self, text: str) -> tuple[float, float]: 294 """Compute glyph-extrema offsets for a text string. 295 296 Args: 297 text: Text to inspect using glyph outlines. 298 299 Returns: 300 Tuple ``(minY, maxY)`` as relative offsets in points. 301 """ 302 min_y, max_y = self.getRawOffsetExtremaForText(text) 303 scale = lambda v: v * self.scale if v is not None else None 304 min_y, max_y = [scale(v) for v in (min_y, max_y)] 305 306 if min_y is None or max_y is None: 307 raise ValueError(f"Could not compute extrema for text: {text!r}") 308 309 return min_y, max_y
Compute glyph-extrema offsets for a text string.
Arguments:
- text: Text to inspect using glyph outlines.
Returns:
Tuple
(minY, maxY)as relative offsets in points.
311 def getRawOffsetExtremaForText(self, text: str) -> tuple[float, float]: 312 """Compute glyph-extrema offsets for a text string in font units. 313 314 Args: 315 text: Text to inspect using glyph outlines. 316 317 Returns: 318 Tuple ``(minY, maxY)`` in font units (unitsPerEm space). 319 """ 320 min_y, max_y = None, None 321 for char in text: 322 glyph_name = self._cmap.get(ord(char)) 323 if glyph_name is None: 324 if DEBUG: 325 print(f"No {self._fontName} glyph for character '{char}'") 326 continue 327 pen = BoundsPen(self._glyphSet) 328 self._glyphSet[glyph_name].draw(pen) 329 if pen.bounds: 330 _, yMin, _, yMax = pen.bounds 331 if min_y is None or yMin < min_y: 332 min_y = yMin 333 if max_y is None or yMax > max_y: 334 max_y = yMax 335 336 if min_y is None or max_y is None: 337 raise ValueError(f"Could not compute extrema for text: {text!r}") 338 339 return min_y, max_y
Compute glyph-extrema offsets for a text string in font units.
Arguments:
- text: Text to inspect using glyph outlines.
Returns:
Tuple
(minY, maxY)in font units (unitsPerEm space).
453 def getBlocks( 454 self, 455 text: str, 456 coords: tuple, 457 ) -> List[MetricBlock]: 458 """Return metric blocks extracted from rendered text bounds. 459 460 Args: 461 text: Input string rendered in the current DrawBot text context. 462 coords: Text box coordinates ``(x, y, w, h)`` passed to 463 ``drawBot.textBoxCharacterBounds()``. 464 465 Returns: 466 A list of ``MetricBlock`` instances grouped by visual line using 467 baseline clustering. 468 """ 469 boundsItems: List[DBBoundsItem] = drawBot.textBoxCharacterBounds(text, coords) 470 seeds = [ 471 _BoundsSeed( 472 text=item.formattedSubString, 473 bounds=item.bounds, 474 baselineY=item.bounds[1] + item.baselineOffset, 475 index=index, 476 ) 477 for index, item in enumerate(boundsItems) 478 if item.formattedSubString.strip() != "" 479 ] 480 481 if len(seeds) == 0: 482 return [] 483 484 groups = self._clusterByBaseline(seeds, epsilon=self._epsilon()) 485 486 return [self._toBlock(group) for group in groups]
Return metric blocks extracted from rendered text bounds.
Arguments:
- text: Input string rendered in the current DrawBot text context.
- coords: Text box coordinates
(x, y, w, h)passed todrawBot.textBoxCharacterBounds().
Returns:
A list of
MetricBlockinstances grouped by visual line using baseline clustering.
488 def getBlock( 489 self, 490 text: str, 491 coords: tuple, 492 ) -> MetricBlock | None: 493 """Return the first metric block for a rendered text box. 494 495 Args: 496 text: Input string rendered in DrawBot. 497 coords: Text box coordinates ``(x, y, w, h)``. 498 499 Returns: 500 First ``MetricBlock`` from ``getBlocks()`` or ``None`` if no visible 501 seeds are produced. 502 """ 503 blocks = self.getBlocks(text=text, coords=coords) 504 return blocks[0] if blocks else None
Return the first metric block for a rendered text box.
Arguments:
- text: Input string rendered in DrawBot.
- coords: Text box coordinates
(x, y, w, h).
Returns:
First
MetricBlockfromgetBlocks()orNoneif no visible seeds are produced.
506 def selectLines( 507 self, 508 block: MetricBlock, 509 names: List[BlockMetricName], 510 distinctFrom: Optional[DistinctRules] = None, 511 ) -> Dict[BlockMetricName, BlockLine]: 512 """Select a named subset of lines from a block. 513 514 Args: 515 block: Source ``MetricBlock``. 516 names: Metric names to keep. 517 distinctFrom: Optional mapping ``{lineA: lineB}`` to keep ``lineA`` 518 only when distinct from ``lineB``. 519 520 Returns: 521 Mapping of selected names to ``BlockLine``. 522 """ 523 return block.select(names=names, distinctFrom=distinctFrom)
Select a named subset of lines from a block.
Arguments:
- block: Source
MetricBlock. - names: Metric names to keep.
- distinctFrom: Optional mapping
{lineA: lineB}to keeplineAonly when distinct fromlineB.
Returns:
Mapping of selected names to
BlockLine.