lib.layout
Layout and coordinate conversion utilities for DrawBot graphics.
This module provides unit conversion (mm, cm, pt), page/box layout helpers, and a unified unit string parser for convenient value specification.
1"""Layout and coordinate conversion utilities for DrawBot graphics. 2 3This module provides unit conversion (mm, cm, pt), page/box layout helpers, and a unified 4unit string parser for convenient value specification. 5""" 6 7import drawBot 8import random 9from math import ceil 10from collections.abc import Sequence 11from typing import TypeAlias, Literal, TYPE_CHECKING, overload 12from loguru import logger 13from icecream import ic 14 15from lib import helpers, DEFAULT_FONT 16 17# Avoid circular import issues, hide from runtime, but keep for type checking 18if TYPE_CHECKING: 19 from lib import graphics 20 21MM_TO_INCH = 25.4 / 72 22MM_TO_PT = 72 / 25.4 23 24 25UnitSource: TypeAlias = int | float | str | None 26"""Acceptable input types for unit parsing: numeric values, strings with units, or None.""" 27 28ImplicitUnit: TypeAlias = Literal["pt", "px", "mm", "cm", "decimal", "auto"] 29"""Fallback unit for bare numeric values when no explicit unit suffix is provided.""" 30 31BoxEdge: TypeAlias = Literal["top", "right", "bottom", "left"] 32"""Represents the four edges of a box.""" 33 34 35PageSizeName = Literal[ 36 "A3", 37 "A3Landscape", 38 "A4", 39 "A4Landscape", 40 "A4Small", 41 "A4SmallLandscape", 42 "A5", 43 "A5Landscape", 44 "B4", 45 "B4Landscape", 46 "B5", 47 "B5Landscape", 48] 49"""Alias for standard page size strings recognized by DrawBot. See https://www.drawbot.com/content/canvas/pages.html#size for available sizes.""" 50 51PageSize = PageSizeName | tuple[int, int] | None 52 53 54def parsePageSize( 55 input: PageSize, implicitUnit: ImplicitUnit = "pt" 56) -> tuple[int, int]: 57 """Parse page size from various input formats. 58 Args: 59 input: Page size as a tuple (width, height) in points, a page size string (e.g., "A4", "A4Landscape"), or None to default to current DrawBot page size. 60 implicitUnit: Fallback unit for bare numeric values when no explicit unit suffix is provided. 61 Returns: 62 The page size as a tuple (width, height) in points. 63 """ 64 65 if isinstance(input, tuple) and len(input) == 2: 66 return tuple(parseUnit(value, implicitUnit=implicitUnit) for value in input) 67 elif isinstance(input, str): 68 try: 69 return drawBot.sizes(input) 70 except (ValueError, KeyError): 71 pass 72 try: 73 return unpackTwo(input, implicitUnit=implicitUnit) 74 except (ValueError, TypeError): 75 raise ValueError(f"Invalid page size: {input}") 76 elif input is None: 77 pageW, pageH = drawBot.width(), drawBot.height() 78 logger.trace( 79 f"Defaulting to {pt(pageW, rounded=True)}×{pt(pageH, rounded=True)}mm" 80 ) 81 return pageW, pageH 82 else: 83 raise ValueError(f"Invalid page size input: {input}") 84 85 86AspectRatioInput = PageSizeName | str | float | tuple[int, int] 87 88 89def parseAspectRatio(input: AspectRatioInput) -> float: 90 """Parse aspect ratio from various input formats. 91 Args: 92 input: Aspect ratio as a float, a string in "width:height" or "width/height" format, or a page size string (e.g., "A4", "A4Landscape"). 93 Returns: 94 The aspect ratio as a float (width divided by height). 95 """ 96 97 if isinstance(input, float): 98 return input 99 elif isinstance(input, tuple) and len(input) == 2: 100 width, height = input 101 return width / height 102 elif isinstance(input, str): 103 if "/" in input: 104 width, height = map(float, input.split("/")) 105 return width / height 106 elif ":" in input: 107 width, height = map(float, input.split(":")) 108 return width / height 109 else: 110 try: 111 pageW, pageH = drawBot.sizes(input) 112 return pageW / pageH 113 except ValueError: 114 raise ValueError(f"Invalid aspect ratio format: {input}") 115 116 117def pageIsEven() -> bool: 118 """Returns True if the current page count is even, otherwise False.""" 119 return True if drawBot.pageCount() % 2 == 0 else False 120 121 122@overload 123def mm(value: int | float) -> float: ... 124@overload 125def mm(value: Sequence[int | float]) -> tuple[float, ...]: ... 126@overload 127def mm(*values: int | float) -> tuple[float, ...]: ... 128 129 130def mm(*values: int | float) -> float | tuple[float, ...]: 131 """ 132 Convert millimeters to points. 133 - mm(5) -> 14.173... 134 - mm(5, 10, 15) -> (14.173..., 28.346..., 42.519...) 135 - mm((5, 10)) -> (14.173..., 28.346...) 136 """ 137 if not values: 138 raise TypeError("mm() requires at least 1 numeric value") 139 140 if ( 141 len(values) == 1 142 and isinstance(values[0], Sequence) 143 and not isinstance(values[0], (str, bytes)) 144 ): 145 values = tuple(values[0]) 146 147 try: 148 converted = tuple(v * MM_TO_PT for v in values) 149 return converted[0] if len(converted) == 1 else converted 150 except TypeError as error: 151 raise TypeError(f"mm() only accepts numeric values, got {values}") from error 152 153 154def cm(number: int) -> float: 155 """Convert centimeters to points. 156 157 Args: 158 number: Value in centimeters. 159 160 Returns: 161 Value converted to points. 162 """ 163 return mm(number * 10) 164 165 166def pt(number: int, rounded=False) -> float: 167 """Convert points to millimeters. 168 169 Args: 170 number: Value in points. 171 rounded: If True, round the result. 172 173 Returns: 174 Value converted to millimeters. 175 """ 176 value = MM_TO_INCH * number 177 return round(value) if rounded else value 178 179 180def parseUnit( 181 value: UnitSource, 182 base: int | float | None = None, 183 implicitUnit: ImplicitUnit = "mm", 184) -> float | None: 185 """Parse a minimal unit format, optionally applying it to a base value. 186 187 Supported string units: `mm`, `m`, `pt`, `%`. 188 No whitespace or case normalization is performed. 189 190 With base: 191 - signed `%` applies a relative increment/decrement (`+10%`, 500 -> 550) 192 - unsigned `%` applies a fraction of base (`10%`, 500 -> 50) 193 - `mm`, `cm`, `pt`, and bare numbers apply absolute increment (`+ parsedValue`) 194 - Bare numbers use `implicitUnit` 195 196 Special `implicitUnit="auto"` behavior for numeric input: 197 - `abs(value) < 1` => decimal relative mode 198 - non-fractional numbers (`int`) => millimeters 199 - floating numbers (`float`) => points 200 201 For bare numeric strings, `auto` falls back to millimeter behavior. 202 """ 203 if base is not None and not isinstance(base, (int, float)): 204 raise TypeError(f"Unsupported base type: {type(base).__name__}") 205 206 if implicitUnit not in ("pt", "px", "mm", "cm", "decimal", "auto"): 207 raise ValueError(f"Unsupported implicit unit: {implicitUnit}") 208 209 if value is None: 210 return base if base is not None else None 211 212 if isinstance(value, (int, float)): 213 if implicitUnit == "auto": 214 parsed = float(value) 215 if abs(parsed) < 1: 216 return parsed if base is None else float(base) + (float(base) * parsed) 217 if isinstance(value, int): 218 parsed = mm(parsed) 219 return parsed if base is None else float(base) + parsed 220 221 if implicitUnit == "decimal" and abs(value) >= 1: 222 raise ValueError( 223 f"Decimal implicit unit requires values lower than 1, got {value}" 224 ) 225 parsed = float(value) 226 if implicitUnit == "mm": 227 parsed = mm(parsed) 228 elif implicitUnit == "cm": 229 parsed = cm(parsed) 230 if implicitUnit == "decimal": 231 return parsed if base is None else float(base) + (float(base) * parsed) 232 return parsed if base is None else float(base) + parsed 233 234 if not isinstance(value, str): 235 raise TypeError(f"Unsupported type: {type(value).__name__}") 236 237 if value.endswith("cm"): 238 parsed = cm(float(value[:-2])) 239 return parsed if base is None else float(base) + parsed 240 241 if value.endswith("mm"): 242 parsed = mm(float(value[:-2])) 243 return parsed if base is None else float(base) + parsed 244 245 if value.endswith("pt") or value.endswith("px"): 246 parsed = float(value[:-2]) 247 return parsed if base is None else float(base) + parsed 248 249 if value.endswith("%"): 250 percentValue = value[:-1] 251 ratio = float(percentValue) / 100 252 if base is None: 253 return ratio 254 if percentValue.startswith(("+", "-")): 255 return float(base) + (float(base) * ratio) 256 return float(base) * ratio 257 258 try: 259 effectiveImplicitUnit = "mm" if implicitUnit == "auto" else implicitUnit 260 parsed = float(value) 261 if effectiveImplicitUnit == "decimal" and abs(parsed) >= 1: 262 raise ValueError( 263 f"Decimal implicit unit requires values lower than 1, got {value}" 264 ) 265 if effectiveImplicitUnit == "mm": 266 parsed = mm(parsed) 267 elif effectiveImplicitUnit == "cm": 268 parsed = cm(parsed) 269 if effectiveImplicitUnit == "decimal": 270 return parsed if base is None else float(base) + (float(base) * parsed) 271 return parsed if base is None else float(base) + parsed 272 except ValueError as error: 273 raise ValueError(f"Unknown unit format: {value}") from error 274 275 276def isAutoRelative(value: UnitSource, implicitUnit: ImplicitUnit) -> bool: 277 """Return True when auto mode should treat a numeric value as decimal-relative.""" 278 return implicitUnit == "auto" and isinstance(value, (int, float)) and abs(value) < 1 279 280 281def isBareNumber(value: UnitSource) -> bool: 282 """Return True for int/float or numeric strings without explicit unit suffix.""" 283 if isinstance(value, (int, float)): 284 return True 285 if not isinstance(value, str): 286 return False 287 if value.endswith(("cm", "mm", "pt", "px", "%")): 288 return False 289 try: 290 float(value) 291 return True 292 except ValueError: 293 return False 294 295 296def isInCentimeters(value: UnitSource) -> bool: 297 """Return True when value is a string ending in `cm` with a valid number prefix.""" 298 if not isinstance(value, str) or not value.endswith("cm"): 299 return False 300 try: 301 float(value[:-2]) 302 return True 303 except ValueError: 304 return False 305 306 307def isInMillimeters(value: UnitSource) -> bool: 308 """Return True when value is a string ending in `mm` with a valid number prefix.""" 309 if not isinstance(value, str) or not value.endswith("mm"): 310 return False 311 try: 312 float(value[:-2]) 313 return True 314 except ValueError: 315 return False 316 317 318def isInPoints(value: UnitSource) -> bool: 319 """Return True for numeric values, numeric strings, or strings ending in `pt` or `px`.""" 320 if isinstance(value, (int, float)): 321 return True 322 if not isinstance(value, str): 323 return False 324 if value.endswith("pt") or value.endswith("px"): 325 try: 326 float(value[:-2]) 327 return True 328 except ValueError: 329 return False 330 try: 331 float(value) 332 return True 333 except ValueError: 334 return False 335 336 337def isInPercent(value: UnitSource) -> bool: 338 """Return True when value is a string ending in `%` with a valid number prefix.""" 339 if not isinstance(value, str) or not value.endswith("%"): 340 return False 341 try: 342 float(value[:-1]) 343 return True 344 except ValueError: 345 return False 346 347 348Coordinates = tuple[float, float, float, float] 349"""Represents a rectangle as (x, y, width, height).""" 350 351Dimensions = tuple[float, float] 352"""Represents dimensions as (width, height).""" 353 354 355def getPageCoords() -> Coordinates: 356 """Returns current page `x, y, w, h`.""" 357 return 0, 0, drawBot.width(), drawBot.height() 358 359 360def normalize(value: tuple) -> Coordinates: 361 """ 362 Normalize tuple of `n` to 4 and parse `pageSize (str)`. 363 364 Args: 365 value: Tuple or page size string. 366 367 Returns: 368 Tuple of (x, y, w, h). 369 """ 370 if isinstance(value, str): 371 value = parsePageSize(value) 372 373 if isinstance(value, tuple) and len(value) == 4: 374 x, y, w, h = value 375 else: 376 x, y, w, h = 0, 0, *value 377 378 return x, y, w, h 379 380 381def mirror(values: tuple) -> tuple: 382 """Flip values horizontally. 383 384 Args: 385 values: Tuple of values to mirror. 386 387 Returns: 388 Mirrored tuple. 389 """ 390 values = helpers.coerceList(values) 391 392 if len(values) == 4: 393 top, outer, bottom, inner = values 394 return top, inner, bottom, outer 395 elif len(values) == 2: 396 outer, inner = values 397 return inner, outer 398 else: 399 return values 400 401 402def toDimensions(value: tuple) -> Dimensions: 403 """Returns `w, h`.""" 404 _, _, w, h = normalize(value) 405 return w, h 406 407 408def toWidth(value: tuple | str) -> int: 409 """ 410 Returns width from coords tuple or text string. 411 412 Args: 413 value: Coords tuple or text string. 414 """ 415 if isinstance(value, str): 416 return drawBot.textSize(value)[0] 417 # Assume it’s already in correct format if number 418 elif isinstance(value, (int, float)): 419 return value 420 else: 421 w, _ = toDimensions(value) 422 return w 423 424 425def toHeight(value: tuple | str) -> int: 426 """ 427 Returns height from coords tuple or text string. 428 429 Args: 430 value: Coords tuple or text string. 431 """ 432 if isinstance(value, str): 433 return drawBot.textSize(value)[1] 434 # Assume it’s already in correct format if number 435 elif isinstance(value, (int, float)): 436 return value 437 else: 438 _, h = toDimensions(value) 439 return h 440 441 442def toPosition(value: tuple) -> tuple[int, int]: 443 """Returns `x, y`.""" 444 x, y, _, _ = normalize(value) 445 return x, y 446 447 448def toCoords(value: tuple) -> Coordinates: 449 """Returns `x, y, w, h`.""" 450 return normalize(value) 451 452 453def toCenter(value: tuple) -> tuple[int, int]: 454 """Returns `x, y` center points.""" 455 x, y, w, h = toCoords(value) 456 return x + w / 2, y + h / 2 457 458 459def unpackTwo( 460 values, 461 separator=" ", 462 implicitUnit: ImplicitUnit = "mm", 463 parse=True, 464): 465 """Unpack two values and infer units automatically. 466 467 Args: 468 values: Values to unpack. 469 separator: Separator for string input. 470 471 Returns: 472 Tuple of two values, converted to `UnitSource`. 473 """ 474 # List coercion 475 values = helpers.coerceNumericList(values, separator) 476 477 # Manipulate tuple 478 if len(values) == 2: 479 x, y = values 480 else: 481 for side in values: 482 x = y = side 483 484 if not parse: 485 return x, y 486 487 return tuple(parseUnit(side, implicitUnit=implicitUnit) for side in [x, y]) 488 489 490def unpackFour( 491 values, 492 separator=" ", 493 implicitUnit: ImplicitUnit = "mm", 494 parse=True, 495): 496 """Unpack four values and infer units automatically. 497 498 Args: 499 values: Values to unpack. 500 separator: Separator for string input. 501 502 Returns: 503 Tuple of four values, converted to `UnitSource`. 504 """ 505 # List coercion 506 values = helpers.coerceNumericList(values, separator) 507 508 # Manipulate tuple 509 if len(values) == 4: 510 top, right, bottom, left = values 511 elif len(values) == 3: 512 top, right, bottom = values 513 left = right 514 elif len(values) == 2: 515 y, x = values 516 top = bottom = y 517 left = right = x 518 else: 519 for side in values: 520 top = right = bottom = left = side 521 522 if not parse: 523 return top, right, bottom, left 524 525 return tuple( 526 parseUnit(side, implicitUnit=implicitUnit) 527 for side in [top, right, bottom, left] 528 ) 529 530 531def move(shape: Coordinates, x=0, y=0, implicitUnit: ImplicitUnit = "mm"): 532 """ 533 Move a shape by x and y offsets. 534 535 Args: 536 shape: Shape tuple (x, y, w, h). 537 x: Horizontal offset. Positive values move right, negative values move left. 538 y: Vertical offset. Positive values move up, negative values move down. 539 540 Returns: 541 Moved shape as (x, y, w, h). 542 """ 543 shapeX, shapeY, shapeW, shapeH = shape 544 545 rawX, rawY = unpackTwo((x, y), implicitUnit=implicitUnit, parse=False) 546 x, y = unpackTwo((rawX, rawY), implicitUnit=implicitUnit) 547 548 xIsRelative = ( 549 isInPercent(rawX) 550 or (implicitUnit == "decimal" and isBareNumber(rawX)) 551 or isAutoRelative(rawX, implicitUnit) 552 ) 553 yIsRelative = ( 554 isInPercent(rawY) 555 or (implicitUnit == "decimal" and isBareNumber(rawY)) 556 or isAutoRelative(rawY, implicitUnit) 557 ) 558 559 if xIsRelative: 560 x = shapeW * x 561 if yIsRelative: 562 y = shapeH * y 563 564 return shapeX + x, shapeY + y, shapeW, shapeH 565 566 567def grow( 568 frame: Coordinates, 569 margin: UnitSource, 570 implicitUnit: ImplicitUnit = "mm", 571 debug=False, 572) -> Coordinates: 573 """Grow in clock-wise manner. 574 575 Args: 576 frame: Frame tuple. 577 margin: Margin values. 578 debug: If True, draw debug overlay. 579 580 Returns: 581 Grown frame as (x, y, w, h). 582 """ 583 frameX, frameY, frameW, frameH = toCoords(frame) 584 rawMargin = unpackFour(margin, implicitUnit=implicitUnit, parse=False) 585 margin = unpackFour(rawMargin, implicitUnit=implicitUnit) 586 587 hasRelativeMargin = any( 588 isInPercent(side) 589 or (implicitUnit == "decimal" and isBareNumber(side)) 590 or isAutoRelative(side, implicitUnit) 591 for side in rawMargin 592 ) 593 594 if hasRelativeMargin: 595 frameSize = (frameH, frameW, frameH, frameW) 596 margin = [ 597 ( 598 f * m 599 if isInPercent(raw) 600 or (implicitUnit == "decimal" and isBareNumber(raw)) 601 or isAutoRelative(raw, implicitUnit) 602 else m 603 ) 604 for f, m, raw in zip(frameSize, margin, rawMargin) 605 ] 606 607 mTop, mRight, mBottom, mLeft = margin 608 x, y = frameX - mLeft, frameY - mBottom 609 w, h = frameW + (mLeft + mRight), frameH + (mTop + mBottom) 610 611 coords = x, y, w, h 612 613 if debug: 614 xray(coords) 615 616 return coords 617 618 619def shrink( 620 frame: Coordinates, 621 margin: UnitSource, 622 implicitUnit: ImplicitUnit = "mm", 623 debug=False, 624) -> Coordinates: 625 """Shrink in clock-wise manner. 626 627 Args: 628 frame: Frame tuple. 629 margin: Margin values. 630 implicitUnit: Implicit unit for margin values. 631 debug: If True, draw debug overlay. 632 633 Returns: 634 Shrunk frame as (x, y, w, h). 635 """ 636 # Inverse margin 637 margin = helpers.inverseValues(margin) 638 return grow(frame, margin, implicitUnit=implicitUnit, debug=debug) 639 640 641def shrinkWidth( 642 frame: tuple, 643 amount: UnitSource, 644 origin: Literal["left", "right"] = "left", 645 implicitUnit: ImplicitUnit = "decimal", 646): 647 """Shorthand to decrease width. 648 649 Args: 650 frame: Frame tuple. 651 amount: Amount to shrink. 652 origin: Side to shrink from ('left' or 'right'). 653 654 Returns: 655 Frame with reduced width. 656 """ 657 margin = (0, amount, 0, 0) if origin == "left" else (0, 0, 0, amount) 658 return shrink(frame, margin=margin, implicitUnit=implicitUnit) 659 660 661def shrinkHeight( 662 frame: tuple, 663 amount: UnitSource, 664 origin: Literal["top", "bottom"] = "top", 665 implicitUnit: ImplicitUnit = "decimal", 666): 667 """Shorthand to decrease height. 668 669 Args: 670 frame: Frame tuple. 671 amount: Amount to shrink. 672 origin: Side to shrink from ('top' or 'bottom'). 673 implicitUnit: Implicit unit for amount (default 'decimal'). 674 675 676 Returns: 677 Frame with reduced height. 678 """ 679 margin = (amount, 0, 0) if origin == "bottom" else (0, 0, amount) 680 return shrink(frame, margin=margin, implicitUnit=implicitUnit) 681 682 683LayoutFlow = Literal["rows", "columns"] 684LayoutOrigin = Literal["start", "end"] 685 686 687def splitRelative( 688 coords: Coordinates, 689 formula: str = "3/1", 690 gap: UnitSource = 5, 691 gapImplicitUnit: ImplicitUnit = "mm", 692 create: LayoutFlow = "columns", 693 debug=False, 694) -> list[Coordinates]: 695 """ 696 Split area by specified ratio. 697 698 Args: 699 coords: Container coordinates. 700 formula: Ratio formula (e.g. '3/1'). 701 gap: Gap between splits. 702 gapImplicitUnit: Implicit unit for gap. 703 create: Split direction ('columns' or 'rows'). 704 debug: If True, draw debug overlay. 705 706 Returns: 707 List of split rectangles as (x, y, w, h). 708 709 Example: 710 `3/1` => `75% 25%` 711 """ 712 items = [float(item) for item in formula.split("/")] 713 length = len(items) 714 count = sum(items) 715 716 gap = parseUnit(gap, implicitUnit=gapImplicitUnit) 717 718 gapCount = length - 1 719 720 containerX, containerY, containerW, containerH = coords 721 722 if create == "rows": 723 dimension = containerH 724 itemW = containerW 725 else: 726 dimension = containerW 727 itemH = containerH 728 729 available = dimension - (gap * gapCount) 730 unit = available / count 731 732 result = [] 733 x, y = containerX, containerY + containerH 734 735 for item in items: 736 itemDimension = item * unit 737 738 if create == "rows": 739 itemH = itemDimension 740 else: 741 itemW = itemDimension 742 743 coords = x, y - itemH, itemW, itemH 744 result.append(coords) 745 746 if create == "rows": 747 y -= itemH + gap 748 else: 749 x += itemW + gap 750 751 if debug: 752 xray(result) 753 754 return result 755 756 757def splitAbsolute( 758 coords: Coordinates, 759 create: LayoutFlow, 760 origin: LayoutOrigin, 761 size: UnitSource, 762 gap: UnitSource, 763 implicitUnit: ImplicitUnit = "mm", 764) -> tuple[Coordinates, Coordinates]: 765 """ 766 Splits a container into two rectangles along the specified axis, with a gap in between. 767 768 Args: 769 coords: (x, y, w, h) of the container. 770 create: Split direction — 'columns' (vertical split) or 'rows' (horizontal split). 771 size: Size of the new rectangle along the split axis. 772 gap: Gap between the two rectangles. 773 implicitUnit: Implicit unit for size and gap. 774 origin: Side the new rectangle is created from. 775 - 'start': left edge for columns, top edge for rows. 776 - 'end': right edge for columns, bottom edge for rows. 777 778 Returns: 779 tuple: (new_rect, remainder_rect) 780 """ 781 x, y, w, h = coords 782 size = parseUnit(size, implicitUnit=implicitUnit) 783 gap = parseUnit(gap, implicitUnit=implicitUnit) 784 785 if create == "columns": 786 if origin == "start": 787 new_rect = (x, y, size, h) 788 remainder_rect = (x + size + gap, y, w - size - gap, h) 789 else: # end 790 new_rect = (x + w - size, y, size, h) 791 remainder_rect = (x, y, w - size - gap, h) 792 elif create == "rows": 793 if origin == "start": 794 new_rect = (x, y + h - size, w, size) 795 remainder_rect = (x, y, w, h - size - gap) 796 else: # end 797 new_rect = (x, y, w, size) 798 remainder_rect = (x, y + size + gap, w, h - size - gap) 799 else: 800 raise ValueError(f"LayoutFlow must be 'columns' or 'rows', got {create}.") 801 return new_rect, remainder_rect 802 803 804def inferBlockHeight(lines: int) -> float: 805 """Calculate block height from number of lines and current font settings.""" 806 fontFilePath = drawBot.fontFilePath() 807 if fontFilePath == DEFAULT_FONT: 808 logger.warning("Default font used to infer line height.") 809 810 _, lineHeight = drawBot.textSize("\n".join(["H" for _ in range(lines)])) 811 if any(font in fontFilePath for font in [DEFAULT_FONT, "Arial.ttf"]): 812 # ! Weird edge case: Some system fonts report lower line height, causing text to overflow. 813 return ceil(lineHeight + 0.1) 814 return lineHeight 815 816 817def inferGridItemDimensions( 818 container: Dimensions, 819 rows: int = 1, 820 cols: int = 1, 821 gutter: UnitSource = "5mm", 822) -> Dimensions: 823 """Calculate grid item dimensions based on container size, row/column count, and gutter.""" 824 w, h = toDimensions(container) 825 gutterX, gutterY = unpackTwo(gutter) 826 827 itemW = (w - (gutterX * (cols - 1))) / cols 828 itemH = (h - (gutterY * (rows - 1))) / rows 829 830 return itemW, itemH 831 832 833def makeGrid( 834 container: Coordinates, 835 rows: int = 1, 836 cols: int = 1, 837 gutter: UnitSource = "5mm", 838 debug: bool = False, 839) -> list[Coordinates]: 840 """Create a grid of cells within a container. 841 842 Args: 843 container: Container rectangle (x, y, w, h). 844 rows: Number of rows. 845 cols: Number of columns. 846 gutter: Gutter size between cells. 847 debug: If True, draw debug overlay. 848 849 Returns: 850 List of cell rectangles as (x, y, w, h). 851 """ 852 containerX, containerY, containerW, containerH = container 853 containerDims = containerW, containerH 854 gutterX, gutterY = unpackTwo(gutter) 855 cells: list[Coordinates] = [] 856 857 # Cell width, height 858 w, h = inferGridItemDimensions(containerDims, rows, cols, gutter) 859 860 for row in range(rows): 861 offsetY = (h + gutterY) * row 862 y = containerY + (containerH - h) - offsetY 863 864 for col in range(cols): 865 offsetX = (w + gutterX) * col 866 x = containerX + offsetX 867 868 coords = (x, y, w, h) 869 cells.append(coords) 870 871 if debug: 872 xray(coords) 873 874 return cells 875 876 877AlignX: TypeAlias = Literal["left", "center", "right", "stretch", None] 878"""Horizontal alignment options.""" 879 880AlignY: TypeAlias = Literal["top", "center", "bottom", "stretch", None] 881"""Vertical alignment options.""" 882 883 884def align( 885 parent: tuple, 886 child: tuple, 887 position: tuple[AlignX, AlignY] = ("center", "center"), 888 clip=True, 889 debug=False, 890 output: Literal["position", "coords"] = "coords", 891 nudgeY: float = 1.0, 892) -> tuple: 893 """ 894 Align a child element within a parent rectangle. 895 896 Args: 897 parent: Parent rectangle (x, y, w, h). 898 child: Child rectangle or text. 899 position: Tuple of horizontal and vertical alignment. 900 clip: If True, clip child to parent size. 901 debug: If True, draw debug overlay. 902 output: Return type, either 'position' (tuple of 2) or 'coords' (tuple of 4). 903 nudgeY: Optical vertical nudge strength for centered alignment. 904 `0` disables nudge entirely, `1` applies the default compensation, 905 and larger values increase compensation. 906 907 Returns: 908 Aligned `position` or `coords` (based on output). 909 """ 910 positionX, positionY = unpackTwo(position, parse=False) 911 parentX, parentY, parentW, parentH = parent 912 913 childX, childY = toPosition(child) 914 childW, childH = toDimensions(child) 915 916 # Dimensions 917 if positionX in ["stretch", 3]: 918 w = parentW 919 elif clip: 920 w = min(parentW, childW) 921 else: 922 w = childW 923 924 if positionY in ["stretch", 3]: 925 h = parentH 926 elif clip: 927 h = min(parentH, childH) 928 else: 929 h = childH 930 931 difW, difH = parentW - w, parentH - h 932 933 # Position 934 if positionX in ["left", 0, "stretch", 3]: 935 x = parentX 936 elif positionX in ["right", 2]: 937 x = parentX + difW 938 elif positionX in ["center", 1]: 939 x = parentX + difW / 2 940 else: 941 # None = Leave as is 942 x = childX 943 944 if positionY in ["top", 0]: 945 y = parentY + difH 946 elif positionY in ["bottom", 2, "stretch", 3]: 947 y = parentY 948 elif positionY in ["center", 1]: 949 y = parentY + difH / 2 950 else: 951 # None = Leave as is 952 y = childY 953 954 if positionY == "center" and nudgeY > 0 and parentH > 0 and childH <= parentH: 955 # Default optical base is 5% of the available vertical room. 956 baseNudge = 0.05 * max(0, difH) 957 958 # Optical compensation fades out as child height approaches parent height. 959 ratio = childH / parentH 960 opticalFactor = (1 - min(1, ratio)) ** 2 961 y += baseNudge * opticalFactor * nudgeY 962 963 coords = x, y, w, h 964 965 if debug: 966 xray(coords) 967 968 return coords if output == "coords" else toPosition(coords) 969 970 971def justify( 972 parent: Coordinates, 973 child: Coordinates, 974 count: int, 975 create: LayoutFlow, 976 crossAlign: AlignX | AlignY | None = None, 977) -> list[Coordinates]: 978 """Justify child elements evenly within a parent rectangle. 979 980 Args: 981 parent: Parent rectangle (x, y, w, h). 982 child: Child rectangle (used for size reference). 983 count: Number of child elements to justify. 984 create: Justification direction ('columns' or 'rows'). 985 crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent. 986 987 Returns: 988 List of child rectangles as (x, y, w, h). 989 """ 990 991 parentX, parentY, parentW, parentH = parent 992 childW, childH = toDimensions(child) 993 994 if create == "columns": 995 if crossAlign not in ["top", "center", "bottom", "stretch", None]: 996 raise ValueError( 997 f"Invalid crossAlign for columns: {crossAlign}. Must be 'top', 'center', 'bottom', 'stretch', or None." 998 ) 999 totalChildW = childW * count 1000 gap = (parentW - totalChildW) / (count - 1) if count > 1 else 0 1001 xPositions = [parentX + (gap + childW) * i for i in range(count)] 1002 if count == 1: 1003 xPositions = [parentX + (parentW - childW) / 2] 1004 yPositions = [parentY] * count 1005 elif create == "rows": 1006 if crossAlign not in ["left", "center", "right", "stretch", None]: 1007 raise ValueError( 1008 f"Invalid crossAlign for rows: {crossAlign}. Must be 'left', 'center', 'right', 'stretch', or None." 1009 ) 1010 totalChildH = childH * count 1011 gap = (parentH - totalChildH) / (count - 1) if count > 1 else 0 1012 yPositions = [parentY + (gap + childH) * i for i in range(count)] 1013 if count == 1: 1014 yPositions = [parentY + (parentH - childH) / 2] 1015 xPositions = [parentX] * count 1016 else: 1017 raise ValueError(f"LayoutFlow must be 'columns' or 'rows', got {create}.") 1018 1019 justified = [] 1020 for x, y in zip(xPositions, yPositions): 1021 coords = (x, y, childW, childH) 1022 if crossAlign: 1023 position = (None, crossAlign) if create == "columns" else (crossAlign, None) 1024 coords = align(parent, coords, position=position, clip=False, nudgeY=0) 1025 justified.append(coords) 1026 1027 return justified 1028 1029 1030def canFitInside(parent: tuple, child: tuple) -> bool: 1031 """Returns True if the child fits inside the parent dimensions.""" 1032 parentW, parentH = parent if len(parent) == 2 else toDimensions(parent) 1033 childW, childH = child if len(child) == 2 else toDimensions(child) 1034 return childW <= parentW and childH <= parentH 1035 1036 1037def xray( 1038 shapes: list, 1039 color=None, 1040 stroke=0.5, 1041 pattern: "graphics.LinePattern" = "solid", 1042 enabled: bool = True, 1043): 1044 """Draw debug rectangles for given shapes. 1045 1046 Args: 1047 shapes: List of shape tuples. 1048 color: Optional color for rectangles. If None, random colors are used. 1049 stroke: Stroke width for rectangles. 1050 pattern: Stroke pattern, either 'solid' or 'dashed'. 1051 enabled: If True, draw the rectangles. Otherwise, do nothing. 1052 """ 1053 if not enabled: 1054 return 1055 1056 from datatypes import DLineStyle 1057 1058 with drawBot.savedState(): 1059 for shape in helpers.flattenTuples(shapes): 1060 c = color or [random.uniform(0, 1) for _ in range(3)] 1061 if stroke: 1062 DLineStyle( 1063 strokeWidth=stroke, 1064 strokeColor=c, 1065 lineDash=(1, 1) if pattern == "dashed" else None, 1066 lineCap="butt", 1067 clearFill=True, 1068 ).apply() 1069 else: 1070 drawBot.fill(*c) 1071 drawBot.rect(*shape)
Acceptable input types for unit parsing: numeric values, strings with units, or None.
Fallback unit for bare numeric values when no explicit unit suffix is provided.
Represents the four edges of a box.
Alias for standard page size strings recognized by DrawBot. See https://www.drawbot.com/content/canvas/pages.html#size for available sizes.
55def parsePageSize( 56 input: PageSize, implicitUnit: ImplicitUnit = "pt" 57) -> tuple[int, int]: 58 """Parse page size from various input formats. 59 Args: 60 input: Page size as a tuple (width, height) in points, a page size string (e.g., "A4", "A4Landscape"), or None to default to current DrawBot page size. 61 implicitUnit: Fallback unit for bare numeric values when no explicit unit suffix is provided. 62 Returns: 63 The page size as a tuple (width, height) in points. 64 """ 65 66 if isinstance(input, tuple) and len(input) == 2: 67 return tuple(parseUnit(value, implicitUnit=implicitUnit) for value in input) 68 elif isinstance(input, str): 69 try: 70 return drawBot.sizes(input) 71 except (ValueError, KeyError): 72 pass 73 try: 74 return unpackTwo(input, implicitUnit=implicitUnit) 75 except (ValueError, TypeError): 76 raise ValueError(f"Invalid page size: {input}") 77 elif input is None: 78 pageW, pageH = drawBot.width(), drawBot.height() 79 logger.trace( 80 f"Defaulting to {pt(pageW, rounded=True)}×{pt(pageH, rounded=True)}mm" 81 ) 82 return pageW, pageH 83 else: 84 raise ValueError(f"Invalid page size input: {input}")
Parse page size from various input formats.
Arguments:
- input: Page size as a tuple (width, height) in points, a page size string (e.g., "A4", "A4Landscape"), or None to default to current DrawBot page size.
- implicitUnit: Fallback unit for bare numeric values when no explicit unit suffix is provided.
Returns:
The page size as a tuple (width, height) in points.
90def parseAspectRatio(input: AspectRatioInput) -> float: 91 """Parse aspect ratio from various input formats. 92 Args: 93 input: Aspect ratio as a float, a string in "width:height" or "width/height" format, or a page size string (e.g., "A4", "A4Landscape"). 94 Returns: 95 The aspect ratio as a float (width divided by height). 96 """ 97 98 if isinstance(input, float): 99 return input 100 elif isinstance(input, tuple) and len(input) == 2: 101 width, height = input 102 return width / height 103 elif isinstance(input, str): 104 if "/" in input: 105 width, height = map(float, input.split("/")) 106 return width / height 107 elif ":" in input: 108 width, height = map(float, input.split(":")) 109 return width / height 110 else: 111 try: 112 pageW, pageH = drawBot.sizes(input) 113 return pageW / pageH 114 except ValueError: 115 raise ValueError(f"Invalid aspect ratio format: {input}")
Parse aspect ratio from various input formats.
Arguments:
- input: Aspect ratio as a float, a string in "width:height" or "width/height" format, or a page size string (e.g., "A4", "A4Landscape").
Returns:
The aspect ratio as a float (width divided by height).
118def pageIsEven() -> bool: 119 """Returns True if the current page count is even, otherwise False.""" 120 return True if drawBot.pageCount() % 2 == 0 else False
Returns True if the current page count is even, otherwise False.
131def mm(*values: int | float) -> float | tuple[float, ...]: 132 """ 133 Convert millimeters to points. 134 - mm(5) -> 14.173... 135 - mm(5, 10, 15) -> (14.173..., 28.346..., 42.519...) 136 - mm((5, 10)) -> (14.173..., 28.346...) 137 """ 138 if not values: 139 raise TypeError("mm() requires at least 1 numeric value") 140 141 if ( 142 len(values) == 1 143 and isinstance(values[0], Sequence) 144 and not isinstance(values[0], (str, bytes)) 145 ): 146 values = tuple(values[0]) 147 148 try: 149 converted = tuple(v * MM_TO_PT for v in values) 150 return converted[0] if len(converted) == 1 else converted 151 except TypeError as error: 152 raise TypeError(f"mm() only accepts numeric values, got {values}") from error
Convert millimeters to points.
- mm(5) -> 14.173...
- mm(5, 10, 15) -> (14.173..., 28.346..., 42.519...)
- mm((5, 10)) -> (14.173..., 28.346...)
155def cm(number: int) -> float: 156 """Convert centimeters to points. 157 158 Args: 159 number: Value in centimeters. 160 161 Returns: 162 Value converted to points. 163 """ 164 return mm(number * 10)
Convert centimeters to points.
Arguments:
- number: Value in centimeters.
Returns:
Value converted to points.
167def pt(number: int, rounded=False) -> float: 168 """Convert points to millimeters. 169 170 Args: 171 number: Value in points. 172 rounded: If True, round the result. 173 174 Returns: 175 Value converted to millimeters. 176 """ 177 value = MM_TO_INCH * number 178 return round(value) if rounded else value
Convert points to millimeters.
Arguments:
- number: Value in points.
- rounded: If True, round the result.
Returns:
Value converted to millimeters.
181def parseUnit( 182 value: UnitSource, 183 base: int | float | None = None, 184 implicitUnit: ImplicitUnit = "mm", 185) -> float | None: 186 """Parse a minimal unit format, optionally applying it to a base value. 187 188 Supported string units: `mm`, `m`, `pt`, `%`. 189 No whitespace or case normalization is performed. 190 191 With base: 192 - signed `%` applies a relative increment/decrement (`+10%`, 500 -> 550) 193 - unsigned `%` applies a fraction of base (`10%`, 500 -> 50) 194 - `mm`, `cm`, `pt`, and bare numbers apply absolute increment (`+ parsedValue`) 195 - Bare numbers use `implicitUnit` 196 197 Special `implicitUnit="auto"` behavior for numeric input: 198 - `abs(value) < 1` => decimal relative mode 199 - non-fractional numbers (`int`) => millimeters 200 - floating numbers (`float`) => points 201 202 For bare numeric strings, `auto` falls back to millimeter behavior. 203 """ 204 if base is not None and not isinstance(base, (int, float)): 205 raise TypeError(f"Unsupported base type: {type(base).__name__}") 206 207 if implicitUnit not in ("pt", "px", "mm", "cm", "decimal", "auto"): 208 raise ValueError(f"Unsupported implicit unit: {implicitUnit}") 209 210 if value is None: 211 return base if base is not None else None 212 213 if isinstance(value, (int, float)): 214 if implicitUnit == "auto": 215 parsed = float(value) 216 if abs(parsed) < 1: 217 return parsed if base is None else float(base) + (float(base) * parsed) 218 if isinstance(value, int): 219 parsed = mm(parsed) 220 return parsed if base is None else float(base) + parsed 221 222 if implicitUnit == "decimal" and abs(value) >= 1: 223 raise ValueError( 224 f"Decimal implicit unit requires values lower than 1, got {value}" 225 ) 226 parsed = float(value) 227 if implicitUnit == "mm": 228 parsed = mm(parsed) 229 elif implicitUnit == "cm": 230 parsed = cm(parsed) 231 if implicitUnit == "decimal": 232 return parsed if base is None else float(base) + (float(base) * parsed) 233 return parsed if base is None else float(base) + parsed 234 235 if not isinstance(value, str): 236 raise TypeError(f"Unsupported type: {type(value).__name__}") 237 238 if value.endswith("cm"): 239 parsed = cm(float(value[:-2])) 240 return parsed if base is None else float(base) + parsed 241 242 if value.endswith("mm"): 243 parsed = mm(float(value[:-2])) 244 return parsed if base is None else float(base) + parsed 245 246 if value.endswith("pt") or value.endswith("px"): 247 parsed = float(value[:-2]) 248 return parsed if base is None else float(base) + parsed 249 250 if value.endswith("%"): 251 percentValue = value[:-1] 252 ratio = float(percentValue) / 100 253 if base is None: 254 return ratio 255 if percentValue.startswith(("+", "-")): 256 return float(base) + (float(base) * ratio) 257 return float(base) * ratio 258 259 try: 260 effectiveImplicitUnit = "mm" if implicitUnit == "auto" else implicitUnit 261 parsed = float(value) 262 if effectiveImplicitUnit == "decimal" and abs(parsed) >= 1: 263 raise ValueError( 264 f"Decimal implicit unit requires values lower than 1, got {value}" 265 ) 266 if effectiveImplicitUnit == "mm": 267 parsed = mm(parsed) 268 elif effectiveImplicitUnit == "cm": 269 parsed = cm(parsed) 270 if effectiveImplicitUnit == "decimal": 271 return parsed if base is None else float(base) + (float(base) * parsed) 272 return parsed if base is None else float(base) + parsed 273 except ValueError as error: 274 raise ValueError(f"Unknown unit format: {value}") from error
Parse a minimal unit format, optionally applying it to a base value.
Supported string units: mm, m, pt, %.
No whitespace or case normalization is performed.
With base:
- signed
%applies a relative increment/decrement (+10%, 500 -> 550) - unsigned
%applies a fraction of base (10%, 500 -> 50) mm,cm,pt, and bare numbers apply absolute increment (+ parsedValue)- Bare numbers use
implicitUnit
Special implicitUnit="auto" behavior for numeric input:
abs(value) < 1=> decimal relative mode- non-fractional numbers (
int) => millimeters - floating numbers (
float) => points
For bare numeric strings, auto falls back to millimeter behavior.
277def isAutoRelative(value: UnitSource, implicitUnit: ImplicitUnit) -> bool: 278 """Return True when auto mode should treat a numeric value as decimal-relative.""" 279 return implicitUnit == "auto" and isinstance(value, (int, float)) and abs(value) < 1
Return True when auto mode should treat a numeric value as decimal-relative.
282def isBareNumber(value: UnitSource) -> bool: 283 """Return True for int/float or numeric strings without explicit unit suffix.""" 284 if isinstance(value, (int, float)): 285 return True 286 if not isinstance(value, str): 287 return False 288 if value.endswith(("cm", "mm", "pt", "px", "%")): 289 return False 290 try: 291 float(value) 292 return True 293 except ValueError: 294 return False
Return True for int/float or numeric strings without explicit unit suffix.
297def isInCentimeters(value: UnitSource) -> bool: 298 """Return True when value is a string ending in `cm` with a valid number prefix.""" 299 if not isinstance(value, str) or not value.endswith("cm"): 300 return False 301 try: 302 float(value[:-2]) 303 return True 304 except ValueError: 305 return False
Return True when value is a string ending in cm with a valid number prefix.
308def isInMillimeters(value: UnitSource) -> bool: 309 """Return True when value is a string ending in `mm` with a valid number prefix.""" 310 if not isinstance(value, str) or not value.endswith("mm"): 311 return False 312 try: 313 float(value[:-2]) 314 return True 315 except ValueError: 316 return False
Return True when value is a string ending in mm with a valid number prefix.
319def isInPoints(value: UnitSource) -> bool: 320 """Return True for numeric values, numeric strings, or strings ending in `pt` or `px`.""" 321 if isinstance(value, (int, float)): 322 return True 323 if not isinstance(value, str): 324 return False 325 if value.endswith("pt") or value.endswith("px"): 326 try: 327 float(value[:-2]) 328 return True 329 except ValueError: 330 return False 331 try: 332 float(value) 333 return True 334 except ValueError: 335 return False
Return True for numeric values, numeric strings, or strings ending in pt or px.
338def isInPercent(value: UnitSource) -> bool: 339 """Return True when value is a string ending in `%` with a valid number prefix.""" 340 if not isinstance(value, str) or not value.endswith("%"): 341 return False 342 try: 343 float(value[:-1]) 344 return True 345 except ValueError: 346 return False
Return True when value is a string ending in % with a valid number prefix.
Represents a rectangle as (x, y, width, height).
Represents dimensions as (width, height).
356def getPageCoords() -> Coordinates: 357 """Returns current page `x, y, w, h`.""" 358 return 0, 0, drawBot.width(), drawBot.height()
Returns current page x, y, w, h.
361def normalize(value: tuple) -> Coordinates: 362 """ 363 Normalize tuple of `n` to 4 and parse `pageSize (str)`. 364 365 Args: 366 value: Tuple or page size string. 367 368 Returns: 369 Tuple of (x, y, w, h). 370 """ 371 if isinstance(value, str): 372 value = parsePageSize(value) 373 374 if isinstance(value, tuple) and len(value) == 4: 375 x, y, w, h = value 376 else: 377 x, y, w, h = 0, 0, *value 378 379 return x, y, w, h
Normalize tuple of n to 4 and parse pageSize (str).
Arguments:
- value: Tuple or page size string.
Returns:
Tuple of (x, y, w, h).
382def mirror(values: tuple) -> tuple: 383 """Flip values horizontally. 384 385 Args: 386 values: Tuple of values to mirror. 387 388 Returns: 389 Mirrored tuple. 390 """ 391 values = helpers.coerceList(values) 392 393 if len(values) == 4: 394 top, outer, bottom, inner = values 395 return top, inner, bottom, outer 396 elif len(values) == 2: 397 outer, inner = values 398 return inner, outer 399 else: 400 return values
Flip values horizontally.
Arguments:
- values: Tuple of values to mirror.
Returns:
Mirrored tuple.
403def toDimensions(value: tuple) -> Dimensions: 404 """Returns `w, h`.""" 405 _, _, w, h = normalize(value) 406 return w, h
Returns w, h.
409def toWidth(value: tuple | str) -> int: 410 """ 411 Returns width from coords tuple or text string. 412 413 Args: 414 value: Coords tuple or text string. 415 """ 416 if isinstance(value, str): 417 return drawBot.textSize(value)[0] 418 # Assume it’s already in correct format if number 419 elif isinstance(value, (int, float)): 420 return value 421 else: 422 w, _ = toDimensions(value) 423 return w
Returns width from coords tuple or text string.
Arguments:
- value: Coords tuple or text string.
426def toHeight(value: tuple | str) -> int: 427 """ 428 Returns height from coords tuple or text string. 429 430 Args: 431 value: Coords tuple or text string. 432 """ 433 if isinstance(value, str): 434 return drawBot.textSize(value)[1] 435 # Assume it’s already in correct format if number 436 elif isinstance(value, (int, float)): 437 return value 438 else: 439 _, h = toDimensions(value) 440 return h
Returns height from coords tuple or text string.
Arguments:
- value: Coords tuple or text string.
443def toPosition(value: tuple) -> tuple[int, int]: 444 """Returns `x, y`.""" 445 x, y, _, _ = normalize(value) 446 return x, y
Returns x, y.
449def toCoords(value: tuple) -> Coordinates: 450 """Returns `x, y, w, h`.""" 451 return normalize(value)
Returns x, y, w, h.
454def toCenter(value: tuple) -> tuple[int, int]: 455 """Returns `x, y` center points.""" 456 x, y, w, h = toCoords(value) 457 return x + w / 2, y + h / 2
Returns x, y center points.
460def unpackTwo( 461 values, 462 separator=" ", 463 implicitUnit: ImplicitUnit = "mm", 464 parse=True, 465): 466 """Unpack two values and infer units automatically. 467 468 Args: 469 values: Values to unpack. 470 separator: Separator for string input. 471 472 Returns: 473 Tuple of two values, converted to `UnitSource`. 474 """ 475 # List coercion 476 values = helpers.coerceNumericList(values, separator) 477 478 # Manipulate tuple 479 if len(values) == 2: 480 x, y = values 481 else: 482 for side in values: 483 x = y = side 484 485 if not parse: 486 return x, y 487 488 return tuple(parseUnit(side, implicitUnit=implicitUnit) for side in [x, y])
Unpack two values and infer units automatically.
Arguments:
- values: Values to unpack.
- separator: Separator for string input.
Returns:
Tuple of two values, converted to
UnitSource.
491def unpackFour( 492 values, 493 separator=" ", 494 implicitUnit: ImplicitUnit = "mm", 495 parse=True, 496): 497 """Unpack four values and infer units automatically. 498 499 Args: 500 values: Values to unpack. 501 separator: Separator for string input. 502 503 Returns: 504 Tuple of four values, converted to `UnitSource`. 505 """ 506 # List coercion 507 values = helpers.coerceNumericList(values, separator) 508 509 # Manipulate tuple 510 if len(values) == 4: 511 top, right, bottom, left = values 512 elif len(values) == 3: 513 top, right, bottom = values 514 left = right 515 elif len(values) == 2: 516 y, x = values 517 top = bottom = y 518 left = right = x 519 else: 520 for side in values: 521 top = right = bottom = left = side 522 523 if not parse: 524 return top, right, bottom, left 525 526 return tuple( 527 parseUnit(side, implicitUnit=implicitUnit) 528 for side in [top, right, bottom, left] 529 )
Unpack four values and infer units automatically.
Arguments:
- values: Values to unpack.
- separator: Separator for string input.
Returns:
Tuple of four values, converted to
UnitSource.
532def move(shape: Coordinates, x=0, y=0, implicitUnit: ImplicitUnit = "mm"): 533 """ 534 Move a shape by x and y offsets. 535 536 Args: 537 shape: Shape tuple (x, y, w, h). 538 x: Horizontal offset. Positive values move right, negative values move left. 539 y: Vertical offset. Positive values move up, negative values move down. 540 541 Returns: 542 Moved shape as (x, y, w, h). 543 """ 544 shapeX, shapeY, shapeW, shapeH = shape 545 546 rawX, rawY = unpackTwo((x, y), implicitUnit=implicitUnit, parse=False) 547 x, y = unpackTwo((rawX, rawY), implicitUnit=implicitUnit) 548 549 xIsRelative = ( 550 isInPercent(rawX) 551 or (implicitUnit == "decimal" and isBareNumber(rawX)) 552 or isAutoRelative(rawX, implicitUnit) 553 ) 554 yIsRelative = ( 555 isInPercent(rawY) 556 or (implicitUnit == "decimal" and isBareNumber(rawY)) 557 or isAutoRelative(rawY, implicitUnit) 558 ) 559 560 if xIsRelative: 561 x = shapeW * x 562 if yIsRelative: 563 y = shapeH * y 564 565 return shapeX + x, shapeY + y, shapeW, shapeH
Move a shape by x and y offsets.
Arguments:
- shape: Shape tuple (x, y, w, h).
- x: Horizontal offset. Positive values move right, negative values move left.
- y: Vertical offset. Positive values move up, negative values move down.
Returns:
Moved shape as (x, y, w, h).
568def grow( 569 frame: Coordinates, 570 margin: UnitSource, 571 implicitUnit: ImplicitUnit = "mm", 572 debug=False, 573) -> Coordinates: 574 """Grow in clock-wise manner. 575 576 Args: 577 frame: Frame tuple. 578 margin: Margin values. 579 debug: If True, draw debug overlay. 580 581 Returns: 582 Grown frame as (x, y, w, h). 583 """ 584 frameX, frameY, frameW, frameH = toCoords(frame) 585 rawMargin = unpackFour(margin, implicitUnit=implicitUnit, parse=False) 586 margin = unpackFour(rawMargin, implicitUnit=implicitUnit) 587 588 hasRelativeMargin = any( 589 isInPercent(side) 590 or (implicitUnit == "decimal" and isBareNumber(side)) 591 or isAutoRelative(side, implicitUnit) 592 for side in rawMargin 593 ) 594 595 if hasRelativeMargin: 596 frameSize = (frameH, frameW, frameH, frameW) 597 margin = [ 598 ( 599 f * m 600 if isInPercent(raw) 601 or (implicitUnit == "decimal" and isBareNumber(raw)) 602 or isAutoRelative(raw, implicitUnit) 603 else m 604 ) 605 for f, m, raw in zip(frameSize, margin, rawMargin) 606 ] 607 608 mTop, mRight, mBottom, mLeft = margin 609 x, y = frameX - mLeft, frameY - mBottom 610 w, h = frameW + (mLeft + mRight), frameH + (mTop + mBottom) 611 612 coords = x, y, w, h 613 614 if debug: 615 xray(coords) 616 617 return coords
Grow in clock-wise manner.
Arguments:
- frame: Frame tuple.
- margin: Margin values.
- debug: If True, draw debug overlay.
Returns:
Grown frame as (x, y, w, h).
620def shrink( 621 frame: Coordinates, 622 margin: UnitSource, 623 implicitUnit: ImplicitUnit = "mm", 624 debug=False, 625) -> Coordinates: 626 """Shrink in clock-wise manner. 627 628 Args: 629 frame: Frame tuple. 630 margin: Margin values. 631 implicitUnit: Implicit unit for margin values. 632 debug: If True, draw debug overlay. 633 634 Returns: 635 Shrunk frame as (x, y, w, h). 636 """ 637 # Inverse margin 638 margin = helpers.inverseValues(margin) 639 return grow(frame, margin, implicitUnit=implicitUnit, debug=debug)
Shrink in clock-wise manner.
Arguments:
- frame: Frame tuple.
- margin: Margin values.
- implicitUnit: Implicit unit for margin values.
- debug: If True, draw debug overlay.
Returns:
Shrunk frame as (x, y, w, h).
642def shrinkWidth( 643 frame: tuple, 644 amount: UnitSource, 645 origin: Literal["left", "right"] = "left", 646 implicitUnit: ImplicitUnit = "decimal", 647): 648 """Shorthand to decrease width. 649 650 Args: 651 frame: Frame tuple. 652 amount: Amount to shrink. 653 origin: Side to shrink from ('left' or 'right'). 654 655 Returns: 656 Frame with reduced width. 657 """ 658 margin = (0, amount, 0, 0) if origin == "left" else (0, 0, 0, amount) 659 return shrink(frame, margin=margin, implicitUnit=implicitUnit)
Shorthand to decrease width.
Arguments:
- frame: Frame tuple.
- amount: Amount to shrink.
- origin: Side to shrink from ('left' or 'right').
Returns:
Frame with reduced width.
662def shrinkHeight( 663 frame: tuple, 664 amount: UnitSource, 665 origin: Literal["top", "bottom"] = "top", 666 implicitUnit: ImplicitUnit = "decimal", 667): 668 """Shorthand to decrease height. 669 670 Args: 671 frame: Frame tuple. 672 amount: Amount to shrink. 673 origin: Side to shrink from ('top' or 'bottom'). 674 implicitUnit: Implicit unit for amount (default 'decimal'). 675 676 677 Returns: 678 Frame with reduced height. 679 """ 680 margin = (amount, 0, 0) if origin == "bottom" else (0, 0, amount) 681 return shrink(frame, margin=margin, implicitUnit=implicitUnit)
Shorthand to decrease height.
Arguments:
- frame: Frame tuple.
- amount: Amount to shrink.
- origin: Side to shrink from ('top' or 'bottom').
- implicitUnit: Implicit unit for amount (default 'decimal').
Returns:
Frame with reduced height.
688def splitRelative( 689 coords: Coordinates, 690 formula: str = "3/1", 691 gap: UnitSource = 5, 692 gapImplicitUnit: ImplicitUnit = "mm", 693 create: LayoutFlow = "columns", 694 debug=False, 695) -> list[Coordinates]: 696 """ 697 Split area by specified ratio. 698 699 Args: 700 coords: Container coordinates. 701 formula: Ratio formula (e.g. '3/1'). 702 gap: Gap between splits. 703 gapImplicitUnit: Implicit unit for gap. 704 create: Split direction ('columns' or 'rows'). 705 debug: If True, draw debug overlay. 706 707 Returns: 708 List of split rectangles as (x, y, w, h). 709 710 Example: 711 `3/1` => `75% 25%` 712 """ 713 items = [float(item) for item in formula.split("/")] 714 length = len(items) 715 count = sum(items) 716 717 gap = parseUnit(gap, implicitUnit=gapImplicitUnit) 718 719 gapCount = length - 1 720 721 containerX, containerY, containerW, containerH = coords 722 723 if create == "rows": 724 dimension = containerH 725 itemW = containerW 726 else: 727 dimension = containerW 728 itemH = containerH 729 730 available = dimension - (gap * gapCount) 731 unit = available / count 732 733 result = [] 734 x, y = containerX, containerY + containerH 735 736 for item in items: 737 itemDimension = item * unit 738 739 if create == "rows": 740 itemH = itemDimension 741 else: 742 itemW = itemDimension 743 744 coords = x, y - itemH, itemW, itemH 745 result.append(coords) 746 747 if create == "rows": 748 y -= itemH + gap 749 else: 750 x += itemW + gap 751 752 if debug: 753 xray(result) 754 755 return result
Split area by specified ratio.
Arguments:
- coords: Container coordinates.
- formula: Ratio formula (e.g. '3/1').
- gap: Gap between splits.
- gapImplicitUnit: Implicit unit for gap.
- create: Split direction ('columns' or 'rows').
- debug: If True, draw debug overlay.
Returns:
List of split rectangles as (x, y, w, h).
Example:
3/1=>75% 25%
758def splitAbsolute( 759 coords: Coordinates, 760 create: LayoutFlow, 761 origin: LayoutOrigin, 762 size: UnitSource, 763 gap: UnitSource, 764 implicitUnit: ImplicitUnit = "mm", 765) -> tuple[Coordinates, Coordinates]: 766 """ 767 Splits a container into two rectangles along the specified axis, with a gap in between. 768 769 Args: 770 coords: (x, y, w, h) of the container. 771 create: Split direction — 'columns' (vertical split) or 'rows' (horizontal split). 772 size: Size of the new rectangle along the split axis. 773 gap: Gap between the two rectangles. 774 implicitUnit: Implicit unit for size and gap. 775 origin: Side the new rectangle is created from. 776 - 'start': left edge for columns, top edge for rows. 777 - 'end': right edge for columns, bottom edge for rows. 778 779 Returns: 780 tuple: (new_rect, remainder_rect) 781 """ 782 x, y, w, h = coords 783 size = parseUnit(size, implicitUnit=implicitUnit) 784 gap = parseUnit(gap, implicitUnit=implicitUnit) 785 786 if create == "columns": 787 if origin == "start": 788 new_rect = (x, y, size, h) 789 remainder_rect = (x + size + gap, y, w - size - gap, h) 790 else: # end 791 new_rect = (x + w - size, y, size, h) 792 remainder_rect = (x, y, w - size - gap, h) 793 elif create == "rows": 794 if origin == "start": 795 new_rect = (x, y + h - size, w, size) 796 remainder_rect = (x, y, w, h - size - gap) 797 else: # end 798 new_rect = (x, y, w, size) 799 remainder_rect = (x, y + size + gap, w, h - size - gap) 800 else: 801 raise ValueError(f"LayoutFlow must be 'columns' or 'rows', got {create}.") 802 return new_rect, remainder_rect
Splits a container into two rectangles along the specified axis, with a gap in between.
Arguments:
- coords: (x, y, w, h) of the container.
- create: Split direction — 'columns' (vertical split) or 'rows' (horizontal split).
- size: Size of the new rectangle along the split axis.
- gap: Gap between the two rectangles.
- implicitUnit: Implicit unit for size and gap.
- origin: Side the new rectangle is created from.
- 'start': left edge for columns, top edge for rows.
- 'end': right edge for columns, bottom edge for rows.
Returns:
tuple: (new_rect, remainder_rect)
805def inferBlockHeight(lines: int) -> float: 806 """Calculate block height from number of lines and current font settings.""" 807 fontFilePath = drawBot.fontFilePath() 808 if fontFilePath == DEFAULT_FONT: 809 logger.warning("Default font used to infer line height.") 810 811 _, lineHeight = drawBot.textSize("\n".join(["H" for _ in range(lines)])) 812 if any(font in fontFilePath for font in [DEFAULT_FONT, "Arial.ttf"]): 813 # ! Weird edge case: Some system fonts report lower line height, causing text to overflow. 814 return ceil(lineHeight + 0.1) 815 return lineHeight
Calculate block height from number of lines and current font settings.
818def inferGridItemDimensions( 819 container: Dimensions, 820 rows: int = 1, 821 cols: int = 1, 822 gutter: UnitSource = "5mm", 823) -> Dimensions: 824 """Calculate grid item dimensions based on container size, row/column count, and gutter.""" 825 w, h = toDimensions(container) 826 gutterX, gutterY = unpackTwo(gutter) 827 828 itemW = (w - (gutterX * (cols - 1))) / cols 829 itemH = (h - (gutterY * (rows - 1))) / rows 830 831 return itemW, itemH
Calculate grid item dimensions based on container size, row/column count, and gutter.
834def makeGrid( 835 container: Coordinates, 836 rows: int = 1, 837 cols: int = 1, 838 gutter: UnitSource = "5mm", 839 debug: bool = False, 840) -> list[Coordinates]: 841 """Create a grid of cells within a container. 842 843 Args: 844 container: Container rectangle (x, y, w, h). 845 rows: Number of rows. 846 cols: Number of columns. 847 gutter: Gutter size between cells. 848 debug: If True, draw debug overlay. 849 850 Returns: 851 List of cell rectangles as (x, y, w, h). 852 """ 853 containerX, containerY, containerW, containerH = container 854 containerDims = containerW, containerH 855 gutterX, gutterY = unpackTwo(gutter) 856 cells: list[Coordinates] = [] 857 858 # Cell width, height 859 w, h = inferGridItemDimensions(containerDims, rows, cols, gutter) 860 861 for row in range(rows): 862 offsetY = (h + gutterY) * row 863 y = containerY + (containerH - h) - offsetY 864 865 for col in range(cols): 866 offsetX = (w + gutterX) * col 867 x = containerX + offsetX 868 869 coords = (x, y, w, h) 870 cells.append(coords) 871 872 if debug: 873 xray(coords) 874 875 return cells
Create a grid of cells within a container.
Arguments:
- container: Container rectangle (x, y, w, h).
- rows: Number of rows.
- cols: Number of columns.
- gutter: Gutter size between cells.
- debug: If True, draw debug overlay.
Returns:
List of cell rectangles as (x, y, w, h).
Horizontal alignment options.
Vertical alignment options.
885def align( 886 parent: tuple, 887 child: tuple, 888 position: tuple[AlignX, AlignY] = ("center", "center"), 889 clip=True, 890 debug=False, 891 output: Literal["position", "coords"] = "coords", 892 nudgeY: float = 1.0, 893) -> tuple: 894 """ 895 Align a child element within a parent rectangle. 896 897 Args: 898 parent: Parent rectangle (x, y, w, h). 899 child: Child rectangle or text. 900 position: Tuple of horizontal and vertical alignment. 901 clip: If True, clip child to parent size. 902 debug: If True, draw debug overlay. 903 output: Return type, either 'position' (tuple of 2) or 'coords' (tuple of 4). 904 nudgeY: Optical vertical nudge strength for centered alignment. 905 `0` disables nudge entirely, `1` applies the default compensation, 906 and larger values increase compensation. 907 908 Returns: 909 Aligned `position` or `coords` (based on output). 910 """ 911 positionX, positionY = unpackTwo(position, parse=False) 912 parentX, parentY, parentW, parentH = parent 913 914 childX, childY = toPosition(child) 915 childW, childH = toDimensions(child) 916 917 # Dimensions 918 if positionX in ["stretch", 3]: 919 w = parentW 920 elif clip: 921 w = min(parentW, childW) 922 else: 923 w = childW 924 925 if positionY in ["stretch", 3]: 926 h = parentH 927 elif clip: 928 h = min(parentH, childH) 929 else: 930 h = childH 931 932 difW, difH = parentW - w, parentH - h 933 934 # Position 935 if positionX in ["left", 0, "stretch", 3]: 936 x = parentX 937 elif positionX in ["right", 2]: 938 x = parentX + difW 939 elif positionX in ["center", 1]: 940 x = parentX + difW / 2 941 else: 942 # None = Leave as is 943 x = childX 944 945 if positionY in ["top", 0]: 946 y = parentY + difH 947 elif positionY in ["bottom", 2, "stretch", 3]: 948 y = parentY 949 elif positionY in ["center", 1]: 950 y = parentY + difH / 2 951 else: 952 # None = Leave as is 953 y = childY 954 955 if positionY == "center" and nudgeY > 0 and parentH > 0 and childH <= parentH: 956 # Default optical base is 5% of the available vertical room. 957 baseNudge = 0.05 * max(0, difH) 958 959 # Optical compensation fades out as child height approaches parent height. 960 ratio = childH / parentH 961 opticalFactor = (1 - min(1, ratio)) ** 2 962 y += baseNudge * opticalFactor * nudgeY 963 964 coords = x, y, w, h 965 966 if debug: 967 xray(coords) 968 969 return coords if output == "coords" else toPosition(coords)
Align a child element within a parent rectangle.
Arguments:
- parent: Parent rectangle (x, y, w, h).
- child: Child rectangle or text.
- position: Tuple of horizontal and vertical alignment.
- clip: If True, clip child to parent size.
- debug: If True, draw debug overlay.
- output: Return type, either 'position' (tuple of 2) or 'coords' (tuple of 4).
- nudgeY: Optical vertical nudge strength for centered alignment.
0disables nudge entirely,1applies the default compensation, and larger values increase compensation.
Returns:
Aligned
positionorcoords(based on output).
972def justify( 973 parent: Coordinates, 974 child: Coordinates, 975 count: int, 976 create: LayoutFlow, 977 crossAlign: AlignX | AlignY | None = None, 978) -> list[Coordinates]: 979 """Justify child elements evenly within a parent rectangle. 980 981 Args: 982 parent: Parent rectangle (x, y, w, h). 983 child: Child rectangle (used for size reference). 984 count: Number of child elements to justify. 985 create: Justification direction ('columns' or 'rows'). 986 crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent. 987 988 Returns: 989 List of child rectangles as (x, y, w, h). 990 """ 991 992 parentX, parentY, parentW, parentH = parent 993 childW, childH = toDimensions(child) 994 995 if create == "columns": 996 if crossAlign not in ["top", "center", "bottom", "stretch", None]: 997 raise ValueError( 998 f"Invalid crossAlign for columns: {crossAlign}. Must be 'top', 'center', 'bottom', 'stretch', or None." 999 ) 1000 totalChildW = childW * count 1001 gap = (parentW - totalChildW) / (count - 1) if count > 1 else 0 1002 xPositions = [parentX + (gap + childW) * i for i in range(count)] 1003 if count == 1: 1004 xPositions = [parentX + (parentW - childW) / 2] 1005 yPositions = [parentY] * count 1006 elif create == "rows": 1007 if crossAlign not in ["left", "center", "right", "stretch", None]: 1008 raise ValueError( 1009 f"Invalid crossAlign for rows: {crossAlign}. Must be 'left', 'center', 'right', 'stretch', or None." 1010 ) 1011 totalChildH = childH * count 1012 gap = (parentH - totalChildH) / (count - 1) if count > 1 else 0 1013 yPositions = [parentY + (gap + childH) * i for i in range(count)] 1014 if count == 1: 1015 yPositions = [parentY + (parentH - childH) / 2] 1016 xPositions = [parentX] * count 1017 else: 1018 raise ValueError(f"LayoutFlow must be 'columns' or 'rows', got {create}.") 1019 1020 justified = [] 1021 for x, y in zip(xPositions, yPositions): 1022 coords = (x, y, childW, childH) 1023 if crossAlign: 1024 position = (None, crossAlign) if create == "columns" else (crossAlign, None) 1025 coords = align(parent, coords, position=position, clip=False, nudgeY=0) 1026 justified.append(coords) 1027 1028 return justified
Justify child elements evenly within a parent rectangle.
Arguments:
- parent: Parent rectangle (x, y, w, h).
- child: Child rectangle (used for size reference).
- count: Number of child elements to justify.
- create: Justification direction ('columns' or 'rows').
- crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent.
Returns:
List of child rectangles as (x, y, w, h).
1031def canFitInside(parent: tuple, child: tuple) -> bool: 1032 """Returns True if the child fits inside the parent dimensions.""" 1033 parentW, parentH = parent if len(parent) == 2 else toDimensions(parent) 1034 childW, childH = child if len(child) == 2 else toDimensions(child) 1035 return childW <= parentW and childH <= parentH
Returns True if the child fits inside the parent dimensions.
1038def xray( 1039 shapes: list, 1040 color=None, 1041 stroke=0.5, 1042 pattern: "graphics.LinePattern" = "solid", 1043 enabled: bool = True, 1044): 1045 """Draw debug rectangles for given shapes. 1046 1047 Args: 1048 shapes: List of shape tuples. 1049 color: Optional color for rectangles. If None, random colors are used. 1050 stroke: Stroke width for rectangles. 1051 pattern: Stroke pattern, either 'solid' or 'dashed'. 1052 enabled: If True, draw the rectangles. Otherwise, do nothing. 1053 """ 1054 if not enabled: 1055 return 1056 1057 from datatypes import DLineStyle 1058 1059 with drawBot.savedState(): 1060 for shape in helpers.flattenTuples(shapes): 1061 c = color or [random.uniform(0, 1) for _ in range(3)] 1062 if stroke: 1063 DLineStyle( 1064 strokeWidth=stroke, 1065 strokeColor=c, 1066 lineDash=(1, 1) if pattern == "dashed" else None, 1067 lineCap="butt", 1068 clearFill=True, 1069 ).apply() 1070 else: 1071 drawBot.fill(*c) 1072 drawBot.rect(*shape)
Draw debug rectangles for given shapes.
Arguments:
- shapes: List of shape tuples.
- color: Optional color for rectangles. If None, random colors are used.
- stroke: Stroke width for rectangles.
- pattern: Stroke pattern, either 'solid' or 'dashed'.
- enabled: If True, draw the rectangles. Otherwise, do nothing.