classes.c20_box

  1from typing import Literal, Union
  2from loguru import logger
  3import colorama
  4import drawBot
  5
  6from lib import layout, helpers, color, fonts
  7
  8
  9class KBox:
 10    "A layout primitive with coordinates, dimensions and spatial utilities."
 11
 12    def __init__(self, coords: tuple, text: str | None = None) -> None:
 13        """
 14        Initialize a KBox with given coordinates.
 15
 16        See `lib.layout.parsePageSize` for possible coordinate types.
 17
 18        Args:
 19            coords: A tuple of (x, y, width, height) coordinates.
 20
 21        Example:
 22        ```python
 23        # Create a new box with coordinates
 24        box = KBox((10, 10, 100, 50))  # x, y, width, height
 25
 26        # Move the box
 27        box.move(x=20, y=10)
 28
 29        # Grow the box by adding margin
 30        box.grow((5, 5, 5, 5))  # left, bottom, right, top margins
 31        ```
 32        """
 33        self.x, self.y, self.width, self.height = layout.toCoords(coords)
 34        self.text = text
 35
 36    def __str__(self) -> str:
 37        """Return a string representation of the KBox instance."""
 38        x = f"{colorama.Fore.LIGHTYELLOW_EX}X {self.x}"
 39        y = f"{colorama.Fore.LIGHTBLUE_EX}Y {self.y}"
 40        w = f"{colorama.Fore.LIGHTMAGENTA_EX}W {self.width}"
 41        h = f"{colorama.Fore.LIGHTCYAN_EX}H {self.height}"
 42        t = f"{colorama.Fore.LIGHTWHITE_EX}T {self.text}" if self.text else ""
 43        return f"{colorama.Fore.BLACK}{colorama.Back.LIGHTGREEN_EX}KBox{colorama.Style.RESET_ALL} {x} {y} {w} {h} {t}{colorama.Style.RESET_ALL}"
 44
 45    @staticmethod
 46    def fromDimension(unit: layout.UnitSource) -> "KBox":
 47        """
 48        Create a square KBox with the same width and height.
 49
 50        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
 51        """
 52        value = layout.parseUnit(unit, implicitUnit="pt")
 53        return KBox((0, 0, value, value))
 54
 55    @staticmethod
 56    def fromDimensions(width: layout.UnitSource, height: layout.UnitSource) -> "KBox":
 57        """
 58        Create a KBox from width and height, with x and y set to 0.
 59
 60        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
 61        """
 62        width, height = [
 63            layout.parseUnit(d, implicitUnit="pt") for d in (width, height)
 64        ]
 65        return KBox((0, 0, width, height))
 66
 67    @staticmethod
 68    def fromText(str: str, textWidth: float = None) -> "KBox":
 69        """
 70        Create a KBox that fits the given text. Font settings must be already set.
 71
 72        Args:
 73            str: The text to fit.
 74            textWidth: Optional maximum width for the text box. If provided, the text will wrap to fit within this width.
 75
 76        Returns:
 77            A new KBox instance that fits the text.
 78        """
 79        return KBox((0, 0, *drawBot.textSize(str, width=textWidth)), text=str)
 80
 81    @property
 82    def position(self) -> tuple[int, int]:
 83        """
 84        Returns the x and y coordinates of the bottom-left corner.
 85        """
 86        return self.x, self.y
 87
 88    @position.setter
 89    def position(self, value: tuple[int, int]) -> None:
 90        """
 91        Set the position (x, y) of the box.
 92        """
 93        try:
 94            self.x, self.y = value
 95        except Exception as e:
 96            logger.warning("Unable to set KBox position: {}. Error: {}", value, e)
 97
 98    @property
 99    def dimensions(self) -> tuple[int, int]:
100        """
101        Returns the dimensions (width, height) of the box in points.
102        """
103        return self.width, self.height
104
105    @dimensions.setter
106    def dimensions(self, value: tuple[int, int]) -> None:
107        """
108        Set the dimensions (width, height) of the box in points.
109        """
110        try:
111            self.width, self.height = value
112        except Exception as e:
113            logger.warning("Unable to set KBox dimensions: {}. Error: {}", value, e)
114
115    @property
116    def coords(self) -> tuple[int, int, int, int]:
117        """
118        Returns the complete coordinates (x, y, width, height) of the box.
119        """
120        return self.x, self.y, self.width, self.height
121
122    @coords.setter
123    def coords(self, value: tuple[int, int, int, int]) -> None:
124        """
125        Set the complete coordinates (x, y, width, height) of the box.
126        """
127        try:
128            self.x, self.y, self.width, self.height = value
129        except Exception as e:
130            logger.warning("Unable to set KBox coords: {}. Error: {}", value, e)
131
132    @property
133    def left(self) -> int:
134        """
135        Returns the x-coordinate of the left edge of the box.
136        """
137        return self.x
138
139    @property
140    def right(self) -> int:
141        """
142        Returns the x-coordinate of the right edge of the box.
143        """
144        return self.x + self.width
145
146    @property
147    def top(self) -> int:
148        """
149        Returns the y-coordinate of the top edge of the box.
150        """
151        return self.y + self.height
152
153    @property
154    def bottom(self) -> int:
155        """
156        Returns the y-coordinate of the bottom edge of the box.
157        """
158        return self.y
159
160    @property
161    def middle(self) -> tuple[int, int]:
162        """
163        Returns the (x, y) coordinates of the middle point of the box.
164        """
165        return (self.middleX, self.middleY)
166
167    @property
168    def middleX(self) -> int:
169        """
170        Returns the x-coordinate of the middle of the box.
171        """
172        return self.x + self.width / 2
173
174    @property
175    def middleY(self) -> int:
176        """
177        Returns the y-coordinate of the middle of the box.
178        """
179        return self.y + self.height / 2
180
181    def xray(self, draw: bool = True) -> "KBox":
182        """
183        Visualize the box using `lib.layout.xray` function.
184
185        Args:
186            draw: Whether to draw the box. Defaults to True.
187        """
188        if draw:
189            layout.xray(self.coords)
190        return self
191
192    def draw(self, align: fonts.TextAlign = "left"):
193        """
194        Draw the box's text content using `drawBot.textBox`. Font settings must be already set.
195
196        Args:
197            align: Text alignment within the box. Defaults to "left". Can be "left", "center", or "right".
198
199        Returns:
200            The result of `drawBot.textBox` which is overset text (if any).
201        """
202        if not self.text:
203            raise ValueError(
204                "No text to draw in this KBox. Set the 'text' attribute first."
205            )
206        return drawBot.textBox(self.text, self.coords, align=align)
207
208    def move(
209        self,
210        x: layout.ImplicitUnit = 0,
211        y: layout.ImplicitUnit = 0,
212        implicitUnit: layout.ImplicitUnit = "mm",
213    ) -> "KBox":
214        """
215        Move the box by the specified amounts in x and y directions.
216
217        Args:
218            x: Horizontal offset. Positive values move right, negative values move left.
219            y: Vertical offset. Positive values move up, negative values move down.
220        """
221        self.coords = layout.move(self.coords, x=x, y=y, implicitUnit=implicitUnit)
222        return self
223
224    def grow(
225        self,
226        margin: tuple[layout.UnitSource],
227        implicitUnit: layout.ImplicitUnit = "mm",
228    ) -> "KBox":
229        """
230        Grow the box by adding margins via CSS-style tuple or single value.
231
232        Args:
233            margin: A tuple of (left, bottom, right, top) margins to add.
234        """
235        self.coords = layout.grow(self.coords, margin=margin, implicitUnit=implicitUnit)
236        return self
237
238    def shrink(
239        self,
240        margin: tuple[layout.UnitSource],
241        implicitUnit: layout.ImplicitUnit = "mm",
242    ) -> "KBox":
243        """
244        Shrink the box by adding padding via CSS-style tuple or single value.
245
246        Args:
247            margin: A tuple of (left, bottom, right, top) margins to subtract.
248        """
249        self.coords = layout.shrink(
250            self.coords,
251            margin=margin,
252            implicitUnit=implicitUnit,
253        )
254        return self
255
256    def offsetFrame(
257        self,
258        moveX: layout.UnitSource = 0,
259        moveY: layout.UnitSource = 0,
260        grow: layout.UnitSource = None,
261        shrink: layout.UnitSource = None,
262        implicitUnit: layout.ImplicitUnit = "mm",
263    ) -> layout.Coordinates:
264        """
265        Return a derived frame without mutating this box.
266
267        `grow` and `shrink` are optional, but mutually exclusive.
268
269        Args:
270            moveX: Horizontal offset of the derived frame.
271            moveY: Vertical offset of the derived frame.
272            grow: Margin to grow outward.
273            shrink: Margin to inset inward.
274            implicitUnit: Implicit unit for all numeric values.
275
276        Returns:
277            Offset frame as (x, y, w, h).
278        """
279        if grow is not None and shrink is not None:
280            raise ValueError("offsetFrame() allows only one of 'grow' or 'shrink'.")
281
282        frame = layout.move(self.coords, x=moveX, y=moveY, implicitUnit=implicitUnit)
283
284        if grow is not None:
285            return layout.grow(frame, margin=grow, implicitUnit=implicitUnit)
286
287        if shrink is not None:
288            return layout.shrink(frame, margin=shrink, implicitUnit=implicitUnit)
289
290        return frame
291
292    def duplicate(
293        self,
294        moveX: layout.UnitSource = 0,
295        moveY: layout.UnitSource = 0,
296        grow: layout.UnitSource = None,
297        shrink: layout.UnitSource = None,
298        implicitUnit: layout.ImplicitUnit = "mm",
299    ) -> "KBox":
300        """
301        Create a duplicate of this box with optional offset and size adjustments.
302
303        Args:
304            moveX: Horizontal offset for the duplicate box.
305            moveY: Vertical offset for the duplicate box.
306            grow: Margin to grow the duplicate box outward.
307            shrink: Margin to shrink the duplicate box inward.
308            implicitUnit: Implicit unit for all numeric values.
309
310        Returns:
311            A new KBox instance with the applied transformations.
312        """
313        newCoords = self.offsetFrame(
314            moveX=moveX,
315            moveY=moveY,
316            grow=grow,
317            shrink=shrink,
318            implicitUnit=implicitUnit,
319        )
320        return KBox(newCoords)
321
322    def shrinkWidth(
323        self,
324        amount: layout.UnitSource,
325        origin: Literal["left", "right"] = "left",
326        implicitUnit: layout.ImplicitUnit = "decimal",
327    ) -> "KBox":
328        """
329        Shrink the width of the box by a specified amount.
330
331        Args:
332            amount: The amount to shrink the width by.
333            origin: The side from which to shrink. Defaults to "left".
334        """
335        self.coords = layout.shrinkWidth(
336            self.coords,
337            amount=amount,
338            origin=origin,
339            implicitUnit=implicitUnit,
340        )
341        return self
342
343    def shrinkHeight(
344        self,
345        amount: layout.UnitSource,
346        origin: Literal["top", "bottom"] = "top",
347        implicitUnit: layout.ImplicitUnit = "decimal",
348    ) -> "KBox":
349        """
350        Shrink the height of the box by a specified amount.
351
352        Args:
353            amount: The amount to shrink the height by.
354            origin: The side from which to shrink. Defaults to "top".
355        """
356        self.coords = layout.shrinkHeight(
357            self.coords,
358            amount=amount,
359            origin=origin,
360            implicitUnit=implicitUnit,
361        )
362        return self
363
364    def splitRelative(
365        self,
366        formula: str = "1/1",
367        gap: layout.UnitSource = 5,
368        gapImplicitUnit: layout.ImplicitUnit = "mm",
369        create: layout.LayoutFlow = "columns",
370    ) -> list["KBox"]:
371        """Split into child boxes by specified ratio. See `layout.splitRelative`."""
372        return map(
373            KBox,
374            layout.splitRelative(self.coords, formula, gap, gapImplicitUnit, create),
375        )
376
377    def splitAbsolute(
378        self,
379        create: layout.LayoutFlow,
380        origin: layout.LayoutOrigin,
381        size: layout.UnitSource,
382        gap: layout.UnitSource,
383        implicitUnit: layout.ImplicitUnit = "mm",
384    ) -> list["KBox"]:
385        """Split into child boxes by absolute size. See `layout.splitAbsolute`."""
386        return map(
387            KBox,
388            layout.splitAbsolute(self.coords, create, origin, size, gap, implicitUnit),
389        )
390
391    def partition(
392        self,
393        create: layout.LayoutFlow,
394        origin: layout.LayoutOrigin,
395        size: layout.UnitSource,
396        gap: layout.UnitSource,
397        implicitUnit: layout.ImplicitUnit = "pt",
398        mutate: bool = True,
399    ) -> "KBox":
400        """Partition the box by carving out a new box of specified size from a specified edge.
401
402        Args:
403            create: Whether to split into "rows" or "columns".
404            origin: Logical edge from which to partition: "start" (top/left) or "end" (bottom/right).
405            size: The size of the new box to carve out.
406            gap: The gap between the new box and the remaining box.
407            implicitUnit: The implicit unit for size and gap values.
408            mutate: If True, modify this box in place. If False, keep this box unchanged.
409
410        Returns:
411            The new KBox that was carved out.
412        """
413        # ! Handle percentage-based size by converting to absolute value based on box dimensions
414        if layout.isInPercent(size):
415            size = layout.parseUnit(
416                size, base=self.height if create == "rows" else self.width
417            )
418
419        new_rect, remainder_rect = layout.splitAbsolute(
420            self.coords, create, origin, size, gap, implicitUnit
421        )
422        if mutate:
423            self.coords = remainder_rect
424        return KBox(new_rect)
425
426    def addHeader(
427        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
428    ) -> "KBox":
429        """
430        Add a header box and optionally modify this box in place (shorthand for partition).
431
432        Args:
433            gap: Space between header and body.
434            lines: Number of lines in the header. Font properties must be set beforehand.
435            mutate: If True, modify this box in place. If False, keep this box unchanged.
436
437        Returns:
438            The header KBox.
439        """
440        return self.partition(
441            "rows", "start", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
442        )
443
444    def addFooter(
445        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
446    ) -> "KBox":
447        """
448        Add a footer box and optionally modify this box in place (shorthand for partition).
449
450        Args:
451            gap: Space between footer and body.
452            lines: Number of lines in the footer. Font properties must be set beforehand.
453            mutate: If True, modify this box in place. If False, keep this box unchanged.
454
455        Returns:
456            The footer KBox.
457        """
458        return self.partition(
459            "rows", "end", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
460        )
461
462    def makeGrid(
463        self,
464        rows: int = 1,
465        cols: int = 1,
466        gutter: layout.UnitSource = "5mm",
467        debug: bool = False,
468    ) -> list["KBox"]:
469        """Split into a grid of specified `KBox` rows and columns with gutter. See `layout.makeGrid`."""
470        return list(map(KBox, layout.makeGrid(self.coords, rows, cols, gutter, debug)))
471
472    def probeGridItem(
473        self, rows: int = 1, cols: int = 1, gutter: layout.UnitSource = "5mm"
474    ) -> "KBox":
475        """Calculate the dimensions of a grid item without positioning. Useful for layout calculations."""
476        return KBox(layout.inferGridItemDimensions(self.dimensions, rows, cols, gutter))
477
478    def alignInside(
479        self,
480        container: Union["KBox", tuple],
481        position: tuple[layout.AlignX, layout.AlignY] = ("center", "center"),
482        clip: bool = True,
483        nudgeY: float = 1.0,
484    ) -> "KBox":
485        """
486        Align this box inside a container box.
487
488        Args:
489            container: The container `KBox` or `tuple` of coordinates.
490            position: The alignment position (x, y). Defaults to ("center", "center").
491            clip: If True, shrink this box to fit inside container bounds.
492                If False, keep dimensions and allow overflow.
493            nudgeY: Optical vertical nudge strength. `0` disables nudge,
494                `1` applies default compensation, and larger values increase it.
495        """
496        self.coords = layout.align(
497            container.coords if isinstance(container, KBox) else container,
498            self.coords,
499            position=position,
500            clip=clip,
501            nudgeY=nudgeY,
502        )
503        return self
504
505    def justifyInside(
506        self,
507        container: Union["KBox", tuple],
508        count: int,
509        create: layout.LayoutFlow,
510        crossAlign: layout.AlignX | layout.AlignY | None = None,
511    ) -> list["KBox"]:
512        """
513        Distribute multiple boxes evenly inside a container box.
514
515        Args:
516            container: The container `KBox` or `tuple` of coordinates.
517            count: The number of boxes to distribute.
518            create: Whether to distribute in "rows" or "columns".
519            crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent.
520        Returns:
521            A list of new `KBox` instances that are distributed inside the container.
522        """
523        return list(
524            map(
525                KBox,
526                layout.justify(
527                    container.coords if isinstance(container, KBox) else container,
528                    self.coords,
529                    count,
530                    create,
531                    crossAlign,
532                ),
533            )
534        )
535
536    def snapHorizontally(self, x: int, snapTo: layout.AlignX = "center") -> "KBox":
537        if snapTo == "left":
538            self.x = x
539        elif snapTo == "center":
540            self.x = x - self.width / 2
541        elif snapTo == "right":
542            self.x = x - self.width
543        else:
544            logger.warning("Invalid horizontal snap position: {}", snapTo)
545        return self
546
547    def snapVertically(self, y: int, snapTo: layout.AlignY = "center") -> "KBox":
548        if snapTo == "bottom":
549            self.y = y - self.height
550        elif snapTo == "center":
551            self.y = y - self.height / 2
552        elif snapTo == "top":
553            self.y = y
554        else:
555            logger.warning("Invalid vertical snap position: {}", snapTo)
556        return self
557
558    def addCropMarks(
559        self,
560        edges: list[layout.BoxEdge] = ["top", "right", "bottom", "left"],
561        length: float = 1.5,
562        offset: float = 0.5,
563    ) -> "KBox":
564        """
565        Add crop marks to the box.
566
567        Args:
568            edges: The edges to add crop marks to. Defaults to all four edges.
569            length: The length of crop marks in mm. Defaults to 1.5.
570            offset: The offset of crop marks in mm. Defaults to 0.5.
571        """
572        length, offset = layout.mm(length), layout.mm(offset)
573
574        def _setLineAttrs():
575            drawBot.fill(None)
576            drawBot.cmykStroke(*color.CMYK(k=100).asCMYKA)
577            drawBot.strokeWidth(0.5)
578
579        def _drawVertical(x: int):
580            drawBot.line((x, self.bottom - offset - length), (x, self.bottom - offset))
581            drawBot.line((x, self.top + offset), (x, self.top + offset + length))
582
583        def _drawHorizontal(y: int):
584            drawBot.line((self.left - offset - length, y), (self.left - offset, y))
585            drawBot.line((self.right + offset, y), (self.right + offset + length, y))
586
587        with drawBot.savedState():
588            _setLineAttrs()
589
590            for edge in helpers.coerceList(edges):
591                func = _drawHorizontal if edge in ["top", "bottom"] else _drawVertical
592                coordinate: int = self.__getattribute__(edge)  # self.top => 123
593                func(coordinate)
594
595        return self
class KBox:
 10class KBox:
 11    "A layout primitive with coordinates, dimensions and spatial utilities."
 12
 13    def __init__(self, coords: tuple, text: str | None = None) -> None:
 14        """
 15        Initialize a KBox with given coordinates.
 16
 17        See `lib.layout.parsePageSize` for possible coordinate types.
 18
 19        Args:
 20            coords: A tuple of (x, y, width, height) coordinates.
 21
 22        Example:
 23        ```python
 24        # Create a new box with coordinates
 25        box = KBox((10, 10, 100, 50))  # x, y, width, height
 26
 27        # Move the box
 28        box.move(x=20, y=10)
 29
 30        # Grow the box by adding margin
 31        box.grow((5, 5, 5, 5))  # left, bottom, right, top margins
 32        ```
 33        """
 34        self.x, self.y, self.width, self.height = layout.toCoords(coords)
 35        self.text = text
 36
 37    def __str__(self) -> str:
 38        """Return a string representation of the KBox instance."""
 39        x = f"{colorama.Fore.LIGHTYELLOW_EX}X {self.x}"
 40        y = f"{colorama.Fore.LIGHTBLUE_EX}Y {self.y}"
 41        w = f"{colorama.Fore.LIGHTMAGENTA_EX}W {self.width}"
 42        h = f"{colorama.Fore.LIGHTCYAN_EX}H {self.height}"
 43        t = f"{colorama.Fore.LIGHTWHITE_EX}T {self.text}" if self.text else ""
 44        return f"{colorama.Fore.BLACK}{colorama.Back.LIGHTGREEN_EX}KBox{colorama.Style.RESET_ALL} {x} {y} {w} {h} {t}{colorama.Style.RESET_ALL}"
 45
 46    @staticmethod
 47    def fromDimension(unit: layout.UnitSource) -> "KBox":
 48        """
 49        Create a square KBox with the same width and height.
 50
 51        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
 52        """
 53        value = layout.parseUnit(unit, implicitUnit="pt")
 54        return KBox((0, 0, value, value))
 55
 56    @staticmethod
 57    def fromDimensions(width: layout.UnitSource, height: layout.UnitSource) -> "KBox":
 58        """
 59        Create a KBox from width and height, with x and y set to 0.
 60
 61        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
 62        """
 63        width, height = [
 64            layout.parseUnit(d, implicitUnit="pt") for d in (width, height)
 65        ]
 66        return KBox((0, 0, width, height))
 67
 68    @staticmethod
 69    def fromText(str: str, textWidth: float = None) -> "KBox":
 70        """
 71        Create a KBox that fits the given text. Font settings must be already set.
 72
 73        Args:
 74            str: The text to fit.
 75            textWidth: Optional maximum width for the text box. If provided, the text will wrap to fit within this width.
 76
 77        Returns:
 78            A new KBox instance that fits the text.
 79        """
 80        return KBox((0, 0, *drawBot.textSize(str, width=textWidth)), text=str)
 81
 82    @property
 83    def position(self) -> tuple[int, int]:
 84        """
 85        Returns the x and y coordinates of the bottom-left corner.
 86        """
 87        return self.x, self.y
 88
 89    @position.setter
 90    def position(self, value: tuple[int, int]) -> None:
 91        """
 92        Set the position (x, y) of the box.
 93        """
 94        try:
 95            self.x, self.y = value
 96        except Exception as e:
 97            logger.warning("Unable to set KBox position: {}. Error: {}", value, e)
 98
 99    @property
100    def dimensions(self) -> tuple[int, int]:
101        """
102        Returns the dimensions (width, height) of the box in points.
103        """
104        return self.width, self.height
105
106    @dimensions.setter
107    def dimensions(self, value: tuple[int, int]) -> None:
108        """
109        Set the dimensions (width, height) of the box in points.
110        """
111        try:
112            self.width, self.height = value
113        except Exception as e:
114            logger.warning("Unable to set KBox dimensions: {}. Error: {}", value, e)
115
116    @property
117    def coords(self) -> tuple[int, int, int, int]:
118        """
119        Returns the complete coordinates (x, y, width, height) of the box.
120        """
121        return self.x, self.y, self.width, self.height
122
123    @coords.setter
124    def coords(self, value: tuple[int, int, int, int]) -> None:
125        """
126        Set the complete coordinates (x, y, width, height) of the box.
127        """
128        try:
129            self.x, self.y, self.width, self.height = value
130        except Exception as e:
131            logger.warning("Unable to set KBox coords: {}. Error: {}", value, e)
132
133    @property
134    def left(self) -> int:
135        """
136        Returns the x-coordinate of the left edge of the box.
137        """
138        return self.x
139
140    @property
141    def right(self) -> int:
142        """
143        Returns the x-coordinate of the right edge of the box.
144        """
145        return self.x + self.width
146
147    @property
148    def top(self) -> int:
149        """
150        Returns the y-coordinate of the top edge of the box.
151        """
152        return self.y + self.height
153
154    @property
155    def bottom(self) -> int:
156        """
157        Returns the y-coordinate of the bottom edge of the box.
158        """
159        return self.y
160
161    @property
162    def middle(self) -> tuple[int, int]:
163        """
164        Returns the (x, y) coordinates of the middle point of the box.
165        """
166        return (self.middleX, self.middleY)
167
168    @property
169    def middleX(self) -> int:
170        """
171        Returns the x-coordinate of the middle of the box.
172        """
173        return self.x + self.width / 2
174
175    @property
176    def middleY(self) -> int:
177        """
178        Returns the y-coordinate of the middle of the box.
179        """
180        return self.y + self.height / 2
181
182    def xray(self, draw: bool = True) -> "KBox":
183        """
184        Visualize the box using `lib.layout.xray` function.
185
186        Args:
187            draw: Whether to draw the box. Defaults to True.
188        """
189        if draw:
190            layout.xray(self.coords)
191        return self
192
193    def draw(self, align: fonts.TextAlign = "left"):
194        """
195        Draw the box's text content using `drawBot.textBox`. Font settings must be already set.
196
197        Args:
198            align: Text alignment within the box. Defaults to "left". Can be "left", "center", or "right".
199
200        Returns:
201            The result of `drawBot.textBox` which is overset text (if any).
202        """
203        if not self.text:
204            raise ValueError(
205                "No text to draw in this KBox. Set the 'text' attribute first."
206            )
207        return drawBot.textBox(self.text, self.coords, align=align)
208
209    def move(
210        self,
211        x: layout.ImplicitUnit = 0,
212        y: layout.ImplicitUnit = 0,
213        implicitUnit: layout.ImplicitUnit = "mm",
214    ) -> "KBox":
215        """
216        Move the box by the specified amounts in x and y directions.
217
218        Args:
219            x: Horizontal offset. Positive values move right, negative values move left.
220            y: Vertical offset. Positive values move up, negative values move down.
221        """
222        self.coords = layout.move(self.coords, x=x, y=y, implicitUnit=implicitUnit)
223        return self
224
225    def grow(
226        self,
227        margin: tuple[layout.UnitSource],
228        implicitUnit: layout.ImplicitUnit = "mm",
229    ) -> "KBox":
230        """
231        Grow the box by adding margins via CSS-style tuple or single value.
232
233        Args:
234            margin: A tuple of (left, bottom, right, top) margins to add.
235        """
236        self.coords = layout.grow(self.coords, margin=margin, implicitUnit=implicitUnit)
237        return self
238
239    def shrink(
240        self,
241        margin: tuple[layout.UnitSource],
242        implicitUnit: layout.ImplicitUnit = "mm",
243    ) -> "KBox":
244        """
245        Shrink the box by adding padding via CSS-style tuple or single value.
246
247        Args:
248            margin: A tuple of (left, bottom, right, top) margins to subtract.
249        """
250        self.coords = layout.shrink(
251            self.coords,
252            margin=margin,
253            implicitUnit=implicitUnit,
254        )
255        return self
256
257    def offsetFrame(
258        self,
259        moveX: layout.UnitSource = 0,
260        moveY: layout.UnitSource = 0,
261        grow: layout.UnitSource = None,
262        shrink: layout.UnitSource = None,
263        implicitUnit: layout.ImplicitUnit = "mm",
264    ) -> layout.Coordinates:
265        """
266        Return a derived frame without mutating this box.
267
268        `grow` and `shrink` are optional, but mutually exclusive.
269
270        Args:
271            moveX: Horizontal offset of the derived frame.
272            moveY: Vertical offset of the derived frame.
273            grow: Margin to grow outward.
274            shrink: Margin to inset inward.
275            implicitUnit: Implicit unit for all numeric values.
276
277        Returns:
278            Offset frame as (x, y, w, h).
279        """
280        if grow is not None and shrink is not None:
281            raise ValueError("offsetFrame() allows only one of 'grow' or 'shrink'.")
282
283        frame = layout.move(self.coords, x=moveX, y=moveY, implicitUnit=implicitUnit)
284
285        if grow is not None:
286            return layout.grow(frame, margin=grow, implicitUnit=implicitUnit)
287
288        if shrink is not None:
289            return layout.shrink(frame, margin=shrink, implicitUnit=implicitUnit)
290
291        return frame
292
293    def duplicate(
294        self,
295        moveX: layout.UnitSource = 0,
296        moveY: layout.UnitSource = 0,
297        grow: layout.UnitSource = None,
298        shrink: layout.UnitSource = None,
299        implicitUnit: layout.ImplicitUnit = "mm",
300    ) -> "KBox":
301        """
302        Create a duplicate of this box with optional offset and size adjustments.
303
304        Args:
305            moveX: Horizontal offset for the duplicate box.
306            moveY: Vertical offset for the duplicate box.
307            grow: Margin to grow the duplicate box outward.
308            shrink: Margin to shrink the duplicate box inward.
309            implicitUnit: Implicit unit for all numeric values.
310
311        Returns:
312            A new KBox instance with the applied transformations.
313        """
314        newCoords = self.offsetFrame(
315            moveX=moveX,
316            moveY=moveY,
317            grow=grow,
318            shrink=shrink,
319            implicitUnit=implicitUnit,
320        )
321        return KBox(newCoords)
322
323    def shrinkWidth(
324        self,
325        amount: layout.UnitSource,
326        origin: Literal["left", "right"] = "left",
327        implicitUnit: layout.ImplicitUnit = "decimal",
328    ) -> "KBox":
329        """
330        Shrink the width of the box by a specified amount.
331
332        Args:
333            amount: The amount to shrink the width by.
334            origin: The side from which to shrink. Defaults to "left".
335        """
336        self.coords = layout.shrinkWidth(
337            self.coords,
338            amount=amount,
339            origin=origin,
340            implicitUnit=implicitUnit,
341        )
342        return self
343
344    def shrinkHeight(
345        self,
346        amount: layout.UnitSource,
347        origin: Literal["top", "bottom"] = "top",
348        implicitUnit: layout.ImplicitUnit = "decimal",
349    ) -> "KBox":
350        """
351        Shrink the height of the box by a specified amount.
352
353        Args:
354            amount: The amount to shrink the height by.
355            origin: The side from which to shrink. Defaults to "top".
356        """
357        self.coords = layout.shrinkHeight(
358            self.coords,
359            amount=amount,
360            origin=origin,
361            implicitUnit=implicitUnit,
362        )
363        return self
364
365    def splitRelative(
366        self,
367        formula: str = "1/1",
368        gap: layout.UnitSource = 5,
369        gapImplicitUnit: layout.ImplicitUnit = "mm",
370        create: layout.LayoutFlow = "columns",
371    ) -> list["KBox"]:
372        """Split into child boxes by specified ratio. See `layout.splitRelative`."""
373        return map(
374            KBox,
375            layout.splitRelative(self.coords, formula, gap, gapImplicitUnit, create),
376        )
377
378    def splitAbsolute(
379        self,
380        create: layout.LayoutFlow,
381        origin: layout.LayoutOrigin,
382        size: layout.UnitSource,
383        gap: layout.UnitSource,
384        implicitUnit: layout.ImplicitUnit = "mm",
385    ) -> list["KBox"]:
386        """Split into child boxes by absolute size. See `layout.splitAbsolute`."""
387        return map(
388            KBox,
389            layout.splitAbsolute(self.coords, create, origin, size, gap, implicitUnit),
390        )
391
392    def partition(
393        self,
394        create: layout.LayoutFlow,
395        origin: layout.LayoutOrigin,
396        size: layout.UnitSource,
397        gap: layout.UnitSource,
398        implicitUnit: layout.ImplicitUnit = "pt",
399        mutate: bool = True,
400    ) -> "KBox":
401        """Partition the box by carving out a new box of specified size from a specified edge.
402
403        Args:
404            create: Whether to split into "rows" or "columns".
405            origin: Logical edge from which to partition: "start" (top/left) or "end" (bottom/right).
406            size: The size of the new box to carve out.
407            gap: The gap between the new box and the remaining box.
408            implicitUnit: The implicit unit for size and gap values.
409            mutate: If True, modify this box in place. If False, keep this box unchanged.
410
411        Returns:
412            The new KBox that was carved out.
413        """
414        # ! Handle percentage-based size by converting to absolute value based on box dimensions
415        if layout.isInPercent(size):
416            size = layout.parseUnit(
417                size, base=self.height if create == "rows" else self.width
418            )
419
420        new_rect, remainder_rect = layout.splitAbsolute(
421            self.coords, create, origin, size, gap, implicitUnit
422        )
423        if mutate:
424            self.coords = remainder_rect
425        return KBox(new_rect)
426
427    def addHeader(
428        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
429    ) -> "KBox":
430        """
431        Add a header box and optionally modify this box in place (shorthand for partition).
432
433        Args:
434            gap: Space between header and body.
435            lines: Number of lines in the header. Font properties must be set beforehand.
436            mutate: If True, modify this box in place. If False, keep this box unchanged.
437
438        Returns:
439            The header KBox.
440        """
441        return self.partition(
442            "rows", "start", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
443        )
444
445    def addFooter(
446        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
447    ) -> "KBox":
448        """
449        Add a footer box and optionally modify this box in place (shorthand for partition).
450
451        Args:
452            gap: Space between footer and body.
453            lines: Number of lines in the footer. Font properties must be set beforehand.
454            mutate: If True, modify this box in place. If False, keep this box unchanged.
455
456        Returns:
457            The footer KBox.
458        """
459        return self.partition(
460            "rows", "end", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
461        )
462
463    def makeGrid(
464        self,
465        rows: int = 1,
466        cols: int = 1,
467        gutter: layout.UnitSource = "5mm",
468        debug: bool = False,
469    ) -> list["KBox"]:
470        """Split into a grid of specified `KBox` rows and columns with gutter. See `layout.makeGrid`."""
471        return list(map(KBox, layout.makeGrid(self.coords, rows, cols, gutter, debug)))
472
473    def probeGridItem(
474        self, rows: int = 1, cols: int = 1, gutter: layout.UnitSource = "5mm"
475    ) -> "KBox":
476        """Calculate the dimensions of a grid item without positioning. Useful for layout calculations."""
477        return KBox(layout.inferGridItemDimensions(self.dimensions, rows, cols, gutter))
478
479    def alignInside(
480        self,
481        container: Union["KBox", tuple],
482        position: tuple[layout.AlignX, layout.AlignY] = ("center", "center"),
483        clip: bool = True,
484        nudgeY: float = 1.0,
485    ) -> "KBox":
486        """
487        Align this box inside a container box.
488
489        Args:
490            container: The container `KBox` or `tuple` of coordinates.
491            position: The alignment position (x, y). Defaults to ("center", "center").
492            clip: If True, shrink this box to fit inside container bounds.
493                If False, keep dimensions and allow overflow.
494            nudgeY: Optical vertical nudge strength. `0` disables nudge,
495                `1` applies default compensation, and larger values increase it.
496        """
497        self.coords = layout.align(
498            container.coords if isinstance(container, KBox) else container,
499            self.coords,
500            position=position,
501            clip=clip,
502            nudgeY=nudgeY,
503        )
504        return self
505
506    def justifyInside(
507        self,
508        container: Union["KBox", tuple],
509        count: int,
510        create: layout.LayoutFlow,
511        crossAlign: layout.AlignX | layout.AlignY | None = None,
512    ) -> list["KBox"]:
513        """
514        Distribute multiple boxes evenly inside a container box.
515
516        Args:
517            container: The container `KBox` or `tuple` of coordinates.
518            count: The number of boxes to distribute.
519            create: Whether to distribute in "rows" or "columns".
520            crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent.
521        Returns:
522            A list of new `KBox` instances that are distributed inside the container.
523        """
524        return list(
525            map(
526                KBox,
527                layout.justify(
528                    container.coords if isinstance(container, KBox) else container,
529                    self.coords,
530                    count,
531                    create,
532                    crossAlign,
533                ),
534            )
535        )
536
537    def snapHorizontally(self, x: int, snapTo: layout.AlignX = "center") -> "KBox":
538        if snapTo == "left":
539            self.x = x
540        elif snapTo == "center":
541            self.x = x - self.width / 2
542        elif snapTo == "right":
543            self.x = x - self.width
544        else:
545            logger.warning("Invalid horizontal snap position: {}", snapTo)
546        return self
547
548    def snapVertically(self, y: int, snapTo: layout.AlignY = "center") -> "KBox":
549        if snapTo == "bottom":
550            self.y = y - self.height
551        elif snapTo == "center":
552            self.y = y - self.height / 2
553        elif snapTo == "top":
554            self.y = y
555        else:
556            logger.warning("Invalid vertical snap position: {}", snapTo)
557        return self
558
559    def addCropMarks(
560        self,
561        edges: list[layout.BoxEdge] = ["top", "right", "bottom", "left"],
562        length: float = 1.5,
563        offset: float = 0.5,
564    ) -> "KBox":
565        """
566        Add crop marks to the box.
567
568        Args:
569            edges: The edges to add crop marks to. Defaults to all four edges.
570            length: The length of crop marks in mm. Defaults to 1.5.
571            offset: The offset of crop marks in mm. Defaults to 0.5.
572        """
573        length, offset = layout.mm(length), layout.mm(offset)
574
575        def _setLineAttrs():
576            drawBot.fill(None)
577            drawBot.cmykStroke(*color.CMYK(k=100).asCMYKA)
578            drawBot.strokeWidth(0.5)
579
580        def _drawVertical(x: int):
581            drawBot.line((x, self.bottom - offset - length), (x, self.bottom - offset))
582            drawBot.line((x, self.top + offset), (x, self.top + offset + length))
583
584        def _drawHorizontal(y: int):
585            drawBot.line((self.left - offset - length, y), (self.left - offset, y))
586            drawBot.line((self.right + offset, y), (self.right + offset + length, y))
587
588        with drawBot.savedState():
589            _setLineAttrs()
590
591            for edge in helpers.coerceList(edges):
592                func = _drawHorizontal if edge in ["top", "bottom"] else _drawVertical
593                coordinate: int = self.__getattribute__(edge)  # self.top => 123
594                func(coordinate)
595
596        return self

A layout primitive with coordinates, dimensions and spatial utilities.

KBox(coords: tuple, text: str | None = None)
13    def __init__(self, coords: tuple, text: str | None = None) -> None:
14        """
15        Initialize a KBox with given coordinates.
16
17        See `lib.layout.parsePageSize` for possible coordinate types.
18
19        Args:
20            coords: A tuple of (x, y, width, height) coordinates.
21
22        Example:
23        ```python
24        # Create a new box with coordinates
25        box = KBox((10, 10, 100, 50))  # x, y, width, height
26
27        # Move the box
28        box.move(x=20, y=10)
29
30        # Grow the box by adding margin
31        box.grow((5, 5, 5, 5))  # left, bottom, right, top margins
32        ```
33        """
34        self.x, self.y, self.width, self.height = layout.toCoords(coords)
35        self.text = text

Initialize a KBox with given coordinates.

See lib.layout.parsePageSize for possible coordinate types.

Arguments:
  • coords: A tuple of (x, y, width, height) coordinates.

Example:

# Create a new box with coordinates
box = KBox((10, 10, 100, 50))  # x, y, width, height

# Move the box
box.move(x=20, y=10)

# Grow the box by adding margin
box.grow((5, 5, 5, 5))  # left, bottom, right, top margins
text
@staticmethod
def fromDimension(unit: int | float | str | None) -> KBox:
46    @staticmethod
47    def fromDimension(unit: layout.UnitSource) -> "KBox":
48        """
49        Create a square KBox with the same width and height.
50
51        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
52        """
53        value = layout.parseUnit(unit, implicitUnit="pt")
54        return KBox((0, 0, value, value))

Create a square KBox with the same width and height.

Implicit unit is pt but can be changed by passing a string like 10mm.

@staticmethod
def fromDimensions( width: int | float | str | None, height: int | float | str | None) -> KBox:
56    @staticmethod
57    def fromDimensions(width: layout.UnitSource, height: layout.UnitSource) -> "KBox":
58        """
59        Create a KBox from width and height, with x and y set to 0.
60
61        Implicit unit is `pt` but can be changed by passing a string like `10mm`.
62        """
63        width, height = [
64            layout.parseUnit(d, implicitUnit="pt") for d in (width, height)
65        ]
66        return KBox((0, 0, width, height))

Create a KBox from width and height, with x and y set to 0.

Implicit unit is pt but can be changed by passing a string like 10mm.

@staticmethod
def fromText(str: str, textWidth: float = None) -> KBox:
68    @staticmethod
69    def fromText(str: str, textWidth: float = None) -> "KBox":
70        """
71        Create a KBox that fits the given text. Font settings must be already set.
72
73        Args:
74            str: The text to fit.
75            textWidth: Optional maximum width for the text box. If provided, the text will wrap to fit within this width.
76
77        Returns:
78            A new KBox instance that fits the text.
79        """
80        return KBox((0, 0, *drawBot.textSize(str, width=textWidth)), text=str)

Create a KBox that fits the given text. Font settings must be already set.

Arguments:
  • str: The text to fit.
  • textWidth: Optional maximum width for the text box. If provided, the text will wrap to fit within this width.
Returns:

A new KBox instance that fits the text.

position: tuple[int, int]
82    @property
83    def position(self) -> tuple[int, int]:
84        """
85        Returns the x and y coordinates of the bottom-left corner.
86        """
87        return self.x, self.y

Returns the x and y coordinates of the bottom-left corner.

dimensions: tuple[int, int]
 99    @property
100    def dimensions(self) -> tuple[int, int]:
101        """
102        Returns the dimensions (width, height) of the box in points.
103        """
104        return self.width, self.height

Returns the dimensions (width, height) of the box in points.

coords: tuple[int, int, int, int]
116    @property
117    def coords(self) -> tuple[int, int, int, int]:
118        """
119        Returns the complete coordinates (x, y, width, height) of the box.
120        """
121        return self.x, self.y, self.width, self.height

Returns the complete coordinates (x, y, width, height) of the box.

left: int
133    @property
134    def left(self) -> int:
135        """
136        Returns the x-coordinate of the left edge of the box.
137        """
138        return self.x

Returns the x-coordinate of the left edge of the box.

right: int
140    @property
141    def right(self) -> int:
142        """
143        Returns the x-coordinate of the right edge of the box.
144        """
145        return self.x + self.width

Returns the x-coordinate of the right edge of the box.

top: int
147    @property
148    def top(self) -> int:
149        """
150        Returns the y-coordinate of the top edge of the box.
151        """
152        return self.y + self.height

Returns the y-coordinate of the top edge of the box.

bottom: int
154    @property
155    def bottom(self) -> int:
156        """
157        Returns the y-coordinate of the bottom edge of the box.
158        """
159        return self.y

Returns the y-coordinate of the bottom edge of the box.

middle: tuple[int, int]
161    @property
162    def middle(self) -> tuple[int, int]:
163        """
164        Returns the (x, y) coordinates of the middle point of the box.
165        """
166        return (self.middleX, self.middleY)

Returns the (x, y) coordinates of the middle point of the box.

middleX: int
168    @property
169    def middleX(self) -> int:
170        """
171        Returns the x-coordinate of the middle of the box.
172        """
173        return self.x + self.width / 2

Returns the x-coordinate of the middle of the box.

middleY: int
175    @property
176    def middleY(self) -> int:
177        """
178        Returns the y-coordinate of the middle of the box.
179        """
180        return self.y + self.height / 2

Returns the y-coordinate of the middle of the box.

def xray(self, draw: bool = True) -> KBox:
182    def xray(self, draw: bool = True) -> "KBox":
183        """
184        Visualize the box using `lib.layout.xray` function.
185
186        Args:
187            draw: Whether to draw the box. Defaults to True.
188        """
189        if draw:
190            layout.xray(self.coords)
191        return self

Visualize the box using lib.layout.xray function.

Arguments:
  • draw: Whether to draw the box. Defaults to True.
def draw(self, align: Literal['left', 'center', 'right'] = 'left'):
193    def draw(self, align: fonts.TextAlign = "left"):
194        """
195        Draw the box's text content using `drawBot.textBox`. Font settings must be already set.
196
197        Args:
198            align: Text alignment within the box. Defaults to "left". Can be "left", "center", or "right".
199
200        Returns:
201            The result of `drawBot.textBox` which is overset text (if any).
202        """
203        if not self.text:
204            raise ValueError(
205                "No text to draw in this KBox. Set the 'text' attribute first."
206            )
207        return drawBot.textBox(self.text, self.coords, align=align)

Draw the box's text content using drawBot.textBox. Font settings must be already set.

Arguments:
  • align: Text alignment within the box. Defaults to "left". Can be "left", "center", or "right".
Returns:

The result of drawBot.textBox which is overset text (if any).

def move( self, x: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 0, y: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 0, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> KBox:
209    def move(
210        self,
211        x: layout.ImplicitUnit = 0,
212        y: layout.ImplicitUnit = 0,
213        implicitUnit: layout.ImplicitUnit = "mm",
214    ) -> "KBox":
215        """
216        Move the box by the specified amounts in x and y directions.
217
218        Args:
219            x: Horizontal offset. Positive values move right, negative values move left.
220            y: Vertical offset. Positive values move up, negative values move down.
221        """
222        self.coords = layout.move(self.coords, x=x, y=y, implicitUnit=implicitUnit)
223        return self

Move the box by the specified amounts in x and y directions.

Arguments:
  • x: Horizontal offset. Positive values move right, negative values move left.
  • y: Vertical offset. Positive values move up, negative values move down.
def grow( self, margin: tuple[int | float | str | None], implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> KBox:
225    def grow(
226        self,
227        margin: tuple[layout.UnitSource],
228        implicitUnit: layout.ImplicitUnit = "mm",
229    ) -> "KBox":
230        """
231        Grow the box by adding margins via CSS-style tuple or single value.
232
233        Args:
234            margin: A tuple of (left, bottom, right, top) margins to add.
235        """
236        self.coords = layout.grow(self.coords, margin=margin, implicitUnit=implicitUnit)
237        return self

Grow the box by adding margins via CSS-style tuple or single value.

Arguments:
  • margin: A tuple of (left, bottom, right, top) margins to add.
def shrink( self, margin: tuple[int | float | str | None], implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> KBox:
239    def shrink(
240        self,
241        margin: tuple[layout.UnitSource],
242        implicitUnit: layout.ImplicitUnit = "mm",
243    ) -> "KBox":
244        """
245        Shrink the box by adding padding via CSS-style tuple or single value.
246
247        Args:
248            margin: A tuple of (left, bottom, right, top) margins to subtract.
249        """
250        self.coords = layout.shrink(
251            self.coords,
252            margin=margin,
253            implicitUnit=implicitUnit,
254        )
255        return self

Shrink the box by adding padding via CSS-style tuple or single value.

Arguments:
  • margin: A tuple of (left, bottom, right, top) margins to subtract.
def offsetFrame( self, moveX: int | float | str | None = 0, moveY: int | float | str | None = 0, grow: int | float | str | None = None, shrink: int | float | str | None = None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> tuple[float, float, float, float]:
257    def offsetFrame(
258        self,
259        moveX: layout.UnitSource = 0,
260        moveY: layout.UnitSource = 0,
261        grow: layout.UnitSource = None,
262        shrink: layout.UnitSource = None,
263        implicitUnit: layout.ImplicitUnit = "mm",
264    ) -> layout.Coordinates:
265        """
266        Return a derived frame without mutating this box.
267
268        `grow` and `shrink` are optional, but mutually exclusive.
269
270        Args:
271            moveX: Horizontal offset of the derived frame.
272            moveY: Vertical offset of the derived frame.
273            grow: Margin to grow outward.
274            shrink: Margin to inset inward.
275            implicitUnit: Implicit unit for all numeric values.
276
277        Returns:
278            Offset frame as (x, y, w, h).
279        """
280        if grow is not None and shrink is not None:
281            raise ValueError("offsetFrame() allows only one of 'grow' or 'shrink'.")
282
283        frame = layout.move(self.coords, x=moveX, y=moveY, implicitUnit=implicitUnit)
284
285        if grow is not None:
286            return layout.grow(frame, margin=grow, implicitUnit=implicitUnit)
287
288        if shrink is not None:
289            return layout.shrink(frame, margin=shrink, implicitUnit=implicitUnit)
290
291        return frame

Return a derived frame without mutating this box.

grow and shrink are optional, but mutually exclusive.

Arguments:
  • moveX: Horizontal offset of the derived frame.
  • moveY: Vertical offset of the derived frame.
  • grow: Margin to grow outward.
  • shrink: Margin to inset inward.
  • implicitUnit: Implicit unit for all numeric values.
Returns:

Offset frame as (x, y, w, h).

def duplicate( self, moveX: int | float | str | None = 0, moveY: int | float | str | None = 0, grow: int | float | str | None = None, shrink: int | float | str | None = None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> KBox:
293    def duplicate(
294        self,
295        moveX: layout.UnitSource = 0,
296        moveY: layout.UnitSource = 0,
297        grow: layout.UnitSource = None,
298        shrink: layout.UnitSource = None,
299        implicitUnit: layout.ImplicitUnit = "mm",
300    ) -> "KBox":
301        """
302        Create a duplicate of this box with optional offset and size adjustments.
303
304        Args:
305            moveX: Horizontal offset for the duplicate box.
306            moveY: Vertical offset for the duplicate box.
307            grow: Margin to grow the duplicate box outward.
308            shrink: Margin to shrink the duplicate box inward.
309            implicitUnit: Implicit unit for all numeric values.
310
311        Returns:
312            A new KBox instance with the applied transformations.
313        """
314        newCoords = self.offsetFrame(
315            moveX=moveX,
316            moveY=moveY,
317            grow=grow,
318            shrink=shrink,
319            implicitUnit=implicitUnit,
320        )
321        return KBox(newCoords)

Create a duplicate of this box with optional offset and size adjustments.

Arguments:
  • moveX: Horizontal offset for the duplicate box.
  • moveY: Vertical offset for the duplicate box.
  • grow: Margin to grow the duplicate box outward.
  • shrink: Margin to shrink the duplicate box inward.
  • implicitUnit: Implicit unit for all numeric values.
Returns:

A new KBox instance with the applied transformations.

def shrinkWidth( self, amount: int | float | str | None, origin: Literal['left', 'right'] = 'left', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'decimal') -> KBox:
323    def shrinkWidth(
324        self,
325        amount: layout.UnitSource,
326        origin: Literal["left", "right"] = "left",
327        implicitUnit: layout.ImplicitUnit = "decimal",
328    ) -> "KBox":
329        """
330        Shrink the width of the box by a specified amount.
331
332        Args:
333            amount: The amount to shrink the width by.
334            origin: The side from which to shrink. Defaults to "left".
335        """
336        self.coords = layout.shrinkWidth(
337            self.coords,
338            amount=amount,
339            origin=origin,
340            implicitUnit=implicitUnit,
341        )
342        return self

Shrink the width of the box by a specified amount.

Arguments:
  • amount: The amount to shrink the width by.
  • origin: The side from which to shrink. Defaults to "left".
def shrinkHeight( self, amount: int | float | str | None, origin: Literal['top', 'bottom'] = 'top', implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'decimal') -> KBox:
344    def shrinkHeight(
345        self,
346        amount: layout.UnitSource,
347        origin: Literal["top", "bottom"] = "top",
348        implicitUnit: layout.ImplicitUnit = "decimal",
349    ) -> "KBox":
350        """
351        Shrink the height of the box by a specified amount.
352
353        Args:
354            amount: The amount to shrink the height by.
355            origin: The side from which to shrink. Defaults to "top".
356        """
357        self.coords = layout.shrinkHeight(
358            self.coords,
359            amount=amount,
360            origin=origin,
361            implicitUnit=implicitUnit,
362        )
363        return self

Shrink the height of the box by a specified amount.

Arguments:
  • amount: The amount to shrink the height by.
  • origin: The side from which to shrink. Defaults to "top".
def splitRelative( self, formula: str = '1/1', gap: int | float | str | None = 5, gapImplicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm', create: Literal['rows', 'columns'] = 'columns') -> list[KBox]:
365    def splitRelative(
366        self,
367        formula: str = "1/1",
368        gap: layout.UnitSource = 5,
369        gapImplicitUnit: layout.ImplicitUnit = "mm",
370        create: layout.LayoutFlow = "columns",
371    ) -> list["KBox"]:
372        """Split into child boxes by specified ratio. See `layout.splitRelative`."""
373        return map(
374            KBox,
375            layout.splitRelative(self.coords, formula, gap, gapImplicitUnit, create),
376        )

Split into child boxes by specified ratio. See layout.splitRelative.

def splitAbsolute( self, create: Literal['rows', 'columns'], origin: Literal['start', 'end'], size: int | float | str | None, gap: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'mm') -> list[KBox]:
378    def splitAbsolute(
379        self,
380        create: layout.LayoutFlow,
381        origin: layout.LayoutOrigin,
382        size: layout.UnitSource,
383        gap: layout.UnitSource,
384        implicitUnit: layout.ImplicitUnit = "mm",
385    ) -> list["KBox"]:
386        """Split into child boxes by absolute size. See `layout.splitAbsolute`."""
387        return map(
388            KBox,
389            layout.splitAbsolute(self.coords, create, origin, size, gap, implicitUnit),
390        )

Split into child boxes by absolute size. See layout.splitAbsolute.

def partition( self, create: Literal['rows', 'columns'], origin: Literal['start', 'end'], size: int | float | str | None, gap: int | float | str | None, implicitUnit: Literal['pt', 'px', 'mm', 'cm', 'decimal', 'auto'] = 'pt', mutate: bool = True) -> KBox:
392    def partition(
393        self,
394        create: layout.LayoutFlow,
395        origin: layout.LayoutOrigin,
396        size: layout.UnitSource,
397        gap: layout.UnitSource,
398        implicitUnit: layout.ImplicitUnit = "pt",
399        mutate: bool = True,
400    ) -> "KBox":
401        """Partition the box by carving out a new box of specified size from a specified edge.
402
403        Args:
404            create: Whether to split into "rows" or "columns".
405            origin: Logical edge from which to partition: "start" (top/left) or "end" (bottom/right).
406            size: The size of the new box to carve out.
407            gap: The gap between the new box and the remaining box.
408            implicitUnit: The implicit unit for size and gap values.
409            mutate: If True, modify this box in place. If False, keep this box unchanged.
410
411        Returns:
412            The new KBox that was carved out.
413        """
414        # ! Handle percentage-based size by converting to absolute value based on box dimensions
415        if layout.isInPercent(size):
416            size = layout.parseUnit(
417                size, base=self.height if create == "rows" else self.width
418            )
419
420        new_rect, remainder_rect = layout.splitAbsolute(
421            self.coords, create, origin, size, gap, implicitUnit
422        )
423        if mutate:
424            self.coords = remainder_rect
425        return KBox(new_rect)

Partition the box by carving out a new box of specified size from a specified edge.

Arguments:
  • create: Whether to split into "rows" or "columns".
  • origin: Logical edge from which to partition: "start" (top/left) or "end" (bottom/right).
  • size: The size of the new box to carve out.
  • gap: The gap between the new box and the remaining box.
  • implicitUnit: The implicit unit for size and gap values.
  • mutate: If True, modify this box in place. If False, keep this box unchanged.
Returns:

The new KBox that was carved out.

def addHeader( self, gap: int | float | str | None = '5mm', lines: int = 1, mutate: bool = True) -> KBox:
427    def addHeader(
428        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
429    ) -> "KBox":
430        """
431        Add a header box and optionally modify this box in place (shorthand for partition).
432
433        Args:
434            gap: Space between header and body.
435            lines: Number of lines in the header. Font properties must be set beforehand.
436            mutate: If True, modify this box in place. If False, keep this box unchanged.
437
438        Returns:
439            The header KBox.
440        """
441        return self.partition(
442            "rows", "start", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
443        )

Add a header box and optionally modify this box in place (shorthand for partition).

Arguments:
  • gap: Space between header and body.
  • lines: Number of lines in the header. Font properties must be set beforehand.
  • mutate: If True, modify this box in place. If False, keep this box unchanged.
Returns:

The header KBox.

def addFooter( self, gap: int | float | str | None = '5mm', lines: int = 1, mutate: bool = True) -> KBox:
445    def addFooter(
446        self, gap: layout.UnitSource = "5mm", lines: int = 1, mutate: bool = True
447    ) -> "KBox":
448        """
449        Add a footer box and optionally modify this box in place (shorthand for partition).
450
451        Args:
452            gap: Space between footer and body.
453            lines: Number of lines in the footer. Font properties must be set beforehand.
454            mutate: If True, modify this box in place. If False, keep this box unchanged.
455
456        Returns:
457            The footer KBox.
458        """
459        return self.partition(
460            "rows", "end", layout.inferBlockHeight(lines), gap=gap, mutate=mutate
461        )

Add a footer box and optionally modify this box in place (shorthand for partition).

Arguments:
  • gap: Space between footer and body.
  • lines: Number of lines in the footer. Font properties must be set beforehand.
  • mutate: If True, modify this box in place. If False, keep this box unchanged.
Returns:

The footer KBox.

def makeGrid( self, rows: int = 1, cols: int = 1, gutter: int | float | str | None = '5mm', debug: bool = False) -> list[KBox]:
463    def makeGrid(
464        self,
465        rows: int = 1,
466        cols: int = 1,
467        gutter: layout.UnitSource = "5mm",
468        debug: bool = False,
469    ) -> list["KBox"]:
470        """Split into a grid of specified `KBox` rows and columns with gutter. See `layout.makeGrid`."""
471        return list(map(KBox, layout.makeGrid(self.coords, rows, cols, gutter, debug)))

Split into a grid of specified KBox rows and columns with gutter. See layout.makeGrid.

def probeGridItem( self, rows: int = 1, cols: int = 1, gutter: int | float | str | None = '5mm') -> KBox:
473    def probeGridItem(
474        self, rows: int = 1, cols: int = 1, gutter: layout.UnitSource = "5mm"
475    ) -> "KBox":
476        """Calculate the dimensions of a grid item without positioning. Useful for layout calculations."""
477        return KBox(layout.inferGridItemDimensions(self.dimensions, rows, cols, gutter))

Calculate the dimensions of a grid item without positioning. Useful for layout calculations.

def alignInside( self, container: Union[KBox, tuple], position: tuple[typing.Literal['left', 'center', 'right', 'stretch', None], typing.Literal['top', 'center', 'bottom', 'stretch', None]] = ('center', 'center'), clip: bool = True, nudgeY: float = 1.0) -> KBox:
479    def alignInside(
480        self,
481        container: Union["KBox", tuple],
482        position: tuple[layout.AlignX, layout.AlignY] = ("center", "center"),
483        clip: bool = True,
484        nudgeY: float = 1.0,
485    ) -> "KBox":
486        """
487        Align this box inside a container box.
488
489        Args:
490            container: The container `KBox` or `tuple` of coordinates.
491            position: The alignment position (x, y). Defaults to ("center", "center").
492            clip: If True, shrink this box to fit inside container bounds.
493                If False, keep dimensions and allow overflow.
494            nudgeY: Optical vertical nudge strength. `0` disables nudge,
495                `1` applies default compensation, and larger values increase it.
496        """
497        self.coords = layout.align(
498            container.coords if isinstance(container, KBox) else container,
499            self.coords,
500            position=position,
501            clip=clip,
502            nudgeY=nudgeY,
503        )
504        return self

Align this box inside a container box.

Arguments:
  • container: The container KBox or tuple of coordinates.
  • position: The alignment position (x, y). Defaults to ("center", "center").
  • clip: If True, shrink this box to fit inside container bounds. If False, keep dimensions and allow overflow.
  • nudgeY: Optical vertical nudge strength. 0 disables nudge, 1 applies default compensation, and larger values increase it.
def justifyInside( self, container: Union[KBox, tuple], count: int, create: Literal['rows', 'columns'], crossAlign: Union[Literal['left', 'center', 'right', 'stretch', None], Literal['top', 'center', 'bottom', 'stretch', None], NoneType] = None) -> list[KBox]:
506    def justifyInside(
507        self,
508        container: Union["KBox", tuple],
509        count: int,
510        create: layout.LayoutFlow,
511        crossAlign: layout.AlignX | layout.AlignY | None = None,
512    ) -> list["KBox"]:
513        """
514        Distribute multiple boxes evenly inside a container box.
515
516        Args:
517            container: The container `KBox` or `tuple` of coordinates.
518            count: The number of boxes to distribute.
519            create: Whether to distribute in "rows" or "columns".
520            crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent.
521        Returns:
522            A list of new `KBox` instances that are distributed inside the container.
523        """
524        return list(
525            map(
526                KBox,
527                layout.justify(
528                    container.coords if isinstance(container, KBox) else container,
529                    self.coords,
530                    count,
531                    create,
532                    crossAlign,
533                ),
534            )
535        )

Distribute multiple boxes evenly inside a container box.

Arguments:
  • container: The container KBox or tuple of coordinates.
  • count: The number of boxes to distribute.
  • create: Whether to distribute in "rows" or "columns".
  • crossAlign: Optional alignment for the non-justified axis ('left', 'center', 'right', 'stretch' for columns; 'top', 'center', 'bottom', 'stretch' for rows). 'stretch' expands each child to fill the full cross-axis extent of the parent.
Returns:

A list of new KBox instances that are distributed inside the container.

def snapHorizontally( self, x: int, snapTo: Literal['left', 'center', 'right', 'stretch', None] = 'center') -> KBox:
537    def snapHorizontally(self, x: int, snapTo: layout.AlignX = "center") -> "KBox":
538        if snapTo == "left":
539            self.x = x
540        elif snapTo == "center":
541            self.x = x - self.width / 2
542        elif snapTo == "right":
543            self.x = x - self.width
544        else:
545            logger.warning("Invalid horizontal snap position: {}", snapTo)
546        return self
def snapVertically( self, y: int, snapTo: Literal['top', 'center', 'bottom', 'stretch', None] = 'center') -> KBox:
548    def snapVertically(self, y: int, snapTo: layout.AlignY = "center") -> "KBox":
549        if snapTo == "bottom":
550            self.y = y - self.height
551        elif snapTo == "center":
552            self.y = y - self.height / 2
553        elif snapTo == "top":
554            self.y = y
555        else:
556            logger.warning("Invalid vertical snap position: {}", snapTo)
557        return self
def addCropMarks( self, edges: list[typing.Literal['top', 'right', 'bottom', 'left']] = ['top', 'right', 'bottom', 'left'], length: float = 1.5, offset: float = 0.5) -> KBox:
559    def addCropMarks(
560        self,
561        edges: list[layout.BoxEdge] = ["top", "right", "bottom", "left"],
562        length: float = 1.5,
563        offset: float = 0.5,
564    ) -> "KBox":
565        """
566        Add crop marks to the box.
567
568        Args:
569            edges: The edges to add crop marks to. Defaults to all four edges.
570            length: The length of crop marks in mm. Defaults to 1.5.
571            offset: The offset of crop marks in mm. Defaults to 0.5.
572        """
573        length, offset = layout.mm(length), layout.mm(offset)
574
575        def _setLineAttrs():
576            drawBot.fill(None)
577            drawBot.cmykStroke(*color.CMYK(k=100).asCMYKA)
578            drawBot.strokeWidth(0.5)
579
580        def _drawVertical(x: int):
581            drawBot.line((x, self.bottom - offset - length), (x, self.bottom - offset))
582            drawBot.line((x, self.top + offset), (x, self.top + offset + length))
583
584        def _drawHorizontal(y: int):
585            drawBot.line((self.left - offset - length, y), (self.left - offset, y))
586            drawBot.line((self.right + offset, y), (self.right + offset + length, y))
587
588        with drawBot.savedState():
589            _setLineAttrs()
590
591            for edge in helpers.coerceList(edges):
592                func = _drawHorizontal if edge in ["top", "bottom"] else _drawVertical
593                coordinate: int = self.__getattribute__(edge)  # self.top => 123
594                func(coordinate)
595
596        return self

Add crop marks to the box.

Arguments:
  • edges: The edges to add crop marks to. Defaults to all four edges.
  • length: The length of crop marks in mm. Defaults to 1.5.
  • offset: The offset of crop marks in mm. Defaults to 0.5.