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
FontStyle: TypeAlias = Literal['upright', 'italic']

Font style (slope) alias.

FontType: TypeAlias = Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']

Font type alias.

TextAlign: TypeAlias = Literal['left', 'center', 'right']

Text alignment alias.

FeatureId: TypeAlias = Literal['liga', 'dlig', 'calt', 'locl', 'titl', 'case', 'pnum', 'lnum', 'onum', 'tnum', 'zero', 'subs', 'sups', 'numr', 'dnom', 'frac', 'ordn', 'kern', 'ss01', 'ss02', 'ss03', 'ss04', 'ss05', 'ss06', 'ss07', 'ss08', 'ss09', 'ss10']

OpenType font feature code alias.

class ParsedFontName(typing.NamedTuple):
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).

ParsedFontName( vendorName: str | None, fullName: str, familyName: str, shortName: str, styleNumber: str | None, styleName: str | None)

Create new instance of ParsedFontName(vendorName, fullName, familyName, shortName, styleNumber, styleName)

vendorName: str | None

Alias for field number 0

fullName: str

Alias for field number 1

familyName: str

Alias for field number 2

shortName: str

Alias for field number 3

styleNumber: str | None

Alias for field number 4

styleName: str | None

Alias for field number 5

def getFeatureIdList() -> list[typing.Literal['liga', 'dlig', 'calt', 'locl', 'titl', 'case', 'pnum', 'lnum', 'onum', 'tnum', 'zero', 'subs', 'sups', 'numr', 'dnom', 'frac', 'ordn', 'kern', 'ss01', 'ss02', 'ss03', 'ss04', 'ss05', 'ss06', 'ss07', 'ss08', 'ss09', 'ss10']]:
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).

def getFontName(fontPath: str) -> str:
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

def getFontVersion(fontPath: str) -> str:
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.

def getFontsFromFolder(fontFolder: str) -> list[str]:
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.

def getFonts(source: str) -> list[str]:
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.

def parseNameObject( fontString: str, separate: bool = False) -> str | ParsedFontName:
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 separate is True, returns a ParsedFontName with attributes:
    • fullName: e.g. Stabil Grotesk 400 Regular
    • familyName: e.g. Stabil Grotesk
    • shortName: e.g. 400 Regular
    • styleNumber: e.g. 400
    • styleName: e.g. Regular
  • If separate is False, returns the fullName string.
def parseVendorName(string: str) -> str | None:
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.

def separateVendorName(string: str) -> tuple[str | None, str]:
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.

def parseStyle( fontSize: int | tuple[int, int], leading=1, tracking=0, separator='/'):
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) 72 or as range (tuple) 72, 12
  • leading: Line height multiplier or value
    • lineHeight int or leading float
  • tracking: Tracking value.
  • separator: Separator string.
def getStyleNumber(fontString: str, format: Literal['str', 'int'] = 'str') -> str | int:
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.
def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None:
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 None if not found.

def makeSlopeUpright(fontNumber: int) -> int:
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.

def makeSlopeItalic(fontNumber: int) -> int:
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.

def isFontType( fontPath: str, criterion: Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']) -> bool:
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.

def isDisplay(fontString: str) -> bool:
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.

def getFontType( fontString: str) -> list[typing.Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']]:
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.

def filterFonts( fonts: list[str], criteria: Union[Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any'], list[Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']]] = 'upright', strategy: Literal['pick', 'omit'] = 'pick') -> list[str]:
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: FontType or list of FontTypes to filter by.
  • strategy: Filtering strategy, "pick" or "omit".
Returns:

List of filtered font strings.

class FitBinaryResult(typing.NamedTuple):
471class FitBinaryResult(NamedTuple):
472    fontSize: int
473    lineHeight: float
474    tracking: float
475    textWidth: float
476    textHeight: float

FitBinaryResult(fontSize, lineHeight, tracking, textWidth, textHeight)

FitBinaryResult( fontSize: int, lineHeight: float, tracking: float, textWidth: float, textHeight: float)

Create new instance of FitBinaryResult(fontSize, lineHeight, tracking, textWidth, textHeight)

fontSize: int

Alias for field number 0

lineHeight: float

Alias for field number 1

tracking: float

Alias for field number 2

textWidth: float

Alias for field number 3

textHeight: float

Alias for field number 4

def fitBinary( text: str, container: tuple[float, float] | tuple[float, float, float, float], leading: float = 1, letterSpacing: int = 0, whitespace: Literal['nowrap', 'wrap', 'auto'] = 'nowrap', saveState: bool = True, debug: bool = False, precision: int = 0) -> FitBinaryResult:
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.

class FitFormattedResult(typing.NamedTuple):
609class FitFormattedResult(NamedTuple):
610    fitString: drawBot.FormattedString
611    fontSize: int

FitFormattedResult(fitString, fontSize)

FitFormattedResult( fitString: drawBot.context.baseContext.FormattedString, fontSize: int)

Create new instance of FitFormattedResult(fitString, fontSize)

fitString: drawBot.context.baseContext.FormattedString

Alias for field number 0

fontSize: int

Alias for field number 1

def fitBinaryFS( contents: list, container: tuple, fontProps: dict, maxSize: int = None, whitespace: str = 'nowrap', debug=False) -> FitFormattedResult:
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.

def scaleFS( template: drawBot.context.baseContext.FormattedString, refSize: int, targetSize: int) -> drawBot.context.baseContext.FormattedString:
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.

def fitBinaryFSTemplate( template: drawBot.context.baseContext.FormattedString, container: tuple, refSize: int | float = 100, maxSize: int = None, whitespace: str = 'nowrap', debug: bool = False) -> FitFormattedResult:
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.

@contextmanager
def resetFont( font: str = '/Users/christianjansky/Library/CloudStorage/Dropbox/KOMETA-Draw/10 Specimen/engine/IBMPlexMono-Regular.ttf', fontSize: int = 10, leading: float = 1, tracking: int = 0):
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.