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
UnitType: TypeAlias = Literal['mm', 'pt']

Unit type for measurement, either millimeters ('mm') or points ('pt').

UnitMode: TypeAlias = Literal['mm', 'pt', 'relative']

Mode for interpreting units: millimeters, points, or relative (percentage-based).

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

Represents the four edges of a box.

PageSize: TypeAlias = None | str | tuple[int, int]

Acceptable page size formats:

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

def mm(number: int) -> float:
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.

def cm(number: int) -> float:
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.

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

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

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

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

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

def toDimensions(value: tuple) -> tuple[int, int]:
149def toDimensions(value: tuple) -> tuple[int, int]:
150    """Returns `w, h`."""
151    _, _, w, h = normalize(value)
152    return w, h

Returns w, h.

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

Returns x, y.

def toCoords(value: tuple) -> tuple[int, int, int, int]:
195def toCoords(value: tuple) -> tuple[int, int, int, int]:
196    """Returns `x, y, w, h`."""
197    return normalize(value)

Returns x, y, w, h.

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

def unpackTwo(values, separator=' ', unit: Literal['mm', 'pt'] = 'mm'):
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.

def unpackFour(values, separator=' ', unit: Literal['mm', 'pt'] = 'mm'):
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.

def move(shape: tuple, x=0, y=0, mode: Literal['mm', 'pt', 'relative'] = 'mm'):
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: relative to move by percentage of size
Returns:

Moved shape as (x, y, w, h).

def grow( frame: tuple, margin: tuple, mode: Literal['mm', 'pt', 'relative'] = 'mm', debug=False):
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).

def shrink( frame: tuple, margin: tuple, mode: Literal['mm', 'pt', 'relative'] = 'mm', debug=False):
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).

def shrinkWidth( frame: tuple, amount: int = 0.1, origin: Literal['left', 'right'] = 'left', mode: Literal['mm', 'pt', 'relative'] = 'relative'):
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.

def shrinkHeight( frame: tuple, amount: int = 0.1, origin: Literal['top', 'bottom'] = 'top', mode: Literal['mm', 'pt', 'relative'] = 'relative'):
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.

LayoutDirection = typing.Literal['columns', 'rows']
def splitRelative( coords: tuple, formula='3/1', gap=5, mode: Literal['columns', 'rows'] = 'columns', unit: Literal['mm', 'pt'] = 'mm', debug=False) -> list[int]:
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%

def split( coords: tuple, formula='3/1', gap=5, mode: Literal['columns', 'rows'] = 'columns', unit: Literal['mm', 'pt'] = 'mm', debug=False) -> list[int]:
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%

SplitMode = typing.Literal['horizontal', 'vertical']
Coordinates = tuple[float, float, float, float]
def splitAbsolute( container: tuple[float, float, float, float], split: Literal['horizontal', 'vertical'], size: float, gap: float) -> tuple[tuple[float, float, float, float], tuple[float, float, float, float]]:
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)

def makeGrid( frame: tuple, rows=1, cols=1, gutter=5, unit: Literal['mm', 'pt'] = 'mm', debug=False) -> list[tuple]:
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).

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

Horizontal alignment options.

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

Vertical alignment options.

AlignMode: TypeAlias = Literal['precise', 'visual']

Alignment calculation mode: 'precise' for mathematical, 'visual' for optical centering.

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', mode: Literal['precise', 'visual'] = 'precise') -> tuple:
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 mathematically
    • visual: Nudge up if AlignY=center
Returns:

Aligned position or coords (based on output).

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

def xray(shapes: list, color=None, stroke=0.5):
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.
def draftBar( frame: tuple, lines=1, position: Literal['top', 'bottom'] = 'top', debug=False):
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(), fontSize and lineHeight must 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.

def draftBody( frame: tuple, header: tuple = None, footer: tuple = None, gap=4, unit: Literal['mm', 'pt'] = 'mm', debug=False):
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 coords tuple 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.