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)