1
0
Fork 0
mirror of https://git.lynn.is/Gwen/python-layout.git synced 2024-01-13 01:31:55 +01:00
python-layout/pillow_layout/internal/textlayouter.py
2023-02-07 23:56:17 +01:00

200 lines
8.6 KiB
Python

import unicodedata
from .. 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)