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:
commit
cd4c111c78
11
__init__.py
Normal file
11
__init__.py
Normal 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
100
box.py
Normal 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
149
container.py
Normal 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
80
document.py
Normal 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
98
enums.py
Normal 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
64
flex.py
Normal 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
267
image.py
Normal 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
494
internal/flexlayouter.py
Normal 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
45
internal/helpers.py
Normal 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
199
internal/textlayouter.py
Normal 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
400
layout.py
Normal 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
505
readme.md
Normal 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
106
text.py
Normal 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
|
Loading…
Reference in a new issue