lib.fonts
1import re 2import os 3from drawBot import _drawBotDrawingTool as drawBot # ? For intellisense autocompletion 4from drawBot import drawBotDrawingTools # ? For typing hints 5from typing import Literal, TypeAlias 6from pathlib import Path 7from math import ceil 8from loguru import logger 9from icecream import ic 10 11from lib import content, helpers, layout 12 13FontStyle: TypeAlias = Literal["upright", "italic"] 14"""Font style (slope) alias.""" 15 16FontType: TypeAlias = Literal[ 17 "upright", "italic", "display", "text", "thin", "thick", "any" 18] 19"""Font type alias.""" 20 21TextAlign: TypeAlias = Literal["left", "center", "right"] 22"""Text alignment alias.""" 23 24FontFeature: TypeAlias = Literal[ 25 "calt", 26 "case", 27 "dlig", 28 "frac", 29 "liga", 30 "kern", 31 "lnum", 32 "onum", 33 "ordn", 34 "pnum", 35 "ss01", 36 "ss02", 37 "ss03", 38 "ss04", 39 "ss05", 40 "ss06", 41 "ss07", 42 "ss08", 43 "ss09", 44 "ss10", 45 "subs", 46 "sups", 47 "titl", 48 "tnum", 49] 50"""OpenType font feature code alias.""" 51 52 53def getFontName(fontPath: str) -> str: 54 """ 55 Get the font name without extension. 56 57 Args: 58 fontPath: Path to the font file. 59 60 Example: 61 `/folder/fileName-styleName.otf` => `fileName-styleName` 62 """ 63 fullName = Path(fontPath).stem 64 return fullName 65 66 67def getFontsFromFolder(fontFolder: str) -> list[str]: 68 """ 69 Get all font file paths from a folder. 70 71 Args: 72 fontFolder: Path to the folder containing font files. 73 74 Returns: 75 List of font file paths with .otf or .ttf extensions. 76 """ 77 fontPaths = [] 78 for root, dirs, files in os.walk(fontFolder): 79 files.sort() 80 for fileName in files: 81 basePath, ext = os.path.splitext(fileName) 82 if ext in [".otf", ".ttf"]: 83 fontPaths.append(os.path.join(root, fileName)) 84 return fontPaths 85 86 87def getFonts(source: str) -> list[str]: 88 """ 89 Get font file paths from a source, which can be a directory, file, or list of files. 90 91 Args: 92 source: Directory path, file path, or list of file paths. 93 94 Returns: 95 List of valid font file paths. 96 """ 97 isMultiple = type(source) is list 98 isSingle = isinstance(source, str) 99 100 if isSingle: 101 if helpers.isDirectory(source): 102 return getFontsFromFolder(source) 103 elif helpers.isFile(source): 104 return [source] 105 else: 106 logger.warning("[Fonts Not Found] {}", source) 107 elif isMultiple: 108 # Check if files exist 109 def check(file): 110 if helpers.isFile(file): 111 return file 112 else: 113 print("File not found: ", file) 114 115 mapped = [check(file) for file in source] 116 # Omit None 117 return [file for file in mapped if file] 118 119 120def parseNameObject(fontString: str, separate=False) -> str | dict: 121 """ 122 Parse a font string into its components. 123 124 Args: 125 fontString: The font string or path. 126 separate: If True, returns a dict of components. If False, returns the full name. 127 128 Returns: 129 - If `separate` is True, returns a dict with keys: 130 - `fullName`: e.g. `Stabil Grotesk 400 Regular` 131 - `familyName`: e.g. `Stabil Grotesk` 132 - `shortName`: e.g. `400 Regular` 133 - `styleNumber`: e.g. `400` 134 - `styleName`: e.g. `Regular` 135 - If `separate` is False, returns the `fullName` string. 136 """ 137 fontName = getFontName(fontString) 138 familyName, *suffix = fontName.split("-") 139 140 # KUniforma => Uniforma 141 familyName = stripVendorName(familyName) 142 # StabilGrotesk => Stabil Grotesk 143 familyName = content.toPascalCase(familyName).strip() 144 145 # ['55RegularItalic'] => '50RegularItalic' 146 suffix = "".join(suffix).strip() 147 hasStyleName = re.search(r"\D+", suffix) 148 hasStyleNumber = re.search(r"\d+", suffix) 149 150 # RegularItalic => Regular Italic 151 styleName = content.toPascalCase(hasStyleName.group()) if hasStyleName else None 152 # 55/None 153 styleNumber = hasStyleNumber.group() if hasStyleNumber else None 154 # [None, 'Regular Italic] => ['Regular Italic'] 155 omitBlank = [part for part in [styleNumber, styleName] if part] 156 fullName = (" ").join([familyName, *omitBlank]) 157 shortName = (" ").join(omitBlank) 158 159 if separate: 160 return { 161 "fullName": fullName, # Stabil Grotesk 400 Regular 162 "familyName": familyName, # Stabil Grotesk 163 "shortName": shortName, # 400 Regular 164 "styleNumber": styleNumber, # 400 165 "styleName": styleName, # Regular 166 } 167 else: 168 return fullName 169 170 171def stripVendorName(string: str) -> str: 172 # Strip K or KOMETA (optionally enclosed by parentheses) and optional hyphen if followed by '1 uppercase and any lowercase': (K)-Uniforma => Uniforma 173 string = re.sub(r"^\(?(K|KOMETA)\)?-?(?=[A-Z][a-z])", "", string) 174 return string 175 176 177def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"): 178 """ 179 Returns human readable font properties. 180 181 Args: 182 fontSize: Font size as int or tuple for range 183 - `(int) 72` or as range `(tuple) 72, 12` 184 leading: Line height multiplier or value 185 - lineHeight `int` or leading `float` 186 tracking: Tracking value. 187 separator: Separator string. 188 """ 189 if isinstance(fontSize, tuple): 190 # Skip showing lineHeight for range of fontSizes 191 fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize))) 192 else: 193 # Assume it’s leading if ≤ 2 194 lineHeight = fontSize * leading if leading <= 2 else leading 195 fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}" 196 197 if tracking: 198 return f"{fsAndLh}{separator}{round(tracking, 1)}" 199 else: 200 # Round both 201 return fsAndLh 202 203 204def getStyleNumber(fontString: str, format: Literal["str", "int"] = "str") -> str | int: 205 """ 206 Get the style number from a font string. 207 208 Args: 209 fontString: The font string or style number. 210 format: Output format, string or integer. 211 """ 212 isStyleNumber = fontString.isnumeric() 213 214 styleNumber = ( 215 fontString 216 if isStyleNumber 217 else parseNameObject(fontString, separate=True)["styleNumber"] 218 ) 219 220 return int(styleNumber) if format == "int" else str(styleNumber) 221 222 223def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None: 224 """ 225 Find the font in fontList with the closest style number to fontNumber. 226 227 Args: 228 fontList: List of font strings. 229 fontNumber: Target style number. 230 231 Returns: 232 The font string with the closest style number, or `None` if not found. 233 """ 234 # Get numbers, None if not found 235 numbers = [getStyleNumber(font, format="int") for font in fontList] 236 237 # Compact array 238 numbers = [number for number in numbers if isinstance(number, int)] 239 240 if len(numbers): 241 closest = helpers.findClosestValue(numbers, fontNumber) 242 closestIndex = numbers.index(closest) 243 return fontList[closestIndex] 244 245 246def isFontType(fontPath: str, criterion: FontType) -> bool: 247 """ 248 Check if a font matches a given FontType criterion. 249 250 Args: 251 fontPath: Path to the font file. 252 criterion: FontType criterion. 253 254 Returns: 255 True if the font matches the criterion, False otherwise. 256 """ 257 # Special case 258 if criterion == "any": 259 return True 260 261 styleNumber = getStyleNumber(fontPath) 262 return criterion in getFontType(styleNumber) 263 264 265def isDisplay(fontString: str) -> bool: 266 """Returns True if the font is of type `display`.""" 267 return isFontType(fontString, "display") 268 269 270def getFontType(fontString: str) -> list[FontType]: 271 """ 272 Get the list of `FontType` that match the font's style number. See source for regex patterns. 273 274 Args: 275 fontString: The font string or path. 276 277 Returns: 278 List of matching FontTypes. 279 """ 280 281 def _isMatch(pattern: str) -> bool: 282 return bool(re.compile(rf"^{pattern}").search(styleNumber)) 283 284 fontTypes = { 285 "upright": r"\d0", 286 "italic": r"\d5", 287 "display": "[^4-6]", 288 "text": "[4-6]", 289 "thin": "[1-3]", 290 "thick": "[7-9]", 291 } 292 293 styleNumber = getStyleNumber(fontString) 294 return [fontType for [fontType, pattern] in fontTypes.items() if _isMatch(pattern)] 295 296 297def filterFonts( 298 fonts: list[str], 299 criteria: FontType | list[FontType] = "upright", 300 strategy: helpers.Strategy = "pick", 301) -> list[str]: 302 """ 303 Filter fonts by given criteria and strategy. 304 305 Args: 306 fonts: List of font strings. 307 criteria: `FontType` or list of FontTypes to filter by. 308 strategy: Filtering strategy, "pick" or "omit". 309 310 Returns: 311 List of filtered font strings. 312 """ 313 314 def _evaluate(predicate: bool): 315 "Inverse logic for `omit` strategy" 316 return predicate if strategy == "pick" else not predicate 317 318 def _fontsForCriterion(criterion: FontType) -> list[str]: 319 return [font for font in fonts if _evaluate(isFontType(font, criterion))] 320 321 criteria = helpers.coerceList(criteria) 322 # Groups of matches 323 allMatches = [_fontsForCriterion(criterion) for criterion in criteria] 324 # Intersect matches and sort them by number 325 return helpers.intersect(allMatches) 326 327 328def groupFontsBySlope(fonts: list[str], reverseOrder=True): 329 """ 330 Group fonts by slope (upright/italic). 331 332 Args: 333 fonts: List of font strings. 334 reverseOrder: If True, reverse the order of each group. 335 336 Returns: 337 Tuple of (upright_fonts, italic_fonts). 338 """ 339 fontsUpright = filterFonts(fonts, "upright") 340 fontsItalic = filterFonts(fonts, "italic") 341 342 groups = (fontsUpright, fontsItalic) 343 344 if reverseOrder: 345 for group in groups: 346 group.reverse() 347 348 return groups 349 350 351def groupFontsByWeight(fonts: list[str], reverseOrder=True): 352 """ 353 Group fonts by weight, pairing upright and italic fonts. 354 355 Args: 356 fonts: List of font strings. 357 reverseOrder: If True, reverse the order of each group. 358 359 Returns: 360 List of tuples pairing upright and italic fonts by weight. 361 """ 362 fontsUpright, fontsItalic = groupFontsBySlope( 363 fonts=fonts, reverseOrder=reverseOrder 364 ) 365 366 return list(zip(fontsUpright, fontsItalic)) 367 368 369def fitBinary( 370 text: str, 371 container: tuple, 372 leading: float = 1, 373 letterSpacing: int = 0, 374 isFluid: bool = False, 375 saveState: bool = True, 376 debug: bool = False, 377 precision: int = 0, 378) -> tuple: 379 """ 380 Find the largest font size to fit text in a container, using a binary search algorithm. Calculates font size in integer space for performance by default, can be adjusted with `precision`. 381 382 Args: 383 text: The text string to fit. 384 container: The container as a tuple of `(width, height)` or `(x, y, width, height)`. 385 leading: Line height multiplier (default: 1). 386 letterSpacing: Letter spacing value (default: 0). 387 isFluid: If True, allows text wrapping to fit width; if False, fits text on a single line. 388 saveState: If True, preserves the current DrawBot drawing state. 389 debug: If True, outputs debug information. 390 precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths). 391 392 Returns: 393 A tuple containing: 394 - fontSize: The maximum font size that fits. 395 - lineHeight: The calculated line height for this font size. 396 - tracking: The calculated tracking for this font size. 397 - textW: The width of the rendered text at this size. 398 - textH: The height of the rendered text at this size. 399 """ 400 401 def _getDimensions(size): 402 def modifyState(): 403 drawBot.fontSize(size) 404 drawBot.lineHeight(lead(size)) 405 drawBot.tracking(track(size)) 406 textW, textH = drawBot.textSize(text, width=containerW if isFluid else None) 407 408 # Edge case: font metrics may include leading, account for that in height 409 NSFontLeading = drawBot.fontLeading() 410 if NSFontLeading: 411 lineCount = ceil(textH / drawBot.fontLineHeight()) 412 leadingCount = lineCount - 1 413 leadingExpansion = leadingCount * ceil(NSFontLeading) 414 textH += leadingExpansion 415 416 # Compensate for tracking 417 return textW - track(size), textH 418 419 # Used for preparatory calculations 420 if saveState: 421 # Important! savedState() triggers newPage() for blank drawing 422 with drawBot.savedState(): 423 return modifyState() 424 else: 425 return modifyState() 426 427 def _canFit(fs): 428 textW, textH = _getDimensions(fs) 429 return textW <= containerW and textH <= containerH 430 431 lead = lambda fs: fs * leading 432 track = lambda fs: letterSpacing * (fs / 1000) 433 434 containerW, containerH = layout.toDimensions(container) 435 436 # Adapt precision based on parameter (default to integer search) 437 factor = 10**precision 438 439 # Convert to integer space for binary search 440 low, high = 0, int(max(containerW, containerH) * factor) 441 maxFs = -1 442 triesNo, triesMax = 0, 100 + (precision * 10) # More precision needs more tries 443 444 while low <= high and triesNo < triesMax: 445 triesNo += 1 446 fs_int = (low + high) // 2 447 fs = fs_int / factor # Convert back to float for testing 448 449 if _canFit(fs): 450 maxFs = fs 451 low = fs_int + 1 452 else: 453 high = fs_int - 1 454 455 if triesNo == triesMax: 456 logger.warning(f"{triesMax} tries exceeded") 457 458 if debug: 459 logger.trace(f"Found after {triesNo} attempts: {maxFs}") 460 461 # Round to desired precision 462 maxFs = round(maxFs, precision) if precision > 0 else int(maxFs) 463 464 return maxFs, lead(maxFs), track(maxFs), *_getDimensions(maxFs) 465 466 467def fitBinaryFS( 468 contents: list, 469 container: tuple, 470 fontProps: dict, 471 maxSize: int = None, 472 isFluid=False, 473 debug=False, 474) -> tuple[drawBotDrawingTools.FormattedString, int]: 475 """ 476 Find the maximum font size for a FormattedString that fits in the container. 477 478 Args: 479 contents: List of (text, fontPath) tuples. 480 container: The container dimensions. 481 fontProps: Dictionary of font properties. 482 maxSize: Clamp maximum font size. 483 isFluid: If True, use fluid width. 484 debug: If True, print debug information. 485 486 Returns: 487 Tuple of (FormattedString, fontSize). 488 """ 489 490 def _createFS(formattedProps: dict) -> drawBotDrawingTools.FormattedString: 491 """ 492 Create a FormattedString with the given properties. 493 494 Args: 495 formattedProps: Dictionary of formatting properties. 496 497 Returns: 498 FormattedString object. 499 """ 500 dummy = drawBot.FormattedString(**formattedProps) 501 for [text, font] in contents: 502 dummy.append(text, font=font) 503 return dummy 504 505 def _getFormattedProps(fontSize: int, factor=10): 506 """ 507 Get formatted properties for a given font size. 508 509 Args: 510 fontSize: Font size. 511 factor: Tracking factor. 512 513 Returns: 514 Dictionary of formatted properties. 515 """ 516 lead = lambda fs: fs * fontProps.get("leading", 1) 517 track = lambda fs: (fs / factor) * fontProps.get("tracking", 0) 518 textAlign = fontProps.get("align", "left") 519 tabs = fontProps.get("tabs") 520 521 props = { 522 "fontSize": fontSize, 523 "lineHeight": lead(fontSize), 524 "tracking": track(fontSize), 525 "align": textAlign, 526 "tabs": tabs, 527 } 528 529 rgbFill = fontProps.get("fill") 530 cmykFill = fontProps.get("cmykFill") 531 532 if rgbFill: 533 props["fill"] = rgbFill 534 elif cmykFill: 535 props["cmykFill"] = cmykFill 536 537 return props 538 539 def _canFitString(fontSize: int) -> bool: 540 """ 541 Check if a FormattedString of a given font size fits in the container. 542 543 Args: 544 fontSize: Font size. 545 546 Returns: 547 True if it fits, False otherwise. 548 """ 549 dummy = _createFS(_getFormattedProps(fontSize)) 550 dummySize = drawBot.textSize(dummy, width=containerW if isFluid else None) 551 return layout.canFitInside(containerSize, dummySize) 552 553 contents = helpers.coerceList(contents, strict=True) 554 containerSize = containerW, _ = layout.toDimensions(container) 555 low, high = 0, min(containerSize) 556 best = -1 557 triesNo, triesMax = 0, 100 558 559 while low <= high and triesNo < triesMax: 560 triesNo += 1 561 mid = (low + high) // 2 562 563 if _canFitString(fontSize=mid): 564 best = mid 565 low = mid + 1 566 else: 567 high = mid - 1 568 569 if triesNo == triesMax: 570 logger.debug(f"{triesMax} tries exceeded") 571 572 if debug: 573 logger.debug(f"Found after {triesNo} attempts:", best) 574 575 if maxSize: 576 best = min(best, maxSize) 577 578 return _createFS(_getFormattedProps(best)), best 579 580 581def makeTabs( 582 container: tuple | int, 583 n=1, 584 mode: Literal["apply", "kwargs"] = "apply", 585 alignment: Literal["left", "center", "right"] = "center", 586): 587 """ 588 Make `n` tabs based on `container` width. 589 590 Args: 591 container: Accepts `coords` tuple or `width` int. 592 n: Number of tabs. 593 mode: `apply` to set instantly via drawBot.tabs(), `kwargs` for FormattedString(). 594 alignment: Tab alignment, `left`, `center`, or `right`. Default is `center`, except for the last tab which is always `right`. 595 596 Returns: 597 - If mode is `apply`, sets tabs and returns None. 598 - If mode is `kwargs`, returns a list of tab positions and alignments. 599 """ 600 width = layout.toWidth(container) 601 advanceW = width / n 602 result = [] 603 604 for tab in range(1, n + 1): 605 isTabLast = tab == n 606 tabAlign = alignment if not isTabLast else "right" 607 result.append((tab * advanceW, tabAlign)) 608 609 if mode == "apply": 610 drawBot.tabs(*result) 611 else: 612 return result
Font style (slope) alias.
Font type alias.
Text alignment alias.
OpenType font feature code alias.
54def getFontName(fontPath: str) -> str: 55 """ 56 Get the font name without extension. 57 58 Args: 59 fontPath: Path to the font file. 60 61 Example: 62 `/folder/fileName-styleName.otf` => `fileName-styleName` 63 """ 64 fullName = Path(fontPath).stem 65 return fullName
Get the font name without extension.
Arguments:
- fontPath: Path to the font file.
Example:
/folder/fileName-styleName.otf=>fileName-styleName
68def getFontsFromFolder(fontFolder: str) -> list[str]: 69 """ 70 Get all font file paths from a folder. 71 72 Args: 73 fontFolder: Path to the folder containing font files. 74 75 Returns: 76 List of font file paths with .otf or .ttf extensions. 77 """ 78 fontPaths = [] 79 for root, dirs, files in os.walk(fontFolder): 80 files.sort() 81 for fileName in files: 82 basePath, ext = os.path.splitext(fileName) 83 if ext in [".otf", ".ttf"]: 84 fontPaths.append(os.path.join(root, fileName)) 85 return fontPaths
Get all font file paths from a folder.
Arguments:
- fontFolder: Path to the folder containing font files.
Returns:
List of font file paths with .otf or .ttf extensions.
88def getFonts(source: str) -> list[str]: 89 """ 90 Get font file paths from a source, which can be a directory, file, or list of files. 91 92 Args: 93 source: Directory path, file path, or list of file paths. 94 95 Returns: 96 List of valid font file paths. 97 """ 98 isMultiple = type(source) is list 99 isSingle = isinstance(source, str) 100 101 if isSingle: 102 if helpers.isDirectory(source): 103 return getFontsFromFolder(source) 104 elif helpers.isFile(source): 105 return [source] 106 else: 107 logger.warning("[Fonts Not Found] {}", source) 108 elif isMultiple: 109 # Check if files exist 110 def check(file): 111 if helpers.isFile(file): 112 return file 113 else: 114 print("File not found: ", file) 115 116 mapped = [check(file) for file in source] 117 # Omit None 118 return [file for file in mapped if file]
Get font file paths from a source, which can be a directory, file, or list of files.
Arguments:
- source: Directory path, file path, or list of file paths.
Returns:
List of valid font file paths.
121def parseNameObject(fontString: str, separate=False) -> str | dict: 122 """ 123 Parse a font string into its components. 124 125 Args: 126 fontString: The font string or path. 127 separate: If True, returns a dict of components. If False, returns the full name. 128 129 Returns: 130 - If `separate` is True, returns a dict with keys: 131 - `fullName`: e.g. `Stabil Grotesk 400 Regular` 132 - `familyName`: e.g. `Stabil Grotesk` 133 - `shortName`: e.g. `400 Regular` 134 - `styleNumber`: e.g. `400` 135 - `styleName`: e.g. `Regular` 136 - If `separate` is False, returns the `fullName` string. 137 """ 138 fontName = getFontName(fontString) 139 familyName, *suffix = fontName.split("-") 140 141 # KUniforma => Uniforma 142 familyName = stripVendorName(familyName) 143 # StabilGrotesk => Stabil Grotesk 144 familyName = content.toPascalCase(familyName).strip() 145 146 # ['55RegularItalic'] => '50RegularItalic' 147 suffix = "".join(suffix).strip() 148 hasStyleName = re.search(r"\D+", suffix) 149 hasStyleNumber = re.search(r"\d+", suffix) 150 151 # RegularItalic => Regular Italic 152 styleName = content.toPascalCase(hasStyleName.group()) if hasStyleName else None 153 # 55/None 154 styleNumber = hasStyleNumber.group() if hasStyleNumber else None 155 # [None, 'Regular Italic] => ['Regular Italic'] 156 omitBlank = [part for part in [styleNumber, styleName] if part] 157 fullName = (" ").join([familyName, *omitBlank]) 158 shortName = (" ").join(omitBlank) 159 160 if separate: 161 return { 162 "fullName": fullName, # Stabil Grotesk 400 Regular 163 "familyName": familyName, # Stabil Grotesk 164 "shortName": shortName, # 400 Regular 165 "styleNumber": styleNumber, # 400 166 "styleName": styleName, # Regular 167 } 168 else: 169 return fullName
Parse a font string into its components.
Arguments:
- fontString: The font string or path.
- separate: If True, returns a dict of components. If False, returns the full name.
Returns:
- If
separateis True, returns a dict with keys:fullName: e.g.Stabil Grotesk 400 RegularfamilyName: e.g.Stabil GroteskshortName: e.g.400 RegularstyleNumber: e.g.400styleName: e.g.Regular
- If
separateis False, returns thefullNamestring.
178def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"): 179 """ 180 Returns human readable font properties. 181 182 Args: 183 fontSize: Font size as int or tuple for range 184 - `(int) 72` or as range `(tuple) 72, 12` 185 leading: Line height multiplier or value 186 - lineHeight `int` or leading `float` 187 tracking: Tracking value. 188 separator: Separator string. 189 """ 190 if isinstance(fontSize, tuple): 191 # Skip showing lineHeight for range of fontSizes 192 fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize))) 193 else: 194 # Assume it’s leading if ≤ 2 195 lineHeight = fontSize * leading if leading <= 2 else leading 196 fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}" 197 198 if tracking: 199 return f"{fsAndLh}{separator}{round(tracking, 1)}" 200 else: 201 # Round both 202 return fsAndLh
Returns human readable font properties.
Arguments:
- fontSize: Font size as int or tuple for range
(int) 72or as range(tuple) 72, 12
- leading: Line height multiplier or value
- lineHeight
intor leadingfloat
- lineHeight
- tracking: Tracking value.
- separator: Separator string.
205def getStyleNumber(fontString: str, format: Literal["str", "int"] = "str") -> str | int: 206 """ 207 Get the style number from a font string. 208 209 Args: 210 fontString: The font string or style number. 211 format: Output format, string or integer. 212 """ 213 isStyleNumber = fontString.isnumeric() 214 215 styleNumber = ( 216 fontString 217 if isStyleNumber 218 else parseNameObject(fontString, separate=True)["styleNumber"] 219 ) 220 221 return int(styleNumber) if format == "int" else str(styleNumber)
Get the style number from a font string.
Arguments:
- fontString: The font string or style number.
- format: Output format, string or integer.
224def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None: 225 """ 226 Find the font in fontList with the closest style number to fontNumber. 227 228 Args: 229 fontList: List of font strings. 230 fontNumber: Target style number. 231 232 Returns: 233 The font string with the closest style number, or `None` if not found. 234 """ 235 # Get numbers, None if not found 236 numbers = [getStyleNumber(font, format="int") for font in fontList] 237 238 # Compact array 239 numbers = [number for number in numbers if isinstance(number, int)] 240 241 if len(numbers): 242 closest = helpers.findClosestValue(numbers, fontNumber) 243 closestIndex = numbers.index(closest) 244 return fontList[closestIndex]
Find the font in fontList with the closest style number to fontNumber.
Arguments:
- fontList: List of font strings.
- fontNumber: Target style number.
Returns:
The font string with the closest style number, or
Noneif not found.
247def isFontType(fontPath: str, criterion: FontType) -> bool: 248 """ 249 Check if a font matches a given FontType criterion. 250 251 Args: 252 fontPath: Path to the font file. 253 criterion: FontType criterion. 254 255 Returns: 256 True if the font matches the criterion, False otherwise. 257 """ 258 # Special case 259 if criterion == "any": 260 return True 261 262 styleNumber = getStyleNumber(fontPath) 263 return criterion in getFontType(styleNumber)
Check if a font matches a given FontType criterion.
Arguments:
- fontPath: Path to the font file.
- criterion: FontType criterion.
Returns:
True if the font matches the criterion, False otherwise.
266def isDisplay(fontString: str) -> bool: 267 """Returns True if the font is of type `display`.""" 268 return isFontType(fontString, "display")
Returns True if the font is of type display.
271def getFontType(fontString: str) -> list[FontType]: 272 """ 273 Get the list of `FontType` that match the font's style number. See source for regex patterns. 274 275 Args: 276 fontString: The font string or path. 277 278 Returns: 279 List of matching FontTypes. 280 """ 281 282 def _isMatch(pattern: str) -> bool: 283 return bool(re.compile(rf"^{pattern}").search(styleNumber)) 284 285 fontTypes = { 286 "upright": r"\d0", 287 "italic": r"\d5", 288 "display": "[^4-6]", 289 "text": "[4-6]", 290 "thin": "[1-3]", 291 "thick": "[7-9]", 292 } 293 294 styleNumber = getStyleNumber(fontString) 295 return [fontType for [fontType, pattern] in fontTypes.items() if _isMatch(pattern)]
Get the list of FontType that match the font's style number. See source for regex patterns.
Arguments:
- fontString: The font string or path.
Returns:
List of matching FontTypes.
298def filterFonts( 299 fonts: list[str], 300 criteria: FontType | list[FontType] = "upright", 301 strategy: helpers.Strategy = "pick", 302) -> list[str]: 303 """ 304 Filter fonts by given criteria and strategy. 305 306 Args: 307 fonts: List of font strings. 308 criteria: `FontType` or list of FontTypes to filter by. 309 strategy: Filtering strategy, "pick" or "omit". 310 311 Returns: 312 List of filtered font strings. 313 """ 314 315 def _evaluate(predicate: bool): 316 "Inverse logic for `omit` strategy" 317 return predicate if strategy == "pick" else not predicate 318 319 def _fontsForCriterion(criterion: FontType) -> list[str]: 320 return [font for font in fonts if _evaluate(isFontType(font, criterion))] 321 322 criteria = helpers.coerceList(criteria) 323 # Groups of matches 324 allMatches = [_fontsForCriterion(criterion) for criterion in criteria] 325 # Intersect matches and sort them by number 326 return helpers.intersect(allMatches)
Filter fonts by given criteria and strategy.
Arguments:
- fonts: List of font strings.
- criteria:
FontTypeor list of FontTypes to filter by. - strategy: Filtering strategy, "pick" or "omit".
Returns:
List of filtered font strings.
329def groupFontsBySlope(fonts: list[str], reverseOrder=True): 330 """ 331 Group fonts by slope (upright/italic). 332 333 Args: 334 fonts: List of font strings. 335 reverseOrder: If True, reverse the order of each group. 336 337 Returns: 338 Tuple of (upright_fonts, italic_fonts). 339 """ 340 fontsUpright = filterFonts(fonts, "upright") 341 fontsItalic = filterFonts(fonts, "italic") 342 343 groups = (fontsUpright, fontsItalic) 344 345 if reverseOrder: 346 for group in groups: 347 group.reverse() 348 349 return groups
Group fonts by slope (upright/italic).
Arguments:
- fonts: List of font strings.
- reverseOrder: If True, reverse the order of each group.
Returns:
Tuple of (upright_fonts, italic_fonts).
352def groupFontsByWeight(fonts: list[str], reverseOrder=True): 353 """ 354 Group fonts by weight, pairing upright and italic fonts. 355 356 Args: 357 fonts: List of font strings. 358 reverseOrder: If True, reverse the order of each group. 359 360 Returns: 361 List of tuples pairing upright and italic fonts by weight. 362 """ 363 fontsUpright, fontsItalic = groupFontsBySlope( 364 fonts=fonts, reverseOrder=reverseOrder 365 ) 366 367 return list(zip(fontsUpright, fontsItalic))
Group fonts by weight, pairing upright and italic fonts.
Arguments:
- fonts: List of font strings.
- reverseOrder: If True, reverse the order of each group.
Returns:
List of tuples pairing upright and italic fonts by weight.
370def fitBinary( 371 text: str, 372 container: tuple, 373 leading: float = 1, 374 letterSpacing: int = 0, 375 isFluid: bool = False, 376 saveState: bool = True, 377 debug: bool = False, 378 precision: int = 0, 379) -> tuple: 380 """ 381 Find the largest font size to fit text in a container, using a binary search algorithm. Calculates font size in integer space for performance by default, can be adjusted with `precision`. 382 383 Args: 384 text: The text string to fit. 385 container: The container as a tuple of `(width, height)` or `(x, y, width, height)`. 386 leading: Line height multiplier (default: 1). 387 letterSpacing: Letter spacing value (default: 0). 388 isFluid: If True, allows text wrapping to fit width; if False, fits text on a single line. 389 saveState: If True, preserves the current DrawBot drawing state. 390 debug: If True, outputs debug information. 391 precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths). 392 393 Returns: 394 A tuple containing: 395 - fontSize: The maximum font size that fits. 396 - lineHeight: The calculated line height for this font size. 397 - tracking: The calculated tracking for this font size. 398 - textW: The width of the rendered text at this size. 399 - textH: The height of the rendered text at this size. 400 """ 401 402 def _getDimensions(size): 403 def modifyState(): 404 drawBot.fontSize(size) 405 drawBot.lineHeight(lead(size)) 406 drawBot.tracking(track(size)) 407 textW, textH = drawBot.textSize(text, width=containerW if isFluid else None) 408 409 # Edge case: font metrics may include leading, account for that in height 410 NSFontLeading = drawBot.fontLeading() 411 if NSFontLeading: 412 lineCount = ceil(textH / drawBot.fontLineHeight()) 413 leadingCount = lineCount - 1 414 leadingExpansion = leadingCount * ceil(NSFontLeading) 415 textH += leadingExpansion 416 417 # Compensate for tracking 418 return textW - track(size), textH 419 420 # Used for preparatory calculations 421 if saveState: 422 # Important! savedState() triggers newPage() for blank drawing 423 with drawBot.savedState(): 424 return modifyState() 425 else: 426 return modifyState() 427 428 def _canFit(fs): 429 textW, textH = _getDimensions(fs) 430 return textW <= containerW and textH <= containerH 431 432 lead = lambda fs: fs * leading 433 track = lambda fs: letterSpacing * (fs / 1000) 434 435 containerW, containerH = layout.toDimensions(container) 436 437 # Adapt precision based on parameter (default to integer search) 438 factor = 10**precision 439 440 # Convert to integer space for binary search 441 low, high = 0, int(max(containerW, containerH) * factor) 442 maxFs = -1 443 triesNo, triesMax = 0, 100 + (precision * 10) # More precision needs more tries 444 445 while low <= high and triesNo < triesMax: 446 triesNo += 1 447 fs_int = (low + high) // 2 448 fs = fs_int / factor # Convert back to float for testing 449 450 if _canFit(fs): 451 maxFs = fs 452 low = fs_int + 1 453 else: 454 high = fs_int - 1 455 456 if triesNo == triesMax: 457 logger.warning(f"{triesMax} tries exceeded") 458 459 if debug: 460 logger.trace(f"Found after {triesNo} attempts: {maxFs}") 461 462 # Round to desired precision 463 maxFs = round(maxFs, precision) if precision > 0 else int(maxFs) 464 465 return maxFs, lead(maxFs), track(maxFs), *_getDimensions(maxFs)
Find the largest font size to fit text in a container, using a binary search algorithm. Calculates font size in integer space for performance by default, can be adjusted with precision.
Arguments:
- text: The text string to fit.
- container: The container as a tuple of
(width, height)or(x, y, width, height). - leading: Line height multiplier (default: 1).
- letterSpacing: Letter spacing value (default: 0).
- isFluid: If True, allows text wrapping to fit width; if False, fits text on a single line.
- saveState: If True, preserves the current DrawBot drawing state.
- debug: If True, outputs debug information.
- precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths).
Returns:
A tuple containing: - fontSize: The maximum font size that fits. - lineHeight: The calculated line height for this font size. - tracking: The calculated tracking for this font size. - textW: The width of the rendered text at this size. - textH: The height of the rendered text at this size.
468def fitBinaryFS( 469 contents: list, 470 container: tuple, 471 fontProps: dict, 472 maxSize: int = None, 473 isFluid=False, 474 debug=False, 475) -> tuple[drawBotDrawingTools.FormattedString, int]: 476 """ 477 Find the maximum font size for a FormattedString that fits in the container. 478 479 Args: 480 contents: List of (text, fontPath) tuples. 481 container: The container dimensions. 482 fontProps: Dictionary of font properties. 483 maxSize: Clamp maximum font size. 484 isFluid: If True, use fluid width. 485 debug: If True, print debug information. 486 487 Returns: 488 Tuple of (FormattedString, fontSize). 489 """ 490 491 def _createFS(formattedProps: dict) -> drawBotDrawingTools.FormattedString: 492 """ 493 Create a FormattedString with the given properties. 494 495 Args: 496 formattedProps: Dictionary of formatting properties. 497 498 Returns: 499 FormattedString object. 500 """ 501 dummy = drawBot.FormattedString(**formattedProps) 502 for [text, font] in contents: 503 dummy.append(text, font=font) 504 return dummy 505 506 def _getFormattedProps(fontSize: int, factor=10): 507 """ 508 Get formatted properties for a given font size. 509 510 Args: 511 fontSize: Font size. 512 factor: Tracking factor. 513 514 Returns: 515 Dictionary of formatted properties. 516 """ 517 lead = lambda fs: fs * fontProps.get("leading", 1) 518 track = lambda fs: (fs / factor) * fontProps.get("tracking", 0) 519 textAlign = fontProps.get("align", "left") 520 tabs = fontProps.get("tabs") 521 522 props = { 523 "fontSize": fontSize, 524 "lineHeight": lead(fontSize), 525 "tracking": track(fontSize), 526 "align": textAlign, 527 "tabs": tabs, 528 } 529 530 rgbFill = fontProps.get("fill") 531 cmykFill = fontProps.get("cmykFill") 532 533 if rgbFill: 534 props["fill"] = rgbFill 535 elif cmykFill: 536 props["cmykFill"] = cmykFill 537 538 return props 539 540 def _canFitString(fontSize: int) -> bool: 541 """ 542 Check if a FormattedString of a given font size fits in the container. 543 544 Args: 545 fontSize: Font size. 546 547 Returns: 548 True if it fits, False otherwise. 549 """ 550 dummy = _createFS(_getFormattedProps(fontSize)) 551 dummySize = drawBot.textSize(dummy, width=containerW if isFluid else None) 552 return layout.canFitInside(containerSize, dummySize) 553 554 contents = helpers.coerceList(contents, strict=True) 555 containerSize = containerW, _ = layout.toDimensions(container) 556 low, high = 0, min(containerSize) 557 best = -1 558 triesNo, triesMax = 0, 100 559 560 while low <= high and triesNo < triesMax: 561 triesNo += 1 562 mid = (low + high) // 2 563 564 if _canFitString(fontSize=mid): 565 best = mid 566 low = mid + 1 567 else: 568 high = mid - 1 569 570 if triesNo == triesMax: 571 logger.debug(f"{triesMax} tries exceeded") 572 573 if debug: 574 logger.debug(f"Found after {triesNo} attempts:", best) 575 576 if maxSize: 577 best = min(best, maxSize) 578 579 return _createFS(_getFormattedProps(best)), best
Find the maximum font size for a FormattedString that fits in the container.
Arguments:
- contents: List of (text, fontPath) tuples.
- container: The container dimensions.
- fontProps: Dictionary of font properties.
- maxSize: Clamp maximum font size.
- isFluid: If True, use fluid width.
- debug: If True, print debug information.
Returns:
Tuple of (FormattedString, fontSize).
582def makeTabs( 583 container: tuple | int, 584 n=1, 585 mode: Literal["apply", "kwargs"] = "apply", 586 alignment: Literal["left", "center", "right"] = "center", 587): 588 """ 589 Make `n` tabs based on `container` width. 590 591 Args: 592 container: Accepts `coords` tuple or `width` int. 593 n: Number of tabs. 594 mode: `apply` to set instantly via drawBot.tabs(), `kwargs` for FormattedString(). 595 alignment: Tab alignment, `left`, `center`, or `right`. Default is `center`, except for the last tab which is always `right`. 596 597 Returns: 598 - If mode is `apply`, sets tabs and returns None. 599 - If mode is `kwargs`, returns a list of tab positions and alignments. 600 """ 601 width = layout.toWidth(container) 602 advanceW = width / n 603 result = [] 604 605 for tab in range(1, n + 1): 606 isTabLast = tab == n 607 tabAlign = alignment if not isTabLast else "right" 608 result.append((tab * advanceW, tabAlign)) 609 610 if mode == "apply": 611 drawBot.tabs(*result) 612 else: 613 return result
Make n tabs based on container width.
Arguments:
- container: Accepts
coordstuple orwidthint. - n: Number of tabs.
- mode:
applyto set instantly via drawBot.tabs(),kwargsfor FormattedString(). - alignment: Tab alignment,
left,center, orright. Default iscenter, except for the last tab which is alwaysright.
Returns:
- If mode is
apply, sets tabs and returns None. - If mode is
kwargs, returns a list of tab positions and alignments.