lib.layout
1import drawBot 2import random 3from typing import TypeAlias, Literal 4from loguru import logger 5from icecream import ic 6 7from lib import helpers 8 9UnitType: TypeAlias = Literal["mm", "pt"] 10"""Unit type for measurement, either millimeters ('mm') or points ('pt').""" 11 12UnitMode: TypeAlias = Literal["mm", "pt", "relative"] 13"""Mode for interpreting units: millimeters, points, or relative (percentage-based).""" 14 15BoxEdge: TypeAlias = Literal["top", "right", "bottom", "left"] 16"""Represents the four edges of a box.""" 17 18PageSize: TypeAlias = None | str | tuple[int, int] 19"""Acceptable page size formats: 20 21- `None` 22 - Default to current page size 23- `str` 24 - Page size name (e.g. `A4`, `Letter`, etc.) 25 - See https://www.drawbot.com/content/canvas/pages.html#size for available sizes 26- `tuple` 27 - explicit `(width, height)` in points 28""" 29 30 31def pageIsEven() -> bool: 32 """Returns True if the current page count is even, otherwise False.""" 33 return True if drawBot.pageCount() % 2 == 0 else False 34 35 36def mm(number: int) -> float: 37 """Convert millimeters to points. 38 39 Args: 40 number: Value in millimeters. 41 42 Returns: 43 Value converted to points. 44 """ 45 mmToInch = 25.4 / 72 46 return number / mmToInch 47 48 49def cm(number: int) -> float: 50 """Convert centimeters to points. 51 52 Args: 53 number: Value in centimeters. 54 55 Returns: 56 Value converted to points. 57 """ 58 return mm(number * 10) 59 60 61def pt(number: int, rounded=False) -> float: 62 """Convert points to millimeters. 63 64 Args: 65 number: Value in points. 66 rounded: If True, round the result. 67 68 Returns: 69 Value converted to millimeters. 70 """ 71 mmToInch = 25.4 / 72 72 value = mmToInch * number 73 return round(value) if rounded else value 74 75 76def getPageCoords() -> tuple[int]: 77 """Returns current page `x, y, w, h`.""" 78 return 0, 0, drawBot.width(), drawBot.height() 79 80 81def parsePageSize(value: PageSize) -> tuple[int, int]: 82 """Parse `PageSize` into a tuple. 83 84 Args: 85 value: Page size in one of the accepted formats. 86 87 Returns: 88 Tuple of (width, height) in points. 89 """ 90 if value == None: 91 pageW, pageH = drawBot.width(), drawBot.height() 92 logger.trace( 93 f"Defaulting to {pt(pageW, rounded=True)}×{pt(pageH, rounded=True)}mm" 94 ) 95 return pageW, pageH 96 elif isinstance(value, str): 97 try: 98 return drawBot.sizes(value) 99 except: 100 raise ValueError( 101 f"Invalid page size: {value}. Use a tuple or a valid page size string." 102 ) 103 else: 104 return value 105 106 107def normalize(value: tuple) -> tuple: 108 """ 109 Normalize tuple of `n` to 4 and parse `pageSize (str)`. 110 111 Args: 112 value: Tuple or page size string. 113 114 Returns: 115 Tuple of (x, y, w, h). 116 """ 117 value = parsePageSize(value) 118 119 if isinstance(value, tuple) and len(value) == 4: 120 x, y, w, h = value 121 else: 122 x, y, w, h = 0, 0, *value 123 124 return x, y, w, h 125 126 127def mirror(values: tuple) -> tuple: 128 """Flip values horizontally. 129 130 Args: 131 values: Tuple of values to mirror. 132 133 Returns: 134 Mirrored tuple. 135 """ 136 values = helpers.coerceList(values) 137 138 if len(values) == 4: 139 top, outer, bottom, inner = values 140 return top, inner, bottom, outer 141 elif len(values) == 2: 142 outer, inner = values 143 return inner, outer 144 else: 145 return values 146 147 148def toDimensions(value: tuple) -> tuple[int, int]: 149 """Returns `w, h`.""" 150 _, _, w, h = normalize(value) 151 return w, h 152 153 154def toWidth(value: tuple | str) -> int: 155 """ 156 Returns width from coords tuple or text string. 157 158 Args: 159 value: Coords tuple or text string. 160 """ 161 if isinstance(value, str): 162 return drawBot.textSize(value)[0] 163 # Assume it’s already in correct format if number 164 elif isinstance(value, (int, float)): 165 return value 166 else: 167 w, _ = toDimensions(value) 168 return w 169 170 171def toHeight(value: tuple | str) -> int: 172 """ 173 Returns height from coords tuple or text string. 174 175 Args: 176 value: Coords tuple or text string. 177 """ 178 if isinstance(value, str): 179 return drawBot.textSize(value)[1] 180 # Assume it’s already in correct format if number 181 elif isinstance(value, (int, float)): 182 return value 183 else: 184 _, h = toDimensions(value) 185 return h 186 187 188def toPosition(value: tuple) -> tuple[int, int]: 189 """Returns `x, y`.""" 190 x, y, _, _ = normalize(value) 191 return x, y 192 193 194def toCoords(value: tuple) -> tuple[int, int, int, int]: 195 """Returns `x, y, w, h`.""" 196 return normalize(value) 197 198 199def toCenter(value: tuple) -> tuple[int, int]: 200 """Returns `x, y` center points.""" 201 x, y, w, h = toCoords(value) 202 return x + w / 2, y + h / 2 203 204 205def unpackTwo(values, separator=" ", unit: UnitType = "mm"): 206 """Unpack two values, optionally converting to points. 207 208 Args: 209 values: Values to unpack. 210 separator: Separator for string input. 211 unit: Unit type for conversion. 212 213 Returns: 214 Tuple of two values, possibly converted to points. 215 """ 216 # List coercion 217 values = helpers.coerceNumericList(values, separator) 218 219 # Manipulate tuple 220 if len(values) == 2: 221 x, y = values 222 else: 223 for side in values: 224 x = y = side 225 226 if unit == "mm": 227 return map(mm, [x, y]) 228 else: 229 return x, y 230 231 232def unpackFour(values, separator=" ", unit: UnitType = "mm"): 233 """Unpack four values, optionally converting to points. 234 235 Args: 236 values: Values to unpack. 237 separator: Separator for string input. 238 unit: Unit type for conversion. 239 240 Returns: 241 Tuple of four values, possibly converted to points. 242 """ 243 # List coercion 244 values = helpers.coerceNumericList(values, separator) 245 246 # Manipulate tuple 247 if len(values) == 4: 248 top, right, bottom, left = values 249 elif len(values) == 3: 250 top, right, bottom = values 251 left = right 252 elif len(values) == 2: 253 y, x = values 254 top = bottom = y 255 left = right = x 256 else: 257 for side in values: 258 top = right = bottom = left = side 259 260 if unit == "mm": 261 return map(mm, [top, right, bottom, left]) 262 else: 263 return top, right, bottom, left 264 265 266def move(shape: tuple, x=0, y=0, mode: UnitMode = "mm"): 267 """ 268 Move a shape by x and y offsets. 269 270 Args: 271 shape: Shape tuple (x, y, w, h). 272 x: Horizontal offset. 273 y: Vertical offset. 274 mode: `relative` to move by percentage of size 275 276 Returns: 277 Moved shape as (x, y, w, h). 278 """ 279 shapeX, shapeY, shapeW, shapeH = shape 280 281 if mode == "relative": 282 x, y = x * shapeW, -y * shapeH 283 elif mode == "mm": 284 x, y = map(mm, [x, y]) 285 286 return shapeX + x, shapeY - y, shapeW, shapeH 287 288 289def grow( 290 frame: tuple, 291 margin: tuple, 292 mode: UnitMode = "mm", 293 debug=False, 294): 295 """Grow in clock-wise manner. 296 297 Args: 298 frame: Frame tuple. 299 margin: Margin values. 300 mode: Unit mode. 301 debug: If True, draw debug overlay. 302 303 Returns: 304 Grown frame as (x, y, w, h). 305 """ 306 frameX, frameY, frameW, frameH = toCoords(frame) 307 margin = unpackFour(margin, unit=mode) 308 309 if mode == "relative": 310 frameSize = (frameW, frameH) * 2 311 margin = [f * m for f, m in zip(frameSize, margin)] 312 313 mTop, mRight, mBottom, mLeft = margin 314 x, y = frameX - mLeft, frameY - mBottom 315 w, h = frameW + (mLeft + mRight), frameH + (mTop + mBottom) 316 317 coords = x, y, w, h 318 319 if debug: 320 xray(coords) 321 322 return coords 323 324 325def shrink( 326 frame: tuple, 327 margin: tuple, 328 mode: UnitMode = "mm", 329 debug=False, 330): 331 """Shrink in clock-wise manner. 332 333 Args: 334 frame: Frame tuple. 335 margin: Margin values. 336 mode: Unit mode. 337 debug: If True, draw debug overlay. 338 339 Returns: 340 Shrunk frame as (x, y, w, h). 341 """ 342 # Inverse margin 343 margin = helpers.inverseValues(margin) 344 return grow(frame, margin, mode, debug) 345 346 347def shrinkWidth( 348 frame: tuple, 349 amount: int = 0.1, 350 origin: Literal["left", "right"] = "left", 351 mode: UnitMode = "relative", 352): 353 """Shorthand to decrease width. 354 355 Args: 356 frame: Frame tuple. 357 amount: Amount to shrink. 358 origin: Side to shrink from ('left' or 'right'). 359 mode: Unit mode. 360 361 Returns: 362 Frame with reduced width. 363 """ 364 if mode == "relative": 365 amount *= 2 # grow() modifies one side by default 366 367 margin = (0, amount, 0, 0) if origin == "left" else (0, 0, 0, amount) 368 return shrink(frame, margin=margin, mode=mode) 369 370 371def shrinkHeight( 372 frame: tuple, 373 amount: int = 0.1, 374 origin: Literal["top", "bottom"] = "top", 375 mode: UnitMode = "relative", 376): 377 """Shorthand to decrease height. 378 379 Args: 380 frame: Frame tuple. 381 amount: Amount to shrink. 382 origin: Side to shrink from ('top' or 'bottom'). 383 mode: Unit mode. 384 385 Returns: 386 Frame with reduced height. 387 """ 388 if mode == "relative": 389 amount *= 2 # grow() modifies one side by default 390 391 margin = (amount, 0, 0) if origin == "bottom" else (0, 0, amount) 392 return shrink(frame, margin=margin, mode=mode) 393 394 395LayoutDirection = Literal["columns", "rows"] 396 397 398def splitRelative( 399 coords: tuple, 400 formula="3/1", 401 gap=5, 402 mode: LayoutDirection = "columns", 403 unit: UnitType = "mm", 404 debug=False, 405) -> list[int]: 406 """ 407 Split area by specified ratio. 408 409 Args: 410 coords: Container coordinates. 411 formula: Ratio formula (e.g. '3/1'). 412 gap: Gap between splits. 413 mode: Split direction ('columns' or 'rows'). 414 unit: Unit type for gap. 415 debug: If True, draw debug overlay. 416 417 Returns: 418 List of split rectangles as (x, y, w, h). 419 420 Example: 421 `3/1` => `75% 25%` 422 """ 423 items = [float(item) for item in formula.split("/")] 424 length = len(items) 425 count = sum(items) 426 427 if unit == "mm": 428 gap = mm(gap) 429 430 gapCount = length - 1 431 432 containerX, containerY, containerW, containerH = coords 433 434 if mode == "rows": 435 dimension = containerH 436 itemW = containerW 437 else: 438 dimension = containerW 439 itemH = containerH 440 441 available = dimension - (gap * gapCount) 442 unit = available / count 443 444 result = [] 445 x, y = containerX, containerY + containerH 446 447 for item in items: 448 itemDimension = item * unit 449 450 if mode == "rows": 451 itemH = itemDimension 452 else: 453 itemW = itemDimension 454 455 coords = x, y - itemH, itemW, itemH 456 result.append(coords) 457 458 if mode == "rows": 459 y -= itemH + gap 460 else: 461 x += itemW + gap 462 463 if debug: 464 xray(result) 465 466 return result 467 468 469# TODO: Deprecate alias 470# TODO: Rename mode to split 471split = splitRelative 472 473 474SplitMode = Literal["horizontal", "vertical"] 475 476Coordinates = tuple[float, float, float, float] 477 478 479def splitAbsolute( 480 container: Coordinates, split: SplitMode, size: float, gap: float 481) -> tuple[Coordinates, Coordinates]: 482 """ 483 Splits a container into two rectangles along the specified mode, with a gap in between. 484 485 Args: 486 container (tuple): (x, y, w, h) of the container. 487 split (str): 'horizontal' or 'vertical'. 488 size (float): Size in mm of the new rectangle along the split axis. 489 gap (float): Gap in mm between the rectangles. 490 491 Returns: 492 tuple: (new_rect, remainder_rect) 493 """ 494 x, y, w, h = container 495 size = mm(size) 496 gap = mm(gap) 497 498 if split == "horizontal": 499 new_rect = (x, y, size, h) 500 remainder_rect = (x + size + gap, y, w - size - gap, h) 501 elif split == "vertical": 502 new_rect = (x, y, w, size) 503 remainder_rect = (x, y + size + gap, w, h - size - gap) 504 else: 505 raise ValueError("mode must be 'horizontal' or 'vertical'") 506 return new_rect, remainder_rect 507 508 509def makeGrid( 510 frame: tuple, 511 rows=1, 512 cols=1, 513 gutter=5, 514 unit: UnitType = "mm", 515 debug=False, 516) -> list[tuple]: 517 """Create a grid of cells within a frame. 518 519 Args: 520 frame: Frame tuple. 521 rows: Number of rows. 522 cols: Number of columns. 523 gutter: Gutter size between cells. 524 unit: Unit type for gutter. 525 debug: If True, draw debug overlay. 526 527 Returns: 528 List of cell rectangles as (x, y, w, h). 529 """ 530 frameX, frameY, frameW, frameH = frame 531 gutterX, gutterY = unpackTwo(gutter, unit=unit) 532 cells = [] 533 534 # Cell width, height 535 w = (frameW - gutterX * (cols - 1)) / cols 536 h = (frameH - gutterY * (rows - 1)) / rows 537 538 for row in range(rows): 539 offsetY = (h + gutterY) * row 540 y = frameY + (frameH - h) - offsetY 541 542 for col in range(cols): 543 offsetX = (w + gutterX) * col 544 x = frameX + offsetX 545 546 coords = (x, y, w, h) 547 cells.append(coords) 548 549 if debug: 550 xray(coords) 551 552 return cells 553 554 555AlignX: TypeAlias = Literal["left", "center", "right", "stretch", None] 556"""Horizontal alignment options.""" 557 558AlignY: TypeAlias = Literal["top", "center", "bottom", "stretch", None] 559"""Vertical alignment options.""" 560 561AlignMode: TypeAlias = Literal["precise", "visual"] 562"""Alignment calculation mode: 'precise' for mathematical, 'visual' for optical centering.""" 563 564 565def align( 566 parent: tuple, 567 child: tuple, 568 position: tuple[AlignX, AlignY] = ("center", "center"), 569 clip=True, 570 debug=False, 571 output: Literal["position", "coords"] = "coords", 572 mode: AlignMode = "precise", 573) -> tuple: 574 """ 575 Align a child element within a parent rectangle. 576 577 Args: 578 parent: Parent rectangle (x, y, w, h). 579 child: Child rectangle or text. 580 position: Tuple of horizontal and vertical alignment. 581 clip: If True, clip child to parent size. 582 debug: If True, draw debug overlay. 583 output: Return type, either 'position' (tuple of 2) or 'coords' (tuple of 4). 584 mode: Alignment mode 585 - `precise`: Align mathematically 586 - `visual`: Nudge up if `AlignY=center` 587 588 Returns: 589 Aligned `position` or `coords` (based on output). 590 """ 591 positionX, positionY = position 592 parentX, parentY, parentW, parentH = parent 593 594 childX, childY = toPosition(child) 595 childW, childH = toDimensions(child) 596 597 # Dimensions 598 if positionX in ["stretch", 3]: 599 w = parentW 600 elif clip: 601 w = min(parentW, childW) 602 else: 603 w = childW 604 605 if positionY in ["stretch", 3]: 606 h = parentH 607 elif clip: 608 h = min(parentH, childH) 609 else: 610 h = childH 611 612 difW, difH = parentW - w, parentH - h 613 614 # Position 615 if positionX in ["left", 0, "stretch", 3]: 616 x = parentX 617 elif positionX in ["right", 2]: 618 x = parentX + difW 619 elif positionX in ["center", 1]: 620 x = parentX + difW / 2 621 else: 622 # None = Leave as is 623 x = childX 624 625 if positionY in ["top", 0]: 626 y = parentY + difH 627 elif positionY in ["bottom", 2, "stretch", 3]: 628 y = parentY 629 elif positionY in ["center", 1]: 630 y = parentY + difH / 2 631 else: 632 # None = Leave as is 633 y = childY 634 635 if positionY == "center" and mode == "visual": 636 y += difH * 0.05 # Nudge up for visual centering 637 638 coords = x, y, w, h 639 640 if debug: 641 xray(coords) 642 643 return coords if output == "coords" else toPosition(coords) 644 645 646def canFitInside(parent: tuple, child: tuple) -> bool: 647 """Returns True if the child fits inside the parent dimensions.""" 648 parentW, parentH = parent if len(parent) == 2 else toDimensions(parent) 649 childW, childH = child if len(child) == 2 else toDimensions(child) 650 return childW <= parentW and childH <= parentH 651 652 653def xray(shapes: list, color=None, stroke=0.5): 654 """Draw debug rectangles for given shapes. 655 656 Args: 657 shapes: List of shape tuples. 658 color: Optional color for rectangles. 659 stroke: Stroke width for rectangles. 660 """ 661 with drawBot.savedState(): 662 for shape in helpers.flattenTuples(shapes): 663 c = color or [random.uniform(0, 1) for _ in range(3)] 664 if stroke: 665 drawBot.strokeWidth(stroke) 666 drawBot.stroke(*c) 667 drawBot.fill(None) 668 else: 669 drawBot.fill(*c) 670 drawBot.rect(*shape) 671 672 673def draftBar( 674 frame: tuple, 675 lines=1, 676 position: Literal["top", "bottom"] = "top", 677 debug=False, 678): 679 """ 680 Draft header/footer for given number of `lines`. 681 682 - `font()`, `fontSize` and `lineHeight` must already be set 683 684 Args: 685 frame: Frame tuple. 686 lines: Number of lines for the bar. 687 position: 'top' or 'bottom' of the frame. 688 debug: If True, draw debug overlay. 689 690 Returns: 691 Coords tuple for the draft bar. 692 """ 693 frameX, frameY, frameW, frameH = frame 694 txtDummy = ("").join(["\n" for _ in range(lines)]) 695 696 _, boxH = drawBot.textSize(txtDummy, width=frameW) 697 698 if position == "bottom": 699 coords = frameX, frameY, frameW, boxH 700 else: 701 coords = frameX, frameY + frameH - boxH, frameW, boxH 702 703 if debug: 704 xray(coords) 705 706 return coords 707 708 709def draftBody( 710 frame: tuple, 711 header: tuple = None, 712 footer: tuple = None, 713 gap=4, 714 unit: UnitType = "mm", 715 debug=False, 716): 717 """ 718 Substract header/footer. 719 720 - Returns `coords` tuple of body 721 722 Args: 723 frame: Frame tuple. 724 header: Header rectangle. 725 footer: Footer rectangle. 726 gap: Gap between sections. 727 unit: Unit type for gap. 728 debug: If True, draw debug overlay. 729 730 Returns: 731 Coords tuple for the body area. 732 """ 733 if unit == "mm": 734 gap = mm(gap) 735 736 frameX, frameY, frameW, frameH = frame 737 h, y = frameH, frameY 738 739 if header: 740 _, _, _, headerH = header 741 h -= headerH + gap 742 743 if footer: 744 _, _, _, footerH = footer 745 delta = footerH + gap 746 h -= delta 747 y += delta 748 749 coords = frameX, y, frameW, h 750 751 if debug: 752 xray(coords) 753 754 return coords
Unit type for measurement, either millimeters ('mm') or points ('pt').
Mode for interpreting units: millimeters, points, or relative (percentage-based).
Represents the four edges of a box.
Acceptable page size formats:
None- Default to current page size
str- Page size name (e.g.
A4,Letter, etc.) - See https://www.drawbot.com/content/canvas/pages.html#size for available sizes
- Page size name (e.g.
tuple- explicit
(width, height)in points
- explicit
32def pageIsEven() -> bool: 33 """Returns True if the current page count is even, otherwise False.""" 34 return True if drawBot.pageCount() % 2 == 0 else False
Returns True if the current page count is even, otherwise False.
37def mm(number: int) -> float: 38 """Convert millimeters to points. 39 40 Args: 41 number: Value in millimeters. 42 43 Returns: 44 Value converted to points. 45 """ 46 mmToInch = 25.4 / 72 47 return number / mmToInch
Convert millimeters to points.
Arguments:
- number: Value in millimeters.
Returns:
Value converted to points.
50def cm(number: int) -> float: 51 """Convert centimeters to points. 52 53 Args: 54 number: Value in centimeters. 55 56 Returns: 57 Value converted to points. 58 """ 59 return mm(number * 10)
Convert centimeters to points.
Arguments:
- number: Value in centimeters.
Returns:
Value converted to points.
62def pt(number: int, rounded=False) -> float: 63 """Convert points to millimeters. 64 65 Args: 66 number: Value in points. 67 rounded: If True, round the result. 68 69 Returns: 70 Value converted to millimeters. 71 """ 72 mmToInch = 25.4 / 72 73 value = mmToInch * number 74 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.
77def getPageCoords() -> tuple[int]: 78 """Returns current page `x, y, w, h`.""" 79 return 0, 0, drawBot.width(), drawBot.height()
Returns current page x, y, w, h.
82def parsePageSize(value: PageSize) -> tuple[int, int]: 83 """Parse `PageSize` into a tuple. 84 85 Args: 86 value: Page size in one of the accepted formats. 87 88 Returns: 89 Tuple of (width, height) in points. 90 """ 91 if value == None: 92 pageW, pageH = drawBot.width(), drawBot.height() 93 logger.trace( 94 f"Defaulting to {pt(pageW, rounded=True)}×{pt(pageH, rounded=True)}mm" 95 ) 96 return pageW, pageH 97 elif isinstance(value, str): 98 try: 99 return drawBot.sizes(value) 100 except: 101 raise ValueError( 102 f"Invalid page size: {value}. Use a tuple or a valid page size string." 103 ) 104 else: 105 return value
Parse PageSize into a tuple.
Arguments:
- value: Page size in one of the accepted formats.
Returns:
Tuple of (width, height) in points.
108def normalize(value: tuple) -> tuple: 109 """ 110 Normalize tuple of `n` to 4 and parse `pageSize (str)`. 111 112 Args: 113 value: Tuple or page size string. 114 115 Returns: 116 Tuple of (x, y, w, h). 117 """ 118 value = parsePageSize(value) 119 120 if isinstance(value, tuple) and len(value) == 4: 121 x, y, w, h = value 122 else: 123 x, y, w, h = 0, 0, *value 124 125 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).
128def mirror(values: tuple) -> tuple: 129 """Flip values horizontally. 130 131 Args: 132 values: Tuple of values to mirror. 133 134 Returns: 135 Mirrored tuple. 136 """ 137 values = helpers.coerceList(values) 138 139 if len(values) == 4: 140 top, outer, bottom, inner = values 141 return top, inner, bottom, outer 142 elif len(values) == 2: 143 outer, inner = values 144 return inner, outer 145 else: 146 return values
Flip values horizontally.
Arguments:
- values: Tuple of values to mirror.
Returns:
Mirrored tuple.
149def toDimensions(value: tuple) -> tuple[int, int]: 150 """Returns `w, h`.""" 151 _, _, w, h = normalize(value) 152 return w, h
Returns w, h.
155def toWidth(value: tuple | str) -> int: 156 """ 157 Returns width from coords tuple or text string. 158 159 Args: 160 value: Coords tuple or text string. 161 """ 162 if isinstance(value, str): 163 return drawBot.textSize(value)[0] 164 # Assume it’s already in correct format if number 165 elif isinstance(value, (int, float)): 166 return value 167 else: 168 w, _ = toDimensions(value) 169 return w
Returns width from coords tuple or text string.
Arguments:
- value: Coords tuple or text string.
172def toHeight(value: tuple | str) -> int: 173 """ 174 Returns height from coords tuple or text string. 175 176 Args: 177 value: Coords tuple or text string. 178 """ 179 if isinstance(value, str): 180 return drawBot.textSize(value)[1] 181 # Assume it’s already in correct format if number 182 elif isinstance(value, (int, float)): 183 return value 184 else: 185 _, h = toDimensions(value) 186 return h
Returns height from coords tuple or text string.
Arguments:
- value: Coords tuple or text string.
189def toPosition(value: tuple) -> tuple[int, int]: 190 """Returns `x, y`.""" 191 x, y, _, _ = normalize(value) 192 return x, y
Returns x, y.
195def toCoords(value: tuple) -> tuple[int, int, int, int]: 196 """Returns `x, y, w, h`.""" 197 return normalize(value)
Returns x, y, w, h.
200def toCenter(value: tuple) -> tuple[int, int]: 201 """Returns `x, y` center points.""" 202 x, y, w, h = toCoords(value) 203 return x + w / 2, y + h / 2
Returns x, y center points.
206def unpackTwo(values, separator=" ", unit: UnitType = "mm"): 207 """Unpack two values, optionally converting to points. 208 209 Args: 210 values: Values to unpack. 211 separator: Separator for string input. 212 unit: Unit type for conversion. 213 214 Returns: 215 Tuple of two values, possibly converted to points. 216 """ 217 # List coercion 218 values = helpers.coerceNumericList(values, separator) 219 220 # Manipulate tuple 221 if len(values) == 2: 222 x, y = values 223 else: 224 for side in values: 225 x = y = side 226 227 if unit == "mm": 228 return map(mm, [x, y]) 229 else: 230 return x, y
Unpack two values, optionally converting to points.
Arguments:
- values: Values to unpack.
- separator: Separator for string input.
- unit: Unit type for conversion.
Returns:
Tuple of two values, possibly converted to points.
233def unpackFour(values, separator=" ", unit: UnitType = "mm"): 234 """Unpack four values, optionally converting to points. 235 236 Args: 237 values: Values to unpack. 238 separator: Separator for string input. 239 unit: Unit type for conversion. 240 241 Returns: 242 Tuple of four values, possibly converted to points. 243 """ 244 # List coercion 245 values = helpers.coerceNumericList(values, separator) 246 247 # Manipulate tuple 248 if len(values) == 4: 249 top, right, bottom, left = values 250 elif len(values) == 3: 251 top, right, bottom = values 252 left = right 253 elif len(values) == 2: 254 y, x = values 255 top = bottom = y 256 left = right = x 257 else: 258 for side in values: 259 top = right = bottom = left = side 260 261 if unit == "mm": 262 return map(mm, [top, right, bottom, left]) 263 else: 264 return top, right, bottom, left
Unpack four values, optionally converting to points.
Arguments:
- values: Values to unpack.
- separator: Separator for string input.
- unit: Unit type for conversion.
Returns:
Tuple of four values, possibly converted to points.
267def move(shape: tuple, x=0, y=0, mode: UnitMode = "mm"): 268 """ 269 Move a shape by x and y offsets. 270 271 Args: 272 shape: Shape tuple (x, y, w, h). 273 x: Horizontal offset. 274 y: Vertical offset. 275 mode: `relative` to move by percentage of size 276 277 Returns: 278 Moved shape as (x, y, w, h). 279 """ 280 shapeX, shapeY, shapeW, shapeH = shape 281 282 if mode == "relative": 283 x, y = x * shapeW, -y * shapeH 284 elif mode == "mm": 285 x, y = map(mm, [x, y]) 286 287 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.
- y: Vertical offset.
- mode:
relativeto move by percentage of size
Returns:
Moved shape as (x, y, w, h).
290def grow( 291 frame: tuple, 292 margin: tuple, 293 mode: UnitMode = "mm", 294 debug=False, 295): 296 """Grow in clock-wise manner. 297 298 Args: 299 frame: Frame tuple. 300 margin: Margin values. 301 mode: Unit mode. 302 debug: If True, draw debug overlay. 303 304 Returns: 305 Grown frame as (x, y, w, h). 306 """ 307 frameX, frameY, frameW, frameH = toCoords(frame) 308 margin = unpackFour(margin, unit=mode) 309 310 if mode == "relative": 311 frameSize = (frameW, frameH) * 2 312 margin = [f * m for f, m in zip(frameSize, margin)] 313 314 mTop, mRight, mBottom, mLeft = margin 315 x, y = frameX - mLeft, frameY - mBottom 316 w, h = frameW + (mLeft + mRight), frameH + (mTop + mBottom) 317 318 coords = x, y, w, h 319 320 if debug: 321 xray(coords) 322 323 return coords
Grow in clock-wise manner.
Arguments:
- frame: Frame tuple.
- margin: Margin values.
- mode: Unit mode.
- debug: If True, draw debug overlay.
Returns:
Grown frame as (x, y, w, h).
326def shrink( 327 frame: tuple, 328 margin: tuple, 329 mode: UnitMode = "mm", 330 debug=False, 331): 332 """Shrink in clock-wise manner. 333 334 Args: 335 frame: Frame tuple. 336 margin: Margin values. 337 mode: Unit mode. 338 debug: If True, draw debug overlay. 339 340 Returns: 341 Shrunk frame as (x, y, w, h). 342 """ 343 # Inverse margin 344 margin = helpers.inverseValues(margin) 345 return grow(frame, margin, mode, debug)
Shrink in clock-wise manner.
Arguments:
- frame: Frame tuple.
- margin: Margin values.
- mode: Unit mode.
- debug: If True, draw debug overlay.
Returns:
Shrunk frame as (x, y, w, h).
348def shrinkWidth( 349 frame: tuple, 350 amount: int = 0.1, 351 origin: Literal["left", "right"] = "left", 352 mode: UnitMode = "relative", 353): 354 """Shorthand to decrease width. 355 356 Args: 357 frame: Frame tuple. 358 amount: Amount to shrink. 359 origin: Side to shrink from ('left' or 'right'). 360 mode: Unit mode. 361 362 Returns: 363 Frame with reduced width. 364 """ 365 if mode == "relative": 366 amount *= 2 # grow() modifies one side by default 367 368 margin = (0, amount, 0, 0) if origin == "left" else (0, 0, 0, amount) 369 return shrink(frame, margin=margin, mode=mode)
Shorthand to decrease width.
Arguments:
- frame: Frame tuple.
- amount: Amount to shrink.
- origin: Side to shrink from ('left' or 'right').
- mode: Unit mode.
Returns:
Frame with reduced width.
372def shrinkHeight( 373 frame: tuple, 374 amount: int = 0.1, 375 origin: Literal["top", "bottom"] = "top", 376 mode: UnitMode = "relative", 377): 378 """Shorthand to decrease height. 379 380 Args: 381 frame: Frame tuple. 382 amount: Amount to shrink. 383 origin: Side to shrink from ('top' or 'bottom'). 384 mode: Unit mode. 385 386 Returns: 387 Frame with reduced height. 388 """ 389 if mode == "relative": 390 amount *= 2 # grow() modifies one side by default 391 392 margin = (amount, 0, 0) if origin == "bottom" else (0, 0, amount) 393 return shrink(frame, margin=margin, mode=mode)
Shorthand to decrease height.
Arguments:
- frame: Frame tuple.
- amount: Amount to shrink.
- origin: Side to shrink from ('top' or 'bottom').
- mode: Unit mode.
Returns:
Frame with reduced height.
399def splitRelative( 400 coords: tuple, 401 formula="3/1", 402 gap=5, 403 mode: LayoutDirection = "columns", 404 unit: UnitType = "mm", 405 debug=False, 406) -> list[int]: 407 """ 408 Split area by specified ratio. 409 410 Args: 411 coords: Container coordinates. 412 formula: Ratio formula (e.g. '3/1'). 413 gap: Gap between splits. 414 mode: Split direction ('columns' or 'rows'). 415 unit: Unit type for gap. 416 debug: If True, draw debug overlay. 417 418 Returns: 419 List of split rectangles as (x, y, w, h). 420 421 Example: 422 `3/1` => `75% 25%` 423 """ 424 items = [float(item) for item in formula.split("/")] 425 length = len(items) 426 count = sum(items) 427 428 if unit == "mm": 429 gap = mm(gap) 430 431 gapCount = length - 1 432 433 containerX, containerY, containerW, containerH = coords 434 435 if mode == "rows": 436 dimension = containerH 437 itemW = containerW 438 else: 439 dimension = containerW 440 itemH = containerH 441 442 available = dimension - (gap * gapCount) 443 unit = available / count 444 445 result = [] 446 x, y = containerX, containerY + containerH 447 448 for item in items: 449 itemDimension = item * unit 450 451 if mode == "rows": 452 itemH = itemDimension 453 else: 454 itemW = itemDimension 455 456 coords = x, y - itemH, itemW, itemH 457 result.append(coords) 458 459 if mode == "rows": 460 y -= itemH + gap 461 else: 462 x += itemW + gap 463 464 if debug: 465 xray(result) 466 467 return result
Split area by specified ratio.
Arguments:
- coords: Container coordinates.
- formula: Ratio formula (e.g. '3/1').
- gap: Gap between splits.
- mode: Split direction ('columns' or 'rows').
- unit: Unit type for gap.
- debug: If True, draw debug overlay.
Returns:
List of split rectangles as (x, y, w, h).
Example:
3/1=>75% 25%
399def splitRelative( 400 coords: tuple, 401 formula="3/1", 402 gap=5, 403 mode: LayoutDirection = "columns", 404 unit: UnitType = "mm", 405 debug=False, 406) -> list[int]: 407 """ 408 Split area by specified ratio. 409 410 Args: 411 coords: Container coordinates. 412 formula: Ratio formula (e.g. '3/1'). 413 gap: Gap between splits. 414 mode: Split direction ('columns' or 'rows'). 415 unit: Unit type for gap. 416 debug: If True, draw debug overlay. 417 418 Returns: 419 List of split rectangles as (x, y, w, h). 420 421 Example: 422 `3/1` => `75% 25%` 423 """ 424 items = [float(item) for item in formula.split("/")] 425 length = len(items) 426 count = sum(items) 427 428 if unit == "mm": 429 gap = mm(gap) 430 431 gapCount = length - 1 432 433 containerX, containerY, containerW, containerH = coords 434 435 if mode == "rows": 436 dimension = containerH 437 itemW = containerW 438 else: 439 dimension = containerW 440 itemH = containerH 441 442 available = dimension - (gap * gapCount) 443 unit = available / count 444 445 result = [] 446 x, y = containerX, containerY + containerH 447 448 for item in items: 449 itemDimension = item * unit 450 451 if mode == "rows": 452 itemH = itemDimension 453 else: 454 itemW = itemDimension 455 456 coords = x, y - itemH, itemW, itemH 457 result.append(coords) 458 459 if mode == "rows": 460 y -= itemH + gap 461 else: 462 x += itemW + gap 463 464 if debug: 465 xray(result) 466 467 return result
Split area by specified ratio.
Arguments:
- coords: Container coordinates.
- formula: Ratio formula (e.g. '3/1').
- gap: Gap between splits.
- mode: Split direction ('columns' or 'rows').
- unit: Unit type for gap.
- debug: If True, draw debug overlay.
Returns:
List of split rectangles as (x, y, w, h).
Example:
3/1=>75% 25%
480def splitAbsolute( 481 container: Coordinates, split: SplitMode, size: float, gap: float 482) -> tuple[Coordinates, Coordinates]: 483 """ 484 Splits a container into two rectangles along the specified mode, with a gap in between. 485 486 Args: 487 container (tuple): (x, y, w, h) of the container. 488 split (str): 'horizontal' or 'vertical'. 489 size (float): Size in mm of the new rectangle along the split axis. 490 gap (float): Gap in mm between the rectangles. 491 492 Returns: 493 tuple: (new_rect, remainder_rect) 494 """ 495 x, y, w, h = container 496 size = mm(size) 497 gap = mm(gap) 498 499 if split == "horizontal": 500 new_rect = (x, y, size, h) 501 remainder_rect = (x + size + gap, y, w - size - gap, h) 502 elif split == "vertical": 503 new_rect = (x, y, w, size) 504 remainder_rect = (x, y + size + gap, w, h - size - gap) 505 else: 506 raise ValueError("mode must be 'horizontal' or 'vertical'") 507 return new_rect, remainder_rect
Splits a container into two rectangles along the specified mode, with a gap in between.
Arguments:
- container (tuple): (x, y, w, h) of the container.
- split (str): 'horizontal' or 'vertical'.
- size (float): Size in mm of the new rectangle along the split axis.
- gap (float): Gap in mm between the rectangles.
Returns:
tuple: (new_rect, remainder_rect)
510def makeGrid( 511 frame: tuple, 512 rows=1, 513 cols=1, 514 gutter=5, 515 unit: UnitType = "mm", 516 debug=False, 517) -> list[tuple]: 518 """Create a grid of cells within a frame. 519 520 Args: 521 frame: Frame tuple. 522 rows: Number of rows. 523 cols: Number of columns. 524 gutter: Gutter size between cells. 525 unit: Unit type for gutter. 526 debug: If True, draw debug overlay. 527 528 Returns: 529 List of cell rectangles as (x, y, w, h). 530 """ 531 frameX, frameY, frameW, frameH = frame 532 gutterX, gutterY = unpackTwo(gutter, unit=unit) 533 cells = [] 534 535 # Cell width, height 536 w = (frameW - gutterX * (cols - 1)) / cols 537 h = (frameH - gutterY * (rows - 1)) / rows 538 539 for row in range(rows): 540 offsetY = (h + gutterY) * row 541 y = frameY + (frameH - h) - offsetY 542 543 for col in range(cols): 544 offsetX = (w + gutterX) * col 545 x = frameX + offsetX 546 547 coords = (x, y, w, h) 548 cells.append(coords) 549 550 if debug: 551 xray(coords) 552 553 return cells
Create a grid of cells within a frame.
Arguments:
- frame: Frame tuple.
- rows: Number of rows.
- cols: Number of columns.
- gutter: Gutter size between cells.
- unit: Unit type for gutter.
- debug: If True, draw debug overlay.
Returns:
List of cell rectangles as (x, y, w, h).
Horizontal alignment options.
Vertical alignment options.
Alignment calculation mode: 'precise' for mathematical, 'visual' for optical centering.
566def align( 567 parent: tuple, 568 child: tuple, 569 position: tuple[AlignX, AlignY] = ("center", "center"), 570 clip=True, 571 debug=False, 572 output: Literal["position", "coords"] = "coords", 573 mode: AlignMode = "precise", 574) -> tuple: 575 """ 576 Align a child element within a parent rectangle. 577 578 Args: 579 parent: Parent rectangle (x, y, w, h). 580 child: Child rectangle or text. 581 position: Tuple of horizontal and vertical alignment. 582 clip: If True, clip child to parent size. 583 debug: If True, draw debug overlay. 584 output: Return type, either 'position' (tuple of 2) or 'coords' (tuple of 4). 585 mode: Alignment mode 586 - `precise`: Align mathematically 587 - `visual`: Nudge up if `AlignY=center` 588 589 Returns: 590 Aligned `position` or `coords` (based on output). 591 """ 592 positionX, positionY = position 593 parentX, parentY, parentW, parentH = parent 594 595 childX, childY = toPosition(child) 596 childW, childH = toDimensions(child) 597 598 # Dimensions 599 if positionX in ["stretch", 3]: 600 w = parentW 601 elif clip: 602 w = min(parentW, childW) 603 else: 604 w = childW 605 606 if positionY in ["stretch", 3]: 607 h = parentH 608 elif clip: 609 h = min(parentH, childH) 610 else: 611 h = childH 612 613 difW, difH = parentW - w, parentH - h 614 615 # Position 616 if positionX in ["left", 0, "stretch", 3]: 617 x = parentX 618 elif positionX in ["right", 2]: 619 x = parentX + difW 620 elif positionX in ["center", 1]: 621 x = parentX + difW / 2 622 else: 623 # None = Leave as is 624 x = childX 625 626 if positionY in ["top", 0]: 627 y = parentY + difH 628 elif positionY in ["bottom", 2, "stretch", 3]: 629 y = parentY 630 elif positionY in ["center", 1]: 631 y = parentY + difH / 2 632 else: 633 # None = Leave as is 634 y = childY 635 636 if positionY == "center" and mode == "visual": 637 y += difH * 0.05 # Nudge up for visual centering 638 639 coords = x, y, w, h 640 641 if debug: 642 xray(coords) 643 644 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).
- mode: Alignment mode
precise: Align mathematicallyvisual: Nudge up ifAlignY=center
Returns:
Aligned
positionorcoords(based on output).
647def canFitInside(parent: tuple, child: tuple) -> bool: 648 """Returns True if the child fits inside the parent dimensions.""" 649 parentW, parentH = parent if len(parent) == 2 else toDimensions(parent) 650 childW, childH = child if len(child) == 2 else toDimensions(child) 651 return childW <= parentW and childH <= parentH
Returns True if the child fits inside the parent dimensions.
654def xray(shapes: list, color=None, stroke=0.5): 655 """Draw debug rectangles for given shapes. 656 657 Args: 658 shapes: List of shape tuples. 659 color: Optional color for rectangles. 660 stroke: Stroke width for rectangles. 661 """ 662 with drawBot.savedState(): 663 for shape in helpers.flattenTuples(shapes): 664 c = color or [random.uniform(0, 1) for _ in range(3)] 665 if stroke: 666 drawBot.strokeWidth(stroke) 667 drawBot.stroke(*c) 668 drawBot.fill(None) 669 else: 670 drawBot.fill(*c) 671 drawBot.rect(*shape)
Draw debug rectangles for given shapes.
Arguments:
- shapes: List of shape tuples.
- color: Optional color for rectangles.
- stroke: Stroke width for rectangles.
674def draftBar( 675 frame: tuple, 676 lines=1, 677 position: Literal["top", "bottom"] = "top", 678 debug=False, 679): 680 """ 681 Draft header/footer for given number of `lines`. 682 683 - `font()`, `fontSize` and `lineHeight` must already be set 684 685 Args: 686 frame: Frame tuple. 687 lines: Number of lines for the bar. 688 position: 'top' or 'bottom' of the frame. 689 debug: If True, draw debug overlay. 690 691 Returns: 692 Coords tuple for the draft bar. 693 """ 694 frameX, frameY, frameW, frameH = frame 695 txtDummy = ("").join(["\n" for _ in range(lines)]) 696 697 _, boxH = drawBot.textSize(txtDummy, width=frameW) 698 699 if position == "bottom": 700 coords = frameX, frameY, frameW, boxH 701 else: 702 coords = frameX, frameY + frameH - boxH, frameW, boxH 703 704 if debug: 705 xray(coords) 706 707 return coords
Draft header/footer for given number of lines.
font(),fontSizeandlineHeightmust already be set
Arguments:
- frame: Frame tuple.
- lines: Number of lines for the bar.
- position: 'top' or 'bottom' of the frame.
- debug: If True, draw debug overlay.
Returns:
Coords tuple for the draft bar.
710def draftBody( 711 frame: tuple, 712 header: tuple = None, 713 footer: tuple = None, 714 gap=4, 715 unit: UnitType = "mm", 716 debug=False, 717): 718 """ 719 Substract header/footer. 720 721 - Returns `coords` tuple of body 722 723 Args: 724 frame: Frame tuple. 725 header: Header rectangle. 726 footer: Footer rectangle. 727 gap: Gap between sections. 728 unit: Unit type for gap. 729 debug: If True, draw debug overlay. 730 731 Returns: 732 Coords tuple for the body area. 733 """ 734 if unit == "mm": 735 gap = mm(gap) 736 737 frameX, frameY, frameW, frameH = frame 738 h, y = frameH, frameY 739 740 if header: 741 _, _, _, headerH = header 742 h -= headerH + gap 743 744 if footer: 745 _, _, _, footerH = footer 746 delta = footerH + gap 747 h -= delta 748 y += delta 749 750 coords = frameX, y, frameW, h 751 752 if debug: 753 xray(coords) 754 755 return coords
Substract header/footer.
- Returns
coordstuple of body
Arguments:
- frame: Frame tuple.
- header: Header rectangle.
- footer: Footer rectangle.
- gap: Gap between sections.
- unit: Unit type for gap.
- debug: If True, draw debug overlay.
Returns:
Coords tuple for the body area.