lib.fonts

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

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

OpenType font feature code alias.

def getFontName(fontPath: str) -> str:
54def getFontName(fontPath: str) -> str:
55    """
56    Get the font name without extension.
57
58    Args:
59        fontPath: Path to the font file.
60
61    Example:
62        `/folder/fileName-styleName.otf` => `fileName-styleName`
63    """
64    fullName = Path(fontPath).stem
65    return fullName

Get the font name without extension.

Arguments:
  • fontPath: Path to the font file.
Example:

/folder/fileName-styleName.otf => fileName-styleName

def getFontsFromFolder(fontFolder: str) -> list[str]:
68def getFontsFromFolder(fontFolder: str) -> list[str]:
69    """
70    Get all font file paths from a folder.
71
72    Args:
73        fontFolder: Path to the folder containing font files.
74
75    Returns:
76        List of font file paths with .otf or .ttf extensions.
77    """
78    fontPaths = []
79    for root, dirs, files in os.walk(fontFolder):
80        files.sort()
81        for fileName in files:
82            basePath, ext = os.path.splitext(fileName)
83            if ext in [".otf", ".ttf"]:
84                fontPaths.append(os.path.join(root, fileName))
85    return fontPaths

Get all font file paths from a folder.

Arguments:
  • fontFolder: Path to the folder containing font files.
Returns:

List of font file paths with .otf or .ttf extensions.

def getFonts(source: str) -> list[str]:
 88def getFonts(source: str) -> list[str]:
 89    """
 90    Get font file paths from a source, which can be a directory, file, or list of files.
 91
 92    Args:
 93        source: Directory path, file path, or list of file paths.
 94
 95    Returns:
 96        List of valid font file paths.
 97    """
 98    isMultiple = type(source) is list
 99    isSingle = isinstance(source, str)
100
101    if isSingle:
102        if helpers.isDirectory(source):
103            return getFontsFromFolder(source)
104        elif helpers.isFile(source):
105            return [source]
106        else:
107            logger.warning("[Fonts Not Found] {}", source)
108    elif isMultiple:
109        # Check if files exist
110        def check(file):
111            if helpers.isFile(file):
112                return file
113            else:
114                print("File not found: ", file)
115
116        mapped = [check(file) for file in source]
117        # Omit None
118        return [file for file in mapped if file]

Get font file paths from a source, which can be a directory, file, or list of files.

Arguments:
  • source: Directory path, file path, or list of file paths.
Returns:

List of valid font file paths.

def parseNameObject(fontString: str, separate=False) -> str | dict:
121def parseNameObject(fontString: str, separate=False) -> str | dict:
122    """
123    Parse a font string into its components.
124
125    Args:
126        fontString: The font string or path.
127        separate: If True, returns a dict of components. If False, returns the full name.
128
129    Returns:
130    - If `separate` is True, returns a dict with keys:
131        - `fullName`: e.g. `Stabil Grotesk 400 Regular`
132        - `familyName`: e.g. `Stabil Grotesk`
133        - `shortName`: e.g. `400 Regular`
134        - `styleNumber`: e.g. `400`
135        - `styleName`: e.g. `Regular`
136    - If `separate` is False, returns the `fullName` string.
137    """
138    fontName = getFontName(fontString)
139    familyName, *suffix = fontName.split("-")
140
141    # KUniforma => Uniforma
142    familyName = stripVendorName(familyName)
143    # StabilGrotesk => Stabil Grotesk
144    familyName = content.toPascalCase(familyName).strip()
145
146    # ['55RegularItalic'] => '50RegularItalic'
147    suffix = "".join(suffix).strip()
148    hasStyleName = re.search(r"\D+", suffix)
149    hasStyleNumber = re.search(r"\d+", suffix)
150
151    # RegularItalic => Regular Italic
152    styleName = content.toPascalCase(hasStyleName.group()) if hasStyleName else None
153    # 55/None
154    styleNumber = hasStyleNumber.group() if hasStyleNumber else None
155    # [None, 'Regular Italic] => ['Regular Italic']
156    omitBlank = [part for part in [styleNumber, styleName] if part]
157    fullName = (" ").join([familyName, *omitBlank])
158    shortName = (" ").join(omitBlank)
159
160    if separate:
161        return {
162            "fullName": fullName,  # Stabil Grotesk 400 Regular
163            "familyName": familyName,  # Stabil Grotesk
164            "shortName": shortName,  # 400 Regular
165            "styleNumber": styleNumber,  # 400
166            "styleName": styleName,  # Regular
167        }
168    else:
169        return fullName

Parse a font string into its components.

Arguments:
  • fontString: The font string or path.
  • separate: If True, returns a dict of components. If False, returns the full name.

Returns:

  • If separate is True, returns a dict with keys:
    • 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 stripVendorName(string: str) -> str:
172def stripVendorName(string: str) -> str:
173    # Strip K or KOMETA (optionally enclosed by parentheses) and optional hyphen if followed by '1 uppercase and any lowercase': (K)-Uniforma => Uniforma
174    string = re.sub(r"^\(?(K|KOMETA)\)?-?(?=[A-Z][a-z])", "", string)
175    return string
def parseStyle( fontSize: int | tuple[int, int], leading=1, tracking=0, separator='/'):
178def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"):
179    """
180    Returns human readable font properties.
181
182    Args:
183        fontSize: Font size as int or tuple for range
184            - `(int) 72` or as range `(tuple) 72, 12`
185        leading: Line height multiplier or value
186            - lineHeight `int` or leading `float`
187        tracking: Tracking value.
188        separator: Separator string.
189    """
190    if isinstance(fontSize, tuple):
191        # Skip showing lineHeight for range of fontSizes
192        fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize)))
193    else:
194        # Assume it’s leading if ≤ 2
195        lineHeight = fontSize * leading if leading <= 2 else leading
196        fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}"
197
198    if tracking:
199        return f"{fsAndLh}{separator}{round(tracking, 1)}"
200    else:
201        # Round both
202        return fsAndLh

Returns human readable font properties.

Arguments:
  • fontSize: Font size as int or tuple for range
    • (int) 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:
205def getStyleNumber(fontString: str, format: Literal["str", "int"] = "str") -> str | int:
206    """
207    Get the style number from a font string.
208
209    Args:
210        fontString: The font string or style number.
211        format: Output format, string or integer.
212    """
213    isStyleNumber = fontString.isnumeric()
214
215    styleNumber = (
216        fontString
217        if isStyleNumber
218        else parseNameObject(fontString, separate=True)["styleNumber"]
219    )
220
221    return int(styleNumber) if format == "int" else str(styleNumber)

Get the style number from a font string.

Arguments:
  • fontString: The font string or style number.
  • format: Output format, string or integer.
def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None:
224def findFontByNumber(fontList: list[str], fontNumber: int) -> str | None:
225    """
226    Find the font in fontList with the closest style number to fontNumber.
227
228    Args:
229        fontList: List of font strings.
230        fontNumber: Target style number.
231
232    Returns:
233        The font string with the closest style number, or `None` if not found.
234    """
235    # Get numbers, None if not found
236    numbers = [getStyleNumber(font, format="int") for font in fontList]
237
238    # Compact array
239    numbers = [number for number in numbers if isinstance(number, int)]
240
241    if len(numbers):
242        closest = helpers.findClosestValue(numbers, fontNumber)
243        closestIndex = numbers.index(closest)
244        return fontList[closestIndex]

Find the font in fontList with the closest style number to fontNumber.

Arguments:
  • fontList: List of font strings.
  • fontNumber: Target style number.
Returns:

The font string with the closest style number, or None if not found.

def isFontType( fontPath: str, criterion: Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']) -> bool:
247def isFontType(fontPath: str, criterion: FontType) -> bool:
248    """
249    Check if a font matches a given FontType criterion.
250
251    Args:
252        fontPath: Path to the font file.
253        criterion: FontType criterion.
254
255    Returns:
256        True if the font matches the criterion, False otherwise.
257    """
258    # Special case
259    if criterion == "any":
260        return True
261
262    styleNumber = getStyleNumber(fontPath)
263    return criterion in getFontType(styleNumber)

Check if a font matches a given FontType criterion.

Arguments:
  • fontPath: Path to the font file.
  • criterion: FontType criterion.
Returns:

True if the font matches the criterion, False otherwise.

def isDisplay(fontString: str) -> bool:
266def isDisplay(fontString: str) -> bool:
267    """Returns True if the font is of type `display`."""
268    return isFontType(fontString, "display")

Returns True if the font is of type display.

def getFontType( fontString: str) -> list[typing.Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']]:
271def getFontType(fontString: str) -> list[FontType]:
272    """
273    Get the list of `FontType` that match the font's style number. See source for regex patterns.
274
275    Args:
276        fontString: The font string or path.
277
278    Returns:
279        List of matching FontTypes.
280    """
281
282    def _isMatch(pattern: str) -> bool:
283        return bool(re.compile(rf"^{pattern}").search(styleNumber))
284
285    fontTypes = {
286        "upright": r"\d0",
287        "italic": r"\d5",
288        "display": "[^4-6]",
289        "text": "[4-6]",
290        "thin": "[1-3]",
291        "thick": "[7-9]",
292    }
293
294    styleNumber = getStyleNumber(fontString)
295    return [fontType for [fontType, pattern] in fontTypes.items() if _isMatch(pattern)]

Get the list of FontType that match the font's style number. See source for regex patterns.

Arguments:
  • fontString: The font string or path.
Returns:

List of matching FontTypes.

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]:
298def filterFonts(
299    fonts: list[str],
300    criteria: FontType | list[FontType] = "upright",
301    strategy: helpers.Strategy = "pick",
302) -> list[str]:
303    """
304    Filter fonts by given criteria and strategy.
305
306    Args:
307        fonts: List of font strings.
308        criteria: `FontType` or list of FontTypes to filter by.
309        strategy: Filtering strategy, "pick" or "omit".
310
311    Returns:
312        List of filtered font strings.
313    """
314
315    def _evaluate(predicate: bool):
316        "Inverse logic for `omit` strategy"
317        return predicate if strategy == "pick" else not predicate
318
319    def _fontsForCriterion(criterion: FontType) -> list[str]:
320        return [font for font in fonts if _evaluate(isFontType(font, criterion))]
321
322    criteria = helpers.coerceList(criteria)
323    # Groups of matches
324    allMatches = [_fontsForCriterion(criterion) for criterion in criteria]
325    # Intersect matches and sort them by number
326    return helpers.intersect(allMatches)

Filter fonts by given criteria and strategy.

Arguments:
  • fonts: List of font strings.
  • criteria: FontType or list of FontTypes to filter by.
  • strategy: Filtering strategy, "pick" or "omit".
Returns:

List of filtered font strings.

def groupFontsBySlope(fonts: list[str], reverseOrder=True):
329def groupFontsBySlope(fonts: list[str], reverseOrder=True):
330    """
331    Group fonts by slope (upright/italic).
332
333    Args:
334        fonts: List of font strings.
335        reverseOrder: If True, reverse the order of each group.
336
337    Returns:
338        Tuple of (upright_fonts, italic_fonts).
339    """
340    fontsUpright = filterFonts(fonts, "upright")
341    fontsItalic = filterFonts(fonts, "italic")
342
343    groups = (fontsUpright, fontsItalic)
344
345    if reverseOrder:
346        for group in groups:
347            group.reverse()
348
349    return groups

Group fonts by slope (upright/italic).

Arguments:
  • fonts: List of font strings.
  • reverseOrder: If True, reverse the order of each group.
Returns:

Tuple of (upright_fonts, italic_fonts).

def groupFontsByWeight(fonts: list[str], reverseOrder=True):
352def groupFontsByWeight(fonts: list[str], reverseOrder=True):
353    """
354    Group fonts by weight, pairing upright and italic fonts.
355
356    Args:
357        fonts: List of font strings.
358        reverseOrder: If True, reverse the order of each group.
359
360    Returns:
361        List of tuples pairing upright and italic fonts by weight.
362    """
363    fontsUpright, fontsItalic = groupFontsBySlope(
364        fonts=fonts, reverseOrder=reverseOrder
365    )
366
367    return list(zip(fontsUpright, fontsItalic))

Group fonts by weight, pairing upright and italic fonts.

Arguments:
  • fonts: List of font strings.
  • reverseOrder: If True, reverse the order of each group.
Returns:

List of tuples pairing upright and italic fonts by weight.

def fitBinary( text: str, container: tuple, leading: float = 1, letterSpacing: int = 0, isFluid: bool = False, saveState: bool = True, debug: bool = False, precision: int = 0) -> tuple:
370def fitBinary(
371    text: str,
372    container: tuple,
373    leading: float = 1,
374    letterSpacing: int = 0,
375    isFluid: bool = False,
376    saveState: bool = True,
377    debug: bool = False,
378    precision: int = 0,
379) -> tuple:
380    """
381    Find the largest font size to fit text in a container, using a binary search algorithm. Calculates font size in integer space for performance by default, can be adjusted with `precision`.
382
383    Args:
384        text: The text string to fit.
385        container: The container as a tuple of `(width, height)` or `(x, y, width, height)`.
386        leading: Line height multiplier (default: 1).
387        letterSpacing: Letter spacing value (default: 0).
388        isFluid: If True, allows text wrapping to fit width; if False, fits text on a single line.
389        saveState: If True, preserves the current DrawBot drawing state.
390        debug: If True, outputs debug information.
391        precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths).
392
393    Returns:
394        A tuple containing:
395            - fontSize: The maximum font size that fits.
396            - lineHeight: The calculated line height for this font size.
397            - tracking: The calculated tracking for this font size.
398            - textW: The width of the rendered text at this size.
399            - textH: The height of the rendered text at this size.
400    """
401
402    def _getDimensions(size):
403        def modifyState():
404            drawBot.fontSize(size)
405            drawBot.lineHeight(lead(size))
406            drawBot.tracking(track(size))
407            textW, textH = drawBot.textSize(text, width=containerW if isFluid else None)
408
409            # Edge case: font metrics may include leading, account for that in height
410            NSFontLeading = drawBot.fontLeading()
411            if NSFontLeading:
412                lineCount = ceil(textH / drawBot.fontLineHeight())
413                leadingCount = lineCount - 1
414                leadingExpansion = leadingCount * ceil(NSFontLeading)
415                textH += leadingExpansion
416
417            # Compensate for tracking
418            return textW - track(size), textH
419
420        # Used for preparatory calculations
421        if saveState:
422            # Important! savedState() triggers newPage() for blank drawing
423            with drawBot.savedState():
424                return modifyState()
425        else:
426            return modifyState()
427
428    def _canFit(fs):
429        textW, textH = _getDimensions(fs)
430        return textW <= containerW and textH <= containerH
431
432    lead = lambda fs: fs * leading
433    track = lambda fs: letterSpacing * (fs / 1000)
434
435    containerW, containerH = layout.toDimensions(container)
436
437    # Adapt precision based on parameter (default to integer search)
438    factor = 10**precision
439
440    # Convert to integer space for binary search
441    low, high = 0, int(max(containerW, containerH) * factor)
442    maxFs = -1
443    triesNo, triesMax = 0, 100 + (precision * 10)  # More precision needs more tries
444
445    while low <= high and triesNo < triesMax:
446        triesNo += 1
447        fs_int = (low + high) // 2
448        fs = fs_int / factor  # Convert back to float for testing
449
450        if _canFit(fs):
451            maxFs = fs
452            low = fs_int + 1
453        else:
454            high = fs_int - 1
455
456        if triesNo == triesMax:
457            logger.warning(f"{triesMax} tries exceeded")
458
459    if debug:
460        logger.trace(f"Found after {triesNo} attempts: {maxFs}")
461
462    # Round to desired precision
463    maxFs = round(maxFs, precision) if precision > 0 else int(maxFs)
464
465    return maxFs, lead(maxFs), track(maxFs), *_getDimensions(maxFs)

Find the largest font size to fit text in a container, using a binary search algorithm. Calculates font size in integer space for performance by default, can be adjusted with precision.

Arguments:
  • text: The text string to fit.
  • container: The container as a tuple of (width, height) or (x, y, width, height).
  • leading: Line height multiplier (default: 1).
  • letterSpacing: Letter spacing value (default: 0).
  • isFluid: If True, allows text wrapping to fit width; if False, fits text on a single line.
  • saveState: If True, preserves the current DrawBot drawing state.
  • debug: If True, outputs debug information.
  • precision: Number of decimal places for font size (0 = integer, 1 = tenths, 2 = hundredths).
Returns:

A tuple containing: - fontSize: The maximum font size that fits. - lineHeight: The calculated line height for this font size. - tracking: The calculated tracking for this font size. - textW: The width of the rendered text at this size. - textH: The height of the rendered text at this size.

def fitBinaryFS( contents: list, container: tuple, fontProps: dict, maxSize: int = None, isFluid=False, debug=False) -> tuple[drawBot.context.baseContext.FormattedString, int]:
468def fitBinaryFS(
469    contents: list,
470    container: tuple,
471    fontProps: dict,
472    maxSize: int = None,
473    isFluid=False,
474    debug=False,
475) -> tuple[drawBotDrawingTools.FormattedString, int]:
476    """
477    Find the maximum font size for a FormattedString that fits in the container.
478
479    Args:
480        contents: List of (text, fontPath) tuples.
481        container: The container dimensions.
482        fontProps: Dictionary of font properties.
483        maxSize: Clamp maximum font size.
484        isFluid: If True, use fluid width.
485        debug: If True, print debug information.
486
487    Returns:
488        Tuple of (FormattedString, fontSize).
489    """
490
491    def _createFS(formattedProps: dict) -> drawBotDrawingTools.FormattedString:
492        """
493        Create a FormattedString with the given properties.
494
495        Args:
496            formattedProps: Dictionary of formatting properties.
497
498        Returns:
499            FormattedString object.
500        """
501        dummy = drawBot.FormattedString(**formattedProps)
502        for [text, font] in contents:
503            dummy.append(text, font=font)
504        return dummy
505
506    def _getFormattedProps(fontSize: int, factor=10):
507        """
508        Get formatted properties for a given font size.
509
510        Args:
511            fontSize: Font size.
512            factor: Tracking factor.
513
514        Returns:
515            Dictionary of formatted properties.
516        """
517        lead = lambda fs: fs * fontProps.get("leading", 1)
518        track = lambda fs: (fs / factor) * fontProps.get("tracking", 0)
519        textAlign = fontProps.get("align", "left")
520        tabs = fontProps.get("tabs")
521
522        props = {
523            "fontSize": fontSize,
524            "lineHeight": lead(fontSize),
525            "tracking": track(fontSize),
526            "align": textAlign,
527            "tabs": tabs,
528        }
529
530        rgbFill = fontProps.get("fill")
531        cmykFill = fontProps.get("cmykFill")
532
533        if rgbFill:
534            props["fill"] = rgbFill
535        elif cmykFill:
536            props["cmykFill"] = cmykFill
537
538        return props
539
540    def _canFitString(fontSize: int) -> bool:
541        """
542        Check if a FormattedString of a given font size fits in the container.
543
544        Args:
545            fontSize: Font size.
546
547        Returns:
548            True if it fits, False otherwise.
549        """
550        dummy = _createFS(_getFormattedProps(fontSize))
551        dummySize = drawBot.textSize(dummy, width=containerW if isFluid else None)
552        return layout.canFitInside(containerSize, dummySize)
553
554    contents = helpers.coerceList(contents, strict=True)
555    containerSize = containerW, _ = layout.toDimensions(container)
556    low, high = 0, min(containerSize)
557    best = -1
558    triesNo, triesMax = 0, 100
559
560    while low <= high and triesNo < triesMax:
561        triesNo += 1
562        mid = (low + high) // 2
563
564        if _canFitString(fontSize=mid):
565            best = mid
566            low = mid + 1
567        else:
568            high = mid - 1
569
570        if triesNo == triesMax:
571            logger.debug(f"{triesMax} tries exceeded")
572
573    if debug:
574        logger.debug(f"Found after {triesNo} attempts:", best)
575
576    if maxSize:
577        best = min(best, maxSize)
578
579    return _createFS(_getFormattedProps(best)), best

Find the maximum font size for a FormattedString that fits in the container.

Arguments:
  • contents: List of (text, fontPath) tuples.
  • container: The container dimensions.
  • fontProps: Dictionary of font properties.
  • maxSize: Clamp maximum font size.
  • isFluid: If True, use fluid width.
  • debug: If True, print debug information.
Returns:

Tuple of (FormattedString, fontSize).

def makeTabs( container: tuple | int, n=1, mode: Literal['apply', 'kwargs'] = 'apply', alignment: Literal['left', 'center', 'right'] = 'center'):
582def makeTabs(
583    container: tuple | int,
584    n=1,
585    mode: Literal["apply", "kwargs"] = "apply",
586    alignment: Literal["left", "center", "right"] = "center",
587):
588    """
589    Make `n` tabs based on `container` width.
590
591    Args:
592        container: Accepts `coords` tuple or `width` int.
593        n: Number of tabs.
594        mode: `apply` to set instantly via drawBot.tabs(), `kwargs` for FormattedString().
595        alignment: Tab alignment, `left`, `center`, or `right`. Default is `center`, except for the last tab which is always `right`.
596
597    Returns:
598    - If mode is `apply`, sets tabs and returns None.
599    - If mode is `kwargs`, returns a list of tab positions and alignments.
600    """
601    width = layout.toWidth(container)
602    advanceW = width / n
603    result = []
604
605    for tab in range(1, n + 1):
606        isTabLast = tab == n
607        tabAlign = alignment if not isTabLast else "right"
608        result.append((tab * advanceW, tabAlign))
609
610    if mode == "apply":
611        drawBot.tabs(*result)
612    else:
613        return result

Make n tabs based on container width.

Arguments:
  • container: Accepts coords tuple or width int.
  • n: Number of tabs.
  • mode: apply to set instantly via drawBot.tabs(), kwargs for FormattedString().
  • alignment: Tab alignment, left, center, or right. Default is center, except for the last tab which is always right.

Returns:

  • If mode is apply, sets tabs and returns None.
  • If mode is kwargs, returns a list of tab positions and alignments.