lib.graphics

  1from typing import Literal, Tuple, TypeAlias
  2from math import ceil, exp, sqrt
  3import random
  4import drawBot
  5from lib import layout, color
  6from icecream import ic
  7
  8BlendMode: TypeAlias = Literal[
  9    "normal",
 10    "multiply",
 11    "screen",
 12    "overlay",
 13    "darken",
 14    "lighten",
 15    "colorDodge",
 16    "colorBurn",
 17    "softLight",
 18    "hardLight",
 19    "difference",
 20    "exclusion",
 21    "hue",
 22    "saturation",
 23    "color",
 24    "luminosity",
 25    "clear",
 26    "copy",
 27    "sourceIn",
 28    "sourceOut",
 29    "sourceAtop",
 30    "destinationOver",
 31    "destinationIn",
 32    "destinationOut",
 33    "destinationAtop",
 34    "xOR",
 35    "plusDarker",
 36    "plusLighter",
 37]
 38"""Supported blend modes.
 39
 40- taken from https://www.drawbot.com/content/color.html#drawBot.blendMode"""
 41
 42
 43LineCap = Literal["butt", "round"]
 44"""Omitted `square` (rarely used)."""
 45
 46LinePattern = Literal["solid", "dashed"]
 47
 48
 49def precomposeImage(image: drawBot.ImageObject, backgroundColor=(1,)):
 50    """Pre-compose an image with a background color into a single image.
 51
 52    Useful for layering image effects such as screens without unwanted transparency artifacts."""
 53    precomp = drawBot.ImageObject()
 54    with precomp:
 55        drawBot.size(*image.size())
 56        drawBot.fill(*backgroundColor)
 57        drawBot.rect(0, 0, *image.size())
 58        drawBot.image(image, (0, 0))
 59    return precomp
 60
 61
 62def setBlendMode(mode: BlendMode):
 63    """Set the blend mode for drawing operations.
 64
 65    Args:
 66        mode: The blend mode to use.
 67    """
 68    drawBot.blendMode(mode)
 69
 70
 71def renderClouds(
 72    dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5
 73) -> drawBot.ImageObject:
 74    """Render a cloud-like image with random dot patterns.
 75
 76    Args:
 77        dimensions: The width and height of the output image.
 78        zoom: The zoom factor for the dot pattern.
 79        dot: The size of the dots.
 80        scale: The scale factor for the inner image.
 81        brightness: Brightness adjustment.
 82        contrast: Contrast adjustment.
 83
 84    Returns:
 85        ImageObject: The generated cloud image.
 86    """
 87    imgOuter = drawBot.ImageObject()
 88    w, h = layout.toDimensions(dimensions)
 89
 90    with imgOuter:
 91        drawBot.size(w, h)
 92        drawBot.fill(0.9)
 93        drawBot.rect(0, 0, w, h)
 94
 95        imgInner = drawBot.ImageObject()
 96        with imgInner:
 97            drawBot.size(*[dim * scale for dim in [w, h]])
 98        imgInner.randomGenerator([ceil(dim / zoom) for dim in imgInner.size()])
 99        imgInner.lanczosScaleTransform(zoom)
100
101        shiftPos = shiftWithin(dimensions, imgInner)
102        drawBot.image(imgInner, shiftPos)
103
104    imgOuter.colorControls(brightness=brightness, contrast=contrast)
105    imgOuter.dotScreen(width=zoom / dot, angle=45, sharpness=1)
106    imgOuter.colorInvert()
107
108    return imgOuter
109
110
111def shiftWithin(parent: tuple, child: drawBot.ImageObject) -> tuple[int, int]:
112    """Return random `x, y` position within parent.
113
114    Args:
115        parent: The dimensions of the parent container.
116        child: The image object to position.
117
118    Returns:
119        tuple[int, int]: The random (x, y) position.
120    """
121    parentW, parentH = layout.toDimensions(parent)
122    childW, childH = child.size()
123
124    difW, difH = parentW - childW, parentH - childH
125    return [random.random() * (dif / 2) for dif in [difW, difH]]
126
127
128def sampleLuminosity(
129    image_path: str,
130    cell_x: float,
131    cell_y: float,
132    cell_width: float,
133    cell_height: float,
134    sample_points: int = 9,
135) -> float:
136    """
137    Sample luminosity from multiple points within a cell for better accuracy
138
139    Args:
140        image_path: Path to the image
141        cell_x, cell_y: Top-left corner of the cell
142        cell_width, cell_height: Cell dimensions
143        sample_points: Number of sample points (will be made into a square grid)
144    """
145    luminosity_sum = 0
146    valid_samples = 0
147
148    # Calculate grid size (make it square)
149    grid_size = int(sqrt(sample_points))
150
151    # Create sample points in a grid within the cell
152    for i in range(grid_size):
153        for j in range(grid_size):
154            # Calculate sample position within the cell
155            sample_x = cell_x + (i + 0.5) * cell_width / grid_size
156            sample_y = cell_y + (j + 0.5) * cell_height / grid_size
157
158            # Sample pixel color at this point
159            pixel_data = drawBot.imagePixelColor(image_path, (sample_x, sample_y))
160
161            if pixel_data:
162                r, g, b, _ = pixel_data
163                luminosity = 0.299 * r + 0.587 * g + 0.114 * b
164                luminosity_sum += luminosity
165                valid_samples += 1
166
167    # Round to 10 decimals (arbitrary) to fix floating point imprecision
168    return round(luminosity_sum / valid_samples, 10) if valid_samples > 0 else 0
169
170
171def adjustContrast(luminosity: float, contrast: float = 1.0):
172    """
173    S-curve contrast adjustment using sigmoid function
174    - < 1.0: reduces contrast
175    - = 1.0: no change
176    - > 1.0: increases contrast
177    """
178    if contrast == 0:
179        return 0.5  # Gray
180    elif contrast == 1:
181        return luminosity  # Unchanged
182
183    # Convert to range that works well with sigmoid
184    x = (luminosity - 0.5) * contrast
185    # Apply sigmoid function
186    sigmoid = 1 / (1 + exp(-x * 6))  # 6 is steepness factor
187    return sigmoid
188
189
190def renderLineScreen(
191    imagePath: str | drawBot.ImageObject,
192    position: Tuple[float, float],
193    rows: int,
194    cols: int,
195    bleed: Tuple[float, float] = (0, 0),
196    sampleOffset: Tuple[float, float] = (0, 0),
197    contrast: float = 1.0,
198    samplePoints: int = 9,
199    lineCap: LineCap = "butt",
200    clampThickness: float | Tuple[float, float] = 0.1,
201    skipThickness: float = 0.0,
202    gain: float = 1.0,
203    fillColor: color.RawRGB | color.CMYK = (0,),
204) -> None:
205    """
206    Karel Martens-style line screen effect
207
208    Args:
209        imagePath: Path to input image
210        rows: Number of grid rows
211        cols: Number of grid columns
212        bleed: Tuple (x, y) - amount to extend cells in each direction
213        sampleOffset: Tuple (x, y) - offset for sampling luminosity
214        contrast: Contrast adjustment factor
215            - < 1.0: reduces contrast
216            - = 1.0: no change
217            - > 1.0: increases contrast
218        samplePoints: Number of points to sample for luminosity calculation
219        lineCap: Line cap style, either "butt" or "round"
220        clampThickness: Clamp stroke thickness. A float sets a minimum floor
221            (thin lines are drawn at that value). A (min, max) tuple adds a
222            ceiling cap as well. Default 0.1 to avoid excessively thin lines.
223        skipThickness: Skip drawing any line whose natural computed thickness
224            (before clamping) falls below this value. Default 0.0 draws all.
225        gain: Scales maximum stroke width as a fraction of cell width.
226            1.0 (default) fills the full cell. 0.5 means the darkest possible
227            line occupies half the cell, guaranteeing equal ink/whitespace.
228    """
229
230    posX, posY = position
231    imgW, imgH = drawBot.imageSize(imagePath)
232    cellW, cellH = imgW / cols, imgH / rows
233
234    # Extract bleed values
235    bleedX, bleedY = bleed
236    sampleOffsetX, sampleOffsetY = sampleOffset
237
238    # Black lines
239    drawBot.fill(None)
240    if isinstance(fillColor, color.CMYK):
241        fillColor.setStroke()
242    else:
243        drawBot.stroke(*fillColor)  # Assume RGB tuple if not CMYK
244    drawBot.lineCap(lineCap)
245
246    for row in range(rows):
247        for col in range(cols):
248            # Original cell position
249            x = col * cellW
250            y = (rows - 1 - row) * cellH
251
252            # Clamp coordinates to image bounds before sampling luminosity
253            sampleX = max(0, min(x + sampleOffsetX, imgW - (cellW / 2)))
254            sampleY = max(0, min(y + sampleOffsetY, imgH - (cellH / 2)))
255
256            luminosity = sampleLuminosity(
257                imagePath,
258                sampleX,
259                sampleY,
260                cellW,
261                cellH,
262                samplePoints,
263            )
264
265            luminosity = adjustContrast(luminosity, contrast)
266
267            # Map to thickness (invert for dark = thick)
268            thicknessRatio = 1 - luminosity
269            naturalW = thicknessRatio * cellW * gain
270
271            if skipThickness > 0 and naturalW < skipThickness:
272                continue
273
274            clampMin, clampMax = (
275                (clampThickness, None)
276                if isinstance(clampThickness, (int, float))
277                else clampThickness
278            )
279            lineW = max(clampMin, naturalW)
280            if clampMax is not None:
281                lineW = min(clampMax, lineW)
282
283            # Center the line in original cell
284            lineX = x + (cellW - lineW) / 2
285
286            # Apply bleed to extend the drawing area
287            extX = x - bleedX
288            extY = y - bleedY
289            extW = cellW + (2 * bleedX)
290            extH = cellH + (2 * bleedY)
291
292            # Extend line width with horizontal bleed
293            extLineX = lineX - bleedX
294            extLineW = lineW + (2 * bleedX)
295
296            # Clamp to canvas boundaries
297            if (
298                lineW > 0
299                and extX < imgW
300                and extY < imgH
301                and extX + extW > 0
302                and extY + extH > 0
303            ):
304                # Calculate actual drawing bounds
305                actualX = max(0, extX)
306                actualY = max(0, extY)
307                actualW = min(imgW - actualX, extW - (actualX - extX))
308                actualH = min(imgH - actualY, extH - (actualY - extY))
309
310                # Calculate actual line bounds
311                actualLineX = max(0, extLineX)
312                actualLineW = min(
313                    imgW - actualLineX,
314                    extLineW - (actualLineX - extLineX),
315                )
316
317                # Draw vertical line with bleed
318                if actualLineW > 0 and actualW > 0 and actualH > 0:
319                    drawBot.strokeWidth(lineW)
320                    drawBot.line(
321                        (posX + actualLineX + lineW / 2, posY + actualY),
322                        (
323                            posX + actualLineX + lineW / 2,
324                            posY + actualY + actualH,
325                        ),
326                    )
BlendMode: TypeAlias = Literal['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'colorDodge', 'colorBurn', 'softLight', 'hardLight', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'clear', 'copy', 'sourceIn', 'sourceOut', 'sourceAtop', 'destinationOver', 'destinationIn', 'destinationOut', 'destinationAtop', 'xOR', 'plusDarker', 'plusLighter']
LineCap = typing.Literal['butt', 'round']

Omitted square (rarely used).

LinePattern = typing.Literal['solid', 'dashed']
def precomposeImage( image: drawBot.context.tools.imageObject.ImageObject, backgroundColor=(1,)):
50def precomposeImage(image: drawBot.ImageObject, backgroundColor=(1,)):
51    """Pre-compose an image with a background color into a single image.
52
53    Useful for layering image effects such as screens without unwanted transparency artifacts."""
54    precomp = drawBot.ImageObject()
55    with precomp:
56        drawBot.size(*image.size())
57        drawBot.fill(*backgroundColor)
58        drawBot.rect(0, 0, *image.size())
59        drawBot.image(image, (0, 0))
60    return precomp

Pre-compose an image with a background color into a single image.

Useful for layering image effects such as screens without unwanted transparency artifacts.

def setBlendMode( mode: Literal['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'colorDodge', 'colorBurn', 'softLight', 'hardLight', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'clear', 'copy', 'sourceIn', 'sourceOut', 'sourceAtop', 'destinationOver', 'destinationIn', 'destinationOut', 'destinationAtop', 'xOR', 'plusDarker', 'plusLighter']):
63def setBlendMode(mode: BlendMode):
64    """Set the blend mode for drawing operations.
65
66    Args:
67        mode: The blend mode to use.
68    """
69    drawBot.blendMode(mode)

Set the blend mode for drawing operations.

Arguments:
  • mode: The blend mode to use.
def renderClouds( dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5) -> drawBot.context.tools.imageObject.ImageObject:
 72def renderClouds(
 73    dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5
 74) -> drawBot.ImageObject:
 75    """Render a cloud-like image with random dot patterns.
 76
 77    Args:
 78        dimensions: The width and height of the output image.
 79        zoom: The zoom factor for the dot pattern.
 80        dot: The size of the dots.
 81        scale: The scale factor for the inner image.
 82        brightness: Brightness adjustment.
 83        contrast: Contrast adjustment.
 84
 85    Returns:
 86        ImageObject: The generated cloud image.
 87    """
 88    imgOuter = drawBot.ImageObject()
 89    w, h = layout.toDimensions(dimensions)
 90
 91    with imgOuter:
 92        drawBot.size(w, h)
 93        drawBot.fill(0.9)
 94        drawBot.rect(0, 0, w, h)
 95
 96        imgInner = drawBot.ImageObject()
 97        with imgInner:
 98            drawBot.size(*[dim * scale for dim in [w, h]])
 99        imgInner.randomGenerator([ceil(dim / zoom) for dim in imgInner.size()])
100        imgInner.lanczosScaleTransform(zoom)
101
102        shiftPos = shiftWithin(dimensions, imgInner)
103        drawBot.image(imgInner, shiftPos)
104
105    imgOuter.colorControls(brightness=brightness, contrast=contrast)
106    imgOuter.dotScreen(width=zoom / dot, angle=45, sharpness=1)
107    imgOuter.colorInvert()
108
109    return imgOuter

Render a cloud-like image with random dot patterns.

Arguments:
  • dimensions: The width and height of the output image.
  • zoom: The zoom factor for the dot pattern.
  • dot: The size of the dots.
  • scale: The scale factor for the inner image.
  • brightness: Brightness adjustment.
  • contrast: Contrast adjustment.
Returns:

ImageObject: The generated cloud image.

def shiftWithin( parent: tuple, child: drawBot.context.tools.imageObject.ImageObject) -> tuple[int, int]:
112def shiftWithin(parent: tuple, child: drawBot.ImageObject) -> tuple[int, int]:
113    """Return random `x, y` position within parent.
114
115    Args:
116        parent: The dimensions of the parent container.
117        child: The image object to position.
118
119    Returns:
120        tuple[int, int]: The random (x, y) position.
121    """
122    parentW, parentH = layout.toDimensions(parent)
123    childW, childH = child.size()
124
125    difW, difH = parentW - childW, parentH - childH
126    return [random.random() * (dif / 2) for dif in [difW, difH]]

Return random x, y position within parent.

Arguments:
  • parent: The dimensions of the parent container.
  • child: The image object to position.
Returns:

tuple[int, int]: The random (x, y) position.

def sampleLuminosity( image_path: str, cell_x: float, cell_y: float, cell_width: float, cell_height: float, sample_points: int = 9) -> float:
129def sampleLuminosity(
130    image_path: str,
131    cell_x: float,
132    cell_y: float,
133    cell_width: float,
134    cell_height: float,
135    sample_points: int = 9,
136) -> float:
137    """
138    Sample luminosity from multiple points within a cell for better accuracy
139
140    Args:
141        image_path: Path to the image
142        cell_x, cell_y: Top-left corner of the cell
143        cell_width, cell_height: Cell dimensions
144        sample_points: Number of sample points (will be made into a square grid)
145    """
146    luminosity_sum = 0
147    valid_samples = 0
148
149    # Calculate grid size (make it square)
150    grid_size = int(sqrt(sample_points))
151
152    # Create sample points in a grid within the cell
153    for i in range(grid_size):
154        for j in range(grid_size):
155            # Calculate sample position within the cell
156            sample_x = cell_x + (i + 0.5) * cell_width / grid_size
157            sample_y = cell_y + (j + 0.5) * cell_height / grid_size
158
159            # Sample pixel color at this point
160            pixel_data = drawBot.imagePixelColor(image_path, (sample_x, sample_y))
161
162            if pixel_data:
163                r, g, b, _ = pixel_data
164                luminosity = 0.299 * r + 0.587 * g + 0.114 * b
165                luminosity_sum += luminosity
166                valid_samples += 1
167
168    # Round to 10 decimals (arbitrary) to fix floating point imprecision
169    return round(luminosity_sum / valid_samples, 10) if valid_samples > 0 else 0

Sample luminosity from multiple points within a cell for better accuracy

Arguments:
  • image_path: Path to the image
  • cell_x, cell_y: Top-left corner of the cell
  • cell_width, cell_height: Cell dimensions
  • sample_points: Number of sample points (will be made into a square grid)
def adjustContrast(luminosity: float, contrast: float = 1.0):
172def adjustContrast(luminosity: float, contrast: float = 1.0):
173    """
174    S-curve contrast adjustment using sigmoid function
175    - < 1.0: reduces contrast
176    - = 1.0: no change
177    - > 1.0: increases contrast
178    """
179    if contrast == 0:
180        return 0.5  # Gray
181    elif contrast == 1:
182        return luminosity  # Unchanged
183
184    # Convert to range that works well with sigmoid
185    x = (luminosity - 0.5) * contrast
186    # Apply sigmoid function
187    sigmoid = 1 / (1 + exp(-x * 6))  # 6 is steepness factor
188    return sigmoid

S-curve contrast adjustment using sigmoid function

  • < 1.0: reduces contrast
  • = 1.0: no change
  • > 1.0: increases contrast
def renderLineScreen( imagePath: str | drawBot.context.tools.imageObject.ImageObject, position: Tuple[float, float], rows: int, cols: int, bleed: Tuple[float, float] = (0, 0), sampleOffset: Tuple[float, float] = (0, 0), contrast: float = 1.0, samplePoints: int = 9, lineCap: Literal['butt', 'round'] = 'butt', clampThickness: Union[float, Tuple[float, float]] = 0.1, skipThickness: float = 0.0, gain: float = 1.0, fillColor: tuple[float, float, float] | lib.color.KCMYKSwatch = (0,)) -> None:
191def renderLineScreen(
192    imagePath: str | drawBot.ImageObject,
193    position: Tuple[float, float],
194    rows: int,
195    cols: int,
196    bleed: Tuple[float, float] = (0, 0),
197    sampleOffset: Tuple[float, float] = (0, 0),
198    contrast: float = 1.0,
199    samplePoints: int = 9,
200    lineCap: LineCap = "butt",
201    clampThickness: float | Tuple[float, float] = 0.1,
202    skipThickness: float = 0.0,
203    gain: float = 1.0,
204    fillColor: color.RawRGB | color.CMYK = (0,),
205) -> None:
206    """
207    Karel Martens-style line screen effect
208
209    Args:
210        imagePath: Path to input image
211        rows: Number of grid rows
212        cols: Number of grid columns
213        bleed: Tuple (x, y) - amount to extend cells in each direction
214        sampleOffset: Tuple (x, y) - offset for sampling luminosity
215        contrast: Contrast adjustment factor
216            - < 1.0: reduces contrast
217            - = 1.0: no change
218            - > 1.0: increases contrast
219        samplePoints: Number of points to sample for luminosity calculation
220        lineCap: Line cap style, either "butt" or "round"
221        clampThickness: Clamp stroke thickness. A float sets a minimum floor
222            (thin lines are drawn at that value). A (min, max) tuple adds a
223            ceiling cap as well. Default 0.1 to avoid excessively thin lines.
224        skipThickness: Skip drawing any line whose natural computed thickness
225            (before clamping) falls below this value. Default 0.0 draws all.
226        gain: Scales maximum stroke width as a fraction of cell width.
227            1.0 (default) fills the full cell. 0.5 means the darkest possible
228            line occupies half the cell, guaranteeing equal ink/whitespace.
229    """
230
231    posX, posY = position
232    imgW, imgH = drawBot.imageSize(imagePath)
233    cellW, cellH = imgW / cols, imgH / rows
234
235    # Extract bleed values
236    bleedX, bleedY = bleed
237    sampleOffsetX, sampleOffsetY = sampleOffset
238
239    # Black lines
240    drawBot.fill(None)
241    if isinstance(fillColor, color.CMYK):
242        fillColor.setStroke()
243    else:
244        drawBot.stroke(*fillColor)  # Assume RGB tuple if not CMYK
245    drawBot.lineCap(lineCap)
246
247    for row in range(rows):
248        for col in range(cols):
249            # Original cell position
250            x = col * cellW
251            y = (rows - 1 - row) * cellH
252
253            # Clamp coordinates to image bounds before sampling luminosity
254            sampleX = max(0, min(x + sampleOffsetX, imgW - (cellW / 2)))
255            sampleY = max(0, min(y + sampleOffsetY, imgH - (cellH / 2)))
256
257            luminosity = sampleLuminosity(
258                imagePath,
259                sampleX,
260                sampleY,
261                cellW,
262                cellH,
263                samplePoints,
264            )
265
266            luminosity = adjustContrast(luminosity, contrast)
267
268            # Map to thickness (invert for dark = thick)
269            thicknessRatio = 1 - luminosity
270            naturalW = thicknessRatio * cellW * gain
271
272            if skipThickness > 0 and naturalW < skipThickness:
273                continue
274
275            clampMin, clampMax = (
276                (clampThickness, None)
277                if isinstance(clampThickness, (int, float))
278                else clampThickness
279            )
280            lineW = max(clampMin, naturalW)
281            if clampMax is not None:
282                lineW = min(clampMax, lineW)
283
284            # Center the line in original cell
285            lineX = x + (cellW - lineW) / 2
286
287            # Apply bleed to extend the drawing area
288            extX = x - bleedX
289            extY = y - bleedY
290            extW = cellW + (2 * bleedX)
291            extH = cellH + (2 * bleedY)
292
293            # Extend line width with horizontal bleed
294            extLineX = lineX - bleedX
295            extLineW = lineW + (2 * bleedX)
296
297            # Clamp to canvas boundaries
298            if (
299                lineW > 0
300                and extX < imgW
301                and extY < imgH
302                and extX + extW > 0
303                and extY + extH > 0
304            ):
305                # Calculate actual drawing bounds
306                actualX = max(0, extX)
307                actualY = max(0, extY)
308                actualW = min(imgW - actualX, extW - (actualX - extX))
309                actualH = min(imgH - actualY, extH - (actualY - extY))
310
311                # Calculate actual line bounds
312                actualLineX = max(0, extLineX)
313                actualLineW = min(
314                    imgW - actualLineX,
315                    extLineW - (actualLineX - extLineX),
316                )
317
318                # Draw vertical line with bleed
319                if actualLineW > 0 and actualW > 0 and actualH > 0:
320                    drawBot.strokeWidth(lineW)
321                    drawBot.line(
322                        (posX + actualLineX + lineW / 2, posY + actualY),
323                        (
324                            posX + actualLineX + lineW / 2,
325                            posY + actualY + actualH,
326                        ),
327                    )

Karel Martens-style line screen effect

Arguments:
  • imagePath: Path to input image
  • rows: Number of grid rows
  • cols: Number of grid columns
  • bleed: Tuple (x, y) - amount to extend cells in each direction
  • sampleOffset: Tuple (x, y) - offset for sampling luminosity
  • contrast: Contrast adjustment factor
    • < 1.0: reduces contrast
    • = 1.0: no change
    • > 1.0: increases contrast
  • samplePoints: Number of points to sample for luminosity calculation
  • lineCap: Line cap style, either "butt" or "round"
  • clampThickness: Clamp stroke thickness. A float sets a minimum floor (thin lines are drawn at that value). A (min, max) tuple adds a ceiling cap as well. Default 0.1 to avoid excessively thin lines.
  • skipThickness: Skip drawing any line whose natural computed thickness (before clamping) falls below this value. Default 0.0 draws all.
  • gain: Scales maximum stroke width as a fraction of cell width. 1.0 (default) fills the full cell. 0.5 means the darkest possible line occupies half the cell, guaranteeing equal ink/whitespace.