lib.fonts

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

Get the font name without extension.

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

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

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

Get all font file paths from a folder.

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

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

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

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

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

List of valid font file paths.

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

Parse a font string into its components.

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

Returns:

  • If 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 parseStyle( fontSize: int | tuple[int, int], leading=1, tracking=0, separator='/'):
170def parseStyle(fontSize: int | tuple[int, int], leading=1, tracking=0, separator="/"):
171    """
172    Returns human readable font properties.
173
174    Args:
175        fontSize: Font size as int or tuple for range
176            - `(int) 72` or as range `(tuple) 72, 12`
177        leading: Line height multiplier or value
178            - lineHeight `int` or leading `float`
179        tracking: Tracking value.
180        separator: Separator string.
181    """
182    if isinstance(fontSize, tuple):
183        # Skip showing lineHeight for range of fontSizes
184        fsAndLh = "—".join(map(str, helpers.pickExtremes(fontSize)))
185    else:
186        # Assume it’s leading if ≤ 2
187        lineHeight = fontSize * leading if leading <= 2 else leading
188        fsAndLh = f"{round(fontSize)}{separator}{round(lineHeight)}"
189
190    if tracking:
191        return f"{fsAndLh}{separator}{round(tracking, 1)}"
192    else:
193        # Round both
194        return fsAndLh

Returns human readable font properties.

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

Get the style number from a font string.

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

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

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

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

def isFontType( fontPath: str, criterion: Literal['upright', 'italic', 'display', 'text', 'thin', 'thick', 'any']) -> bool:
239def isFontType(fontPath: str, criterion: FontType) -> bool:
240    """
241    Check if a font matches a given FontType criterion.
242
243    Args:
244        fontPath: Path to the font file.
245        criterion: FontType criterion.
246
247    Returns:
248        True if the font matches the criterion, False otherwise.
249    """
250    # Special case
251    if criterion == "any":
252        return True
253
254    styleNumber = getStyleNumber(fontPath)
255    return criterion in getFontType(styleNumber)

Check if a font matches a given FontType criterion.

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

True if the font matches the criterion, False otherwise.

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

Returns True if the font is of type display.

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

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

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

List of matching FontTypes.

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

Filter fonts by given criteria and strategy.

Arguments:
  • fonts: List of font strings.
  • criteria: 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):
321def groupFontsBySlope(fonts: list[str], reverseOrder=True):
322    """
323    Group fonts by slope (upright/italic).
324
325    Args:
326        fonts: List of font strings.
327        reverseOrder: If True, reverse the order of each group.
328
329    Returns:
330        Tuple of (upright_fonts, italic_fonts).
331    """
332    fontsUpright = filterFonts(fonts, "upright")
333    fontsItalic = filterFonts(fonts, "italic")
334
335    groups = (fontsUpright, fontsItalic)
336
337    if reverseOrder:
338        for group in groups:
339            group.reverse()
340
341    return groups

Group fonts by slope (upright/italic).

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

Tuple of (upright_fonts, italic_fonts).

def groupFontsByWeight(fonts: list[str], reverseOrder=True):
344def groupFontsByWeight(fonts: list[str], reverseOrder=True):
345    """
346    Group fonts by weight, pairing upright and italic fonts.
347
348    Args:
349        fonts: List of font strings.
350        reverseOrder: If True, reverse the order of each group.
351
352    Returns:
353        List of tuples pairing upright and italic fonts by weight.
354    """
355    fontsUpright, fontsItalic = groupFontsBySlope(
356        fonts=fonts, reverseOrder=reverseOrder
357    )
358
359    return list(zip(fontsUpright, fontsItalic))

Group fonts by weight, pairing upright and italic fonts.

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

List of tuples pairing upright and italic fonts by weight.

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

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

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

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

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

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

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

Tuple of (FormattedString, fontSize).

def makeTabs( container: tuple | int, n=1, mode: Literal['apply', 'kwargs'] = 'apply'):
565def makeTabs(container: tuple | int, n=1, mode: Literal["apply", "kwargs"] = "apply"):
566    """
567    Make `n` tabs based on `container` width.
568
569    Args:
570        container: Accepts `coords` tuple or `width` int.
571        n: Number of tabs.
572        mode: `apply` to set instantly via drawBot.tabs(), `kwargs` for FormattedString().
573
574    Returns:
575    - If mode is `apply`, sets tabs and returns None.
576    - If mode is `kwargs`, returns a list of tab positions and alignments.
577    """
578    width = layout.toWidth(container)
579    advanceW = width / n
580    result = []
581
582    for tab in range(1, n + 1):
583        isTabLast = tab == n
584        tabAlign = "right" if isTabLast else "center"
585        result.append((tab * advanceW, tabAlign))
586
587    if mode == "apply":
588        drawBot.tabs(*result)
589    else:
590        return result

Make n tabs based on container width.

Arguments:
  • container: Accepts coords tuple or width int.
  • n: Number of tabs.
  • mode: apply to set instantly via drawBot.tabs(), kwargs for FormattedString().

Returns:

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