lib.fonts
1import re 2import os 3import drawBot 4from typing import Literal, NamedTuple, TypeAlias, get_args, overload 5from contextlib import contextmanager 6from pathlib import Path 7from math import ceil 8from loguru import logger 9from icecream import ic 10 11from lib import content, files, helpers, layout 12from const import UI_FONT 13 14FontStyle: TypeAlias = Literal["upright", "italic"] 15"""Font style (slope) alias.""" 16 17FontType: TypeAlias = Literal[ 18 "upright", "italic", "display", "text", "thin", "thick", "any" 19] 20"""Font type alias.""" 21 22TextAlign: TypeAlias = Literal["left", "center", "right"] 23"""Text alignment alias.""" 24 25FeatureId: TypeAlias = Literal[ 26 "liga", 27 "dlig", 28 "calt", 29 "locl", 30 "titl", 31 "case", 32 "pnum", 33 "lnum", 34 "onum", 35 "tnum", 36 "zero", 37 "subs", 38 "sups", 39 "numr", 40 "dnom", 41 "frac", 42 "ordn", 43 "kern", 44 "ss01", 45 "ss02", 46 "ss03", 47 "ss04", 48 "ss05", 49 "ss06", 50 "ss07", 51 "ss08", 52 "ss09", 53 "ss10", 54] 55"""OpenType font feature code alias.""" 56 57 58class ParsedFontName(NamedTuple): 59 """Structured font name result for `parseNameObject(..., separate=True)`.""" 60 61 vendorName: str | None 62 fullName: str 63 familyName: str 64 shortName: str 65 styleNumber: str | None 66 styleName: str | None 67 68 69def getFeatureIdList() -> list[FeatureId]: 70 "Get list of all FeatureId values at runtime (used for sorting features)." 71 return list(get_args(FeatureId)) 72 73 74def getFontName(fontPath: str) -> str: 75 """ 76 Get the font name without extension. 77 78 Args: 79 fontPath: Path to the font file. 80 81 Example: 82 `/folder/fileName-styleName.otf` => `fileName-styleName` 83 """ 84 fullName = Path(fontPath).stem 85 return fullName 86 87 88def getFontVersion(fontPath: str) -> str: 89 """Get the font version from the 'head' table of the font file.""" 90 from fontTools.ttLib import TTFont 91 92 tt = TTFont(fontPath) 93 # Get version from head table (Internal table version) 94 head_version = tt["head"].fontRevision 95 # Round to 2 decimal places for cleaner output 96 return str(round(head_version, 2)) 97 98 99def getFontsFromFolder(fontFolder: str) -> list[str]: 100 """ 101 Get all font file paths from a folder. 102 103 Args: 104 fontFolder: Path to the folder containing font files. 105 106 Returns: 107 List of font file paths with .otf or .ttf extensions. 108 """ 109 fontPaths = [] 110 for root, dirs, fontFiles in os.walk(fontFolder): 111 fontFiles.sort() 112 for fileName in fontFiles: 113 basePath, ext = os.path.splitext(fileName) 114 if ext in [".otf", ".ttf"]: 115 fontPaths.append(os.path.join(root, fileName)) 116 return fontPaths 117 118 119def getFonts(source: str) -> list[str]: 120 """ 121 Get font file paths from a source, which can be a directory, file, or list of files. 122 123 Args: 124 source: Directory path, file path, or list of file paths. 125 126 Returns: 127 List of valid font file paths. 128 """ 129 isMultiple = type(source) is list 130 isSingle = isinstance(source, str) 131 132 if isSingle: 133 if files.isDirectory(source): 134 return getFontsFromFolder(source) 135 elif files.isFile(source): 136 return [source] 137 else: 138 logger.warning("[Fonts Not Found] {}", source) 139 elif isMultiple: 140 # Check if files exist 141 def check(file): 142 if files.isFile(file): 143 return file 144 else: 145 print("File not found: ", file) 146 147 mapped = [check(file) for file in source] 148 # Omit None 149 return [file for file in mapped if file] 150 151 152@overload 153def parseNameObject(fontString: str, separate: Literal[False] = False) -> str: ... 154 155 156@overload 157def parseNameObject( 158 fontString: str, separate: Literal[True] = True 159) -> ParsedFontName: ... 160 161 162def parseNameObject(fontString: str, separate: bool = False) -> str | ParsedFontName: 163 """ 164 Parse a font string into its components. 165 166 Args: 167 fontString: The font string or path. 168 separate: If True, returns a `ParsedFontName`. If False, returns the full name. 169 170 Returns: 171 - If `separate` is True, returns a `ParsedFontName` with attributes: 172 - `fullName`: e.g. `Stabil Grotesk 400 Regular` 173 - `familyName`: e.g. `Stabil Grotesk` 174 - `shortName`: e.g. `400 Regular` 175 - `styleNumber`: e.g. `400` 176 - `styleName`: e.g. `Regular` 177 - If `separate` is False, returns the `fullName` string. 178 """ 179 fontName = getFontName(fontString) 180 vendorName, familyName = separateVendorName(fontName) 181 familyName, *suffix = familyName.split("-") 182 183 # StabilGrotesk => Stabil Grotesk 184 familyName = content.toPascalCase(familyName).strip() 185 186 # ['55RegularItalic'] => '50RegularItalic' 187 suffix = "".join(suffix).strip() 188 hasStyleName = re.search(r"\D+", suffix) 189 hasStyleNumber = re.search(r"\d+", suffix) 190 191 # RegularItalic => Regular Italic 192 styleName = content.toPascalCase(hasStyleName.group()) if hasStyleName else None 193 # 55/None 194 styleNumber = hasStyleNumber.group() if hasStyleNumber else None 195 # [None, 'Regular Italic] => ['Regular Italic'] 196 omitBlank = [part for part in [styleNumber, styleName] if part] 197 fullName = (" ").join([familyName, *omitBlank]) 198 shortName = (" ").join(omitBlank) 199 200 if separate: 201 return ParsedFontName( 202 vendorName=vendorName, 203 fullName=fullName, 204 familyName=familyName, 205 shortName=shortName, 206 styleNumber=styleNumber, 207 styleName=styleName, 208 ) 209 else: 210 return fullName 211 212 213def parseVendorName(string: str) -> str | None: 214 """Parse the vendor name from a font string using regex patterns. Returns the vendor name or None if not found.""" 215 proprietaryPrefix = re.match(r"^\(?(KOMETA|K)\)?", string) 216 if proprietaryPrefix: 217 return proprietaryPrefix.group(1) 218 # Match any number of uppercase letters not followed by uppercase + lowercase 219 # example: KOMETAStabilGrotesk => KOMETA, HALTimezone-LightItalic => HAL, K-Uniforma => K 220 uppercasePrefix = re.match(r"^[A-Z]+(?=[A-Z][a-z])", string) 221 if uppercasePrefix: 222 return uppercasePrefix.group(0) 223 # Try splitting by common separators, assume vendor name is the first part if there are more than 2 parts 224 parts = re.split(r"[-_ ]+", string) 225 if parts[0].casefold() in ["hw", "lineto"]: 226 return parts[0] 227 if len(parts) >= 3 and (parts[0].isalpha() and parts[0].isupper()): 228 return parts[0] 229 230 231def separateVendorName(string: str) -> tuple[str | None, str]: 232 """Separate the vendor name from the rest of the font name. Return both as a tuple.""" 233 vendorName = parseVendorName(string) 234 rest = ( 235 re.sub(rf"^\(?{re.escape(vendorName)}\)?[-_ ]*", "", string, count=1) 236 if vendorName 237 else string 238 ) 239 return vendorName, rest 240 241 242def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"): 243 """ 244 Returns human readable font properties. 245 246 Args: 247 fontSize: Font size as int or tuple for range 248 - `(int) 72` or as range `(tuple) 72, 12` 249 leading: Line height multiplier or value 250 - lineHeight `int` or leading `float` 251 tracking: Tracking value. 252 separator: Separator string. 253 """ 254 if isinstance(fontSize, tuple): 255 # Skip showing lineHeight for range of fontSizes 256 fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize))) 257 else: 258 # Assume it’s leading if ≤ 2 259 lineHeight = fontSize * leading if leading <= 2 else leading 260 fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}" 261 262 if tracking: 263 return f"{fsAndLh}{separator}{round(tracking, 1)}" 264 else: 265 # Round both 266 return fsAndLh 267 268 269def getStyleNumber(fontString: str, format: Literal["str", "int"] = "str") -> str | int: 270 """ 271 Get the style number from a font string. 272 273 Args: 274 fontString: The font string or style number. 275 format: Output format, string or integer. 276 """ 277 isStyleNumber = fontString.isnumeric() 278 279 styleNumber = ( 280 fontString 281 if isStyleNumber 282 else parseNameObject(fontString, separate=True).styleNumber 283 ) 284 285 return int(styleNumber) if format == "int" else str(styleNumber) 286 287 288def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None: 289 """ 290 Find the font in fontList with the closest style number to fontNumber. 291 292 Args: 293 fontList: List of font strings. 294 fontNumber: Target style number. 295 296 Returns: 297 The font string with the closest style number, or `None` if not found. 298 """ 299 try: 300 # Get numbers, None if not found 301 numbers = [getStyleNumber(font, format="int") for font in fontList] 302 303 # Compact array 304 numbers = [number for number in numbers if isinstance(number, int)] 305 306 if len(numbers): 307 closest = helpers.findClosestValue(numbers, fontNumber) 308 closestIndex = numbers.index(closest) 309 return fontList[closestIndex] 310 except Exception as e: 311 logger.error(f"Error finding font by number: {e}") 312 return None 313 314 315def _makeSlope(fontNumber: int, targetSlope: Literal["upright", "italic"]) -> int: 316 """Internal function to adjust font slope. 317 318 Args: 319 fontNumber: Font style number (1-99). 320 targetSlope: Target slope - "upright" (even) or "italic" (odd). 321 322 Returns: 323 Adjusted font number. 324 325 Raises: 326 ValueError: If fontNumber is not between 1 and 99. 327 """ 328 if fontNumber < 1 or fontNumber > 99: 329 raise ValueError(f"Font number must be between 1 and 99, got {fontNumber}") 330 331 # Determine target suffix based on slope 332 target_suffix = "0" if targetSlope == "upright" else "5" 333 target_is_even = targetSlope == "upright" 334 335 fontNumberStr = str(fontNumber) 336 337 if len(fontNumberStr) == 1: 338 # Single digit: append target suffix 339 return int(fontNumberStr + target_suffix) 340 elif len(fontNumberStr) == 2: 341 last_digit = int(fontNumberStr[-1]) 342 is_even = last_digit % 2 == 0 343 344 # If already correct slope, return as-is 345 if is_even == target_is_even: 346 return fontNumber 347 else: 348 # Replace last digit with target suffix 349 return int(fontNumberStr[:-1] + target_suffix) 350 else: 351 raise ValueError(f"Font number must be 1 or 2 digits, got {fontNumber}") 352 353 354def makeSlopeUpright(fontNumber: int) -> int: 355 """Convert font number to upright style (even last digit). 356 357 Examples: 358 10 → 10 (already upright) 359 15 → 10 (italic to upright) 360 5 → 50 (single digit) 361 362 Args: 363 fontNumber: Font style number (1-99). 364 365 Returns: 366 Upright font number. 367 """ 368 return _makeSlope(fontNumber, "upright") 369 370 371def makeSlopeItalic(fontNumber: int) -> int: 372 """Convert font number to italic style (odd last digit). 373 374 Examples: 375 10 → 15 (upright to italic) 376 15 → 15 (already italic) 377 5 → 55 (single digit) 378 379 Args: 380 fontNumber: Font style number (1-99). 381 382 Returns: 383 Italic font number. 384 """ 385 return _makeSlope(fontNumber, "italic") 386 387 388def isFontType(fontPath: str, criterion: FontType) -> bool: 389 """ 390 Check if a font matches a given FontType criterion. 391 392 Args: 393 fontPath: Path to the font file. 394 criterion: FontType criterion. 395 396 Returns: 397 True if the font matches the criterion, False otherwise. 398 """ 399 # Special case 400 if criterion == "any": 401 return True 402 403 styleNumber = getStyleNumber(fontPath) 404 return criterion in getFontType(styleNumber) 405 406 407def isDisplay(fontString: str) -> bool: 408 """Returns True if the font is of type `display`.""" 409 return isFontType(fontString, "display") 410 411 412def getFontType(fontString: str) -> list[FontType]: 413 """ 414 Get the list of `FontType` that match the font's style number. See source for regex patterns. 415 416 Args: 417 fontString: The font string or path. 418 419 Returns: 420 List of matching FontTypes. 421 """ 422 423 def _isMatch(pattern: str) -> bool: 424 return bool(re.compile(rf"^{pattern}").search(styleNumber)) 425 426 fontTypes = { 427 "upright": r"\d0", 428 "italic": r"\d5", 429 "display": "[^4-6]", 430 "text": "[4-6]", 431 "thin": "[1-3]", 432 "thick": "[7-9]", 433 } 434 435 styleNumber = getStyleNumber(fontString) 436 return [fontType for [fontType, pattern] in fontTypes.items() if _isMatch(pattern)] 437 438 439def filterFonts( 440 fonts: list[str], 441 criteria: FontType | list[FontType] = "upright", 442 strategy: helpers.Strategy = "pick", 443) -> list[str]: 444 """ 445 Filter fonts by given criteria and strategy. 446 447 Args: 448 fonts: List of font strings. 449 criteria: `FontType` or list of FontTypes to filter by. 450 strategy: Filtering strategy, "pick" or "omit". 451 452 Returns: 453 List of filtered font strings. 454 """ 455 456 def _evaluate(predicate: bool): 457 "Inverse logic for `omit` strategy" 458 return predicate if strategy == "pick" else not predicate 459 460 def _fontsForCriterion(criterion: FontType) -> list[str]: 461 return [font for font in fonts if _evaluate(isFontType(font, criterion))] 462 463 criteria = helpers.coerceList(criteria) 464 # Groups of matches 465 allMatches = [_fontsForCriterion(criterion) for criterion in criteria] 466 # Intersect matches and sort them by number 467 return helpers.intersect(allMatches) 468 469 470class FitBinaryResult(NamedTuple): 471 fontSize: int 472 lineHeight: float 473 tracking: float 474 textWidth: float 475 textHeight: float 476 477 478def _judgeText(string: str) -> bool: 479 """Determine whether text is long enough to warrant wrapping. 480 481 Used internally by `fitBinary` for the ``whitespace="auto"`` mode. 482 483 Returns: 484 True if text is long (should wrap), False if short (single line). 485 """ 486 words = string.split(" ") 487 isSentenceLong = len(string) >= 10 488 isMultipleWords = len(words) > 1 489 areWordsLong = all(len(w) >= 5 for w in words) 490 isExtremelyLong = len(string) >= 20 491 return (isMultipleWords and (isSentenceLong or areWordsLong)) or isExtremelyLong 492 493 494def fitBinary( 495 text: str, 496 container: layout.Dimensions | layout.Coordinates, 497 leading: float = 1, 498 letterSpacing: int = 0, 499 whitespace: Literal["nowrap", "wrap", "auto"] = "nowrap", 500 saveState: bool = True, 501 debug: bool = False, 502 precision: int = 0, 503) -> FitBinaryResult: 504 """ 505 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`. 506 507 Args: 508 text: The text string to fit. 509 container: The container as a tuple of `(width, height)` or `(x, y, width, height)`. 510 leading: Line height multiplier (default: 1). 511 letterSpacing: Letter spacing value (default: 0). 512 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 513 saveState: If True, preserves the current DrawBot drawing state. 514 debug: If True, outputs debug information. 515 precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths). 516 517 Returns: 518 FittedBinaryResult containing: 519 - fontSize: The maximum font size that fits. 520 - lineHeight: The calculated line height for this font size. 521 - tracking: The calculated tracking for this font size. 522 - textWidth: The width of the rendered text at this size. 523 - textHeight: The height of the rendered text at this size. 524 """ 525 526 # Resolve wrapping behaviour from whitespace mode 527 if whitespace == "wrap": 528 _isFluid = True 529 elif whitespace == "auto": 530 _isFluid = _judgeText(text) 531 else: # "nowrap" 532 _isFluid = False 533 534 def _getDimensions(size): 535 def modifyState(): 536 drawBot.fontSize(size) 537 drawBot.lineHeight(lead(size)) 538 drawBot.tracking(track(size)) 539 textW, textH = drawBot.textSize( 540 text, width=containerW if _isFluid else None 541 ) 542 543 # Edge case: font metrics may include leading, account for that in height 544 NSFontLeading = drawBot.fontLeading() 545 if NSFontLeading: 546 lineCount = ceil(textH / drawBot.fontLineHeight()) 547 leadingCount = lineCount - 1 548 leadingExpansion = leadingCount * ceil(NSFontLeading) 549 textH += leadingExpansion 550 551 # Compensate for tracking 552 return textW - track(size), textH 553 554 # Used for preparatory calculations 555 if saveState: 556 # Important! savedState() triggers newPage() for blank drawing 557 with drawBot.savedState(): 558 return modifyState() 559 else: 560 return modifyState() 561 562 def _canFit(fs): 563 textW, textH = _getDimensions(fs) 564 return textW <= containerW and textH <= containerH 565 566 lead = lambda fs: fs * leading 567 track = lambda fs: letterSpacing * (fs / 1000) 568 569 containerW, containerH = layout.toDimensions(container) 570 571 # Adapt precision based on parameter (default to integer search) 572 factor = 10**precision 573 574 # Convert to integer space for binary search 575 low, high = 0, int(max(containerW, containerH) * factor) 576 maxFs = -1 577 triesNo, triesMax = 0, 100 + (precision * 10) # More precision needs more tries 578 579 while low <= high and triesNo < triesMax: 580 triesNo += 1 581 fs_int = (low + high) // 2 582 fs = fs_int / factor # Convert back to float for testing 583 584 if _canFit(fs): 585 maxFs = fs 586 low = fs_int + 1 587 else: 588 high = fs_int - 1 589 590 if triesNo == triesMax: 591 logger.warning(f"{triesMax} tries exceeded") 592 593 if debug: 594 logger.trace(f"Found after {triesNo} attempts: {maxFs}") 595 596 # Round to desired precision 597 maxFs = round(maxFs, precision) if precision > 0 else int(maxFs) 598 599 return FitBinaryResult( 600 maxFs, 601 lead(maxFs), 602 track(maxFs), 603 _getDimensions(maxFs)[0], 604 _getDimensions(maxFs)[1], 605 ) 606 607 608class FitFormattedResult(NamedTuple): 609 fitString: drawBot.FormattedString 610 fontSize: int 611 612 613def fitBinaryFS( 614 contents: list, 615 container: tuple, 616 fontProps: dict, 617 maxSize: int = None, 618 whitespace: str = "nowrap", 619 debug=False, 620) -> FitFormattedResult: 621 """ 622 Find the maximum font size for a FormattedString that fits in the container. 623 624 Args: 625 contents: List of (text, fontPath) tuples. 626 container: The container dimensions. 627 fontProps: Dictionary of font properties. 628 maxSize: Clamp maximum font size. 629 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 630 debug: If True, print debug information. 631 632 Returns: 633 FitFormattedResult where the FS is scaled to `fontSize`. 634 """ 635 636 def _createFS(formattedProps: dict) -> drawBot.FormattedString: 637 """ 638 Create a FormattedString with the given properties. 639 640 Args: 641 formattedProps: Dictionary of formatting properties. 642 643 Returns: 644 FormattedString object. 645 """ 646 dummy = drawBot.FormattedString(**formattedProps) 647 for [text, font] in contents: 648 dummy.append(text, font=font) 649 return dummy 650 651 def _getFormattedProps(fontSize: int, factor=10): 652 """ 653 Get formatted properties for a given font size. 654 655 Args: 656 fontSize: Font size. 657 factor: Tracking factor. 658 659 Returns: 660 Dictionary of formatted properties. 661 """ 662 lead = lambda fs: fs * fontProps.get("leading", 1) 663 track = lambda fs: (fs / factor) * fontProps.get("tracking", 0) 664 textAlign = fontProps.get("align", "left") 665 tabs = fontProps.get("tabs") 666 667 props = { 668 "fontSize": fontSize, 669 "lineHeight": lead(fontSize), 670 "tracking": track(fontSize), 671 "align": textAlign, 672 "tabs": tabs, 673 } 674 675 rgbFill = fontProps.get("fill") 676 cmykFill = fontProps.get("cmykFill") 677 678 if rgbFill: 679 props["fill"] = rgbFill 680 elif cmykFill: 681 props["cmykFill"] = cmykFill 682 683 return props 684 685 def _canFitString(fontSize: int) -> bool: 686 """ 687 Check if a FormattedString of a given font size fits in the container. 688 689 Args: 690 fontSize: Font size. 691 692 Returns: 693 True if it fits, False otherwise. 694 """ 695 dummy = _createFS(_getFormattedProps(fontSize)) 696 dummySize = drawBot.textSize(dummy, width=containerW if _isFluid else None) 697 return layout.canFitInside(containerSize, dummySize) 698 699 contents = helpers.coerceList(contents, strict=True) 700 701 # Resolve wrapping behaviour from whitespace mode 702 if whitespace == "wrap": 703 _isFluid = True 704 elif whitespace == "auto": 705 _isFluid = _judgeText(" ".join(t for t, _ in contents)) 706 else: # "nowrap" 707 _isFluid = False 708 containerSize = containerW, _ = layout.toDimensions(container) 709 low, high = 0, min(containerSize) 710 best = -1 711 triesNo, triesMax = 0, 100 712 713 while low <= high and triesNo < triesMax: 714 triesNo += 1 715 mid = (low + high) // 2 716 717 if _canFitString(fontSize=mid): 718 best = mid 719 low = mid + 1 720 else: 721 high = mid - 1 722 723 if triesNo == triesMax: 724 logger.debug(f"{triesMax} tries exceeded") 725 726 if debug: 727 logger.debug(f"Found after {triesNo} attempts:", best) 728 729 if maxSize: 730 best = min(best, maxSize) 731 732 return FitFormattedResult(_createFS(_getFormattedProps(best)), best) 733 734 735def scaleFS( 736 template: drawBot.FormattedString, 737 refSize: int, 738 targetSize: int, 739) -> drawBot.FormattedString: 740 """ 741 Return a scaled copy of a FormattedString by adjusting all font size, kern, 742 and line-height attributes proportionally via AppKit, without rebuilding the 743 string from source content. 744 745 Args: 746 template: The source FormattedString built at `refSize`. 747 refSize: The font size the template was built at. 748 targetSize: The desired font size for the returned copy. 749 750 Returns: 751 A new FormattedString with all per-run sizes scaled by `targetSize / refSize`. 752 """ 753 from AppKit import ( 754 NSFont, 755 NSFontAttributeName, 756 NSFontManager, 757 NSKernAttributeName, 758 NSParagraphStyleAttributeName, 759 ) 760 761 if refSize == targetSize: 762 return template.copy() 763 764 factor = targetSize / refSize 765 copy = template.copy() 766 ns = copy.getNSObject() # NSMutableAttributedString (DrawBot internal) 767 768 length = ns.length() 769 if length == 0: 770 return copy 771 772 loc = 0 773 while loc < length: 774 attrs, rng = ns.attributesAtIndex_effectiveRange_(loc, None) 775 776 # Scale font point size 777 font = attrs.get(NSFontAttributeName) 778 if font is not None: 779 scaled_size = font.pointSize() * factor 780 scaled_font = NSFont.fontWithName_size_(font.fontName(), scaled_size) 781 782 if scaled_font is None: 783 descriptor = font.fontDescriptor() 784 if descriptor is not None: 785 scaled_descriptor = descriptor.fontDescriptorWithSize_(scaled_size) 786 scaled_font = NSFont.fontWithDescriptor_size_( 787 scaled_descriptor, scaled_size 788 ) 789 790 if scaled_font is None: 791 scaled_font = NSFontManager.sharedFontManager().convertFont_toSize_( 792 font, scaled_size 793 ) 794 795 if scaled_font is not None: 796 ns.addAttribute_value_range_(NSFontAttributeName, scaled_font, rng) 797 798 # Scale kern (absolute tracking in points) 799 kern = attrs.get(NSKernAttributeName) 800 if kern is not None: 801 ns.addAttribute_value_range_(NSKernAttributeName, kern * factor, rng) 802 803 # Scale explicit line height in paragraph style 804 para = attrs.get(NSParagraphStyleAttributeName) 805 if para is not None: 806 mutable_para = para.mutableCopy() 807 max_h = mutable_para.maximumLineHeight() 808 min_h = mutable_para.minimumLineHeight() 809 if max_h != 0: 810 mutable_para.setMaximumLineHeight_(max_h * factor) 811 if min_h != 0: 812 mutable_para.setMinimumLineHeight_(min_h * factor) 813 ns.addAttribute_value_range_( 814 NSParagraphStyleAttributeName, mutable_para, rng 815 ) 816 817 loc = rng.location + rng.length 818 819 return copy 820 821 822def fitBinaryFSTemplate( 823 template: drawBot.FormattedString, 824 container: tuple, 825 refSize: int | float = 100, 826 maxSize: int = None, 827 whitespace: str = "nowrap", 828 debug: bool = False, 829) -> FitFormattedResult: 830 """ 831 Find the maximum font size for a FormattedString that fits in the container, 832 using a pre-built template FS and `scaleFS` to avoid rebuilding the string on 833 every binary-search iteration. 834 835 Args: 836 template: A FormattedString already built at `refSize`. All font paths, 837 text content, OpenType features and text alignment are taken from it. 838 container: The container dimensions as `(w, h)` or `(x, y, w, h)`. 839 refSize: Font size at which the template was constructed. 840 maxSize: Clamp maximum font size. 841 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 842 debug: If True, print debug information. 843 844 Returns: 845 FitFormattedResult where the FS is scaled to `fontSize`. 846 """ 847 848 # Resolve wrapping behaviour from whitespace mode 849 if whitespace == "wrap": 850 _isFluid = True 851 elif whitespace == "auto": 852 _isFluid = _judgeText(str(template)) 853 else: # "nowrap" 854 _isFluid = False 855 856 def _canFit(fontSize: int) -> bool: 857 trial = scaleFS(template, refSize, fontSize) 858 measureWidth = containerW if _isFluid else None 859 trial_size = drawBot.textSize(trial, width=measureWidth) 860 return layout.canFitInside(containerSize, trial_size) 861 862 containerSize = containerW, _ = layout.toDimensions(container) 863 low, high = 0, min(containerSize) 864 best = -1 865 triesNo, triesMax = 0, 100 866 867 while low <= high and triesNo < triesMax: 868 triesNo += 1 869 mid = (low + high) // 2 870 871 if _canFit(mid): 872 best = mid 873 low = mid + 1 874 else: 875 high = mid - 1 876 877 if triesNo == triesMax: 878 logger.debug(f"{triesMax} tries exceeded") 879 880 if debug: 881 logger.debug(f"Found after {triesNo} attempts: {best}") 882 883 if maxSize: 884 best = min(best, maxSize) 885 886 return FitFormattedResult(scaleFS(template, refSize, best), best) 887 888 889@contextmanager 890def resetFont( 891 font: str = UI_FONT, fontSize: int = 10, leading: float = 1, tracking: int = 0 892): 893 """Context manager to reset the font settings in DrawBot.""" 894 with drawBot.savedState(): 895 drawBot.font(font, fontSize) 896 drawBot.lineHeight(fontSize * leading) 897 drawBot.tracking(tracking) 898 yield
Font style (slope) alias.
Font type alias.
Text alignment alias.
OpenType font feature code alias.
59class ParsedFontName(NamedTuple): 60 """Structured font name result for `parseNameObject(..., separate=True)`.""" 61 62 vendorName: str | None 63 fullName: str 64 familyName: str 65 shortName: str 66 styleNumber: str | None 67 styleName: str | None
Structured font name result for parseNameObject(..., separate=True).
70def getFeatureIdList() -> list[FeatureId]: 71 "Get list of all FeatureId values at runtime (used for sorting features)." 72 return list(get_args(FeatureId))
Get list of all FeatureId values at runtime (used for sorting features).
75def getFontName(fontPath: str) -> str: 76 """ 77 Get the font name without extension. 78 79 Args: 80 fontPath: Path to the font file. 81 82 Example: 83 `/folder/fileName-styleName.otf` => `fileName-styleName` 84 """ 85 fullName = Path(fontPath).stem 86 return fullName
Get the font name without extension.
Arguments:
- fontPath: Path to the font file.
Example:
/folder/fileName-styleName.otf=>fileName-styleName
89def getFontVersion(fontPath: str) -> str: 90 """Get the font version from the 'head' table of the font file.""" 91 from fontTools.ttLib import TTFont 92 93 tt = TTFont(fontPath) 94 # Get version from head table (Internal table version) 95 head_version = tt["head"].fontRevision 96 # Round to 2 decimal places for cleaner output 97 return str(round(head_version, 2))
Get the font version from the 'head' table of the font file.
100def getFontsFromFolder(fontFolder: str) -> list[str]: 101 """ 102 Get all font file paths from a folder. 103 104 Args: 105 fontFolder: Path to the folder containing font files. 106 107 Returns: 108 List of font file paths with .otf or .ttf extensions. 109 """ 110 fontPaths = [] 111 for root, dirs, fontFiles in os.walk(fontFolder): 112 fontFiles.sort() 113 for fileName in fontFiles: 114 basePath, ext = os.path.splitext(fileName) 115 if ext in [".otf", ".ttf"]: 116 fontPaths.append(os.path.join(root, fileName)) 117 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.
120def getFonts(source: str) -> list[str]: 121 """ 122 Get font file paths from a source, which can be a directory, file, or list of files. 123 124 Args: 125 source: Directory path, file path, or list of file paths. 126 127 Returns: 128 List of valid font file paths. 129 """ 130 isMultiple = type(source) is list 131 isSingle = isinstance(source, str) 132 133 if isSingle: 134 if files.isDirectory(source): 135 return getFontsFromFolder(source) 136 elif files.isFile(source): 137 return [source] 138 else: 139 logger.warning("[Fonts Not Found] {}", source) 140 elif isMultiple: 141 # Check if files exist 142 def check(file): 143 if files.isFile(file): 144 return file 145 else: 146 print("File not found: ", file) 147 148 mapped = [check(file) for file in source] 149 # Omit None 150 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.
163def parseNameObject(fontString: str, separate: bool = False) -> str | ParsedFontName: 164 """ 165 Parse a font string into its components. 166 167 Args: 168 fontString: The font string or path. 169 separate: If True, returns a `ParsedFontName`. If False, returns the full name. 170 171 Returns: 172 - If `separate` is True, returns a `ParsedFontName` with attributes: 173 - `fullName`: e.g. `Stabil Grotesk 400 Regular` 174 - `familyName`: e.g. `Stabil Grotesk` 175 - `shortName`: e.g. `400 Regular` 176 - `styleNumber`: e.g. `400` 177 - `styleName`: e.g. `Regular` 178 - If `separate` is False, returns the `fullName` string. 179 """ 180 fontName = getFontName(fontString) 181 vendorName, familyName = separateVendorName(fontName) 182 familyName, *suffix = familyName.split("-") 183 184 # StabilGrotesk => Stabil Grotesk 185 familyName = content.toPascalCase(familyName).strip() 186 187 # ['55RegularItalic'] => '50RegularItalic' 188 suffix = "".join(suffix).strip() 189 hasStyleName = re.search(r"\D+", suffix) 190 hasStyleNumber = re.search(r"\d+", suffix) 191 192 # RegularItalic => Regular Italic 193 styleName = content.toPascalCase(hasStyleName.group()) if hasStyleName else None 194 # 55/None 195 styleNumber = hasStyleNumber.group() if hasStyleNumber else None 196 # [None, 'Regular Italic] => ['Regular Italic'] 197 omitBlank = [part for part in [styleNumber, styleName] if part] 198 fullName = (" ").join([familyName, *omitBlank]) 199 shortName = (" ").join(omitBlank) 200 201 if separate: 202 return ParsedFontName( 203 vendorName=vendorName, 204 fullName=fullName, 205 familyName=familyName, 206 shortName=shortName, 207 styleNumber=styleNumber, 208 styleName=styleName, 209 ) 210 else: 211 return fullName
Parse a font string into its components.
Arguments:
- fontString: The font string or path.
- separate: If True, returns a
ParsedFontName. If False, returns the full name.
Returns:
- If
separateis True, returns aParsedFontNamewith attributes: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.
214def parseVendorName(string: str) -> str | None: 215 """Parse the vendor name from a font string using regex patterns. Returns the vendor name or None if not found.""" 216 proprietaryPrefix = re.match(r"^\(?(KOMETA|K)\)?", string) 217 if proprietaryPrefix: 218 return proprietaryPrefix.group(1) 219 # Match any number of uppercase letters not followed by uppercase + lowercase 220 # example: KOMETAStabilGrotesk => KOMETA, HALTimezone-LightItalic => HAL, K-Uniforma => K 221 uppercasePrefix = re.match(r"^[A-Z]+(?=[A-Z][a-z])", string) 222 if uppercasePrefix: 223 return uppercasePrefix.group(0) 224 # Try splitting by common separators, assume vendor name is the first part if there are more than 2 parts 225 parts = re.split(r"[-_ ]+", string) 226 if parts[0].casefold() in ["hw", "lineto"]: 227 return parts[0] 228 if len(parts) >= 3 and (parts[0].isalpha() and parts[0].isupper()): 229 return parts[0]
Parse the vendor name from a font string using regex patterns. Returns the vendor name or None if not found.
232def separateVendorName(string: str) -> tuple[str | None, str]: 233 """Separate the vendor name from the rest of the font name. Return both as a tuple.""" 234 vendorName = parseVendorName(string) 235 rest = ( 236 re.sub(rf"^\(?{re.escape(vendorName)}\)?[-_ ]*", "", string, count=1) 237 if vendorName 238 else string 239 ) 240 return vendorName, rest
Separate the vendor name from the rest of the font name. Return both as a tuple.
243def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"): 244 """ 245 Returns human readable font properties. 246 247 Args: 248 fontSize: Font size as int or tuple for range 249 - `(int) 72` or as range `(tuple) 72, 12` 250 leading: Line height multiplier or value 251 - lineHeight `int` or leading `float` 252 tracking: Tracking value. 253 separator: Separator string. 254 """ 255 if isinstance(fontSize, tuple): 256 # Skip showing lineHeight for range of fontSizes 257 fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize))) 258 else: 259 # Assume it’s leading if ≤ 2 260 lineHeight = fontSize * leading if leading <= 2 else leading 261 fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}" 262 263 if tracking: 264 return f"{fsAndLh}{separator}{round(tracking, 1)}" 265 else: 266 # Round both 267 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.
270def getStyleNumber(fontString: str, format: Literal["str", "int"] = "str") -> str | int: 271 """ 272 Get the style number from a font string. 273 274 Args: 275 fontString: The font string or style number. 276 format: Output format, string or integer. 277 """ 278 isStyleNumber = fontString.isnumeric() 279 280 styleNumber = ( 281 fontString 282 if isStyleNumber 283 else parseNameObject(fontString, separate=True).styleNumber 284 ) 285 286 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.
289def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None: 290 """ 291 Find the font in fontList with the closest style number to fontNumber. 292 293 Args: 294 fontList: List of font strings. 295 fontNumber: Target style number. 296 297 Returns: 298 The font string with the closest style number, or `None` if not found. 299 """ 300 try: 301 # Get numbers, None if not found 302 numbers = [getStyleNumber(font, format="int") for font in fontList] 303 304 # Compact array 305 numbers = [number for number in numbers if isinstance(number, int)] 306 307 if len(numbers): 308 closest = helpers.findClosestValue(numbers, fontNumber) 309 closestIndex = numbers.index(closest) 310 return fontList[closestIndex] 311 except Exception as e: 312 logger.error(f"Error finding font by number: {e}") 313 return None
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.
355def makeSlopeUpright(fontNumber: int) -> int: 356 """Convert font number to upright style (even last digit). 357 358 Examples: 359 10 → 10 (already upright) 360 15 → 10 (italic to upright) 361 5 → 50 (single digit) 362 363 Args: 364 fontNumber: Font style number (1-99). 365 366 Returns: 367 Upright font number. 368 """ 369 return _makeSlope(fontNumber, "upright")
Convert font number to upright style (even last digit).
Examples:
10 → 10 (already upright) 15 → 10 (italic to upright) 5 → 50 (single digit)
Arguments:
- fontNumber: Font style number (1-99).
Returns:
Upright font number.
372def makeSlopeItalic(fontNumber: int) -> int: 373 """Convert font number to italic style (odd last digit). 374 375 Examples: 376 10 → 15 (upright to italic) 377 15 → 15 (already italic) 378 5 → 55 (single digit) 379 380 Args: 381 fontNumber: Font style number (1-99). 382 383 Returns: 384 Italic font number. 385 """ 386 return _makeSlope(fontNumber, "italic")
Convert font number to italic style (odd last digit).
Examples:
10 → 15 (upright to italic) 15 → 15 (already italic) 5 → 55 (single digit)
Arguments:
- fontNumber: Font style number (1-99).
Returns:
Italic font number.
389def isFontType(fontPath: str, criterion: FontType) -> bool: 390 """ 391 Check if a font matches a given FontType criterion. 392 393 Args: 394 fontPath: Path to the font file. 395 criterion: FontType criterion. 396 397 Returns: 398 True if the font matches the criterion, False otherwise. 399 """ 400 # Special case 401 if criterion == "any": 402 return True 403 404 styleNumber = getStyleNumber(fontPath) 405 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.
408def isDisplay(fontString: str) -> bool: 409 """Returns True if the font is of type `display`.""" 410 return isFontType(fontString, "display")
Returns True if the font is of type display.
413def getFontType(fontString: str) -> list[FontType]: 414 """ 415 Get the list of `FontType` that match the font's style number. See source for regex patterns. 416 417 Args: 418 fontString: The font string or path. 419 420 Returns: 421 List of matching FontTypes. 422 """ 423 424 def _isMatch(pattern: str) -> bool: 425 return bool(re.compile(rf"^{pattern}").search(styleNumber)) 426 427 fontTypes = { 428 "upright": r"\d0", 429 "italic": r"\d5", 430 "display": "[^4-6]", 431 "text": "[4-6]", 432 "thin": "[1-3]", 433 "thick": "[7-9]", 434 } 435 436 styleNumber = getStyleNumber(fontString) 437 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.
440def filterFonts( 441 fonts: list[str], 442 criteria: FontType | list[FontType] = "upright", 443 strategy: helpers.Strategy = "pick", 444) -> list[str]: 445 """ 446 Filter fonts by given criteria and strategy. 447 448 Args: 449 fonts: List of font strings. 450 criteria: `FontType` or list of FontTypes to filter by. 451 strategy: Filtering strategy, "pick" or "omit". 452 453 Returns: 454 List of filtered font strings. 455 """ 456 457 def _evaluate(predicate: bool): 458 "Inverse logic for `omit` strategy" 459 return predicate if strategy == "pick" else not predicate 460 461 def _fontsForCriterion(criterion: FontType) -> list[str]: 462 return [font for font in fonts if _evaluate(isFontType(font, criterion))] 463 464 criteria = helpers.coerceList(criteria) 465 # Groups of matches 466 allMatches = [_fontsForCriterion(criterion) for criterion in criteria] 467 # Intersect matches and sort them by number 468 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.
471class FitBinaryResult(NamedTuple): 472 fontSize: int 473 lineHeight: float 474 tracking: float 475 textWidth: float 476 textHeight: float
FitBinaryResult(fontSize, lineHeight, tracking, textWidth, textHeight)
495def fitBinary( 496 text: str, 497 container: layout.Dimensions | layout.Coordinates, 498 leading: float = 1, 499 letterSpacing: int = 0, 500 whitespace: Literal["nowrap", "wrap", "auto"] = "nowrap", 501 saveState: bool = True, 502 debug: bool = False, 503 precision: int = 0, 504) -> FitBinaryResult: 505 """ 506 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`. 507 508 Args: 509 text: The text string to fit. 510 container: The container as a tuple of `(width, height)` or `(x, y, width, height)`. 511 leading: Line height multiplier (default: 1). 512 letterSpacing: Letter spacing value (default: 0). 513 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 514 saveState: If True, preserves the current DrawBot drawing state. 515 debug: If True, outputs debug information. 516 precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths). 517 518 Returns: 519 FittedBinaryResult containing: 520 - fontSize: The maximum font size that fits. 521 - lineHeight: The calculated line height for this font size. 522 - tracking: The calculated tracking for this font size. 523 - textWidth: The width of the rendered text at this size. 524 - textHeight: The height of the rendered text at this size. 525 """ 526 527 # Resolve wrapping behaviour from whitespace mode 528 if whitespace == "wrap": 529 _isFluid = True 530 elif whitespace == "auto": 531 _isFluid = _judgeText(text) 532 else: # "nowrap" 533 _isFluid = False 534 535 def _getDimensions(size): 536 def modifyState(): 537 drawBot.fontSize(size) 538 drawBot.lineHeight(lead(size)) 539 drawBot.tracking(track(size)) 540 textW, textH = drawBot.textSize( 541 text, width=containerW if _isFluid else None 542 ) 543 544 # Edge case: font metrics may include leading, account for that in height 545 NSFontLeading = drawBot.fontLeading() 546 if NSFontLeading: 547 lineCount = ceil(textH / drawBot.fontLineHeight()) 548 leadingCount = lineCount - 1 549 leadingExpansion = leadingCount * ceil(NSFontLeading) 550 textH += leadingExpansion 551 552 # Compensate for tracking 553 return textW - track(size), textH 554 555 # Used for preparatory calculations 556 if saveState: 557 # Important! savedState() triggers newPage() for blank drawing 558 with drawBot.savedState(): 559 return modifyState() 560 else: 561 return modifyState() 562 563 def _canFit(fs): 564 textW, textH = _getDimensions(fs) 565 return textW <= containerW and textH <= containerH 566 567 lead = lambda fs: fs * leading 568 track = lambda fs: letterSpacing * (fs / 1000) 569 570 containerW, containerH = layout.toDimensions(container) 571 572 # Adapt precision based on parameter (default to integer search) 573 factor = 10**precision 574 575 # Convert to integer space for binary search 576 low, high = 0, int(max(containerW, containerH) * factor) 577 maxFs = -1 578 triesNo, triesMax = 0, 100 + (precision * 10) # More precision needs more tries 579 580 while low <= high and triesNo < triesMax: 581 triesNo += 1 582 fs_int = (low + high) // 2 583 fs = fs_int / factor # Convert back to float for testing 584 585 if _canFit(fs): 586 maxFs = fs 587 low = fs_int + 1 588 else: 589 high = fs_int - 1 590 591 if triesNo == triesMax: 592 logger.warning(f"{triesMax} tries exceeded") 593 594 if debug: 595 logger.trace(f"Found after {triesNo} attempts: {maxFs}") 596 597 # Round to desired precision 598 maxFs = round(maxFs, precision) if precision > 0 else int(maxFs) 599 600 return FitBinaryResult( 601 maxFs, 602 lead(maxFs), 603 track(maxFs), 604 _getDimensions(maxFs)[0], 605 _getDimensions(maxFs)[1], 606 )
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).
- whitespace: Text wrapping mode.
"nowrap"fits on a single line (default),"wrap"always allows wrapping,"auto"decides based on text length. - 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:
FittedBinaryResult 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. - textWidth: The width of the rendered text at this size. - textHeight: The height of the rendered text at this size.
FitFormattedResult(fitString, fontSize)
614def fitBinaryFS( 615 contents: list, 616 container: tuple, 617 fontProps: dict, 618 maxSize: int = None, 619 whitespace: str = "nowrap", 620 debug=False, 621) -> FitFormattedResult: 622 """ 623 Find the maximum font size for a FormattedString that fits in the container. 624 625 Args: 626 contents: List of (text, fontPath) tuples. 627 container: The container dimensions. 628 fontProps: Dictionary of font properties. 629 maxSize: Clamp maximum font size. 630 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 631 debug: If True, print debug information. 632 633 Returns: 634 FitFormattedResult where the FS is scaled to `fontSize`. 635 """ 636 637 def _createFS(formattedProps: dict) -> drawBot.FormattedString: 638 """ 639 Create a FormattedString with the given properties. 640 641 Args: 642 formattedProps: Dictionary of formatting properties. 643 644 Returns: 645 FormattedString object. 646 """ 647 dummy = drawBot.FormattedString(**formattedProps) 648 for [text, font] in contents: 649 dummy.append(text, font=font) 650 return dummy 651 652 def _getFormattedProps(fontSize: int, factor=10): 653 """ 654 Get formatted properties for a given font size. 655 656 Args: 657 fontSize: Font size. 658 factor: Tracking factor. 659 660 Returns: 661 Dictionary of formatted properties. 662 """ 663 lead = lambda fs: fs * fontProps.get("leading", 1) 664 track = lambda fs: (fs / factor) * fontProps.get("tracking", 0) 665 textAlign = fontProps.get("align", "left") 666 tabs = fontProps.get("tabs") 667 668 props = { 669 "fontSize": fontSize, 670 "lineHeight": lead(fontSize), 671 "tracking": track(fontSize), 672 "align": textAlign, 673 "tabs": tabs, 674 } 675 676 rgbFill = fontProps.get("fill") 677 cmykFill = fontProps.get("cmykFill") 678 679 if rgbFill: 680 props["fill"] = rgbFill 681 elif cmykFill: 682 props["cmykFill"] = cmykFill 683 684 return props 685 686 def _canFitString(fontSize: int) -> bool: 687 """ 688 Check if a FormattedString of a given font size fits in the container. 689 690 Args: 691 fontSize: Font size. 692 693 Returns: 694 True if it fits, False otherwise. 695 """ 696 dummy = _createFS(_getFormattedProps(fontSize)) 697 dummySize = drawBot.textSize(dummy, width=containerW if _isFluid else None) 698 return layout.canFitInside(containerSize, dummySize) 699 700 contents = helpers.coerceList(contents, strict=True) 701 702 # Resolve wrapping behaviour from whitespace mode 703 if whitespace == "wrap": 704 _isFluid = True 705 elif whitespace == "auto": 706 _isFluid = _judgeText(" ".join(t for t, _ in contents)) 707 else: # "nowrap" 708 _isFluid = False 709 containerSize = containerW, _ = layout.toDimensions(container) 710 low, high = 0, min(containerSize) 711 best = -1 712 triesNo, triesMax = 0, 100 713 714 while low <= high and triesNo < triesMax: 715 triesNo += 1 716 mid = (low + high) // 2 717 718 if _canFitString(fontSize=mid): 719 best = mid 720 low = mid + 1 721 else: 722 high = mid - 1 723 724 if triesNo == triesMax: 725 logger.debug(f"{triesMax} tries exceeded") 726 727 if debug: 728 logger.debug(f"Found after {triesNo} attempts:", best) 729 730 if maxSize: 731 best = min(best, maxSize) 732 733 return FitFormattedResult(_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.
- whitespace: Text wrapping mode.
"nowrap"fits on a single line (default),"wrap"always allows wrapping,"auto"decides based on text length. - debug: If True, print debug information.
Returns:
FitFormattedResult where the FS is scaled to
fontSize.
736def scaleFS( 737 template: drawBot.FormattedString, 738 refSize: int, 739 targetSize: int, 740) -> drawBot.FormattedString: 741 """ 742 Return a scaled copy of a FormattedString by adjusting all font size, kern, 743 and line-height attributes proportionally via AppKit, without rebuilding the 744 string from source content. 745 746 Args: 747 template: The source FormattedString built at `refSize`. 748 refSize: The font size the template was built at. 749 targetSize: The desired font size for the returned copy. 750 751 Returns: 752 A new FormattedString with all per-run sizes scaled by `targetSize / refSize`. 753 """ 754 from AppKit import ( 755 NSFont, 756 NSFontAttributeName, 757 NSFontManager, 758 NSKernAttributeName, 759 NSParagraphStyleAttributeName, 760 ) 761 762 if refSize == targetSize: 763 return template.copy() 764 765 factor = targetSize / refSize 766 copy = template.copy() 767 ns = copy.getNSObject() # NSMutableAttributedString (DrawBot internal) 768 769 length = ns.length() 770 if length == 0: 771 return copy 772 773 loc = 0 774 while loc < length: 775 attrs, rng = ns.attributesAtIndex_effectiveRange_(loc, None) 776 777 # Scale font point size 778 font = attrs.get(NSFontAttributeName) 779 if font is not None: 780 scaled_size = font.pointSize() * factor 781 scaled_font = NSFont.fontWithName_size_(font.fontName(), scaled_size) 782 783 if scaled_font is None: 784 descriptor = font.fontDescriptor() 785 if descriptor is not None: 786 scaled_descriptor = descriptor.fontDescriptorWithSize_(scaled_size) 787 scaled_font = NSFont.fontWithDescriptor_size_( 788 scaled_descriptor, scaled_size 789 ) 790 791 if scaled_font is None: 792 scaled_font = NSFontManager.sharedFontManager().convertFont_toSize_( 793 font, scaled_size 794 ) 795 796 if scaled_font is not None: 797 ns.addAttribute_value_range_(NSFontAttributeName, scaled_font, rng) 798 799 # Scale kern (absolute tracking in points) 800 kern = attrs.get(NSKernAttributeName) 801 if kern is not None: 802 ns.addAttribute_value_range_(NSKernAttributeName, kern * factor, rng) 803 804 # Scale explicit line height in paragraph style 805 para = attrs.get(NSParagraphStyleAttributeName) 806 if para is not None: 807 mutable_para = para.mutableCopy() 808 max_h = mutable_para.maximumLineHeight() 809 min_h = mutable_para.minimumLineHeight() 810 if max_h != 0: 811 mutable_para.setMaximumLineHeight_(max_h * factor) 812 if min_h != 0: 813 mutable_para.setMinimumLineHeight_(min_h * factor) 814 ns.addAttribute_value_range_( 815 NSParagraphStyleAttributeName, mutable_para, rng 816 ) 817 818 loc = rng.location + rng.length 819 820 return copy
Return a scaled copy of a FormattedString by adjusting all font size, kern, and line-height attributes proportionally via AppKit, without rebuilding the string from source content.
Arguments:
- template: The source FormattedString built at
refSize. - refSize: The font size the template was built at.
- targetSize: The desired font size for the returned copy.
Returns:
A new FormattedString with all per-run sizes scaled by
targetSize / refSize.
823def fitBinaryFSTemplate( 824 template: drawBot.FormattedString, 825 container: tuple, 826 refSize: int | float = 100, 827 maxSize: int = None, 828 whitespace: str = "nowrap", 829 debug: bool = False, 830) -> FitFormattedResult: 831 """ 832 Find the maximum font size for a FormattedString that fits in the container, 833 using a pre-built template FS and `scaleFS` to avoid rebuilding the string on 834 every binary-search iteration. 835 836 Args: 837 template: A FormattedString already built at `refSize`. All font paths, 838 text content, OpenType features and text alignment are taken from it. 839 container: The container dimensions as `(w, h)` or `(x, y, w, h)`. 840 refSize: Font size at which the template was constructed. 841 maxSize: Clamp maximum font size. 842 whitespace: Text wrapping mode. ``"nowrap"`` fits on a single line (default), ``"wrap"`` always allows wrapping, ``"auto"`` decides based on text length. 843 debug: If True, print debug information. 844 845 Returns: 846 FitFormattedResult where the FS is scaled to `fontSize`. 847 """ 848 849 # Resolve wrapping behaviour from whitespace mode 850 if whitespace == "wrap": 851 _isFluid = True 852 elif whitespace == "auto": 853 _isFluid = _judgeText(str(template)) 854 else: # "nowrap" 855 _isFluid = False 856 857 def _canFit(fontSize: int) -> bool: 858 trial = scaleFS(template, refSize, fontSize) 859 measureWidth = containerW if _isFluid else None 860 trial_size = drawBot.textSize(trial, width=measureWidth) 861 return layout.canFitInside(containerSize, trial_size) 862 863 containerSize = containerW, _ = layout.toDimensions(container) 864 low, high = 0, min(containerSize) 865 best = -1 866 triesNo, triesMax = 0, 100 867 868 while low <= high and triesNo < triesMax: 869 triesNo += 1 870 mid = (low + high) // 2 871 872 if _canFit(mid): 873 best = mid 874 low = mid + 1 875 else: 876 high = mid - 1 877 878 if triesNo == triesMax: 879 logger.debug(f"{triesMax} tries exceeded") 880 881 if debug: 882 logger.debug(f"Found after {triesNo} attempts: {best}") 883 884 if maxSize: 885 best = min(best, maxSize) 886 887 return FitFormattedResult(scaleFS(template, refSize, best), best)
Find the maximum font size for a FormattedString that fits in the container,
using a pre-built template FS and scaleFS to avoid rebuilding the string on
every binary-search iteration.
Arguments:
- template: A FormattedString already built at
refSize. All font paths, text content, OpenType features and text alignment are taken from it. - container: The container dimensions as
(w, h)or(x, y, w, h). - refSize: Font size at which the template was constructed.
- maxSize: Clamp maximum font size.
- whitespace: Text wrapping mode.
"nowrap"fits on a single line (default),"wrap"always allows wrapping,"auto"decides based on text length. - debug: If True, print debug information.
Returns:
FitFormattedResult where the FS is scaled to
fontSize.
890@contextmanager 891def resetFont( 892 font: str = UI_FONT, fontSize: int = 10, leading: float = 1, tracking: int = 0 893): 894 """Context manager to reset the font settings in DrawBot.""" 895 with drawBot.savedState(): 896 drawBot.font(font, fontSize) 897 drawBot.lineHeight(fontSize * leading) 898 drawBot.tracking(tracking) 899 yield
Context manager to reset the font settings in DrawBot.