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