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