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)
MM_TO_INCH = 0.35277777777777775
MM_TO_PT = 2.834645669291339
UnitSource: TypeAlias = int | float | str | None

Acceptable input types for unit parsing: numeric values, strings with units, or None.

ImplicitUnit: TypeAlias = Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto']

Fallback unit for bare numeric values when no explicit unit suffix is provided.

BoxEdge: TypeAlias = Literal['top', 'right', 'bottom', 'left']

Represents the four edges of a box.

PageSizeName = typing.Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape']

Alias for standard page size strings recognized by DrawBot. See https://www.drawbot.com/content/canvas/pages.html#size for available sizes.

PageSize = typing.Union[typing.Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape'], tuple[int, int], NoneType]
def parsePageSize( input: Union[Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape'], tuple[int, int], NoneType], implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'pt') -> tuple[int, int]:
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.

AspectRatioInput = typing.Union[typing.Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape'], str, float, tuple[int, int]]
def parseAspectRatio( input: Union[Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape'], str, float, tuple[int, int]]) -> float:
 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).

def pageIsEven() -> bool:
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.

def mm(*values: int | float) -> float | tuple[float, ...]:
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...)
def cm(number: int) -> float:
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.

def pt(number: int, rounded=False) -> float:
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.

def parseUnit( value: int | float | str | None, base: int | float | None = None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> float | None:
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.

def isAutoRelative( value: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto']) -> bool:
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.

def isBareNumber(value: int | float | str | None) -> bool:
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.

def isInCentimeters(value: int | float | str | None) -> bool:
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.

def isInMillimeters(value: int | float | str | None) -> bool:
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.

def isInPoints(value: int | float | str | None) -> bool:
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.

def isInPercent(value: int | float | str | None) -> bool:
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.

Coordinates = tuple[float, float, float, float]

Represents a rectangle as (x, y, width, height).

Dimensions = tuple[float, float]

Represents dimensions as (width, height).

def getPageCoords() -> tuple[float, float, float, float]:
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.

def normalize(value: tuple) -> tuple[float, float, float, float]:
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).

def mirror(values: tuple) -> tuple:
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.

def toDimensions(value: tuple) -> tuple[float, float]:
403def toDimensions(value: tuple) -> Dimensions:
404    """Returns `w, h`."""
405    _, _, w, h = normalize(value)
406    return w, h

Returns w, h.

def toWidth(value: tuple | str) -> int:
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.
def toHeight(value: tuple | str) -> int:
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.
def toPosition(value: tuple) -> tuple[int, int]:
443def toPosition(value: tuple) -> tuple[int, int]:
444    """Returns `x, y`."""
445    x, y, _, _ = normalize(value)
446    return x, y

Returns x, y.

def toCoords(value: tuple) -> tuple[float, float, float, float]:
449def toCoords(value: tuple) -> Coordinates:
450    """Returns `x, y, w, h`."""
451    return normalize(value)

Returns x, y, w, h.

def toCenter(value: tuple) -> tuple[int, int]:
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.

def unpackTwo( values, separator=' ', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', parse=True):
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.

def unpackFour( values, separator=' ', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', parse=True):
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.

def move( shape: tuple[float, float, float, float], x=0, y=0, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm'):
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).

def grow( frame: tuple[float, float, float, float], margin: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', debug=False) -> tuple[float, float, float, float]:
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).

def shrink( frame: tuple[float, float, float, float], margin: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', debug=False) -> tuple[float, float, float, float]:
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).

def shrinkWidth( frame: tuple, amount: int | float | str | None, origin: Literal['left', 'right'] = 'left', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'decimal'):
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.

def shrinkHeight( frame: tuple, amount: int | float | str | None, origin: Literal['top', 'bottom'] = 'top', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'decimal'):
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.

LayoutFlow = typing.Literal['rows', 'columns']
LayoutOrigin = typing.Literal['start', 'end']
def splitRelative( coords: tuple[float, float, float, float], formula: str = '3/1', gap: int | float | str | None = 5, gapImplicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', create: Literal['rows', 'columns'] = 'columns', debug=False) -> list[tuple[float, float, float, float]]:
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%

def splitAbsolute( coords: tuple[float, float, float, float], create: Literal['rows', 'columns'], origin: Literal['start', 'end'], size: int | float | str | None, gap: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> tuple[tuple[float, float, float, float], tuple[float, float, float, float]]:
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)

def inferBlockHeight(lines: int) -> float:
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.

def inferGridItemDimensions( container: tuple[float, float], rows: int = 1, cols: int = 1, gutter: int | float | str | None = '5mm') -> tuple[float, float]:
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.

def makeGrid( container: tuple[float, float, float, float], rows: int = 1, cols: int = 1, gutter: int | float | str | None = '5mm', debug: bool = False) -> list[tuple[float, float, float, float]]:
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).

AlignX: TypeAlias = Literal['left', 'center', 'right', 'stretch', None]

Horizontal alignment options.

AlignY: TypeAlias = Literal['top', 'center', 'bottom', 'stretch', None]

Vertical alignment options.

def align( parent: tuple, child: tuple, position: tuple[typing.Literal['left', 'center', 'right', 'stretch', None], typing.Literal['top', 'center', 'bottom', 'stretch', None]] = ('center', 'center'), clip=True, debug=False, output: Literal['position', 'coords'] = 'coords', nudgeY: float = 1.0) -> tuple:
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. 0 disables nudge entirely, 1 applies the default compensation, and larger values increase compensation.
Returns:

Aligned position or coords (based on output).

def justify( parent: tuple[float, float, float, float], child: tuple[float, float, float, float], count: int, create: Literal['rows', 'columns'], crossAlign: Union[Literal['left', 'center', 'right', 'stretch', None], Literal['top', 'center', 'bottom', 'stretch', None], NoneType] = None) -> list[tuple[float, float, float, float]]:
 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).

def canFitInside(parent: tuple, child: tuple) -> bool:
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.

def xray( shapes: list, color=None, stroke=0.5, pattern: Literal['solid', 'dashed'] = 'solid', enabled: bool = True):
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.