lib.content

  1import re
  2import drawBot
  3import random
  4from math import ceil
  5from typing import Literal, TypeAlias, Union
  6from itertools import cycle, islice, dropwhile
  7from string import capwords
  8import regex
  9from caseconverter import camelcase, kebabcase, snakecase
 10from loguru import logger
 11from icecream import ic
 12
 13from lib import helpers, layout, fonts, DEFAULT_FONT
 14
 15
 16WordShape: TypeAlias = Literal["descender", "ascender", "caps"]
 17"""Categories to filter words by their shape.
 18
 19See `classes.c32_pool.KPool.getItemByWidth` for usage.
 20"""
 21
 22TextCase: TypeAlias = Literal[
 23    "UPPER",
 24    "lower",
 25    "title",
 26    "title-force",
 27]
 28"""Defines the supported text casing styles for conversion.
 29
 30- `UPPER`: Converts all letters to uppercase: `hi ibm` → `HI IBM`
 31- `lower`: Converts all letters to lowercase: `HI IBM` → `hi ibm`
 32- `title`: Capitalizes each word, but preserves acronyms in uppercase: `hi ibm USA` → `Hi Ibm USA`
 33- `title-force`: Capitalizes each word and forces acronym recasing: `hi ibm USA` → `Hi Ibm Usa`
 34"""
 35
 36CharacterToken: TypeAlias = Literal["word", "nonword"]
 37"""Type alias for character token types.
 38
 39See `filterByTokens` for usage.
 40"""
 41
 42
 43# ? Common words that should be lowercased in title case (unless at the start/end)
 44commonWords: list[str] = [
 45    # articles
 46    "a",
 47    "an",
 48    "the",
 49    # prepositions <= 3 letters
 50    "at",
 51    "of",
 52    "on",
 53    "up",
 54    "in",
 55    "by",
 56    "to",
 57    # conjunctions
 58    "and",
 59    "as",
 60    "for",
 61    "but",
 62    "nor",
 63    "or",
 64    "so",
 65    "yet",
 66]
 67"""List of common words to be lowercased in title case (unless at start/end)."""
 68
 69
 70def toPascalCase(input: str, space: bool = True) -> str:
 71    """Convert string to PascalCase, optionally inserting spaces between words and numbers.
 72
 73    Args:
 74        input: The input string.
 75        space: If True, insert spaces between (default: True).
 76
 77    Example:
 78        `One123Four` => `One 123 Four`
 79    """
 80    # ? Not using caseconverter.pascalcase() because it alters allcaps words
 81    # Replace separators with spaces
 82    input = re.sub(r"[-_]+", " ", input)
 83    separator = " " if space else ""
 84    # A Aa, a A, A 0
 85    expressions = ["([A-Z0-9])([A-Z][a-z])", "([a-z])([A-Z])", "([A-Za-z])([0-9])"]
 86    for exp in expressions:
 87        input = re.sub(rf"{exp}", rf"\1{separator}\2", input)
 88    return input[:1].upper() + input[1:]
 89
 90
 91def toCamelCase(input: str):
 92    """Convert string to camelCase.
 93
 94    Example:
 95        `Hello World` => `helloWorld`
 96    """
 97    return camelcase(input)
 98
 99
100def toKebabCase(input: str) -> str:
101    """Convert string to kebab-case.
102
103    Example:
104        `Hello World` => `hello-world`
105    """
106    return kebabcase(input)
107
108
109def toSnakeCase(input: str) -> str:
110    """Convert string to snake_case.
111
112    Example:
113        `Hello World` => `hello_world`
114    """
115    return snakecase(input)
116
117
118def toTitleCase(input: str, retainUpper: bool = True) -> str:
119    """
120    Convert string to title case, handling special cases and acronyms.
121
122    Args:
123        input: The input string.
124        retainUpper: If True, retain uppercase acronyms: `True` USA, `False` Usa
125
126    Returns:
127        Title-cased string.
128
129    Example:
130        `sON Of The USA` => `Son of the USA`
131    """
132    specialChars: list[str] = ["-", "/"]
133
134    def _capitalizeAfterPunctuation(
135        word: str,
136    ) -> str:
137        """Capitalize first lowercase letter even when preceded by punctuation."""
138        hasUpperRun = bool(regex.search(r"\p{Lu}{2,}", word))
139        if not retainUpper and hasUpperRun:
140            word = capwords(word)
141
142        return regex.sub(
143            r"^([^\p{L}\p{N}]*)(\p{Ll})",
144            lambda match: f"{match.group(1)}{match.group(2).upper()}",
145            word,
146            count=1,
147        )
148
149    def _hasSpecialChars(word: str) -> bool:
150        """Returns True if word contains special characters."""
151        return any(char in word for char in specialChars)
152
153    def _handleSpecialChars(word: str) -> str:
154        """Apply title case to each part of a word split by special characters."""
155        for char in specialChars:
156            if char in word:
157                # ? Split by special char and apply title case to each part
158                parts = word.split(char)
159                return char.join([_capitalizeAfterPunctuation(part) for part in parts])
160
161    def _processWord(word: str, i: int) -> str:
162        """Process a single word for title casing."""
163        # ? Always lowercase common words in continuous text
164        isOnEitherSide = i == 0 or i == len(words) - 1
165        isCommon = word.casefold() in commonWords
166        if isCommon and not isOnEitherSide:
167            return word.lower()
168
169        # ? Handle special characters
170        if _hasSpecialChars(word):
171            return _handleSpecialChars(word)
172
173        # Uppercase and punctuation 2+ times
174        isCaps = regex.match(r"[\p{Lu}|\p{P}]{2,}", word)
175
176        # capwords() better .title() => retains lowercase ’s
177        return word if isCaps and retainUpper else _capitalizeAfterPunctuation(word)
178
179    words = input.split(" ")
180    words = [_processWord(word, i) for i, word in enumerate(words)]
181    return " ".join(words)
182
183
184def changeCase(
185    input: list[str] | str,
186    case: TextCase = "title",
187) -> list[str] | str:
188    """
189    Change the case of a string or list of strings.
190
191    Args:
192        input: String or list of strings to change case.
193        case: Desired case ("upper", "lower", "title", "title-force").
194
195    Returns:
196        String or list of strings with changed case.
197
198    Example:
199        `the USA`
200        - `upper` => `THE USA`
201        - `lower` => `the usa`
202        - `title`       => `The USA`
203        - `title-force` => `The Usa`
204    """
205
206    def _change(item: str):
207        if case.casefold() == "upper":
208            return item.upper()
209        elif case.casefold() == "lower":
210            return item.lower()
211        elif case.casefold() == "title":
212            return toTitleCase(item)
213        elif case.casefold() == "title-force":
214            return toTitleCase(item, False)
215        else:
216            logger.warning("Unable to change case: {}", case)
217
218    if not case:
219        return input  # Pass through unchanged
220
221    if isinstance(input, list):
222        return [_change(item) for item in input]
223    else:
224        return _change(input)
225
226
227def dedupeCase(input: list[str], case: TextCase = "title") -> list[str]:
228    """
229    Remove duplicates from a list of strings after changing their case.
230
231    Args:
232        input: List of strings to dedupe.
233        case: Case to apply before deduplication.
234    """
235    return list(set(changeCase(input, case)))
236
237
238def dedupeWords(input: str) -> str:
239    """Collapse adjacent duplicate words in a string (case-insensitive)."""
240    duplicateWordPattern = (
241        r"\b([A-Za-zÀ-ÖØ-öø-ÿ0-9]+(?:[-'][A-Za-zÀ-ÖØ-öø-ÿ0-9]+)?)\s+\1\b"
242    )
243
244    output = input
245    previous = None
246    while output != previous:
247        previous = output
248        output = re.sub(duplicateWordPattern, r"\1", output, flags=re.IGNORECASE)
249
250    return output
251
252
253def isTitleCase(input: str) -> bool:
254    """Returns True if all words in the string are title case."""
255    return all([regex.match(r"^[\p{Lu}][\p{Ll}]+$", part) for part in input.split(" ")])
256
257
258def omitMissing(
259    input: str | list[str],
260    font: str = None,
261    mode: Literal["glyphs", "words", "lines"] = "words",
262    debug=False,
263):
264    """
265    Omit missing characters from text or list of text blocks.
266
267    Args:
268        input: A single string or a list of strings to check for missing glyphs.
269        font: Font to use for checking glyphs (optional).
270        mode: Determines the omission granularity:
271            - `glyphs`: Omit only the missing characters, preserving the rest of the text.
272            - `words`: Omit entire words that contain missing glyphs.
273            - `lines`: Omit entire lines that contain missing glyphs.
274        debug: If True, log omitted units.
275
276    Returns:
277        Filtered text or list of text blocks with missing data omitted, depending on mode.
278    """
279    if font:
280        drawBot.font(font)
281    elif drawBot.fontFilePath() == DEFAULT_FONT:
282        logger.warning("Default font used to check character set.")
283
284    isInputString = isinstance(input, str)
285    match mode:
286        case "glyphs":
287            glue = ""
288        case "words":
289            glue = " "
290        case "lines":
291            glue = "\n"
292    blocks = [input] if isInputString else input
293
294    output = []
295    for block in blocks:
296        units = list(block) if mode == "glyphs" else block.split(glue)
297        filtered = [unit for unit in units if drawBot.fontContainsCharacters(unit)]
298
299        if debug:
300            # Log omitted fontName if available
301            _logMessage = lambda unit: (
302                ("[Omitted] {} for {}", unit, fonts.getFontName(font))
303                if font
304                else ("[Omitted] {}", unit)
305            )
306            [
307                logger.trace(*_logMessage(unit))
308                for unit in units
309                if not drawBot.fontContainsCharacters(unit)
310            ]
311
312        # Do not add empty list
313        if filtered:
314            output.append(glue.join(filtered))
315
316    return glue.join(output) if isInputString else output
317
318
319def splitStringToSentences(input: str) -> list[str]:
320    """
321    Split running text into a list of sentences.
322
323    Args:
324        input: The input text.
325
326    Example:
327        `I am a sentence. I am another one.` => `["I am a sentence.", "I am another one."]`
328    """
329    replacements = [
330        # Newlines with spaces
331        (r"\n", " "),
332        # Multiple spaces to single space
333        (r"\s{2,}", " "),
334    ]
335    for [before, after] in replacements:
336        input = re.sub(rf"{before}", after, input)
337
338    # Skip abbreviations: (F. Elastica), Ficus var. elastica
339    sentenceExp = r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<![A-Z]\.)(?<=\.|\?)\s(?![a-z])"
340    return re.split(sentenceExp, input)
341
342
343def rotateList(input: list) -> list:
344    """
345    Rotate a list to produce all cyclic permutations.
346
347    Args:
348        input: The input list.
349
350    Example:
351        `[A, B, C]` => `[[A, B, C], [B, C, A], [C, A, B]]`
352    """
353    output = []
354
355    for item in input:
356        cycled = cycle(input)
357        skipped = dropwhile(lambda x: x != item, cycled)
358        sliced = islice(skipped, None, len(input))
359
360        output.append(list(sliced))
361
362    return output
363
364
365def chopSequence(input: str | list[str], limit: int = None, glue=" ", split=" "):
366    """
367    Split input into meaningful parts, optionally limiting the number of words: `A B C` => `A, AB, ABC`.
368
369    Args:
370        input: String or list of strings to chop.
371        limit: Limit to `n` words.
372        glue: String to join parts.
373        split: String to split input.
374
375    Example:
376    - input: single sentence
377        - `"I was late."` => `["I", "I was", "I was late."]`
378    - input: list of sentences
379        - `["For me.", "Right?"]` => `["For me.", "For me. Right?"]`
380    - limit: 2
381        - `["I", "I was"]`
382    """
383    if split and isinstance(input, str):
384        input = input.split(split)
385
386    inputLen = len(input)
387    # Limit size if provided
388    stop = min(limit, inputLen) if limit else inputLen
389
390    return [glue.join(input[:i]) for i in range(1, stop + 1)]
391
392
393def chopList(
394    input: list[str],
395    clamp: int = None,
396    mode: Literal["separate", "connected"] = "separate",
397    shuffle=False,
398) -> list[str]:
399    """
400    Chop a list of sentences into smaller parts, optionally connecting or shuffling them.
401
402    Args:
403        input: List of sentences.
404        clamp: Limit to n words per iteration.
405        mode: "separate" to chop individually, "connected" to connect chopped sentences.
406        shuffle: If True, shuffle input before chopping.
407
408    Example:
409        `["Hello there.", "Hi you."]` =>
410        - (separate) `["Hello", "Hello there.", "Hi", "Hi you."]`
411        - (connected) `["Hello", "Hello there.", "Hello there. Hi", ...]`
412    """
413    if shuffle:
414        random.shuffle(input)
415
416    if mode == "connected":
417        input = [" ".join(item) for item in rotateList(input)]
418
419    return helpers.flatten([chopSequence(item, clamp) for item in input])
420
421
422def permutate(input: list, clamp=20, shuffle=True) -> list:
423    """
424    Permutate and chop a list of sentences into connected sequences.
425
426    Args:
427        input: List of sentences.
428        clamp: Limit to `n` words per sequence.
429        shuffle: If True, shuffle input before permutation.
430
431    Example:
432        - `["Hi Tim", "Foo bar"]` => list of
433        - `["Hi", "Hi Tim", "Hi Tim Foo", ...], ["Foo", "Foo bar", "Foo bar Hi", ...]`
434    """
435    return chopList(input, clamp, "connected", shuffle)
436
437
438def fillTextOver(container: tuple, content: list, shuffle: bool = True) -> str:
439    """
440    Returns a string that fills the container up to overflow.
441
442    - Font properties need to be already set
443
444    Args:
445        container: Tuple specifying container dimensions.
446        content: List of possible sentences/items.
447        shuffle: If True, shuffle content before filling.
448    """
449    containerW, containerH = layout.toDimensions(container)
450
451    if shuffle:
452        content = helpers.shuffleAtRandomSegment(content)
453
454    strings = []
455
456    for string in content:
457        strings.append(string)
458        stream = " ".join(strings)
459        _, textH = drawBot.textSize(stream, width=containerW)
460        if textH >= containerH:
461            break
462
463    return stream
464
465
466def getStringForWidth(pool: list[str], width: int, threshold: float = 0.995) -> str:
467    """
468    Get a string from the pool that fits within the specified width.
469
470    - Font properties need to be set already
471
472    Args:
473        pool: List of candidate strings.
474        width: Target width.
475        threshold: Minimum width threshold.
476    """
477
478    def _isWidthAppropriate(candidateWidth: int):
479        return minWidth <= candidateWidth <= maxWidth
480
481    minWidth, maxWidth = width * threshold, width
482
483    candidateWidths = []
484    match = None
485
486    for candidate in pool:
487        candidateWidth, _ = drawBot.textSize(candidate)
488        candidateWidths.append(candidateWidth)
489
490        if _isWidthAppropriate(candidateWidth):
491            match = candidate
492            break
493
494    if match:
495        return match
496    else:
497        closestWidth = helpers.findClosestValue(
498            candidateWidths, width, discardLarger=True
499        )
500        i = (
501            candidateWidths.index(closestWidth)
502            if closestWidth in candidateWidths
503            else 0
504        )
505        return pool[i]
506
507
508def _unpackTextBoundsSegment(
509    segment,
510) -> tuple[tuple[float, float, float, float] | None, str]:
511    """Normalize DrawBot text-bounds items across tuple and object variants."""
512
513    def _coerceText(value) -> str:
514        """Convert DrawBot substring payloads to plain text."""
515        if value is None:
516            return ""
517
518        if isinstance(value, str):
519            return value
520
521        rawString = getattr(value, "string", None)
522        if isinstance(rawString, str):
523            return rawString
524
525        try:
526            return str(value)
527        except Exception as e:
528            logger.warning(
529                "Unable to coerce text from segment: {}. Error: {}", value, e
530            )
531            return ""
532
533    if hasattr(segment, "bounds"):
534        text = _coerceText(getattr(segment, "formattedSubString", ""))
535        return segment.bounds, text
536
537    if isinstance(segment, tuple) and len(segment) >= 1:
538        bounds = segment[0]
539        text = ""
540
541        if len(segment) >= 3:
542            text = _coerceText(segment[2])
543        elif len(segment) >= 2:
544            text = _coerceText(segment[1])
545
546        return bounds, text
547
548    return None, ""
549
550
551def _getWrappedLineMetrics(
552    content: Union[str, drawBot.FormattedString], coords: tuple
553) -> list[tuple[float, str]]:
554    """Return wrapped line widths and their concatenated text."""
555
556    textBounds = drawBot.textBoxCharacterBounds(content, coords)
557    linesByY = dict()
558
559    for segment in textBounds:
560        bounds, text = _unpackTextBoundsSegment(segment)
561
562        if not bounds:
563            continue
564
565        _, y, w, _ = bounds
566        key = round(y, 4)
567
568        if not linesByY.get(key):
569            linesByY[key] = dict(width=0.0, parts=[])
570
571        linesByY[key]["width"] += w
572        if text:
573            linesByY[key]["parts"].append(str(text))
574
575    return [
576        (line["width"], "".join(line["parts"]).strip())
577        for line in linesByY.values()
578        if line["width"] > 0
579    ]
580
581
582def _countWords(input: str) -> int:
583    """Count words in a line, including Unicode letters and numbers."""
584
585    return len(
586        regex.findall(
587            r"\p{Letter}[\p{Letter}\p{Mark}\p{Number}'’.-]*|\p{Number}+", input
588        )
589    )
590
591
592def getStringForDimensions(
593    pool: list[str],
594    dimensions: tuple[float, float],
595    threshold: float = 0.85,
596    optimalLastLineRatio: float = 1,
597) -> str:
598    """
599    Get a string from the pool that fits within the specified width and height.
600
601    - Font properties need to be set already
602
603    Args:
604        pool: List of candidate strings.
605        dimensions: Tuple specifying target width and height.
606        threshold: Minimum fill ratio required for every wrapped body line
607            (all lines except the last). Does not affect height scoring.
608        optimalLastLineRatio: Preferred fill ratio for the final wrapped line.
609            `1` prefers the last line to be as full as possible; `0.5` treats
610            half-line endings as ideal.
611    """
612
613    width, height = dimensions
614    minBodyLineWidth, maxWidth = width * threshold, width
615    maxHeight = height
616    optimalLastLineRatio = min(max(optimalLastLineRatio, 0), 1)
617
618    def _measure(candidate: str) -> tuple[float, float]:
619        textW, textH = drawBot.textSize(candidate, width=width)
620
621        # Mirror fitBinary: account for NSFont leading expansion in multiline text.
622        NSFontLeading = drawBot.fontLeading()
623        if NSFontLeading:
624            lineHeight = drawBot.fontLineHeight()
625            if lineHeight:
626                lineCount = ceil(textH / lineHeight)
627                leadingCount = max(0, lineCount - 1)
628                textH += leadingCount * ceil(NSFontLeading)
629
630        return textW, textH
631
632    if not pool:
633        return ""
634
635    measured: list[tuple[str, float, float]] = []
636    for candidate in pool:
637        candidateW, candidateH = _measure(candidate)
638        measured.append((candidate, candidateW, candidateH))
639
640    bestFit: str | None = None
641    bestFitScore: tuple[float, ...] | None = None
642    for candidate, candidateW, candidateH in measured:
643        if candidateW <= maxWidth and candidateH <= maxHeight:
644            widthFill = candidateW / maxWidth if maxWidth else 0
645            heightFill = candidateH / maxHeight if maxHeight else 0
646            fillScore = (heightFill + widthFill) / 2
647
648            bodyLineFillSum = 0.0
649            bodyLineQuality = 0.0
650            lastLineQuality = 0.0
651            lastLineFill = 0.0
652            hasOrphanLastLine = 0
653
654            try:
655                lineMetrics = _getWrappedLineMetrics(candidate, (0, 0, width, height))
656            except Exception:
657                lineMetrics = []
658
659            if lineMetrics:
660                bodyLineMetrics = lineMetrics[:-1]
661
662                if bodyLineMetrics:
663                    if any(
664                        lineWidth < minBodyLineWidth for lineWidth, _ in bodyLineMetrics
665                    ):
666                        continue
667
668                    bodyLineFills = [
669                        (lineWidth / maxWidth if maxWidth else 0)
670                        for lineWidth, _ in bodyLineMetrics
671                    ]
672                    bodyLineFillSum = sum(bodyLineFills)
673                    bodyLineQuality = bodyLineFillSum / len(bodyLineFills)
674                else:
675                    bodyLineQuality = widthFill
676
677                lastLineWidth, lastLineText = lineMetrics[-1]
678                lastLineFill = lastLineWidth / maxWidth if maxWidth else 0
679                lastLineQuality = max(0.0, 1 - abs(lastLineFill - optimalLastLineRatio))
680
681                if len(lineMetrics) > 1 and _countWords(lastLineText) == 1:
682                    hasOrphanLastLine = 1
683            else:
684                bodyLineQuality = widthFill
685
686            orphanPenalty = hasOrphanLastLine * 0.35
687            overallScore = (
688                (bodyLineQuality * 0.45)
689                + (heightFill * 0.3)
690                + (lastLineQuality * 0.25)
691                - orphanPenalty
692            )
693            score = (
694                1 - hasOrphanLastLine,
695                bodyLineFillSum,
696                overallScore,
697                heightFill,
698                bodyLineQuality,
699                fillScore,
700                lastLineQuality,
701                lastLineFill,
702                widthFill,
703            )
704
705            if bestFitScore is None or score > bestFitScore:
706                bestFit = candidate
707                bestFitScore = score
708
709    if bestFit is None:
710        raise ValueError(
711            "No candidates in pool can fit within the specified dimensions."
712        )
713
714    return bestFit
715
716
717def filterByShape(items: list[str], shape: WordShape | list[WordShape]) -> list[str]:
718    """
719    Filter words by descender, ascender, or caps shape.
720
721    Args:
722        items: List of words.
723        shape: Shape(s) to filter by.
724
725    Returns:
726        List of words matching the shape criteria.
727
728    Example:
729        - `["hi", "hey"], "descender"` => `["hi"]`
730    """
731
732    def _isNotShaped(pattern: str, item: str):
733        return not bool(re.search(pattern, item))
734
735    def _checkShape(shape):
736        wordShape = wordShapes.get(shape)
737        return filter(lambda item: _isNotShaped(wordShape, item), items)
738
739    wordShapes = dict(caps="[A-Z0-9]", ascender="[bdfihklt]", descender="[Qgjqpy/,]")
740    shapeSubsets = [_checkShape(shape) for shape in helpers.coerceList(shape)]
741    return helpers.intersect(shapeSubsets, retainOrder=False)
742
743
744def filterByTokens(
745    items: list[str], tokens: list[CharacterToken] = ["word", "nonword"]
746) -> list[str]:
747    """
748    Filter items by Unicode character token.
749
750    Args:
751        items: List of strings to filter.
752        tokens: List of token types to filter by.
753
754    Returns:
755        List of items matching the token criteria.
756
757    Example:
758        - `word`, `nonword` => `["A4", "R&B"]`
759    """
760
761    possiblePatterns = dict(
762        word=r"\p{Letter}", nonword=r"\p{Symbol}|\p{Number}|\p{Punctuation}"
763    )
764    patterns = [possiblePatterns.get(m) for m in tokens]
765
766    def _filterSingleToken(items: list[str], pattern: str):
767        return [item for item in items if bool(regex.search(pattern, item))]
768
769    individual = [_filterSingleToken(items, p) for p in patterns]
770
771    return helpers.intersect(individual)
772
773
774def isRagPretty(
775    content: Union[str, drawBot.FormattedString], coords: tuple
776) -> tuple[bool, bool]:
777    """
778    Evaluate if a paragraph is nicely typeset.
779
780    Args:
781        content: Text content or `FormattedString`.
782        coords: Tuple specifying text box coordinates.
783
784    Returns:
785        Tuple of booleans (isGreat, isOkay).
786        - `isGreat`: All quite long, some very long
787        - `isOkay`: All quite long
788    """
789
790    try:
791        _, _, width, _ = coords
792        lineMetrics = _getWrappedLineMetrics(content, coords)
793
794        if len(lineMetrics) < 2:
795            return False, False
796
797        bodyWidths = [lineWidth for lineWidth, _ in lineMetrics[:-1]]
798        lastWidth = lineMetrics[-1][0]
799        # All lines are quite long
800        areAllGood = all([w >= width * 0.9 for w in bodyWidths])
801        # A portion of lines are very long
802        areSomeGreat = (
803            len([True for w in bodyWidths if w >= width * 0.95]) >= len(bodyWidths) / 3
804        )
805        # Last line is not longest and not an widow
806        isLastGood = max(bodyWidths) >= lastWidth >= width * 2 / 3
807
808        isOkay = areAllGood and isLastGood
809        # isGreat, isOkay
810        return (isOkay and areSomeGreat), isOkay
811    except Exception as e:
812        logger.warning("Failed isRagPretty: {}", e)
813        return False, False
WordShape: TypeAlias = Literal['descender', 'ascender', 'caps']

Categories to filter words by their shape.

See classes.c32_pool.KPool.getItemByWidth for usage.

TextCase: TypeAlias = Literal['UPPER', 'lower', 'title', 'title-force']

Defines the supported text casing styles for conversion.

  • UPPER: Converts all letters to uppercase: hi ibmHI IBM
  • lower: Converts all letters to lowercase: HI IBMhi ibm
  • title: Capitalizes each word, but preserves acronyms in uppercase: hi ibm USAHi Ibm USA
  • title-force: Capitalizes each word and forces acronym recasing: hi ibm USAHi Ibm Usa
CharacterToken: TypeAlias = Literal['word', 'nonword']

Type alias for character token types.

See filterByTokens for usage.

commonWords: list[str] = ['a', 'an', 'the', 'at', 'of', 'on', 'up', 'in', 'by', 'to', 'and', 'as', 'for', 'but', 'nor', 'or', 'so', 'yet']

List of common words to be lowercased in title case (unless at start/end).

def toPascalCase(input: str, space: bool = True) -> str:
71def toPascalCase(input: str, space: bool = True) -> str:
72    """Convert string to PascalCase, optionally inserting spaces between words and numbers.
73
74    Args:
75        input: The input string.
76        space: If True, insert spaces between (default: True).
77
78    Example:
79        `One123Four` => `One 123 Four`
80    """
81    # ? Not using caseconverter.pascalcase() because it alters allcaps words
82    # Replace separators with spaces
83    input = re.sub(r"[-_]+", " ", input)
84    separator = " " if space else ""
85    # A Aa, a A, A 0
86    expressions = ["([A-Z0-9])([A-Z][a-z])", "([a-z])([A-Z])", "([A-Za-z])([0-9])"]
87    for exp in expressions:
88        input = re.sub(rf"{exp}", rf"\1{separator}\2", input)
89    return input[:1].upper() + input[1:]

Convert string to PascalCase, optionally inserting spaces between words and numbers.

Arguments:
  • input: The input string.
  • space: If True, insert spaces between (default: True).
Example:

One123Four => One 123 Four

def toCamelCase(input: str):
92def toCamelCase(input: str):
93    """Convert string to camelCase.
94
95    Example:
96        `Hello World` => `helloWorld`
97    """
98    return camelcase(input)

Convert string to camelCase.

Example:

Hello World => helloWorld

def toKebabCase(input: str) -> str:
101def toKebabCase(input: str) -> str:
102    """Convert string to kebab-case.
103
104    Example:
105        `Hello World` => `hello-world`
106    """
107    return kebabcase(input)

Convert string to kebab-case.

Example:

Hello World => hello-world

def toSnakeCase(input: str) -> str:
110def toSnakeCase(input: str) -> str:
111    """Convert string to snake_case.
112
113    Example:
114        `Hello World` => `hello_world`
115    """
116    return snakecase(input)

Convert string to snake_case.

Example:

Hello World => hello_world

def toTitleCase(input: str, retainUpper: bool = True) -> str:
119def toTitleCase(input: str, retainUpper: bool = True) -> str:
120    """
121    Convert string to title case, handling special cases and acronyms.
122
123    Args:
124        input: The input string.
125        retainUpper: If True, retain uppercase acronyms: `True` USA, `False` Usa
126
127    Returns:
128        Title-cased string.
129
130    Example:
131        `sON Of The USA` => `Son of the USA`
132    """
133    specialChars: list[str] = ["-", "/"]
134
135    def _capitalizeAfterPunctuation(
136        word: str,
137    ) -> str:
138        """Capitalize first lowercase letter even when preceded by punctuation."""
139        hasUpperRun = bool(regex.search(r"\p{Lu}{2,}", word))
140        if not retainUpper and hasUpperRun:
141            word = capwords(word)
142
143        return regex.sub(
144            r"^([^\p{L}\p{N}]*)(\p{Ll})",
145            lambda match: f"{match.group(1)}{match.group(2).upper()}",
146            word,
147            count=1,
148        )
149
150    def _hasSpecialChars(word: str) -> bool:
151        """Returns True if word contains special characters."""
152        return any(char in word for char in specialChars)
153
154    def _handleSpecialChars(word: str) -> str:
155        """Apply title case to each part of a word split by special characters."""
156        for char in specialChars:
157            if char in word:
158                # ? Split by special char and apply title case to each part
159                parts = word.split(char)
160                return char.join([_capitalizeAfterPunctuation(part) for part in parts])
161
162    def _processWord(word: str, i: int) -> str:
163        """Process a single word for title casing."""
164        # ? Always lowercase common words in continuous text
165        isOnEitherSide = i == 0 or i == len(words) - 1
166        isCommon = word.casefold() in commonWords
167        if isCommon and not isOnEitherSide:
168            return word.lower()
169
170        # ? Handle special characters
171        if _hasSpecialChars(word):
172            return _handleSpecialChars(word)
173
174        # Uppercase and punctuation 2+ times
175        isCaps = regex.match(r"[\p{Lu}|\p{P}]{2,}", word)
176
177        # capwords() better .title() => retains lowercase ’s
178        return word if isCaps and retainUpper else _capitalizeAfterPunctuation(word)
179
180    words = input.split(" ")
181    words = [_processWord(word, i) for i, word in enumerate(words)]
182    return " ".join(words)

Convert string to title case, handling special cases and acronyms.

Arguments:
  • input: The input string.
  • retainUpper: If True, retain uppercase acronyms: True USA, False Usa
Returns:

Title-cased string.

Example:

sON Of The USA => Son of the USA

def changeCase( input: list[str] | str, case: Literal['UPPER', 'lower', 'title', 'title-force'] = 'title') -> list[str] | str:
185def changeCase(
186    input: list[str] | str,
187    case: TextCase = "title",
188) -> list[str] | str:
189    """
190    Change the case of a string or list of strings.
191
192    Args:
193        input: String or list of strings to change case.
194        case: Desired case ("upper", "lower", "title", "title-force").
195
196    Returns:
197        String or list of strings with changed case.
198
199    Example:
200        `the USA`
201        - `upper` => `THE USA`
202        - `lower` => `the usa`
203        - `title`       => `The USA`
204        - `title-force` => `The Usa`
205    """
206
207    def _change(item: str):
208        if case.casefold() == "upper":
209            return item.upper()
210        elif case.casefold() == "lower":
211            return item.lower()
212        elif case.casefold() == "title":
213            return toTitleCase(item)
214        elif case.casefold() == "title-force":
215            return toTitleCase(item, False)
216        else:
217            logger.warning("Unable to change case: {}", case)
218
219    if not case:
220        return input  # Pass through unchanged
221
222    if isinstance(input, list):
223        return [_change(item) for item in input]
224    else:
225        return _change(input)

Change the case of a string or list of strings.

Arguments:
  • input: String or list of strings to change case.
  • case: Desired case ("upper", "lower", "title", "title-force").
Returns:

String or list of strings with changed case.

Example:

the USA

  • upper => THE USA
  • lower => the usa
  • title => The USA
  • title-force => The Usa
def dedupeCase( input: list[str], case: Literal['UPPER', 'lower', 'title', 'title-force'] = 'title') -> list[str]:
228def dedupeCase(input: list[str], case: TextCase = "title") -> list[str]:
229    """
230    Remove duplicates from a list of strings after changing their case.
231
232    Args:
233        input: List of strings to dedupe.
234        case: Case to apply before deduplication.
235    """
236    return list(set(changeCase(input, case)))

Remove duplicates from a list of strings after changing their case.

Arguments:
  • input: List of strings to dedupe.
  • case: Case to apply before deduplication.
def dedupeWords(input: str) -> str:
239def dedupeWords(input: str) -> str:
240    """Collapse adjacent duplicate words in a string (case-insensitive)."""
241    duplicateWordPattern = (
242        r"\b([A-Za-zÀ-ÖØ-öø-ÿ0-9]+(?:[-'][A-Za-zÀ-ÖØ-öø-ÿ0-9]+)?)\s+\1\b"
243    )
244
245    output = input
246    previous = None
247    while output != previous:
248        previous = output
249        output = re.sub(duplicateWordPattern, r"\1", output, flags=re.IGNORECASE)
250
251    return output

Collapse adjacent duplicate words in a string (case-insensitive).

def isTitleCase(input: str) -> bool:
254def isTitleCase(input: str) -> bool:
255    """Returns True if all words in the string are title case."""
256    return all([regex.match(r"^[\p{Lu}][\p{Ll}]+$", part) for part in input.split(" ")])

Returns True if all words in the string are title case.

def omitMissing( input: str | list[str], font: str = None, mode: Literal['glyphs', 'words', 'lines'] = 'words', debug=False):
259def omitMissing(
260    input: str | list[str],
261    font: str = None,
262    mode: Literal["glyphs", "words", "lines"] = "words",
263    debug=False,
264):
265    """
266    Omit missing characters from text or list of text blocks.
267
268    Args:
269        input: A single string or a list of strings to check for missing glyphs.
270        font: Font to use for checking glyphs (optional).
271        mode: Determines the omission granularity:
272            - `glyphs`: Omit only the missing characters, preserving the rest of the text.
273            - `words`: Omit entire words that contain missing glyphs.
274            - `lines`: Omit entire lines that contain missing glyphs.
275        debug: If True, log omitted units.
276
277    Returns:
278        Filtered text or list of text blocks with missing data omitted, depending on mode.
279    """
280    if font:
281        drawBot.font(font)
282    elif drawBot.fontFilePath() == DEFAULT_FONT:
283        logger.warning("Default font used to check character set.")
284
285    isInputString = isinstance(input, str)
286    match mode:
287        case "glyphs":
288            glue = ""
289        case "words":
290            glue = " "
291        case "lines":
292            glue = "\n"
293    blocks = [input] if isInputString else input
294
295    output = []
296    for block in blocks:
297        units = list(block) if mode == "glyphs" else block.split(glue)
298        filtered = [unit for unit in units if drawBot.fontContainsCharacters(unit)]
299
300        if debug:
301            # Log omitted fontName if available
302            _logMessage = lambda unit: (
303                ("[Omitted] {} for {}", unit, fonts.getFontName(font))
304                if font
305                else ("[Omitted] {}", unit)
306            )
307            [
308                logger.trace(*_logMessage(unit))
309                for unit in units
310                if not drawBot.fontContainsCharacters(unit)
311            ]
312
313        # Do not add empty list
314        if filtered:
315            output.append(glue.join(filtered))
316
317    return glue.join(output) if isInputString else output

Omit missing characters from text or list of text blocks.

Arguments:
  • input: A single string or a list of strings to check for missing glyphs.
  • font: Font to use for checking glyphs (optional).
  • mode: Determines the omission granularity:
    • glyphs: Omit only the missing characters, preserving the rest of the text.
    • words: Omit entire words that contain missing glyphs.
    • lines: Omit entire lines that contain missing glyphs.
  • debug: If True, log omitted units.
Returns:

Filtered text or list of text blocks with missing data omitted, depending on mode.

def splitStringToSentences(input: str) -> list[str]:
320def splitStringToSentences(input: str) -> list[str]:
321    """
322    Split running text into a list of sentences.
323
324    Args:
325        input: The input text.
326
327    Example:
328        `I am a sentence. I am another one.` => `["I am a sentence.", "I am another one."]`
329    """
330    replacements = [
331        # Newlines with spaces
332        (r"\n", " "),
333        # Multiple spaces to single space
334        (r"\s{2,}", " "),
335    ]
336    for [before, after] in replacements:
337        input = re.sub(rf"{before}", after, input)
338
339    # Skip abbreviations: (F. Elastica), Ficus var. elastica
340    sentenceExp = r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<![A-Z]\.)(?<=\.|\?)\s(?![a-z])"
341    return re.split(sentenceExp, input)

Split running text into a list of sentences.

Arguments:
  • input: The input text.
Example:

I am a sentence. I am another one. => ["I am a sentence.", "I am another one."]

def rotateList(input: list) -> list:
344def rotateList(input: list) -> list:
345    """
346    Rotate a list to produce all cyclic permutations.
347
348    Args:
349        input: The input list.
350
351    Example:
352        `[A, B, C]` => `[[A, B, C], [B, C, A], [C, A, B]]`
353    """
354    output = []
355
356    for item in input:
357        cycled = cycle(input)
358        skipped = dropwhile(lambda x: x != item, cycled)
359        sliced = islice(skipped, None, len(input))
360
361        output.append(list(sliced))
362
363    return output

Rotate a list to produce all cyclic permutations.

Arguments:
  • input: The input list.
Example:

[A, B, C] => [[A, B, C], [B, C, A], [C, A, B]]

def chopSequence(input: str | list[str], limit: int = None, glue=' ', split=' '):
366def chopSequence(input: str | list[str], limit: int = None, glue=" ", split=" "):
367    """
368    Split input into meaningful parts, optionally limiting the number of words: `A B C` => `A, AB, ABC`.
369
370    Args:
371        input: String or list of strings to chop.
372        limit: Limit to `n` words.
373        glue: String to join parts.
374        split: String to split input.
375
376    Example:
377    - input: single sentence
378        - `"I was late."` => `["I", "I was", "I was late."]`
379    - input: list of sentences
380        - `["For me.", "Right?"]` => `["For me.", "For me. Right?"]`
381    - limit: 2
382        - `["I", "I was"]`
383    """
384    if split and isinstance(input, str):
385        input = input.split(split)
386
387    inputLen = len(input)
388    # Limit size if provided
389    stop = min(limit, inputLen) if limit else inputLen
390
391    return [glue.join(input[:i]) for i in range(1, stop + 1)]

Split input into meaningful parts, optionally limiting the number of words: A B C => A, AB, ABC.

Arguments:
  • input: String or list of strings to chop.
  • limit: Limit to n words.
  • glue: String to join parts.
  • split: String to split input.

Example:

  • input: single sentence
    • "I was late." => ["I", "I was", "I was late."]
  • input: list of sentences
    • ["For me.", "Right?"] => ["For me.", "For me. Right?"]
  • limit: 2
    • ["I", "I was"]
def chopList( input: list[str], clamp: int = None, mode: Literal['separate', 'connected'] = 'separate', shuffle=False) -> list[str]:
394def chopList(
395    input: list[str],
396    clamp: int = None,
397    mode: Literal["separate", "connected"] = "separate",
398    shuffle=False,
399) -> list[str]:
400    """
401    Chop a list of sentences into smaller parts, optionally connecting or shuffling them.
402
403    Args:
404        input: List of sentences.
405        clamp: Limit to n words per iteration.
406        mode: "separate" to chop individually, "connected" to connect chopped sentences.
407        shuffle: If True, shuffle input before chopping.
408
409    Example:
410        `["Hello there.", "Hi you."]` =>
411        - (separate) `["Hello", "Hello there.", "Hi", "Hi you."]`
412        - (connected) `["Hello", "Hello there.", "Hello there. Hi", ...]`
413    """
414    if shuffle:
415        random.shuffle(input)
416
417    if mode == "connected":
418        input = [" ".join(item) for item in rotateList(input)]
419
420    return helpers.flatten([chopSequence(item, clamp) for item in input])

Chop a list of sentences into smaller parts, optionally connecting or shuffling them.

Arguments:
  • input: List of sentences.
  • clamp: Limit to n words per iteration.
  • mode: "separate" to chop individually, "connected" to connect chopped sentences.
  • shuffle: If True, shuffle input before chopping.
Example:

["Hello there.", "Hi you."] =>

  • (separate) ["Hello", "Hello there.", "Hi", "Hi you."]
  • (connected) ["Hello", "Hello there.", "Hello there. Hi", ...]
def permutate(input: list, clamp=20, shuffle=True) -> list:
423def permutate(input: list, clamp=20, shuffle=True) -> list:
424    """
425    Permutate and chop a list of sentences into connected sequences.
426
427    Args:
428        input: List of sentences.
429        clamp: Limit to `n` words per sequence.
430        shuffle: If True, shuffle input before permutation.
431
432    Example:
433        - `["Hi Tim", "Foo bar"]` => list of
434        - `["Hi", "Hi Tim", "Hi Tim Foo", ...], ["Foo", "Foo bar", "Foo bar Hi", ...]`
435    """
436    return chopList(input, clamp, "connected", shuffle)

Permutate and chop a list of sentences into connected sequences.

Arguments:
  • input: List of sentences.
  • clamp: Limit to n words per sequence.
  • shuffle: If True, shuffle input before permutation.
Example:
  • ["Hi Tim", "Foo bar"] => list of
  • ["Hi", "Hi Tim", "Hi Tim Foo", ...], ["Foo", "Foo bar", "Foo bar Hi", ...]
def fillTextOver(container: tuple, content: list, shuffle: bool = True) -> str:
439def fillTextOver(container: tuple, content: list, shuffle: bool = True) -> str:
440    """
441    Returns a string that fills the container up to overflow.
442
443    - Font properties need to be already set
444
445    Args:
446        container: Tuple specifying container dimensions.
447        content: List of possible sentences/items.
448        shuffle: If True, shuffle content before filling.
449    """
450    containerW, containerH = layout.toDimensions(container)
451
452    if shuffle:
453        content = helpers.shuffleAtRandomSegment(content)
454
455    strings = []
456
457    for string in content:
458        strings.append(string)
459        stream = " ".join(strings)
460        _, textH = drawBot.textSize(stream, width=containerW)
461        if textH >= containerH:
462            break
463
464    return stream

Returns a string that fills the container up to overflow.

  • Font properties need to be already set
Arguments:
  • container: Tuple specifying container dimensions.
  • content: List of possible sentences/items.
  • shuffle: If True, shuffle content before filling.
def getStringForWidth(pool: list[str], width: int, threshold: float = 0.995) -> str:
467def getStringForWidth(pool: list[str], width: int, threshold: float = 0.995) -> str:
468    """
469    Get a string from the pool that fits within the specified width.
470
471    - Font properties need to be set already
472
473    Args:
474        pool: List of candidate strings.
475        width: Target width.
476        threshold: Minimum width threshold.
477    """
478
479    def _isWidthAppropriate(candidateWidth: int):
480        return minWidth <= candidateWidth <= maxWidth
481
482    minWidth, maxWidth = width * threshold, width
483
484    candidateWidths = []
485    match = None
486
487    for candidate in pool:
488        candidateWidth, _ = drawBot.textSize(candidate)
489        candidateWidths.append(candidateWidth)
490
491        if _isWidthAppropriate(candidateWidth):
492            match = candidate
493            break
494
495    if match:
496        return match
497    else:
498        closestWidth = helpers.findClosestValue(
499            candidateWidths, width, discardLarger=True
500        )
501        i = (
502            candidateWidths.index(closestWidth)
503            if closestWidth in candidateWidths
504            else 0
505        )
506        return pool[i]

Get a string from the pool that fits within the specified width.

  • Font properties need to be set already
Arguments:
  • pool: List of candidate strings.
  • width: Target width.
  • threshold: Minimum width threshold.
def getStringForDimensions( pool: list[str], dimensions: tuple[float, float], threshold: float = 0.85, optimalLastLineRatio: float = 1) -> str:
593def getStringForDimensions(
594    pool: list[str],
595    dimensions: tuple[float, float],
596    threshold: float = 0.85,
597    optimalLastLineRatio: float = 1,
598) -> str:
599    """
600    Get a string from the pool that fits within the specified width and height.
601
602    - Font properties need to be set already
603
604    Args:
605        pool: List of candidate strings.
606        dimensions: Tuple specifying target width and height.
607        threshold: Minimum fill ratio required for every wrapped body line
608            (all lines except the last). Does not affect height scoring.
609        optimalLastLineRatio: Preferred fill ratio for the final wrapped line.
610            `1` prefers the last line to be as full as possible; `0.5` treats
611            half-line endings as ideal.
612    """
613
614    width, height = dimensions
615    minBodyLineWidth, maxWidth = width * threshold, width
616    maxHeight = height
617    optimalLastLineRatio = min(max(optimalLastLineRatio, 0), 1)
618
619    def _measure(candidate: str) -> tuple[float, float]:
620        textW, textH = drawBot.textSize(candidate, width=width)
621
622        # Mirror fitBinary: account for NSFont leading expansion in multiline text.
623        NSFontLeading = drawBot.fontLeading()
624        if NSFontLeading:
625            lineHeight = drawBot.fontLineHeight()
626            if lineHeight:
627                lineCount = ceil(textH / lineHeight)
628                leadingCount = max(0, lineCount - 1)
629                textH += leadingCount * ceil(NSFontLeading)
630
631        return textW, textH
632
633    if not pool:
634        return ""
635
636    measured: list[tuple[str, float, float]] = []
637    for candidate in pool:
638        candidateW, candidateH = _measure(candidate)
639        measured.append((candidate, candidateW, candidateH))
640
641    bestFit: str | None = None
642    bestFitScore: tuple[float, ...] | None = None
643    for candidate, candidateW, candidateH in measured:
644        if candidateW <= maxWidth and candidateH <= maxHeight:
645            widthFill = candidateW / maxWidth if maxWidth else 0
646            heightFill = candidateH / maxHeight if maxHeight else 0
647            fillScore = (heightFill + widthFill) / 2
648
649            bodyLineFillSum = 0.0
650            bodyLineQuality = 0.0
651            lastLineQuality = 0.0
652            lastLineFill = 0.0
653            hasOrphanLastLine = 0
654
655            try:
656                lineMetrics = _getWrappedLineMetrics(candidate, (0, 0, width, height))
657            except Exception:
658                lineMetrics = []
659
660            if lineMetrics:
661                bodyLineMetrics = lineMetrics[:-1]
662
663                if bodyLineMetrics:
664                    if any(
665                        lineWidth < minBodyLineWidth for lineWidth, _ in bodyLineMetrics
666                    ):
667                        continue
668
669                    bodyLineFills = [
670                        (lineWidth / maxWidth if maxWidth else 0)
671                        for lineWidth, _ in bodyLineMetrics
672                    ]
673                    bodyLineFillSum = sum(bodyLineFills)
674                    bodyLineQuality = bodyLineFillSum / len(bodyLineFills)
675                else:
676                    bodyLineQuality = widthFill
677
678                lastLineWidth, lastLineText = lineMetrics[-1]
679                lastLineFill = lastLineWidth / maxWidth if maxWidth else 0
680                lastLineQuality = max(0.0, 1 - abs(lastLineFill - optimalLastLineRatio))
681
682                if len(lineMetrics) > 1 and _countWords(lastLineText) == 1:
683                    hasOrphanLastLine = 1
684            else:
685                bodyLineQuality = widthFill
686
687            orphanPenalty = hasOrphanLastLine * 0.35
688            overallScore = (
689                (bodyLineQuality * 0.45)
690                + (heightFill * 0.3)
691                + (lastLineQuality * 0.25)
692                - orphanPenalty
693            )
694            score = (
695                1 - hasOrphanLastLine,
696                bodyLineFillSum,
697                overallScore,
698                heightFill,
699                bodyLineQuality,
700                fillScore,
701                lastLineQuality,
702                lastLineFill,
703                widthFill,
704            )
705
706            if bestFitScore is None or score > bestFitScore:
707                bestFit = candidate
708                bestFitScore = score
709
710    if bestFit is None:
711        raise ValueError(
712            "No candidates in pool can fit within the specified dimensions."
713        )
714
715    return bestFit

Get a string from the pool that fits within the specified width and height.

  • Font properties need to be set already
Arguments:
  • pool: List of candidate strings.
  • dimensions: Tuple specifying target width and height.
  • threshold: Minimum fill ratio required for every wrapped body line (all lines except the last). Does not affect height scoring.
  • optimalLastLineRatio: Preferred fill ratio for the final wrapped line. 1 prefers the last line to be as full as possible; 0.5 treats half-line endings as ideal.
def filterByShape( items: list[str], shape: Union[Literal['descender', 'ascender', 'caps'], list[Literal['descender', 'ascender', 'caps']]]) -> list[str]:
718def filterByShape(items: list[str], shape: WordShape | list[WordShape]) -> list[str]:
719    """
720    Filter words by descender, ascender, or caps shape.
721
722    Args:
723        items: List of words.
724        shape: Shape(s) to filter by.
725
726    Returns:
727        List of words matching the shape criteria.
728
729    Example:
730        - `["hi", "hey"], "descender"` => `["hi"]`
731    """
732
733    def _isNotShaped(pattern: str, item: str):
734        return not bool(re.search(pattern, item))
735
736    def _checkShape(shape):
737        wordShape = wordShapes.get(shape)
738        return filter(lambda item: _isNotShaped(wordShape, item), items)
739
740    wordShapes = dict(caps="[A-Z0-9]", ascender="[bdfihklt]", descender="[Qgjqpy/,]")
741    shapeSubsets = [_checkShape(shape) for shape in helpers.coerceList(shape)]
742    return helpers.intersect(shapeSubsets, retainOrder=False)

Filter words by descender, ascender, or caps shape.

Arguments:
  • items: List of words.
  • shape: Shape(s) to filter by.
Returns:

List of words matching the shape criteria.

Example:
  • ["hi", "hey"], "descender" => ["hi"]
def filterByTokens( items: list[str], tokens: list[typing.Literal['word', 'nonword']] = ['word', 'nonword']) -> list[str]:
745def filterByTokens(
746    items: list[str], tokens: list[CharacterToken] = ["word", "nonword"]
747) -> list[str]:
748    """
749    Filter items by Unicode character token.
750
751    Args:
752        items: List of strings to filter.
753        tokens: List of token types to filter by.
754
755    Returns:
756        List of items matching the token criteria.
757
758    Example:
759        - `word`, `nonword` => `["A4", "R&B"]`
760    """
761
762    possiblePatterns = dict(
763        word=r"\p{Letter}", nonword=r"\p{Symbol}|\p{Number}|\p{Punctuation}"
764    )
765    patterns = [possiblePatterns.get(m) for m in tokens]
766
767    def _filterSingleToken(items: list[str], pattern: str):
768        return [item for item in items if bool(regex.search(pattern, item))]
769
770    individual = [_filterSingleToken(items, p) for p in patterns]
771
772    return helpers.intersect(individual)

Filter items by Unicode character token.

Arguments:
  • items: List of strings to filter.
  • tokens: List of token types to filter by.
Returns:

List of items matching the token criteria.

Example:
  • word, nonword => ["A4", "R&B"]
def isRagPretty( content: Union[str, drawBot.context.baseContext.FormattedString], coords: tuple) -> tuple[bool, bool]:
775def isRagPretty(
776    content: Union[str, drawBot.FormattedString], coords: tuple
777) -> tuple[bool, bool]:
778    """
779    Evaluate if a paragraph is nicely typeset.
780
781    Args:
782        content: Text content or `FormattedString`.
783        coords: Tuple specifying text box coordinates.
784
785    Returns:
786        Tuple of booleans (isGreat, isOkay).
787        - `isGreat`: All quite long, some very long
788        - `isOkay`: All quite long
789    """
790
791    try:
792        _, _, width, _ = coords
793        lineMetrics = _getWrappedLineMetrics(content, coords)
794
795        if len(lineMetrics) < 2:
796            return False, False
797
798        bodyWidths = [lineWidth for lineWidth, _ in lineMetrics[:-1]]
799        lastWidth = lineMetrics[-1][0]
800        # All lines are quite long
801        areAllGood = all([w >= width * 0.9 for w in bodyWidths])
802        # A portion of lines are very long
803        areSomeGreat = (
804            len([True for w in bodyWidths if w >= width * 0.95]) >= len(bodyWidths) / 3
805        )
806        # Last line is not longest and not an widow
807        isLastGood = max(bodyWidths) >= lastWidth >= width * 2 / 3
808
809        isOkay = areAllGood and isLastGood
810        # isGreat, isOkay
811        return (isOkay and areSomeGreat), isOkay
812    except Exception as e:
813        logger.warning("Failed isRagPretty: {}", e)
814        return False, False

Evaluate if a paragraph is nicely typeset.

Arguments:
  • content: Text content or FormattedString.
  • coords: Tuple specifying text box coordinates.
Returns:

Tuple of booleans (isGreat, isOkay).

  • isGreat: All quite long, some very long
  • isOkay: All quite long