diff --git a/CHANGELOG.md b/CHANGELOG.md index b3575a66d..1fd0bbf1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix ffmpeg reading crash when invalid metadata (see pr #2311) - Fix GPU h264_nvenc encoding not working. - Improve perfs of decorator by pre-computing arguments +- Fix textclip being cut or of impredictable height (see issues #2325, #2260 and #2268) ## [v2.1.2](https://github.com/zulko/moviepy/tree/master) diff --git a/moviepy/video/VideoClip.py b/moviepy/video/VideoClip.py index 2b4694920..e64d8a37f 100644 --- a/moviepy/video/VideoClip.py +++ b/moviepy/video/VideoClip.py @@ -1496,10 +1496,15 @@ class TextClip(ImageClip): Width of the stroke, in pixels. Must be an int. method - Either 'label' (default, the picture will be autosized so as to fit - exactly the size) or 'caption' (the text will be drawn in a picture - with fixed size provided with the ``size`` argument). If `caption`, - the text will be wrapped automagically. + Either : + - 'label' (default), the picture will be autosized so as to fit the text + either by auto-computing font size if width is provided or auto-computing + width and eight if font size is defined + + - 'caption' the text will be drawn in a picture with fixed size provided + with the ``size`` argument. The text will be wrapped automagically, + either by auto-computing font size if width and height are provided or adding + line break when necesarry if font size is defined text_align center | left | right. Text align similar to css. Default to ``left``. @@ -1522,10 +1527,23 @@ class TextClip(ImageClip): duration Duration of the clip - bg_radius - A paramater to round the edges of the text background. Defaults to 0 if there - is no background. It will have no effect if there is no bg_colour added. - The higher the value, the more rounded the corners will become. + .. note:: + + ** About final TextClip size ** + + The final TextClip size will be of the absolute maximum height possible + for the font and the number of line. It specifically mean that the final + height might be a bit bigger than the real text height, i.e, absolute + bottom pixel of text - absolute top pixel of text. + This is because in a font, some letter go above standard top line (e.g + letters with accents), and bellow standard baseline (e.g letters such as + p, y, g). + + This notion is knowned under the name ascent and descent meaning the + highest and lowest pixel above and below baseline + + If your first line dont have an "accent character" and your last line + dont have a "descent character", you'll have some "fat" arround """ @convert_path_to_string("filename") @@ -1548,141 +1566,7 @@ def __init__( interline=4, transparent=True, duration=None, - bg_radius=0, # TODO : Move this with other bg_param on next breaking release ): - def break_text( - width, text, font, font_size, stroke_width, align, spacing - ) -> List[str]: - """Break text to never overflow a width""" - img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) - - lines = [] - current_line = "" - words = text.split(" ") - for word in words: - temp_line = current_line + " " + word if current_line else word - temp_left, temp_top, temp_right, temp_bottom = draw.multiline_textbbox( - (0, 0), - temp_line, - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - ) - temp_width = temp_right - temp_left - - if temp_width <= width: - current_line = temp_line - else: - lines.append(current_line) - current_line = word - - if current_line: - lines.append(current_line) - - return lines - - def find_text_size( - text, - font, - font_size, - stroke_width, - align, - spacing, - max_width=None, - allow_break=False, - ) -> tuple[int, int]: - """Find dimensions a text will occupy, return a tuple (width, height)""" - img = Image.new("RGB", (1, 1)) - font_pil = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) - - if max_width is None or not allow_break: - left, top, right, bottom = draw.multiline_textbbox( - (0, 0), - text, - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - anchor="lm", - ) - - return (int(right - left), int(bottom - top)) - - lines = break_text( - width=max_width, - text=text, - font=font, - font_size=font_size, - stroke_width=stroke_width, - align=align, - spacing=spacing, - ) - - left, top, right, bottom = draw.multiline_textbbox( - (0, 0), - "\n".join(lines), - font=font_pil, - spacing=spacing, - align=align, - stroke_width=stroke_width, - anchor="lm", - ) - - return (int(right - left), int(bottom - top)) - - def find_optimum_font_size( - text, - font, - stroke_width, - align, - spacing, - width, - height=None, - allow_break=False, - ): - """Find the best font size to fit as optimally as possible""" - max_font_size = width - min_font_size = 1 - - # Try find best size using bisection - while min_font_size < max_font_size: - avg_font_size = int((max_font_size + min_font_size) // 2) - text_width, text_height = find_text_size( - text, - font, - avg_font_size, - stroke_width, - align, - spacing, - max_width=width, - allow_break=allow_break, - ) - - if text_width <= width and (height is None or text_height <= height): - min_font_size = avg_font_size + 1 - else: - max_font_size = avg_font_size - 1 - - # Check if the last font size tested fits within the given width and height - text_width, text_height = find_text_size( - text, - font, - min_font_size, - stroke_width, - align, - spacing, - max_width=width, - allow_break=allow_break, - ) - if text_width <= width and (height is None or text_height <= height): - return min_font_size - else: - return min_font_size - 1 - try: _ = ImageFont.truetype(font) except Exception as e: @@ -1697,6 +1581,21 @@ def find_optimum_font_size( if text is None: raise ValueError("No text nor filename provided") + if method not in ["caption", "label"]: + raise ValueError("Method must be either `caption` or `label`.") + + # Compute the margin and apply it + if len(margin) == 2: + left_margin = right_margin = int(margin[0] or 0) + top_margin = bottom_margin = int(margin[1] or 0) + elif len(margin) == 4: + left_margin = int(margin[0] or 0) + top_margin = int(margin[1] or 0) + right_margin = int(margin[2] or 0) + bottom_margin = int(margin[3] or 0) + else: + raise ValueError("Margin must be a tuple of either 2 or 4 elements.") + # Compute all img and text sizes if some are missing img_width, img_height = size @@ -1710,7 +1609,7 @@ def find_optimum_font_size( ) if font_size is None: - font_size = find_optimum_font_size( + font_size = self.__find_optimum_font_size( text=text, font=font, stroke_width=stroke_width, @@ -1722,7 +1621,7 @@ def find_optimum_font_size( ) if img_height is None: - img_height = find_text_size( + img_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1735,7 +1634,7 @@ def find_optimum_font_size( # Add line breaks whenever needed text = "\n".join( - break_text( + self.__break_text( width=img_width, text=text, font=font, @@ -1753,7 +1652,7 @@ def find_optimum_font_size( ) if font_size is None: - font_size = find_optimum_font_size( + font_size = self.__find_optimum_font_size( text=text, font=font, stroke_width=stroke_width, @@ -1764,7 +1663,7 @@ def find_optimum_font_size( ) if img_width is None: - img_width = find_text_size( + img_width = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1774,7 +1673,7 @@ def find_optimum_font_size( )[0] if img_height is None: - img_height = find_text_size( + img_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1784,21 +1683,6 @@ def find_optimum_font_size( max_width=img_width, )[1] - else: - raise ValueError("Method must be either `caption` or `label`.") - - # Compute the margin and apply it - if len(margin) == 2: - left_margin = right_margin = int(margin[0] or 0) - top_margin = bottom_margin = int(margin[1] or 0) - elif len(margin) == 4: - left_margin = int(margin[0] or 0) - top_margin = int(margin[1] or 0) - right_margin = int(margin[2] or 0) - bottom_margin = int(margin[3] or 0) - else: - raise ValueError("Margin must be a tuple of either 2 or 4 elements.") - img_width += left_margin + right_margin img_height += top_margin + bottom_margin @@ -1808,23 +1692,12 @@ def find_optimum_font_size( if bg_color is None and transparent: bg_color = (0, 0, 0, 0) - if bg_radius is None: - bg_radius = 0 - - if bg_radius != 0: - img = Image.new(img_mode, (img_width, img_height), color=(0, 0, 0, 0)) - pil_font = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) - draw.rounded_rectangle( - [0, 0, img_width, img_height], radius=bg_radius, fill=bg_color - ) - else: - img = Image.new(img_mode, (img_width, img_height), color=bg_color) - pil_font = ImageFont.truetype(font, font_size) - draw = ImageDraw.Draw(img) + img = Image.new(img_mode, (img_width, img_height), color=bg_color) + pil_font = ImageFont.truetype(font, font_size) + draw = ImageDraw.Draw(img) # Dont need allow break here, because we already breaked in caption - text_width, text_height = find_text_size( + text_width, text_height = self.__find_text_size( text=text, font=font, font_size=font_size, @@ -1840,25 +1713,25 @@ def find_optimum_font_size( elif horizontal_align == "center": x = (img_width - left_margin - right_margin - text_width) / 2 - x += left_margin - y = 0 if vertical_align == "bottom": y = img_height - text_height - top_margin - bottom_margin elif vertical_align == "center": y = (img_height - top_margin - bottom_margin - text_height) / 2 - y += top_margin - - # So, pillow multiline support is horrible, in particular multiline_text - # and multiline_textbbox are not intuitive at all. They cannot use left - # top (see https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html) - # as anchor, so we always have to use left middle instead. Else we would + # We use baseline as our anchor because it is predictable and reliable + # That mean we always have to use left baseline instead. Else we would # always have a useless margin (the diff between ascender and top) on any # text. That mean our Y is actually not from 0 for top, but need to be - # increment by half our text height, since we have to reference from - # middle line. - y += text_height / 2 + # increment by ascent, since we have to reference from baseline. + (ascent, _) = pil_font.getmetrics() + y += ascent + + # Add margins and stroke size to start point + y += top_margin + x += left_margin + y += stroke_width + x += stroke_width draw.multiline_text( xy=(x, y), @@ -1869,7 +1742,7 @@ def find_optimum_font_size( align=text_align, stroke_width=stroke_width, stroke_fill=stroke_color, - anchor="lm", + anchor="ls", ) # We just need the image as a numpy array @@ -1882,6 +1755,185 @@ def find_optimum_font_size( self.color = color self.stroke_color = stroke_color + def __break_text( + self, width, text, font, font_size, stroke_width, align, spacing + ) -> List[str]: + """Break text to never overflow a width""" + img = Image.new("RGB", (1, 1)) + font_pil = ImageFont.truetype(font, font_size) + draw = ImageDraw.Draw(img) + + lines = [] + current_line = "" + words = text.split(" ") + for word in words: + temp_line = current_line + " " + word if current_line else word + temp_left, temp_top, temp_right, temp_bottom = draw.multiline_textbbox( + (0, 0), + temp_line, + font=font_pil, + spacing=spacing, + align=align, + stroke_width=stroke_width, + ) + temp_width = temp_right - temp_left + + if temp_width <= width: + current_line = temp_line + else: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines + + def __find_text_size( + self, + text, + font, + font_size, + stroke_width, + align, + spacing, + max_width=None, + allow_break=False, + ) -> tuple[int, int]: + """Find *real* dimensions a text will occupy, return a tuple (width, height) + + .. note:: + Text height calculation is quite complex due to how `Pillow` works. + When calculating line height, `Pillow` actually uses the letter ``A`` + as a reference height, adding the spacing and the stroke width. + However, ``A`` is a simple letter and does not account for ascent and + descent, such as in ``Ô``. + + This means each line will be considered as having a "standard" + height instead of the real maximum font size (``ascent + descent``). + + When drawing each line, `Pillow` will offset the new line by + ``standard height * number of previous lines``. + This mostly works, but if the spacing is not big enough, + lines will overlap if a letter with an ascent (e.g., ``d``) is above + a letter with a descent (e.g., ``p``). + + For our case, we use the baseline as the text anchor. This means that, + no matter what, we need to draw the absolute top of our first line at + ``0 + ascent + stroke_width`` to ensure the first pixel of any possible + letter is aligned with the top border of the image (ignoring any + additional margins, if needed). + + Therefore, our first line height will not start at ``0`` but at + ``ascent + stroke_width``, and we need to account for that. Each + subsequent line will then be drawn at + ``index * standard height`` from this point. The position of the last + line can be calculated as: + ``(total_lines - 1) * standard height``. + + Finally, as we use the baseline as the text anchor, we also need to + consider that the real size of the last line is not "standard" but + rather ``standard + descent + stroke_width``. + + To summarize, the real height of the text is: + ``initial padding + (lines - 1) * height + end padding`` + or: + ``(ascent + stroke_width) + (lines - 1) * height + (descent + stroke_width)`` + or: + ``real_font_size + (stroke_width * 2) + (lines - 1) * height`` + """ + img = Image.new("RGB", (1, 1)) + font_pil = ImageFont.truetype(font, font_size) + ascent, descent = font_pil.getmetrics() + real_font_size = ascent + descent + draw = ImageDraw.Draw(img) + + # Compute individual line height with spaces using pillow internal method + line_height = draw._multiline_spacing(font_pil, spacing, stroke_width) + + if max_width is not None and allow_break: + lines = self.__break_text( + width=max_width, + text=text, + font=font, + font_size=font_size, + stroke_width=stroke_width, + align=align, + spacing=spacing, + ) + + text = "\n".join(lines) + + # Use multiline textbbox to get width + left, top, right, bottom = draw.multiline_textbbox( + (0, 0), + text, + font=font_pil, + spacing=spacing, + align=align, + stroke_width=stroke_width, + anchor="ls", + ) + + # For height calculate manually as textbbox is not realiable + line_breaks = text.count("\n") + lines_height = line_breaks * line_height + paddings = real_font_size + stroke_width * 2 + + return (int(right - left), int(lines_height + paddings)) + + def __find_optimum_font_size( + self, + text, + font, + stroke_width, + align, + spacing, + width, + height=None, + allow_break=False, + ): + """Find the best font size to fit as optimally as possible + in a box of some width and optionally height + """ + max_font_size = width + min_font_size = 1 + + # Try find best size using bisection + while min_font_size < max_font_size: + avg_font_size = int((max_font_size + min_font_size) // 2) + text_width, text_height = self.__find_text_size( + text, + font, + avg_font_size, + stroke_width, + align, + spacing, + max_width=width, + allow_break=allow_break, + ) + + if text_width <= width and (height is None or text_height <= height): + min_font_size = avg_font_size + 1 + else: + max_font_size = avg_font_size - 1 + + # Check if the last font size tested fits within the given width and height + text_width, text_height = self.__find_text_size( + text, + font, + min_font_size, + stroke_width, + align, + spacing, + max_width=width, + allow_break=allow_break, + ) + if text_width <= width and (height is None or text_height <= height): + return min_font_size + else: + return min_font_size - 1 + class BitmapClip(VideoClip): """Clip made of color bitmaps. Mainly designed for testing purposes.""" diff --git a/tests/test_TextClip.py b/tests/test_TextClip.py index 609f124c3..1d75c1b81 100644 --- a/tests/test_TextClip.py +++ b/tests/test_TextClip.py @@ -73,5 +73,65 @@ def test_no_text_nor_filename_arguments(method, util): ) +def test_label_autosizing(util): + # We test with about all possible letters + text = "abcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + text += "\nabcdefghijklmnopqrstuvwxyzáàâäãåāæąēéèêëīíìîïñōóòôöõøœęý\ + ABCDEFGHIJKLMNOPQRSTUVWXYZÁÀÂÄÃÅĀÆĄĒÉÈÊËĪÍÌÎÏÑŌÓÒÔÖÕØŒĘÝ" + + text_clip_margin = TextClip( + util.FONT, + method="label", + font_size=40, + text=text, + color="red", + bg_color="black", + stroke_width=3, + stroke_color="white", + margin=(1, 1), + ).with_duration(1) + text_clip_no_margin = TextClip( + util.FONT, + method="label", + font_size=40, + text=text, + color="red", + bg_color="black", + stroke_width=3, + stroke_color="white", + ).with_duration(1) + + margin_frame = text_clip_margin.get_frame(1) + no_margin_frame = text_clip_no_margin.get_frame(1) + + # The idea is, if autosizing work as expected, frame with 1px margin will + # have black color all around, where frame without margin will have white somewhere + first_row, last_row = (margin_frame[0], margin_frame[-1]) + first_column, last_column = (margin_frame[:, 0], margin_frame[:, -1]) + + # We add a bit of tolerance (about 1%) to account for possible rounding errors + assert np.allclose(first_row, [0, 0, 0], rtol=0.01) + assert np.allclose(last_row, [0, 0, 0], rtol=0.01) + assert np.allclose(first_column, [0, 0, 0], rtol=0.01) + assert np.allclose(last_column, [0, 0, 0], rtol=0.01) + + # We actually check on three pixels border, because some fonts + # always add a 1px padding all arround and some rounding error can make it two + first_three_rows, last_three_rows = (no_margin_frame[:3], no_margin_frame[-3:]) + first_three_columns, last_three_columns = ( + no_margin_frame[:, :3], + no_margin_frame[:, -3:], + ) + + # We add a bit of tolerance (about 1%) to account for possible rounding errors + assert not np.allclose(first_three_rows, [0, 0, 0], rtol=0.01) + assert not np.allclose(last_three_rows, [0, 0, 0], rtol=0.01) + assert not np.allclose(first_three_columns, [0, 0, 0], rtol=0.01) + assert not np.allclose(last_three_columns, [0, 0, 0], rtol=0.01) + + if __name__ == "__main__": pytest.main()