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
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) -> int:
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.

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

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

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

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

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

def toDimensions(value: tuple) -> tuple[int, int]:
137def toDimensions(value: tuple) -> tuple[int, int]:
138    """Returns `w, h`."""
139    _, _, w, h = normalize(value)
140    return w, h

Returns w, h.

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

Returns x, y.

def toCoords(value: tuple) -> tuple[int, int, int, int]:
183def toCoords(value: tuple) -> tuple[int, int, int, int]:
184    """Returns `x, y, w, h`."""
185    return normalize(value)

Returns x, y, w, h.

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

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

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

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

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

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

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

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

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

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

Aligned position (text) or coords (textBox).

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

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