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
Categories to filter words by their shape.
See classes.c32_pool.KPool.getItemByWidth for usage.
Defines the supported text casing styles for conversion.
UPPER: Converts all letters to uppercase:hi ibm→HI IBMlower: Converts all letters to lowercase:HI IBM→hi ibmtitle: Capitalizes each word, but preserves acronyms in uppercase:hi ibm USA→Hi Ibm USAtitle-force: Capitalizes each word and forces acronym recasing:hi ibm USA→Hi Ibm Usa
Type alias for character token types.
See filterByTokens for usage.
List of common words to be lowercased in title case (unless at start/end).
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
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
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
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
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:
TrueUSA,FalseUsa
Returns:
Title-cased string.
Example:
sON Of The USA=>Son of the USA
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 USAlower=>the usatitle=>The USAtitle-force=>The Usa
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.
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).
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.
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.
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."]
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]]
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
nwords. - 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"]
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", ...]
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
nwords 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", ...]
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.
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.
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.
1prefers the last line to be as full as possible;0.5treats half-line endings as ideal.
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"]
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"]
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 longisOkay: All quite long