1
0
Fork 0
mirror of https://git.lynn.is/Gwen/python-layout.git synced 2024-01-13 01:31:55 +01:00

kinda working version

This commit is contained in:
Gwendolyn 2023-01-31 21:46:27 +01:00
commit cd4c111c78
13 changed files with 2518 additions and 0 deletions

11
__init__.py Normal file
View file

@ -0,0 +1,11 @@
from .enums import FlexDirection, FlexWrap, FlexJustify, FlexAlignItems, FlexAlignContent, TextAlign, \
TextVerticalAlign, TextWrap, ImageMode, ImageAnchor, BoxTitleAnchor
from .layout import Layout
from .document import Document
from .box import Box
from .container import Container
from .flex import Flex
from .image import Image
from .text import Text

100
box.py Normal file
View file

@ -0,0 +1,100 @@
from . import Layout
from PIL import ImageDraw
from .enums import BoxTitleAnchor
class Box(Layout):
def __init__(
self,
title=None,
title_font=None,
title_color=None,
title_anchor=BoxTitleAnchor.LEFT,
title_position=0,
title_padding=0,
content=None,
**kwargs
):
super().__init__(**kwargs)
self._title = title
self._title_font = title_font
self._title_color = title_color
self._title_anchor = title_anchor
self._title_position = title_position
self._title_padding = title_padding
self._content = content
def _children(self):
if self._content is not None:
return [self._content]
else:
return []
def _get_title_font(self):
if self._title_font is not None:
return self._title_font
else:
return self.get_font()
def _get_title_color(self):
if self._title_color is not None:
return self._title_color
else:
return self.get_fg_color()
def get_min_inner_width(self, max_height=None):
if self._content is None:
return 0
else:
return self._content.get_min_outer_width(max_height)
def get_min_inner_height(self, max_width=None):
if self._content is None:
return 0
else:
return self._content.get_min_outer_height(max_width)
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
if self._content is None:
return 0, 0
else:
return self._content.get_ideal_outer_dimensions(min_width, min_height, available_width, available_height)
def render_content(self, rect):
if self._content is None:
return self.make_canvas()
else:
return self._content.render(rect)
def _title_pos(self, rect):
left, top, right, bottom = self._get_title_font().getbbox(self._title)
height = bottom - top
width = right - left
border_radius = self.border_radius()
available_width = rect[2] - rect[0] + 1 - border_radius[1] - border_radius[0]
pos_x = rect[0] + border_radius[0] + self._title_position
if self._title_anchor == BoxTitleAnchor.LEFT:
pos_x += 0
elif self._title_anchor == BoxTitleAnchor.CENTER:
pos_x += (available_width - width) / 2
elif self._title_anchor == BoxTitleAnchor.RIGHT:
pos_x += available_width - width
pos_y = rect[1] - height / 2
return (pos_x, pos_y), (left - self._title_padding, top, right + self._title_padding, bottom)
def modify_border_mask(self, border_mask, rect):
if self._title is None:
return
(x, y), (left, top, right, bottom) = self._title_pos(rect)
d = ImageDraw.Draw(border_mask)
d.rectangle((x + left, y + top, x + right, y + bottom), fill=0)
def render_after_border(self, image, rect):
if self._title is None:
return
(x, y), _ = self._title_pos(rect)
d = ImageDraw.Draw(image)
d.text((x, y), self._title, font=self._get_title_font(), fill=self._get_title_color())

149
container.py Normal file
View file

@ -0,0 +1,149 @@
from . import Layout
from PIL import ImageDraw
from .internal.helpers import min_with_none, max_with_none
class Container(Layout):
def __init__(
self,
contents=None,
**kwargs
):
super().__init__(**kwargs)
if contents is None:
contents = []
self._contents = contents
def _children(self):
return self._contents
def get_min_inner_width(self, max_height=None):
min_width = 0
for c in self._contents:
width = c.get_min_outer_width(max_height)
if c._left is not None:
width += c._left
if c._right is not None:
width += c._right
min_width = max(min_width, width)
if self._min_width is not None:
min_width = max(min_width, self._min_width)
return min_width
def get_min_inner_height(self, max_width=None):
min_height_automatic = 0
min_height_absolute = 0
for c in self._contents:
height = c.get_min_outer_height(max_width)
if c._top is None and c._bottom is None and c._left is None and c._right is None:
min_height_automatic += height
else:
if c._top is not None:
height += c._top
if c._bottom is not None:
height += c._bottom
min_height_absolute = max(min_height_absolute, height)
min_height = max(min_height_automatic, min_height_absolute)
if self._min_height is not None:
min_height = max(min_height, self._min_height)
return min_height
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
width, height_automatic, height_absolute = 0, 0, 0
for c in self._contents:
w, h = c.get_ideal_outer_dimensions(min_width, min_height, available_width, available_height)
if c._left is not None:
w += c._left
if c._right is not None:
w += c._right
if c._top is not None:
h += c._top
if c._bottom is not None:
h += c._bottom
width = max(width, w)
if c._top is None and c._bottom is None and c._left is None and c._right is None:
height_automatic += h
else:
height_absolute = max(height_absolute, h)
height = max(height_automatic, height_absolute)
if self._width is not None:
width = self._width
if self._height is not None:
height = self._height
return width, height
def render_content(self, rect):
image = self.make_canvas()
x1, y1, x2, y2 = rect
automatic_y = 0
for c in self._contents:
soft_max_width, soft_max_height = x2 - x1 + 1, y2 - y1 + 1
is_absolute = not (c._top is None and c._bottom is None and c._left is None and c._right is None)
if c._left is not None:
soft_max_width -= c._left
if c._right is not None:
soft_max_width -= c._right
if c._top is not None:
soft_max_height -= c._top
if c._bottom is not None:
soft_max_height -= c._bottom
hard_max_width, hard_max_height = c._max_width, c._max_height
if c._left is not None and c._right is not None:
hard_max_width = min_with_none(hard_max_width, x2 - x1 + 1 - c._left - c._right)
if c._top is not None and c._bottom is not None:
hard_max_height = min_with_none(hard_max_height, y2 - y1 + 1 - c._top - c._bottom)
width, height = c.get_ideal_outer_dimensions(available_width=soft_max_width, available_height=soft_max_height)
if c._left is not None and c._right is not None:
width = (x2 - c._right) - (x1 + c._left) + 1
if c._top is not None and c._bottom is not None:
height = (y2 - c._bottom) - (y1 + c._top) + 1
min_width, min_height = c.get_min_outer_width(hard_max_height), c.get_min_outer_height(hard_max_width)
width = max_with_none(width, min_width)
height = max_with_none(height, min_height)
width = min_with_none(width, hard_max_width)
height = min_with_none(height, hard_max_height)
if is_absolute:
if c._left is None:
if c._right is None:
cx1 = x1
cx2 = cx1 + width - 1
else:
cx2 = x2 - c._right
cx1 = cx2 - width + 1
else:
cx1 = x1 + c._left
cx2 = cx1 + width - 1
if c._top is None:
if c._bottom is None:
cy1 = y1
cy2 = cy1 + height - 1
else:
cy2 = y2 - c._bottom
cy1 = cy2 - height + 1
else:
cy1 = y1 + c._top
cy2 = cy1 + height - 1
else:
cx1 = x1
cx2 = cx1 + width - 1
cy1 = y1 + automatic_y
cy2 = cy1 + height - 1
automatic_y += height
content_image = c.render((cx1, cy1, cx2, cy2))
image.alpha_composite(content_image)
return image

80
document.py Normal file
View file

@ -0,0 +1,80 @@
from PIL import Image, ImageFont
from layout.enums import ColorMode
from layout.internal.helpers import max_with_none, min_with_none
from layout.layout import Layout
class Document(Layout):
def __init__(
self,
content=None,
**kwargs
):
super().__init__(**kwargs)
if self._overflow is None:
self._overflow = True
if self._font is None:
self._font = ImageFont.load_default()
self._content = content
self._actual_size = None
self.complete_init(None)
def _children(self):
if self._content is not None:
return [self._content]
else:
return []
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
if self._content is None:
return 0,0
else:
return self._content.get_ideal_outer_dimensions(min_width, min_height, available_width, available_height)
def get_min_inner_height(self, max_width=None):
if self._content is None:
return 0
else:
return self._content.get_min_outer_height(max_width)
def get_min_inner_width(self, max_height=None):
if self._content is None:
return 0
else:
return self._content.get_min_outer_width(max_height)
def render_content(self, rect):
image = self.make_canvas()
if self._content is not None:
content_image = self._content.render(rect)
image.alpha_composite(content_image)
return image
def get_image(self):
min_width = max_with_none(self._width, self._min_width)
min_height = max_with_none(self._height, self._min_height)
max_width = min_with_none(self._width, self._max_width)
max_height = min_with_none(self._height, self._max_height)
width, height = self.get_ideal_outer_dimensions(min_width=min_width,
min_height=min_height,
available_width=max_width,
available_height=max_height)
if self._width is not None:
width = self._width
else:
width = min_with_none(max_with_none(width, self._min_width), self._max_width)
if self._height is not None:
height = self._height
else:
height = min_with_none(max_with_none(height, self._min_height), self._max_height)
self._actual_size = (width, height)
background = Image.new('RGBA', (width, height), self._bg_color)
content = self.render((0, 0, width - 1, height - 1))
return Image.alpha_composite(background, content)

98
enums.py Normal file
View file

@ -0,0 +1,98 @@
from enum import Enum
class BoxTitleAnchor(Enum):
LEFT = 0,
CENTER = 1,
RIGHT = 2,
class ColorMode(Enum):
RGBA = 1,
GRAYSCALE = 2,
BW = 3,
class FlexDirection(Enum):
ROW = 0,
ROW_REVERSE = 1,
COLUMN = 2,
COLUMN_REVERSE = 3,
class FlexWrap(Enum):
NO_WRAP = 0,
WRAP = 1,
WRAP_REVERSE = 2,
class FlexJustify(Enum):
START = 0,
END = 1,
CENTER = 2,
SPACE_BETWEEN = 3,
SPACE_AROUND = 4,
SPACE_EVENLY = 5,
class FlexAlignItems(Enum):
START = 0,
END = 1,
CENTER = 2,
STRETCH = 3,
class FlexAlignContent(Enum):
START = 0,
END = 1,
CENTER = 2,
STRETCH = 3,
SPACE_BETWEEN = 4,
SPACE_AROUND = 5,
SPACE_EVENLY = 6,
class TextAlign(Enum):
LEFT = 0,
CENTER = 1,
RIGHT = 2
class TextVerticalAlign(Enum):
TOP = 0,
MIDDLE = 1,
BOTTOM = 2
class TextWrap(Enum):
NO_WRAP = 0,
ONLY_WORDS = 1,
PREFER_WORDS = 2,
EVERYWHERE = 3
class ImageMode(Enum):
ORIGINAL = 0, # use size of image, optionally scaled with `scale` option
STRETCH = 1, # stretch image to available space, ignoring aspect ratio
STRETCH_X = 2, # stretch image to available horizontal space, keep height the same, optionally scaled with `scale` option
STRETCH_Y = 3, # stretch image to available vertical space, keep width the same, optionally scaled with `scale` option
CONTAIN = 4, # scale image to available space, respecting aspect ratio, stretch as large as possible without cutting anything off
COVER = 5, # scale image to available space, respecting aspect ratio, stretch as large as necessary to fill available space completely
REPEAT = 6, # repeat image to fill available space
REPEAT_X = 7, # repeat image to fill available horizontal space, optionally scaled like ORIGINAL
REPEAT_Y = 8, # repeat image to fill available vertical space, optionally scaled like ORIGINAL
REPEAT_X_STRETCH = 9, # stretch image ignoring aspect ratio so that it fills available vertical space, and repeat horizontally to fill available space
REPEAT_Y_STRETCH = 10, # stretch image ignoring aspect ratio so that it fills available horizontal space, and repeat vertically to fill available space
REPEAT_X_FILL = 11, # scale image with aspect ratio so that it fills available vertical space, and repeat horizontally to fill available space
REPEAT_Y_FILL = 12, # scale image with aspect ratio so that it fills available horizontal space, and repeat vertically to fill available space
class ImageAnchor(Enum):
TOP_LEFT = 0,
TOP_CENTER = 1,
TOP_RIGHT = 2,
MIDDLE_LEFT = 3,
MIDDLE_CENTER = 4,
MIDDLE_RIGHT = 5,
BOTTOM_LEFT = 6,
BOTTOM_CENTER = 7,
BOTTOM_RIGHT = 8,

64
flex.py Normal file
View file

@ -0,0 +1,64 @@
from . import Layout
from .internal.flexlayouter import FlexLayouter
from .internal.helpers import min_with_none, max_with_none
from .enums import FlexDirection, FlexWrap, FlexJustify, FlexAlignItems, FlexAlignContent
class Flex(Layout):
def __init__(
self,
contents=None,
direction=FlexDirection.ROW,
wrap=FlexWrap.NO_WRAP,
justify=FlexJustify.START,
align_items=FlexAlignItems.START,
align_content=FlexAlignContent.START,
gap=0,
**kwargs
):
super().__init__(**kwargs)
if contents is None:
contents = []
self._direction = direction
self._wrap = wrap
self._justify = justify
self._align_items = align_items
self._align_content = align_content
self._gap = gap
self._contents = contents
self._flex_layouter = FlexLayouter(contents, gap, direction, wrap, justify, align_content, align_items)
def _children(self):
return self._contents
def get_min_inner_width(self, max_height=None):
max_width = min_with_none(self._max_width, 0)
width, _ = self._flex_layouter.get_dimensions(self._min_width, self._min_height, max_width,
min_with_none(self._max_height, max_height))
return width
def get_min_inner_height(self, max_width=None):
max_height = min_with_none(self._min_height, 0)
_, height = self._flex_layouter.get_dimensions(self._min_width, self._min_height,
min_with_none(self._max_width, max_width), max_height)
return height
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
min_width = max_with_none(min_width, self._min_width)
min_height = max_with_none(min_height, self._min_height)
available_width = min_with_none(available_width, self._max_width, self._width)
available_height = min_with_none(available_height, self._max_height, self._height)
return self._flex_layouter.get_dimensions(min_width, min_height, available_width, available_height)
def render_content(self, rect):
image = self.make_canvas()
x1, y1, x2, y2 = rect
width = x2 - x1 + 1
height = y2 - y1 + 1
contents = self._flex_layouter.arrange(self._min_width, self._min_height, width, height)
for (cx1, cy1, cx2, cy2), content in contents:
content_image = content.render((cx1 + x1, cy1 + y1, cx2 + x1, cy2 + y1))
image.alpha_composite(content_image)
return image

267
image.py Normal file
View file

@ -0,0 +1,267 @@
from PIL import ImageDraw, Image as PilImage
from layout import Layout
from layout.enums import ImageMode, ImageAnchor
from layout.internal.helpers import max_with_none, min_with_none
class Image(Layout):
def __init__(
self,
image=None,
mode=ImageMode.ORIGINAL,
scale=1,
position=ImageAnchor.TOP_LEFT,
offset_x=0,
offset_y=0,
**kwargs
):
super().__init__(**kwargs)
self._image = image
self._mode = mode
self._scale = scale
self._position = position
self._offset_x = offset_x
self._offset_y = offset_y
def scale(self):
if isinstance(self._scale, (tuple, list)):
if len(self._scale) == 0:
return 1, 1
elif len(self._scale) == 1:
return self._scale[0], self._scale[0]
else:
return self._scale[0], self._scale[1]
elif isinstance(self._scale, (int, float)):
return self._scale, self._scale
else:
return 1, 1
def _get_image_width(self):
if self._image is None:
return 0
return self._image.size[0]
def _get_image_height(self):
if self._image is None:
return 0
return self._image.size[1]
def get_min_inner_width(self, max_height=None):
if self._mode in (ImageMode.ORIGINAL, ImageMode.REPEAT_Y, ImageMode.STRETCH_Y):
width = self._get_image_width()
else:
width = 0
return max_with_none(width, self._min_width)
def get_min_inner_height(self, max_width=None):
if self._mode in (ImageMode.ORIGINAL, ImageMode.REPEAT_X, ImageMode.STRETCH_X):
height = self._get_image_height()
else:
height = 0
return max_with_none(height, self._min_height)
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
width, height = 0, 0
if self._mode in (ImageMode.ORIGINAL, ImageMode.REPEAT_Y, ImageMode.STRETCH_Y):
width = self._get_image_width()
if self._mode in (ImageMode.ORIGINAL, ImageMode.REPEAT_X, ImageMode.STRETCH_X):
height = self._get_image_height()
if self._width is not None:
width = self._width
if self._height is not None:
height = self._height
width = min_with_none(width, available_width, self._max_width)
height = min_with_none(height, available_height, self._max_height)
width = max_with_none(width, min_width, self._min_width)
height = max_with_none(height, min_height, self._min_height)
return width, height
def render_content(self, rect):
image = self.make_canvas()
if self._image is None:
return image
x1, y1, x2, y2 = rect
width = x2 - x1 + 1
height = y2 - y1 + 1
if width == 0 or height == 0:
return image
image_width = int(self._image.size[0] * self.scale()[0])
image_height = int(self._image.size[1] * self.scale()[1])
offset_x = self._offset_x
offset_y = self._offset_y
if self._mode == ImageMode.ORIGINAL:
if image_width != self._image.size[0] or image_height != self._image.size[1]:
img = self._image.resize((image_width, image_height), reducing_gap=3.0)
else:
img = self._image
elif self._mode == ImageMode.STRETCH:
img = self._image.resize((width, height), reducing_gap=3.0)
elif self._mode == ImageMode.STRETCH_X:
img = self._image.resize((width, image_height), reducing_gap=3.0)
elif self._mode == ImageMode.STRETCH_Y:
img = self._image.resize((image_width, height), reducing_gap=3.0)
elif self._mode == ImageMode.CONTAIN:
ratio_width = width / image_width
ratio_height = height / image_height
if ratio_width < ratio_height:
w = width
h = int(image_height * width / image_width)
elif ratio_height < ratio_width:
w = int(image_width * height / image_height)
h = height
else:
w = width
h = height
img = self._image.resize((w, h), reducing_gap=3.0)
elif self._mode == ImageMode.COVER:
ratio_width = width / image_width
ratio_height = height / image_height
if ratio_width > ratio_height:
w = width
h = int(image_height * width / image_width)
elif ratio_height > ratio_width:
w = int(image_width * height / image_height)
h = height
else:
w = width
h = height
img = self._image.resize((w, h), reducing_gap=3.0)
if self._mode == ImageMode.REPEAT_X_FILL:
repeat_width = int(image_width * height / image_height)
repeat_height = height
elif self._mode == ImageMode.REPEAT_Y_FILL:
repeat_width = width
repeat_height = int(image_height * width / image_width)
else:
repeat_width = image_width
repeat_height = image_height
if self._mode in (ImageMode.REPEAT, ImageMode.REPEAT_X, ImageMode.REPEAT_X_FILL, ImageMode.REPEAT_X_STRETCH):
if self._position in (ImageAnchor.TOP_LEFT, ImageAnchor.MIDDLE_LEFT, ImageAnchor.BOTTOM_LEFT):
repeat_offset_x = offset_x
elif self._position in (ImageAnchor.TOP_CENTER, ImageAnchor.MIDDLE_CENTER, ImageAnchor.BOTTOM_CENTER):
repeat_offset_x = (width // 2) - repeat_width // 2 + offset_x
elif self._position in (ImageAnchor.TOP_RIGHT, ImageAnchor.MIDDLE_RIGHT, ImageAnchor.BOTTOM_RIGHT):
repeat_offset_x = width - repeat_width + offset_x
else:
raise Exception('invalid image position')
if self._mode in (ImageMode.REPEAT, ImageMode.REPEAT_Y, ImageMode.REPEAT_Y_FILL, ImageMode.REPEAT_Y_STRETCH):
if self._position in (ImageAnchor.TOP_LEFT, ImageAnchor.TOP_CENTER, ImageAnchor.TOP_RIGHT):
repeat_offset_y = offset_y
elif self._position in (ImageAnchor.MIDDLE_LEFT, ImageAnchor.MIDDLE_CENTER, ImageAnchor.MIDDLE_RIGHT):
repeat_offset_y = (height // 2) - repeat_height // 2 + offset_y
elif self._position in (ImageAnchor.BOTTOM_LEFT, ImageAnchor.BOTTOM_CENTER, ImageAnchor.BOTTOM_RIGHT):
repeat_offset_y = height - repeat_height + offset_y
else:
raise Exception('invalid image position')
if self._mode == ImageMode.REPEAT:
if repeat_width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((repeat_width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, height), (0,0,0,0))
num_x = width // repeat_width + 1
num_y = height // repeat_height + 1
ox = repeat_offset_x % (repeat_width if repeat_offset_x >= 0 else -repeat_width)
oy = repeat_offset_y % (repeat_height if repeat_offset_y >= 0 else -repeat_height)
for i in range(-1, num_x):
for j in range(-1, num_y):
img.paste(img_part, (i * repeat_width + ox, j * repeat_height + oy))
offset_x = 0
offset_y = 0
elif self._mode == ImageMode.REPEAT_X:
if repeat_width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((repeat_width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, repeat_height), (0,0,0,0))
num_x = width // repeat_width + 1
ox = repeat_offset_x % (repeat_width if repeat_offset_x >= 0 else -repeat_width)
for i in range(-1, num_x):
img.paste(img_part, (i * repeat_width + ox, 0))
offset_x = 0
elif self._mode == ImageMode.REPEAT_Y:
if repeat_width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((repeat_width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (repeat_width, height), (0,0,0,0))
num_y = height // repeat_height + 1
oy = repeat_offset_y % (repeat_height if repeat_offset_y >= 0 else -repeat_height)
for i in range(-1, num_y):
img.paste(img_part, (0, i * repeat_height + oy))
offset_y = 0
elif self._mode == ImageMode.REPEAT_X_STRETCH:
if repeat_width != self._image.size[0] or height != self._image.size[1]:
img_part = self._image.resize((repeat_width, height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, height), (0,0,0,0))
num_x = width // repeat_width + 1
ox = repeat_offset_x % (repeat_width if repeat_offset_x >= 0 else -repeat_width)
for i in range(-1, num_x):
img.paste(img_part, (i * repeat_width + ox, 0))
offset_x = 0
elif self._mode == ImageMode.REPEAT_Y_STRETCH:
if width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, height), (0,0,0,0))
num_y = height // repeat_height + 1
oy = repeat_offset_y % (repeat_height if repeat_offset_y >= 0 else -repeat_height)
for i in range(-1, num_y):
img.paste(img_part, (0, i * repeat_height + oy))
offset_y = 0
elif self._mode == ImageMode.REPEAT_X_FILL:
if repeat_width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((repeat_width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, height), (0,0,0,0))
num_x = width // repeat_width + 1
ox = repeat_offset_x % (repeat_width if repeat_offset_x >= 0 else -w)
for i in range(-1, num_x):
img.paste(img_part, (i * repeat_width + ox, 0))
offset_x = 0
elif self._mode == ImageMode.REPEAT_Y_FILL:
if repeat_width != self._image.size[0] or repeat_height != self._image.size[1]:
img_part = self._image.resize((repeat_width, repeat_height), reducing_gap=3.0)
else:
img_part = self._image
img = PilImage.new('RGBA', (width, height), (0,0,0,0))
num_y = height // repeat_height + 1
oy = repeat_offset_y % (repeat_height if repeat_offset_y >= 0 else -repeat_height)
for i in range(-1, num_y):
img.paste(img_part, (0, i * repeat_height + oy))
offset_y = 0
if self._position in (ImageAnchor.TOP_LEFT, ImageAnchor.MIDDLE_LEFT, ImageAnchor.BOTTOM_LEFT):
x = x1 + offset_x
elif self._position in (ImageAnchor.TOP_CENTER, ImageAnchor.MIDDLE_CENTER, ImageAnchor.BOTTOM_CENTER):
x = x1 + (width // 2) - img.size[0] // 2 + offset_x
elif self._position in (ImageAnchor.TOP_RIGHT, ImageAnchor.MIDDLE_RIGHT, ImageAnchor.BOTTOM_RIGHT):
x = x1 + width - img.size[0] + offset_x
else:
raise Exception('invalid image position')
if self._position in (ImageAnchor.TOP_LEFT, ImageAnchor.TOP_CENTER, ImageAnchor.TOP_RIGHT):
y = y1 + offset_y
elif self._position in (ImageAnchor.MIDDLE_LEFT, ImageAnchor.MIDDLE_CENTER, ImageAnchor.MIDDLE_RIGHT):
y = y1 + (height // 2) - img.size[1] // 2 + offset_y
elif self._position in (ImageAnchor.BOTTOM_LEFT, ImageAnchor.BOTTOM_CENTER, ImageAnchor.BOTTOM_RIGHT):
y = y1 + height - img.size[1] + offset_y
else:
raise Exception('invalid image position')
image.paste(img, (x, y))
return image

494
internal/flexlayouter.py Normal file
View file

@ -0,0 +1,494 @@
from ..enums import FlexDirection, FlexWrap, FlexJustify, FlexAlignItems, FlexAlignContent
from .helpers import min_with_none, max_with_none
class FlexLineItem():
def __init__(self, content, max_width, max_height, direction):
self.__content = content
self.__max_width = max_width
self.__max_height = max_height
self.__direction = direction
self.__target_main_size = None
self.__main_size = None
self.__cross_size = None
self.__main_pos = None
self.__cross_pos = None
self.__frozen = False
def content(self):
return self.__content
def base_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
if self.__content._width is not None:
return self.__content._width
else:
return self.__content.get_ideal_outer_dimensions(available_width=self.__max_width,
available_height=self.__max_height)[0]
else:
if self.__content._height is not None:
return self.__content._height
else:
return self.__content.get_ideal_outer_dimensions(available_width=self.__max_width,
available_height=self.__max_height)[1]
def min_main_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
return self.__content.get_min_outer_width()
else:
return self.__content.get_min_outer_height()
def min_cross_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
return self.__content.get_min_outer_height()
else:
return self.__content.get_min_outer_width()
def max_main_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
return self.__content._max_width
else:
return self.__content._max_height
def max_cross_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
return self.__content._max_height
else:
return self.__content._max_width
def hypothetical_main_size(self):
return min_with_none(self.max_main_size(), max_with_none(self.min_main_size(), self.base_size()))
def target_main_size(self):
return self.__target_main_size
def main_size(self):
return self.__main_size
def hypothetical_cross_size(self):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
return self.__content.get_ideal_outer_dimensions(available_width=self.__main_size)[1]
else:
return self.__content.get_ideal_outer_dimensions(available_height=self.__main_size)[0]
def cross_size(self):
return self.__cross_size
def main_pos(self):
return self.__main_pos
def cross_pos(self):
return self.__cross_pos
def grow_factor(self):
return self.__content._flex_grow
def shrink_factor(self):
return self.__content._flex_shrink
def scaled_shrink_factor(self):
return self.shrink_factor() * self.base_size()
def set_target_main_size(self, size):
self.__target_main_size = size
def adjust_target_main_size(self, adjust):
self.__target_main_size += adjust
def set_cross_size(self, size):
size = min_with_none(max_with_none(size, self.min_cross_size()), self.max_cross_size())
self.__cross_size = size
def set_main_pos(self, pos):
self.__main_pos = pos
def set_cross_pos(self, pos):
self.__cross_pos = pos
def frozen(self):
return self.__frozen
def freeze(self):
self.__frozen = True
self.__main_size = self.__clamped_target_main_size()
def __clamped_target_main_size(self):
return min_with_none(max_with_none(self.__target_main_size, self.min_main_size()), self.max_main_size())
def violation_size(self):
return self.__clamped_target_main_size() - self.__target_main_size
class FlexLine():
def __init__(self, items, gap, direction, justify, align):
self.__items = items
self.__gap = gap
self.__direction = direction
self.__justify = justify
self.__align = align
self.__cross_size = None
self.__cross_pos = None
def items(self):
return self.__items[:]
def cross_size(self):
return self.__cross_size
def set_cross_pos(self, pos):
self.__cross_pos = pos
def cross_pos(self):
return self.__cross_pos
def compute_main_sizes(self, available_main_space):
sum_main_sizes = sum([item.hypothetical_main_size() for item in self.__items]) + self.__gap * (
len(self.__items) - 1)
if sum_main_sizes > available_main_space:
factor_type = 'shrink'
elif sum_main_sizes < available_main_space:
factor_type = 'grow'
else:
for item in self.__items:
item.set_target_main_size(item.hypothetical_main_size())
item.freeze()
return
for item in self.__items:
flex_factor = item.grow_factor() if factor_type == 'grow' else item.shrink_factor()
if (flex_factor == 0 or
(factor_type == 'grow' and item.base_size() > item.hypothetical_main_size()) or
(factor_type == 'shrink' and item.base_size() < item.hypothetical_main_size())):
item.set_target_main_size(item.hypothetical_main_size())
item.freeze()
initial_used_space = self.__gap * (len(self.__items) - 1)
for item in self.__items:
if item.frozen():
initial_used_space += item.target_main_size()
else:
initial_used_space += item.base_size()
initial_free_space = available_main_space - initial_used_space
while True:
unfrozen_items = [
item for item in self.__items if not item.frozen()]
if len(unfrozen_items) == 0:
break
remaining_used_space = self.__gap * (len(self.__items) - 1)
for item in self.__items:
if item.frozen():
remaining_used_space += item.main_size()
else:
remaining_used_space += item.base_size()
remaining_free_space = available_main_space - remaining_used_space
flex_sum = sum([item.grow_factor() if factor_type == 'grow' else item.shrink_factor()
for item in unfrozen_items])
if flex_sum < 1:
if abs(initial_free_space * flex_sum) < abs(remaining_free_space):
remaining_free_space = initial_free_space * flex_sum
if remaining_free_space != 0:
for item in unfrozen_items:
item.set_target_main_size(item.base_size())
if factor_type == 'grow':
remaining = remaining_free_space
items = sorted(
unfrozen_items, key=lambda x: x.grow_factor())
while remaining > 0 and len(items) > 0:
ratios = [
item.grow_factor() / flex_sum for item in items]
used = 0
for i in range(len(items)):
size = int(ratios[i] * remaining)
used += size
items[i].adjust_target_main_size(size)
remaining = remaining - used
items = items[:-1]
else:
remaining = remaining_free_space
items = sorted(
unfrozen_items, key=lambda x: x.scaled_shrink_factor())
flex_shrink_sum = sum(
[item.scaled_shrink_factor() for item in unfrozen_items])
while remaining < 0 and len(items) > 0:
ratios = [item.scaled_shrink_factor(
) / flex_shrink_sum for item in items]
used = 0
for i in range(len(items)):
size = int(ratios[i] * remaining)
used += size
items[i].adjust_target_main_size(size)
remaining = remaining - used
items = items[:-1]
sum_violations = sum([item.violation_size()
for item in unfrozen_items])
if sum_violations > 0:
for item in unfrozen_items:
if item.violation_size() > 0:
item.freeze()
elif sum_violations < 0:
for item in unfrozen_items:
if item.violation_size() < 0:
item.freeze()
else:
for item in unfrozen_items:
item.freeze()
for item in self.__items:
item.freeze()
def compute_cross_sizes(self, min_cross_space, max_cross_space):
line_size = 0
for item in self.__items:
size = item.hypothetical_cross_size()
item.set_cross_size(size)
line_size = max(line_size, size)
if min_cross_space is not None:
line_size = max(line_size, min_cross_space)
if max_cross_space is not None:
line_size = min(line_size, max_cross_space)
self.__cross_size = line_size
def set_cross_size(self, size):
self.__cross_size = size
def adjust_cross_size(self, adjust):
self.__cross_size += adjust
def arrange(self, available_main_space):
if self.__align == FlexAlignItems.START:
for item in self.__items:
item.set_cross_pos(0)
elif self.__align == FlexAlignItems.END:
for item in self.__items:
item.set_cross_pos(self.__cross_size - item.cross_size())
elif self.__align == FlexAlignItems.CENTER:
for item in self.__items:
item.set_cross_pos((self.__cross_size - item.cross_size()) // 2)
elif self.__align == FlexAlignItems.STRETCH:
for item in self.__items:
item.set_cross_size(self.__cross_size)
item.set_cross_pos(0)
num_items = len(self.__items)
num_inner_gaps = num_items - 1
num_gaps = num_inner_gaps + 2
gaps = [0] + [self.__gap] * num_inner_gaps + [0]
remaining_space = available_main_space - self.__gap * num_inner_gaps - sum(
[item.main_size() for item in self.__items])
justify = self.__justify
if remaining_space < 0 and justify == FlexJustify.SPACE_BETWEEN:
justify = FlexJustify.START
elif remaining_space < 0 and justify in (FlexJustify.SPACE_EVENLY, FlexJustify.SPACE_AROUND):
justify = FlexJustify.CENTER
if justify == FlexJustify.START:
gaps[-1] += remaining_space
elif justify == FlexJustify.END:
gaps[0] += remaining_space
elif justify == FlexJustify.CENTER:
gaps[0] += remaining_space // 2
gaps[-1] += remaining_space - (remaining_space // 2)
elif justify == FlexJustify.SPACE_BETWEEN:
space_parts = [remaining_space // num_inner_gaps + (1 if x < remaining_space % num_inner_gaps else 0) for x
in range(num_inner_gaps)]
for i in range(num_inner_gaps):
gaps[i + 1] += space_parts[i]
elif justify == FlexJustify.SPACE_AROUND:
space_parts = [remaining_space // num_items + (1 if x < remaining_space % num_items else 0) for x in
range(num_items)]
for i in range(num_items):
gaps[i] += space_parts[i] // 2
gaps[i + 1] += space_parts[i] - (space_parts[i] // 2)
elif justify == FlexJustify.SPACE_EVENLY:
space_parts = [remaining_space // num_gaps + (1 if x < remaining_space % num_gaps else 0) for x in
range(num_gaps)]
for i in range(num_gaps):
gaps[i] += space_parts[i]
else:
raise Exception('invalid flex justify')
if self.__direction in (FlexDirection.ROW, FlexDirection.COLUMN):
main_pos = 0
for i in range(num_items):
item = self.__items[i]
space_before = gaps[i]
main_pos += space_before
item.set_main_pos(main_pos)
main_pos += item.main_size()
elif self.__direction in (FlexDirection.ROW_REVERSE, FlexDirection.COLUMN_REVERSE):
main_pos = available_main_space
for i in range(num_items):
item = self.__items[i]
space_before = gaps[i]
main_pos -= space_before
main_pos -= item.main_size()
item.set_main_pos(main_pos)
else:
raise Exception('invalid flex direction')
class FlexLayouter():
def __init__(self, items, gap, direction, wrap, justify, align_content, align_items):
self.__items = items
self.__gap = gap
self.__direction = direction
self.__wrap = wrap
self.__justify = justify
self.__align_content = align_content
self.__align_items = align_items
def arrange(self, min_width=None, min_height=None, max_width=None, max_height=None, consume_space=True):
if self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE):
available_main_space = max_width
available_cross_space = max_height
min_cross_space = min_height
else:
available_main_space = max_height
available_cross_space = max_width
min_cross_space = min_width
items = [FlexLineItem(i, max_width, max_height, self.__direction) for i in self.__items]
if self.__wrap == FlexWrap.NO_WRAP or available_main_space is None:
lines = [items]
if available_main_space is None:
available_main_space = self.__gap * (len(items) - 1) + sum(
[item.hypothetical_main_size() for item in items])
else:
lines = []
current_line = []
current_line_length = 0
for item in items:
size = item.hypothetical_main_size()
new_line_length = current_line_length + size
if len(current_line) > 0: # not the first item in the line, so it needs a gap before it
new_line_length += self.__gap
if new_line_length <= available_main_space: # item fits into the current line
current_line.append(item)
current_line_length = new_line_length
else: # item does not fit into the current line
# current line already has items, so it gets completed first
if len(current_line) > 0:
lines.append(current_line)
current_line = [item]
current_line_length = size
if len(current_line) > 0:
lines.append(current_line)
lines = [FlexLine(items, self.__gap, self.__direction,
self.__justify, self.__align_items) for items in lines]
num_lines = len(lines)
for line in lines:
line.compute_main_sizes(available_main_space)
for line in lines:
if num_lines > 1:
line.compute_cross_sizes(None, None)
else:
line.compute_cross_sizes(min_cross_space, available_cross_space)
if available_cross_space is not None and consume_space:
line.set_cross_size(available_cross_space)
lines_sum_cross_space = self.__gap * (len(lines) - 1) + sum([line.cross_size() for line in lines])
if (consume_space and
self.__align_content == FlexAlignContent.STRETCH and
available_cross_space is not None and
lines_sum_cross_space < available_cross_space):
remaining_cross_space = available_cross_space - lines_sum_cross_space
distributed_cross_space = [remaining_cross_space // num_lines + (
1 if x < remaining_cross_space % num_lines else 0) for x in range(num_lines)]
for i in range(num_lines):
lines[i].adjust_cross_size(distributed_cross_space[i])
for line in lines:
line.arrange(available_main_space)
if len(lines) == 1:
lines[0].set_cross_pos(0)
else:
align_content = self.__align_content
num_lines = len(lines)
num_inner_gaps = num_lines - 1
num_gaps = num_inner_gaps + 2
used_space = self.__gap * num_inner_gaps + sum([line.cross_size() for line in lines])
gaps = [0] + [self.__gap] * num_inner_gaps + [0]
if available_cross_space is None:
# cannot do any other alignments without a definite cross size
align_content = FlexAlignContent.START
available_cross_space = used_space
remaining_space = available_cross_space - used_space
# stretch was already handled before, and behaves like start otherwise
if align_content == FlexAlignContent.START or align_content == FlexAlignContent.STRETCH:
gaps[-1] += remaining_space
elif align_content == FlexAlignContent.END:
gaps[0] += remaining_space
elif align_content == FlexAlignContent.CENTER:
gaps[0] += remaining_space // 2
gaps[-1] += remaining_space - (remaining_space // 2)
elif align_content == FlexAlignContent.SPACE_BETWEEN:
space_parts = [remaining_space // num_inner_gaps + (1 if x < remaining_space % num_inner_gaps else 0)
for x in range(num_inner_gaps)]
for i in range(num_inner_gaps):
gaps[i + 1] += space_parts[i]
elif align_content == FlexAlignContent.SPACE_AROUND:
space_parts = [remaining_space // num_lines + (1 if x < remaining_space % num_lines else 0) for x in
range(num_lines)]
for i in range(num_lines):
gaps[i] += space_parts[i] // 2
gaps[i + 1] += space_parts[i] - (space_parts[i] // 2)
elif align_content == FlexAlignContent.SPACE_EVENLY:
space_parts = [remaining_space // num_gaps + (1 if x < remaining_space % num_gaps else 0) for x in
range(num_gaps)]
for i in range(num_gaps):
gaps[i] += space_parts[i]
else:
raise Exception('invalid flex align content')
if self.__wrap == FlexWrap.WRAP:
cross_pos = 0
for i in range(num_lines):
line = lines[i]
space_before = gaps[i]
cross_pos += space_before
line.set_cross_pos(cross_pos)
cross_pos += line.cross_size()
elif self.__wrap == FlexWrap.WRAP_REVERSE:
cross_pos = available_cross_space
for i in range(num_lines):
line = lines[i]
space_before = gaps[i]
cross_pos -= space_before
cross_pos -= line.cross_size()
line.set_cross_pos(cross_pos)
else:
raise Exception('invalid flex wrap')
items_with_rects = []
horizontal = self.__direction in (FlexDirection.ROW, FlexDirection.ROW_REVERSE)
for line in lines:
if horizontal:
line_x = 0
line_y = line.cross_pos()
else:
line_x = line.cross_pos()
line_y = 0
for item in line.items():
if horizontal:
item_x = item.main_pos()
item_y = item.cross_pos()
item_width = item.main_size()
item_height = item.cross_size()
else:
item_x = item.cross_pos()
item_y = item.main_pos()
item_width = item.cross_size()
item_height = item.main_size()
x1 = line_x + item_x
y1 = line_y + item_y
x2 = x1 + item_width - 1
y2 = y1 + item_height - 1
items_with_rects.append(((x1, y1, x2, y2), item.content()))
return items_with_rects
def get_dimensions(self, min_width=None, min_height=None, max_width=None, max_height=None):
contents = self.arrange(min_width, min_height, max_width, max_height, consume_space=False)
max_x = 0
max_y = 0
for rect, _ in contents:
_, _, x2, y2 = rect
max_x = max(max_x, x2)
max_y = max(max_y, y2)
return max_x, max_y

45
internal/helpers.py Normal file
View file

@ -0,0 +1,45 @@
def get_line_height(font):
# just a bunch of high and low characters, idk how to do this better
_, bb_top, _, bb_bottom = font.getbbox("Ålgjpqvy")
return bb_bottom - bb_top
def min_with_none(*args):
result = None
for arg in args:
if arg is not None:
if result is None:
result = arg
else:
result = min(result, arg)
return result
def max_with_none(*args):
result = None
for arg in args:
if arg is not None:
if result is None:
result = arg
else:
result = max(result, arg)
return result
def line_intersection(line1, line2):
# copied from https://stackoverflow.com/a/20677983
xdiff = (line1[0][0] - line1[1][0], line2[0][0] - line2[1][0])
ydiff = (line1[0][1] - line1[1][1], line2[0][1] - line2[1][1])
def det(a, b):
return a[0] * b[1] - a[1] * b[0]
div = det(xdiff, ydiff)
if div == 0:
print(line1, line2)
raise Exception('lines do not intersect')
d = (det(*line1), det(*line2))
x = det(d, xdiff) / div
y = det(d, ydiff) / div
return x, y

199
internal/textlayouter.py Normal file
View file

@ -0,0 +1,199 @@
import unicodedata
from layout.enums import TextWrap, TextVerticalAlign, TextAlign
class TextLayouter():
def __init__(self,
lines,
align,
vertical_align,
wrap,
font,
line_height
):
self._lines = lines
self._align = align
self._vertical_align = vertical_align
self._wrap = wrap
self._font = font
self._line_height = line_height
# returns a list of tuples (x, y, text)
def get_lines(self, available_width=None, available_height=None):
lines = self._wrap_lines(available_width)
if len(lines) == 0:
return []
content_width = max([self._font.getbbox(line)[2] for line in lines])
content_height = len(lines) * self._line_height
if available_height is None:
available_height = content_height
if available_width is None:
available_width = content_width
positioned_lines = []
y = 0
if self._vertical_align == TextVerticalAlign.MIDDLE:
y = (available_height - content_height) // 2
elif self._vertical_align == TextVerticalAlign.BOTTOM:
y = available_height - content_height
for line in lines:
ly = y
line_width = self._font.getbbox(line)[2]
if self._align == TextAlign.CENTER:
lx = (available_width - line_width) // 2
elif self._align == TextAlign.RIGHT:
lx = available_width - line_width
else:
lx = 0
positioned_lines.append((lx, ly, line))
y += self._line_height
return positioned_lines
def get_dimensions(self, max_width=None, max_height=None):
if max_width == 0:
width = 0
cnt = 0
for line in self._lines:
for ch in line:
if len(ch.strip()) > 0:
w = self._text_width(ch)
width = max(width, w)
cnt += 1
height = cnt * self._line_height
else:
lines = [l[2] for l in self.get_lines(max_width, max_height)]
if len(lines) > 0:
width = max([self._font.getbbox(line)[2] for line in lines])
else:
width = 0
height = len(lines) * self._line_height
return width, height
def _text_width(self, text):
return self._font.getbbox(text)[2]
def _trim_line(self, line):
if self._align == TextAlign.LEFT:
return line.rstrip()
elif self._align == TextAlign.CENTER:
return line.strip()
elif self._align == TextAlign.RIGHT:
return line.lstrip()
def _wrap_lines_everywhere(self, max_width):
lines = []
for line in self._lines:
remaining = self._trim_line(line)
while len(remaining) > 0:
width_remaining = self._text_width(remaining)
if width_remaining <= max_width:
lines.append(remaining)
remaining = ''
else:
part_length = len(remaining) * max_width // width_remaining
while True:
width = self._text_width(self._trim_line(remaining[:part_length]))
if width <= max_width:
break
part_length -= 1
while True:
width = self._text_width(self._trim_line(remaining[:part_length + 1]))
if width > max_width:
break
part_length += 1
# if the max width is less than fits a character, still put a character in the line
# otherwise the algorithm would never finish and no layouting would be possible
if part_length == 0:
part_length = 1
lines.append(self._trim_line(remaining[:part_length]))
remaining = self._trim_line(remaining[part_length:])
return lines
def _get_word_breakpoints(self, line):
break_after = ['SPACE', 'EN QUAD', 'EM QUAD', 'EN SPACE', 'EM SPACE', 'THREE-PER-EM SPACE',
'FOUR-PER-EM SPACE', 'SIX-PER-EM SPACE', 'PUNCTUATION SPACE', 'THIN SPACE', 'HAIR SPACE',
'MEDIUM MATHEMATICAL SPACE', 'IDEOGRAPHIC SPACE', 'TAB', 'ARMENIAN HYPHEN', 'HYPHEN',
'FIGURE DASH', 'EN DASH', 'HYPHENATION POINT', 'VERTICAL LINE']
no_break_before = ['SMALL COMMA', 'SMALL FULL STOP', 'FULLWIDTH COMMA', 'FULLWIDTH FULL STOP', 'COMMA',
'FULL STOP', 'HORIZONTAL ELLIPSIS', 'NON-BREAKING HYPHEN']
breaks = []
no_breaks = []
for i, ch in enumerate(line):
if unicodedata.name(ch) in break_after:
if i + 1 < len(line) or unicodedata.name(line[i + 1]) not in no_break_before:
breaks.append(i + 1)
if unicodedata.name(ch) in no_break_before:
no_breaks.append(i)
return breaks, no_breaks
def _wrap_lines_on_words(self, max_width, allow_inside_word):
lines = []
for line in self._lines:
breakpoints, no_breaks = self._get_word_breakpoints(line)
breakpoints.append(len(line))
# e.g. if the string is 'asdf' then a breakpoint at 3 means the line can be broken into 'asd' and 'f'
previous_break = 0
while previous_break < len(line):
last_successful_break = None
first_unsuccessful_break = None
for bp in breakpoints:
if bp <= previous_break:
continue
width = self._text_width(self._trim_line(line[previous_break:bp]))
if width <= max_width:
last_successful_break = bp
else:
first_unsuccessful_break = bp
break
if last_successful_break is not None:
lines.append(self._trim_line(line[previous_break:last_successful_break]))
previous_break = last_successful_break
else:
if self._text_width(self._trim_line(line[previous_break:])) < max_width:
lines.append(self._trim_line(line[previous_break:]))
previous_break = len(line)
else:
end = first_unsuccessful_break
if not allow_inside_word:
lines.append(self._trim_line(line[previous_break:end]))
previous_break = end
else:
word = self._trim_line(line[previous_break:end])
word_width = self._text_width(word)
beginning_length = len(word) * max_width // word_width
while True:
change = 1
while previous_break + beginning_length - change in no_breaks:
change += 1
beginning_length -= change
width = self._text_width(self._trim_line(word[:beginning_length]))
if width <= max_width:
break
while True:
change = 1
while previous_break + beginning_length + change in no_breaks:
change += 1
width = self._text_width(self._trim_line(word[:beginning_length + change]))
if width > max_width and beginning_length > 0:
break
beginning_length += change
if beginning_length == 0:
beginning_length = 1
lines.append(self._trim_line(word[:beginning_length]))
previous_break = previous_break + beginning_length
return lines
def _wrap_lines(self, max_width):
if self._wrap == TextWrap.NO_WRAP or max_width is None:
return self._lines
if self._wrap == TextWrap.EVERYWHERE:
return self._wrap_lines_everywhere(max_width)
else:
return self._wrap_lines_on_words(max_width, self._wrap == TextWrap.PREFER_WORDS)

400
layout.py Normal file
View file

@ -0,0 +1,400 @@
import math
from PIL import Image, ImageDraw
import layout
from layout.internal.helpers import line_intersection
class Layout:
def __init__(
self,
width=None,
height=None,
min_width=None,
min_height=None,
max_width=None,
max_height=None,
fg_color=None,
bg_color=None,
border_color=None,
border_width=0,
border_radius=0,
padding=0,
font=None,
overflow=None,
left=None,
top=None,
right=None,
bottom=None,
flex_grow=0,
flex_shrink=0,
debug_layout=False,
):
self._width = width
self._height = height
self._min_width = min_width
self._min_height = min_height
self._max_width = max_width
self._max_height = max_height
self._fg_color = fg_color
self._bg_color = bg_color
self._border_color = border_color
self._border_width = border_width
self._border_radius = border_radius
self._padding = padding
self._font = font
self._overflow = overflow
self._left = left
self._top = top
self._right = right
self._bottom = bottom
self._flex_grow = flex_grow
self._flex_shrink = flex_shrink
self._debug_layout = debug_layout
self._container = None
def complete_init(self, container):
self._container = container
for c in self._children():
c.complete_init(self)
def _children(self):
return []
def get_document_size(self):
if isinstance(self, layout.Document):
return self._actual_size
else:
return self._container.get_document_size()
def get_fg_color(self):
if self._fg_color is not None:
return self._fg_color
elif self._container is not None:
return self._container.get_fg_color()
else:
return None
def get_font(self):
if self._font is not None:
return self._font
elif self._container is not None:
return self._container.get_font()
else:
raise Exception('no font defined in {0}'.format(self.__class__))
def get_overflow(self):
if self._overflow is not None:
return self._overflow
elif self._container is not None:
return self._container.get_overflow()
else:
return None
def _get_tuple_property(self, name, default, allow_tuple=True):
value = getattr(self, name)
if isinstance(value, list) or (allow_tuple and isinstance(value, tuple)):
if len(value) == 0:
return default, default, default, default
elif len(value) == 1:
return value[0], value[0], value[0], value[0]
elif len(value) == 2:
return tuple(value + value)
elif len(value) == 3:
return value[0], value[1], value[2], value[1]
else:
return tuple(value[:4])
elif value is None:
return default, default, default, default
else:
return value, value, value, value
def padding(self):
return self._get_tuple_property('_padding', 0)
def border_width(self):
return self._get_tuple_property('_border_width', 0)
def border_color(self):
return self._get_tuple_property('_border_color', None, False)
def border_radius(self):
return self._get_tuple_property('_border_radius', None)
def get_min_inner_width(self, max_height=None):
return self._min_width
def get_min_inner_height(self, max_width=None):
return self._min_height
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
return self._width, self._height
def get_x_padding_border_size(self):
border, padding = self.border_width(), self.padding()
return border[0] + border[2] + padding[0] + padding[2]
def get_y_padding_border_size(self):
border, padding = self.border_width(), self.padding()
return border[1] + border[3] + padding[1] + padding[3]
def get_min_outer_width(self, max_outer_height=None):
if max_outer_height is not None:
max_height = max_outer_height - self.get_y_padding_border_size()
else:
max_height = None
return self.get_min_inner_width(max_height) + self.get_x_padding_border_size()
def get_min_outer_height(self, max_outer_width=None):
if max_outer_width is not None:
max_width = max_outer_width - self.get_x_padding_border_size()
else:
max_width = None
return self.get_min_inner_height(max_width) + self.get_y_padding_border_size()
def get_ideal_outer_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
x = self.get_x_padding_border_size()
y = self.get_y_padding_border_size()
if min_width is not None:
min_width -= x
if available_width is not None:
available_width -= x
if min_height is not None:
min_height -= y
if available_height is not None:
available_height -= y
w, h = self.get_ideal_inner_dimensions(min_width, min_height, available_width, available_height)
return w + x, h + y
def make_canvas(self):
return Image.new('RGBA', self.get_document_size(), (0, 0, 0, 0))
def make_border_corner_mask(self, radius_x, radius_y, x, y):
mask = Image.new('1', (radius_x, radius_y), 1)
d = ImageDraw.Draw(mask)
x1 = x * -radius_x
y1 = y * -radius_y
x2 = (2 - x) * radius_x
y2 = (2 - y) * radius_y
d.ellipse((x1, y1, x2 - 1, y2 - 1), fill=0)
return mask
def make_border_outside_mask(self, rect, border_radii):
x1, y1, x2, y2 = rect
mask = Image.new('1', self.get_document_size(), 0)
d = ImageDraw.Draw(mask)
d.rectangle(rect, fill=1)
radius_top_left, radius_top_right, radius_bottom_right, radius_bottom_left = border_radii
if radius_top_left > 0:
mask_top_left = self.make_border_corner_mask(radius_top_left, radius_top_left, 0, 0)
mask.paste(0, (x1, y1), mask=mask_top_left)
if radius_top_right > 0:
mask_top_right = self.make_border_corner_mask(radius_top_right, radius_top_right, 1, 0)
mask.paste(0, (x2 - radius_top_right + 1, y1), mask=mask_top_right)
if radius_bottom_right > 0:
mask_bottom_right = self.make_border_corner_mask(radius_bottom_right, radius_bottom_right, 1, 1)
mask.paste(0, (x2 - radius_bottom_right + 1, y2 - radius_bottom_right + 1), mask=mask_bottom_right)
if radius_bottom_left > 0:
mask_bottom_left = self.make_border_corner_mask(radius_bottom_left, radius_bottom_left, 0, 1)
mask.paste(0, (x1, y2 - radius_bottom_left + 1), mask=mask_bottom_left)
return mask
def make_border_inside_mask(self, rect, border_radii):
mask = Image.new('1', self.get_document_size(), 0)
x1, y1, x2, y2 = rect
width_left, width_top, width_right, width_bottom = self.border_width()
radius_top_left, radius_top_right, radius_bottom_right, radius_bottom_left = border_radii
inner_rect = (x1 + width_left, y1 + width_top, x2 - width_right, y2 - width_bottom)
d = ImageDraw.Draw(mask)
d.rectangle(inner_rect, fill=1)
if radius_top_left > width_top and radius_top_left > width_left:
mask_top_left = self.make_border_corner_mask(radius_top_left - width_left,
radius_top_left - width_top, 0, 0)
mask.paste(0, (x1 + width_left, y1 + width_top), mask=mask_top_left)
if radius_top_right > width_top and radius_top_right > width_right:
mask_top_right = self.make_border_corner_mask(radius_top_right - width_right,
radius_top_right - width_top, 1, 0)
mask.paste(0, (x2 - radius_top_right + 1, y1 + width_top), mask=mask_top_right)
if radius_bottom_right > width_bottom and radius_bottom_right > width_right:
mask_bottom_right = self.make_border_corner_mask(radius_bottom_right - width_right,
radius_bottom_right - width_bottom, 1, 1)
mask.paste(0, (x2 - radius_bottom_right + 1, y2 - radius_bottom_right + 1), mask=mask_bottom_right)
if radius_bottom_left > width_bottom and radius_bottom_left > width_left:
mask_bottom_right = self.make_border_corner_mask(radius_bottom_left - width_left,
radius_bottom_left - width_bottom, 0, 1)
mask.paste(0, (x1 + width_left, y2 - radius_bottom_left + 1), mask=mask_bottom_right)
return mask
def make_border_color_image(self, rect, border_radii):
x1, y1, x2, y2 = rect
color_left, color_top, color_right, color_bottom = self.border_color()
width_left, width_top, width_right, width_bottom = self.border_width()
radius_top_left, radius_top_right, radius_bottom_right, radius_bottom_left = border_radii
image = self.make_canvas()
d = ImageDraw.Draw(image)
if width_left > 0:
p1 = (x1, y1)
p1_ = (x1 + width_left, y1 + width_top)
p2 = (x1, y2)
p2_ = (x1 + width_left, y2 - width_bottom)
left = x1 + max(width_left, radius_top_left, radius_bottom_left)
intersect = line_intersection((p1, p1_), (p2, p2_))
left = min(left, intersect[0])
left_line = ((left, 0), (left, 1))
intersect_1 = line_intersection((p1, p1_), left_line)
intersect_2 = line_intersection((p2, p2_), left_line)
d.polygon([p1, intersect_1, intersect_2, p2], fill=color_left)
if width_top > 0:
p1 = (x1, y1)
p1_ = (x1 + width_left, y1 + width_top)
p2 = (x2, y1)
p2_ = (x2 - width_right, y1 + width_top)
top = y1 + max(width_top, radius_top_left, radius_top_right)
intersect = line_intersection((p1, p1_), (p2, p2_))
top = min(top, intersect[1])
top_line = ((0, top), (1, top))
intersect_1 = line_intersection((p1, p1_), top_line)
intersect_2 = line_intersection((p2, p2_), top_line)
d.polygon([p1, intersect_1, intersect_2, p2], fill=color_top)
if width_right > 0:
p1 = (x2, y1)
p1_ = (x2 - width_right, y1 + width_top)
p2 = (x2, y2)
p2_ = (x2 - width_right, y2 - width_bottom)
right = x2 - max(width_right, radius_top_right, radius_bottom_right)
intersect = line_intersection((p1, p1_), (p2, p2_))
right = max(right, intersect[0])
right_line = ((right, 0), (right, 1))
intersect_1 = line_intersection((p1, p1_), right_line)
intersect_2 = line_intersection((p2, p2_), right_line)
d.polygon([p1, intersect_1, intersect_2, p2], fill=color_right)
if width_bottom > 0:
p1 = (x1, y2)
p1_ = (x1 + width_left, y2 - width_bottom)
p2 = (x2, y2)
p2_ = (x2 - width_right, y2 - width_bottom)
bottom = y2 - max(width_bottom, radius_bottom_right, radius_bottom_left)
intersect = line_intersection((p1, p1_), (p2, p2_))
bottom = max(bottom, intersect[1])
bottom_line = ((0, bottom), (1, bottom))
intersect_1 = line_intersection((p1, p1_), bottom_line)
intersect_2 = line_intersection((p2, p2_), bottom_line)
d.polygon([p1, intersect_1, intersect_2, p2], fill=color_bottom)
return image
def actual_border_radii(self, rect):
x1, y1, x2, y2 = rect
width, height = x2 - x1 + 1, y2 - y1 + 1
border_radii = self.border_radius()
radius_sum_top = border_radii[0] + border_radii[1]
radius_sum_right = border_radii[1] + border_radii[2]
radius_sum_bottom = border_radii[2] + border_radii[3]
radius_sum_left = border_radii[3] + border_radii[0]
radius_factor = 1
if radius_sum_top > width:
radius_factor = min(radius_factor, width / radius_sum_top)
if radius_sum_bottom > width:
radius_factor = min(radius_factor, width / radius_sum_bottom)
if radius_sum_left > height:
radius_factor = min(radius_factor, height / radius_sum_left)
if radius_sum_right > height:
radius_factor = min(radius_factor, height / radius_sum_right)
return tuple([int(r * radius_factor) for r in border_radii])
def render_base(self, image, rect):
pass
def render_after_background(self, image, rect):
pass
def render_after_border(self, image, rect):
pass
def render_after_content(self, image, rect):
pass
def modify_outside_mask(self, outside_mask, rect):
pass
def modify_inside_mask(self, inside_mask, rect):
pass
def modify_border_mask(self, border_mask, rect):
pass
def render(self, rect):
image = self.make_canvas()
self.render_base(image, rect)
border_radii = self.actual_border_radii(rect)
outside_mask = self.make_border_outside_mask(rect, border_radii)
inside_mask = self.make_border_inside_mask(rect, border_radii)
# this should composite the outside mask over a black background, with inside_mask as the alpha channel it is
# somewhat unclear to me why this works, as I would expect (intuitively and from the documentation of related
# functions) that pixels that are 0 in the alpha channel get copied from the black background and pixels that
# are 1 in the alpha channel get copied from the outside mask, but apparently it is the other way round?
border_mask = Image.composite(0, outside_mask, mask=inside_mask)
self.modify_outside_mask(outside_mask, rect)
self.modify_inside_mask(inside_mask, rect)
self.modify_border_mask(border_mask, rect)
if self._bg_color is not None:
image.paste(self._bg_color, mask=outside_mask)
self.render_after_background(image, rect)
border_color = self.make_border_color_image(rect, border_radii)
border_image = self.make_canvas()
border_image.paste(border_color, mask=border_mask)
image.alpha_composite(border_image)
self.render_after_border(image, rect)
border_width = self.border_width()
padding = self.padding()
content_x1 = rect[0] + border_width[0] + padding[0]
content_y1 = rect[1] + border_width[1] + padding[1]
content_x2 = rect[2] - border_width[2] - padding[2]
content_y2 = rect[3] - border_width[3] - padding[3]
content_rect = (content_x1, content_y1, content_x2, content_y2)
content_image = self.render_content(content_rect)
if not self.get_overflow():
actual_content_image = content_image
content_image = self.make_canvas()
content_image.paste(actual_content_image, mask=inside_mask)
image.alpha_composite(content_image)
self.render_after_content(image, rect)
if self._debug_layout:
dc = Image.new('RGBA', image.size, (0, 0, 0, 0))
d = ImageDraw.Draw(dc)
# outside margin
d.rectangle(rect, outline=(255, 0, 0, 50), width=1)
# border (this does not account for actual border width)
d.rectangle((rect[0] + border_width[0], rect[1] + border_width[1], rect[2] - border_width[2],
rect[3] - border_width[3]),
outline=(0, 0, 255, 50), width=1)
# inside padding
d.rectangle(content_rect,
outline=(0, 255, 0, 50), width=1)
image.alpha_composite(dc)
return image
def render_content(self, rect):
return self.make_canvas()

505
readme.md Normal file
View file

@ -0,0 +1,505 @@
# Example
```python
from layout import Document, Flex, Text, TextAlign, TextVerticalAlign,
FlexAlignContent, FlexDirection, FlexAlignItems, FlexWrap
doc = Document(
width=800,
height=400,
bg_color=(206, 249, 242, 255),
content=Flex(
direction=FlexDirection.ROW,
wrap=FlexWrap.WRAP,
align_items=FlexAlignItems.STRETCH,
align_content=FlexAlignContent.STRETCH,
gap=20,
padding=20,
contents=[
Text(
text="ITEM 1",
bg_color=(4, 231, 98, 128),
width=100,
flex_grow=1,
flex_shrink=1,
text_align=TextAlign.CENTER,
vertical_text_align=TextVerticalAlign.MIDDLE
),
Text(
text="ITEM 2",
bg_color=(34, 49, 39, 128),
width=300,
flex_grow=1,
flex_shrink=1,
text_align=TextAlign.CENTER,
vertical_text_align=TextVerticalAlign.MIDDLE
),
Text(
text="ITEM 3",
bg_color=(220, 0, 115, 128),
width=200,
flex_grow=1,
flex_shrink=1,
text_align=TextAlign.CENTER,
vertical_text_align=TextVerticalAlign.MIDDLE
),
Text(
text="ITEM 4",
bg_color=(0, 139, 248, 128),
width=400,
flex_grow=1,
flex_shrink=1,
text_align=TextAlign.CENTER,
vertical_text_align=TextVerticalAlign.MIDDLE
),
Text(
text="ITEM 5",
bg_color=(71, 0, 99, 128),
width=250,
flex_grow=1,
flex_shrink=1,
text_align=TextAlign.CENTER,
vertical_text_align=TextVerticalAlign.MIDDLE
),
]
)
)
image = doc.get_image()
image.show()
```
# Layouts
## Layout
Base layout class, do not use directly. Provides the following properties:
```python
from layout import Layout
Layout(
width=None, # width of the layout element
height=None, # height of the layout element
min_width=None, # minimum width of the layout element
min_height=None, # minimum height of the layout element
max_width=None, # maximum width of the layout element
max_height=None, # maximum height of the layout element
fg_color=None, # foreground color (inherited)
bg_color=None, # background color (inherited)
border_color=None, # border color (see section Borders)
border_width=0, # border width (see section Borders)
border_radius=0, # border radius (see section Borders)
padding=0, # padding of the layout element
font=None, # font for texts (inherited)
overflow=None, # allow overflow of layout content
left=None, # left absolute position (in children of Container layout)
top=None, # top absolute position (in children of Container layout)
right=None, # right absolute position (in children of Container layout)
bottom=None, # bottom absolute position (in children of Container layout)
flex_grow=0, # flex grow proportion (in children of Flex layout)
flex_shrink=0, # flex grow proportion (in children of Flex layout)
)
```
### Borders
Borders are painted over the background but below potentially overflowing content.
Every border property can be a single value or a tuple (except color) or list of up to four values.
Multiple values for border width and color are given in the order (`left`, `top`, `right`, `bottom`).
Multiple values for border radius are given in the order (`top-left`, `top-right`, `bottom-right`, `bottom-left`).
The border color property can only be given as a single value or list of values, not as a tuple, since colors are
defined as RGBA-tuples and a tuple value is interpreted as a single color.
## Container
Simple layout container with absolute and automatic positioning.
Children with any of their `left`/`top`/`right`/`bottom` properties set to something other than `None` are positioned
absolutely.
Other children are positioned automatically.
```python
from layout import Container
Container(
contents=[], # child layouts
)
```
## Flex
Flexible layout container roughly based on the CSS flex specification.
Children of this layout can use the `flex_grow` and `flex_shrink` properties.
```python
from layout import Flex, FlexDirection, FlexWrap, FlexJustify, FlexAlignItems, FlexAlignContent
Flex(
contents=[], # child layouts
direction=FlexDirection.ROW,
wrap=FlexWrap.NO_WRAP,
justify=FlexJustify.START,
align_items=FlexAlignItems.START,
align_content=FlexAlignContent.START,
gap=0, # gap between flex items
)
```
The properties `direction`, `wrap`, `justify`, `align_items`, `align_content` and `gap` work roughly like their CSS
counterparts.
Flex layout arranges items into one or multiple lines. A line is a row in `ROW`/`ROW_REVERSE` and a column
in `COLUMN`/`COLUMN_REVERSE` direction.
The "main axis" is the axis in which direction the items are arranged, i.e. the x-axis for rows and the y-axis for
columns.
The "cross axis" is the other axis.
### FlexDirection
Specifies the direction in which child layouts (items) are arranged in the layout.
Other flex properties with values like `START` and `END` depend on the direction.
#### FlexDirection.ROW
Items are arranged in rows.
#### FlexDirection.ROW_REVERSE
Items are arranged in rows, in reverse order.
The item that is first in the `contents` list is at the end and vice versa.
Items are still *rendered* in the same order as they appear in the `contents` list, so overlapping items will be
rendered on top of each other in that order.
#### FlexDirection.COLUMN
Items are arranged in columns.
#### FlexDirection.COLUMN_REVERSE
Like `ROW_REVERSE`, but with columns.
### FlexWrap
Specifies whether and how flex items can wrap into multiple lines (rows/columns).
#### FlexWrap.NO_WRAP
Items can not wrap, all items are displayed in one line.
#### FlexWrap.WRAP
Items can wrap into multiple lines if they do not fit into the available space.
#### FlexWrap.WRAP_REVERSE
like `WRAP`, but lines are arranged in reverse order.
### FlexJustify
This property corresponds to `justify-content` in CSS flex layouts.
It determines how items within a line are arranged on the main axis if there is space left over along the main axis
after growing/shrinking items.
#### FlexJustify.START
The available space is at the end of the line, the items at the beginning.
#### FlexJustify.END
The available space is at the beginning of the line, the items at the end.
#### FlexJustify.CENTER
The available space is equally distributed at the beginning and end of the line, the items are in the center.
#### FlexJustify.SPACE_BETWEEN
The available space is equally distributed between the items, the first and last item are at the beginning and end of
the line.
#### FlexJustify.SPACE_AROUND
The available space is equally distributed to the items, and for each item equally distributed before and after the
item.
There is twice as much space between two items as there is before the first and after the last item.
#### FlexJustify.SPACE_EVENLY
The available space is equally distributed before, after and between the items.
### FlexAlignItems
Specifies how items within a line are arranged on the cross axis if they don't fill the entire cross size of the line.
#### FlexAlignItems.START
Items are arranged at the start of the line, i.e. at the top for rows and at the left for columns.
#### FlexAlignItems.END
Items are arranged at the end of the line, i.e. at the bottom for rows and at the right for columns.
#### FlexAlignItems.CENTER
Items are arranged at the center of the line.
#### FlexAlignItems.STRETCH
Items are stretched out to fill the line.
### FlexAlignContent
Specifies how multiple lines are arranged on the cross axis within the layout when they don't fill the cross size of the
layout.
This is only relevant for layouts with multiple lines, a single line is always stretched to fill the layout (the items
within such a line can be controlled with `FlexAlignItems`).
#### FlexAlignContent.START
The lines are at the beginning of the layout, i.e. at the top for rows and at the left for columns.
#### FlexAlignContent.END
The lines are at the end of the layout, i.e. at the bottom for rows and at the right for columns.
#### FlexAlignContent.CENTER
The lines are at the center of the layout.
#### FlexAlignContent.STRETCH
The lines are stretched to fill the available space. The available space is distributed equally between the lines.
#### FlexAlignContent.SPACE_BETWEEN
The available space is distributed equally between the lines but not around them.
#### FlexAlignContent.SPACE_AROUND
The available space is distributed equally to the lines, and for each line distributed equally before and after that
line.
The space between two lines is twice as much as the space before the first and after the last line.
#### FlexAlignContent.SPACE_EVENLY
The available space is distributed equally before, between and after the lines.
## Box
This layout can render a title over its own top border.
```python
from layout import Box, BoxTitleAnchor
Box(
title=None,
title_font=None,
title_color=None,
title_anchor=BoxTitleAnchor.LEFT, # anchor for positioning
title_position=0, # x position of the title relative to the anchor
title_padding=0, # additional space to remove from the border on the left and right of the text
content=None,
)
```
### BoxTitleAnchor
Where to anchor the title along the top border.
The top left and top right border radii are subtracted from the available space.
#### BoxTitleAnchor.LEFT
The title is anchored at the left edge of the border, plus the top left border radius.
#### BoxTitleAnchor.CENTER
The title is anchored in the middle of the border.
#### BoxTitleAnchor.RIGHT
The title is anchored at the right edge of the border, minus the top right border radius.
## Text
This layout can render text.
The layout algorithm is somewhat inefficient and arranging large amounts of texts can take a while.
The line-breaking algorithm is very simplistic.
Whitespace at the beginning (if left-aligned), end (if right-aligned) or begining and end (if center-aligned) is not
rendered.
```python
from layout import Text, TextAlign, TextVerticalAlign, TextWrap
Text(
text=None,
# can be provided as a single string or as a list of lines, if a list then lines must not contain line breaks
text_align=TextAlign.LEFT,
vertical_text_align=TextVerticalAlign.TOP,
text_wrap=TextWrap.NO_WRAP,
line_spacing=0 # spacing between the end of one line and the beginning of the next
)
```
### TextAlign
How each line is aligned within the available horizontal space.
#### TextAlign.LEFT
#### TextAlign.CENTER
#### TextAlign.RIGHT
### TextVerticalAlign
How all the lines together are aligned within the available vertical space.
#### TextVerticalAlign.TOP
#### TextVerticalAlign.MIDDLE
#### TextVerticalAlign.BOTTOM
### TextWrap
#### TextWrap.NO_WRAP
Text is not automatically wrapped to fit into the available horizontal space, but explicit line breaks are respected.
#### TextWrap.ONLY_WORDS
Text is wrapped on word boundaries. Words that are too long to fit into a single line overflow.
#### TextWrap.PREFER_WORDS
Text is wrapped on word boundaries, and words that are too long to fit into a single line are wrapped as well.
#### TextWrap.EVERYWHERE
Lines are filled up as much as possible and text is wrapped wherever necessary.
## Image
```python
from layout import Image, ImageMode, ImageAnchor
Image(
image=None,
mode=ImageMode.ORIGINAL,
scale=1, # can be a number or a tuple of x- and y-scale
position=ImageAnchor.TOP_LEFT, # anchor for positioning
offset_x=0, # horizontal position of the image relative to the anchor
offset_y=0, # vertical position of the image relative to the anchor
)
```
### ImageMode
How the image is displayed.
This also affects how much space this layout tries to take up:
With `ORIGINAL` mode both width and height correspond to the (scaled) width and height of the image.
With `STRETCH_X` and `REPEAT_X` mode the height corresponds to the (scaled) height of the image, but the width is 0
unless forced otherwise.
With `STRETCH_Y` and `REPEAT_Y` mode the width corresponds to the (scaled) width of the image, but the height is 0
unless forced otherwise.
With all other modes the width and height are 0 unless forced otherwise.
All modes except `STRETCH`, `CONTAIN` and `COVER` respect the `scale` property in some way.
#### ImageMode.ORIGINAL
The image is rendered at its original size, optionally scaled by the `scale` property.
#### ImageMode.STRETCH
The image is stretched to fill the available space.
#### ImageMode.STRETCH_X
The image is stretched in the x-axis to fill the available space and unmodified in the y-axis.
#### ImageMode.STRETCH_Y
The image is stretched in the y-axis to fill the available space and unmodified in the x-axis.
#### ImageMode.CONTAIN
The image is scaled proportionally to fill the available space as much as possible without being cut off.
The image can still be cut off in places by positioning it or by rounded corners of the container.
This can make the image smaller if the available space is smaller than the original image size.
#### ImageMode.COVER
The image is scaled proportionally to fill the available space completely.
This can make the image smaller if the available space is smaller than the original image size.
#### ImageMode.REPEAT
The image is repeated on both axes to fill the available space.
#### ImageMode.REPEAT_X
The image is repeated on the x-axis to fill the available space.
#### ImageMode.REPEAT_Y
The image is repeated on the y-axis to fill the available space.
#### ImageMode.REPEAT_X_STRETCH
The image is repeated on the x-axis and stretched on the y-axis to fill the available space.
#### ImageMode.REPEAT_Y_STRETCH
The image is repeated on the y-axis and stretched on the x-axis to fill the available space.
#### ImageMode.REPEAT_X_FILL
The image is scaled like with `CONTAIN` on the y-axis and then repeated along the x-axis to fill the remaining space.
#### ImageMode.REPEAT_Y_FILL
The image is scaled like with `CONTAIN` on the x-axis and then repeated along the y-axis to fill the remaining space.
### ImageAnchor
Where the image is anchored for positioning with `offset_x` and `offset_y`.
#### ImageAnchor.TOP_LEFT
#### ImageAnchor.TOP_CENTER
#### ImageAnchor.TOP_RIGHT
#### ImageAnchor.MIDDLE_LEFT
#### ImageAnchor.MIDDLE_CENTER
#### ImageAnchor.MIDDLE_RIGHT
#### ImageAnchor.BOTTOM_LEFT
#### ImageAnchor.BOTTOM_CENTER
#### ImageAnchor.BOTTOM_RIGHT
####
# possible future features
## border modes
option to render borders as outlines instead (i.e. not taking up layout space), with the following options:
- `NORMAL`: border takes up layout space
- `OUTLINE_INSIDE`: border is an overlay that takes up no space, inside of the layout rect
- `OUTLINE_OUTSIDE`: border is an overlay that takes up no space, outside of the layout rect
(only visible with overflow=true)
- `OUTLINE_CENTERED`: border is an overlay that takes up no space, centered on the layout rect
(only completely visible with overflow=true)
## border styles
border styles like dotted, dashed, inset etc like in CSS

106
text.py Normal file
View file

@ -0,0 +1,106 @@
from PIL import ImageDraw, ImageFont
from . import Layout
from .internal.textlayouter import TextLayouter
from .internal.helpers import min_with_none, max_with_none, get_line_height
from .enums import TextAlign, TextVerticalAlign, TextWrap
class Text(Layout):
def __init__(
self,
text=None,
text_align=TextAlign.LEFT,
vertical_text_align=TextVerticalAlign.TOP,
text_wrap=TextWrap.NO_WRAP,
line_spacing=0,
**kwargs
):
super().__init__(**kwargs)
self._text_layouter = None
self._text = text
self._text_align = text_align
self._vertical_text_align = vertical_text_align
self._text_wrap = text_wrap
self._line_spacing = line_spacing
def complete_init(self, container):
super().complete_init(container)
if self._is_empty():
lines = []
elif isinstance(self._text, list):
lines = self._text
else:
lines = self._text.splitlines()
self._text_layouter = TextLayouter(lines=lines,
align=self._text_align,
vertical_align=self._vertical_text_align,
wrap=self._text_wrap,
font=self.get_font(),
line_height=self._get_line_height_with_spacing())
def _is_empty(self):
return self._text is None or self._text == '' or (isinstance(self._text, list)
and len(self._text) == 0)
def get_min_inner_width(self, max_height=None):
if self._is_empty():
width = 0
else:
if max_height is not None:
max_height += self._line_spacing
width = self._text_layouter.get_dimensions(0, max_height)[0]
return max_with_none(width, self._min_width)
def get_min_inner_height(self, max_width=None):
if self._is_empty():
height = 0
else:
# no spacing after the last line
height = self._text_layouter.get_dimensions(max_width, 0)[1] - self._line_spacing
return max_with_none(height, self._min_height)
def get_ideal_inner_dimensions(self, min_width=None, min_height=None, available_width=None, available_height=None):
available_width = min_with_none(available_width, self._width)
available_height = min_with_none(available_height, self._height)
width, height = self._text_layouter.get_dimensions(available_width, available_height)
height -= self._line_spacing # no spacing after the last line
if self._width is not None:
width = self._width
if self._height is not None:
height = self._height
width = max_with_none(width, self._min_width)
height = max_with_none(height, self._min_height)
width = min_with_none(width, self._max_width)
height = min_with_none(height, self._max_height)
return width, height
def render_content(self, rect):
image = self.make_canvas()
d = ImageDraw.Draw(image)
x1, y1, x2, y2 = rect
width, height = x2 - x1 + 1, y2 - y1 + 1
if self._max_width is not None:
width = min(width, self._max_width)
if self._min_width is not None:
width = max(width, self._min_width)
if self._max_height is not None:
height = min(height, self._max_height)
if self._min_height is not None:
height = max(height, self._min_height)
lines = self._text_layouter.get_lines(width, height)
color = self.get_fg_color()
font = self.get_font()
for posx, posy, text in lines:
d.text((x1 + posx, y1 + posy), text, font=font, fill=color)
return image
def _get_line_height_with_spacing(self):
return get_line_height(self.get_font()) + self._line_spacing