From cd4c111c7811dea452d5c61a43a49124ce2a66ee Mon Sep 17 00:00:00 2001 From: Gwendolyn Date: Tue, 31 Jan 2023 21:46:27 +0100 Subject: [PATCH] kinda working version --- __init__.py | 11 + box.py | 100 ++++++++ container.py | 149 ++++++++++++ document.py | 80 +++++++ enums.py | 98 ++++++++ flex.py | 64 +++++ image.py | 267 +++++++++++++++++++++ internal/flexlayouter.py | 494 ++++++++++++++++++++++++++++++++++++++ internal/helpers.py | 45 ++++ internal/textlayouter.py | 199 +++++++++++++++ layout.py | 400 +++++++++++++++++++++++++++++++ readme.md | 505 +++++++++++++++++++++++++++++++++++++++ text.py | 106 ++++++++ 13 files changed, 2518 insertions(+) create mode 100644 __init__.py create mode 100644 box.py create mode 100644 container.py create mode 100644 document.py create mode 100644 enums.py create mode 100644 flex.py create mode 100644 image.py create mode 100644 internal/flexlayouter.py create mode 100644 internal/helpers.py create mode 100644 internal/textlayouter.py create mode 100644 layout.py create mode 100644 readme.md create mode 100644 text.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c31dfc9 --- /dev/null +++ b/__init__.py @@ -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 diff --git a/box.py b/box.py new file mode 100644 index 0000000..ea0ad1f --- /dev/null +++ b/box.py @@ -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()) diff --git a/container.py b/container.py new file mode 100644 index 0000000..b775f49 --- /dev/null +++ b/container.py @@ -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 diff --git a/document.py b/document.py new file mode 100644 index 0000000..1bf6e15 --- /dev/null +++ b/document.py @@ -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) diff --git a/enums.py b/enums.py new file mode 100644 index 0000000..dd2a978 --- /dev/null +++ b/enums.py @@ -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, diff --git a/flex.py b/flex.py new file mode 100644 index 0000000..ab45cdd --- /dev/null +++ b/flex.py @@ -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 diff --git a/image.py b/image.py new file mode 100644 index 0000000..c9fa15b --- /dev/null +++ b/image.py @@ -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 diff --git a/internal/flexlayouter.py b/internal/flexlayouter.py new file mode 100644 index 0000000..76abf34 --- /dev/null +++ b/internal/flexlayouter.py @@ -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 diff --git a/internal/helpers.py b/internal/helpers.py new file mode 100644 index 0000000..86a9301 --- /dev/null +++ b/internal/helpers.py @@ -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 diff --git a/internal/textlayouter.py b/internal/textlayouter.py new file mode 100644 index 0000000..1df5531 --- /dev/null +++ b/internal/textlayouter.py @@ -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) diff --git a/layout.py b/layout.py new file mode 100644 index 0000000..bb42f85 --- /dev/null +++ b/layout.py @@ -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() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..4d6f6b9 --- /dev/null +++ b/readme.md @@ -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 \ No newline at end of file diff --git a/text.py b/text.py new file mode 100644 index 0000000..0ad03f5 --- /dev/null +++ b/text.py @@ -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