Repository: Kozea/WeasyPrint
Pull request #384 (2016-11-03 23:39:44)

Better performance
The main point of this pull request is to make `StyleDict` inherit from `dict` instead of the copy-on-write-like strategy used before.

This change really improves the speed (yeah, profiling helps), but unfortunately has a high memory footprint. That's why memory usage has been also improved, including an impressive don't-store-the-dummy-cairo-context-in-the-layout-forever in 152193c (already incluled in the `font-face` branch).

The commits are small and hopefully easy to understand and to revert if needed. I've got exactly the same PDFs before and after these commits with many examples, but I'm sure that bugs have been introduced.

Here are the results (with reliable science inside):

## Offline HTML5 Specification

https://www.w3.org/TR/html5/

Long document with a lot of lists and underlined links.

0.31:
- 6.0s
- 146MB memory peak

font-face:
- 6.3s (+5%)
- 130MB memory peak (-11%)

font-face + perf:
- 4.5s (-25%)
- 159MB memory peak (+9%)

## Offline Alice Adventures in Wonderland

https://www.gutenberg.org/files/11/11-h/11-h.htm

Long document with repetitive justified text.

0.31:
- 12.5s
- 162MB memory peak

font-face:
- 9.8s (-21%)
- 134MB memory peak (-17%)

font-face + perf:
- 7.1s (-43%, **too good to be true**)
- 161MB memory peak (=)

## Online WeasyPrint

http://weasyprint.org/

Simple website, uses online fonts (that's why it's slower now).

0.31:
- 1.6s
- 66MB memory peak

font-face:
- 2.2s (+38%)
- 68MB memory peak (+3%)

font-face + perf:
- 2.0s (+25%)
- 70MB memory peak (+6%)

## Online Wikipedia

https://en.wikipedia.org/w/index.php?title=HTML5&printable=yes

Printable version of a Wikipedia page, long left-aligned paragraphs with floats.

0.31:
- 7.9s
- 134MB memory peak

font-face:
- 8.2s (+4%)
- 124MB memory peak (-7%)

font-face + perf:
- 6.6s (-16%)
- 141MB memory peak (+5%)


Patch:
diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py
index 047a5c22..97750def 100644
--- a/weasyprint/css/__init__.py
+++ b/weasyprint/css/__init__.py
@@ -72,81 +72,39 @@ RE_INITIAL_NOT_COMPUTED = re.compile(
     '(border_[a-z]+|outline|column_rule)_(width|color))$').match
 
 
-class StyleDict(object):
+class StyleDict(dict):
     """A mapping (dict-like) that allows attribute access to values.
 
     Allow eg. ``style.font_size`` instead of ``style['font-size']``.
 
-    :param parent: if given, should be a mapping. Values missing from this
-                   dict will be looked up in the parent dict. Setting a value
-                   in this dict masks any value in the parent.
-
     """
-    def __init__(self, data=None, parent=None):
-        if data is None:
-            data = {}
-        else:
-            data = dict(data)
-        if parent is None:
-            parent = {}
-        # work around our own __setattr__
-        object.__setattr__(self, '_storage', data)
-        object.__setattr__(self, '_parent', parent)
-
-    def __getitem__(self, key):
-        storage = self._storage
-        if key in storage:
-            return storage[key]
-        else:
-            return self._parent[key]
 
-    def __setitem__(self, key, value):
-        self._storage[key] = value
+    # TODO: We should remove that. Some attributes (eg. "clear") exist as
+    # dict methods and can only be accessed with getitem.
+    __getattr__ = dict.__getitem__
 
     def get_color(self, key):
         value = self[key]
-        return value if value != 'currentColor' else self.color
-
-    def updated_copy(self, other):
-        copy = self.copy()
-        copy._storage.update(other)
-        return copy
-
-    def __contains__(self, key):
-        return key in self._parent or key in self._storage
-
-    __getattr__ = __getitem__  # May raise KeyError instead of AttributeError
-    __setattr__ = __setitem__
+        return value if value != 'currentColor' else self['color']
 
     def copy(self):
-        """Copy the ``StyleDict``.
-
-        Create a new StyleDict with this one as the parent. This is a cheap
-        "copy-on-write". Modifications in the copy will not affect
-        the original, but modifications in the original *may* affect the
-        copy.
-
-        """
-        if self._storage:
-            parent = self
-        else:
-            parent = self._parent
-        style = type(self)(parent=parent)
-        if self.anonymous:
-            object.__setattr__(style, 'anonymous', True)
+        """Copy the ``StyleDict``."""
+        style = type(self)(self)
+        style.anonymous = self.anonymous
         return style
 
     def inherit_from(self):
         """Return a new StyleDict with inherited properties from this one.
 
         Non-inherited properties get their initial values.
-        This is the styles for an anonymous box.
+        This is the method used for an anonymous box.
+
         """
         style = computed_from_cascaded(
             cascaded={}, parent_style=self,
             # Only used by non-inherited properties. eg `content: attr(href)`
             element=None)
-        object.__setattr__(style, 'anonymous', True)
+        style.anonymous = True
         return style
 
     # Default values, may be overriden on instances
@@ -550,7 +508,7 @@ def computed_from_cascaded(element, cascaded, parent_style, pseudo_type=None,
     if not cascaded and parent_style is not None:
         # Fast path for anonymous boxes:
         # no cascaded style, only implicitly initial or inherited values.
-        computed = StyleDict(parent=properties.INITIAL_VALUES)
+        computed = StyleDict(properties.INITIAL_VALUES)
         for name in properties.INHERITED:
             computed[name] = parent_style[name]
         # border-*-style is none, so border-width computes to zero.
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index 37d355f9..95bbd416 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -564,20 +564,36 @@ def strut_layout(style, context=None):
     The baseline is given from the top edge of line height.
 
     """
-    # TODO: cache these results for a given set of styles?
-    line_height = style.line_height
+    # TODO: always get the real value for `context`? (if we really care…)
+
     if style.font_size == 0:
-        pango_height = baseline = 0
-    else:
-        # TODO: get the real value for `context`? (if we really care…)
-        _, _, _, _, pango_height, baseline = text.split_first_line(
-            '', style, context=context, max_width=None, line_width=None)
-    if line_height == 'normal':
-        return pango_height, baseline
-    type_, value = line_height
+        return 0, 0
+
+    if context:
+        key = (
+            style['font_size'], style['font_language_override'], style['lang'],
+            tuple(style['font_family']), style['font_style'],
+            style['font_stretch'], style['font_weight'], style['line_height'])
+        if key in context.strut_layouts:
+            return context.strut_layouts[key]
+
+    layout = text.Layout(
+        context=context, font_size=style.font_size, style=style)
+    metrics = layout.get_font_metrics()
+    baseline = metrics.ascent
+    text_height = baseline + metrics.descent
+    if style['line_height'] == 'normal':
+        result = text_height, baseline
+        if context:
+            context.strut_layouts[key] = result
+        return result
+    type_, line_height = style['line_height']
     if type_ == 'NUMBER':
-        value *= style.font_size
-    return value, baseline + (value - pango_height) / 2
+        line_height *= style['font_size']
+    result = line_height, baseline + (line_height - text_height) / 2
+    if context:
+        context.strut_layouts[key] = result
+    return result
 
 
 def ex_ratio(style):
diff --git a/weasyprint/draw.py b/weasyprint/draw.py
index 538bf96f..d95bf62d 100644
--- a/weasyprint/draw.py
+++ b/weasyprint/draw.py
@@ -903,11 +903,14 @@ def draw_text(context, textbox, enable_hinting):
     show_first_line(context, textbox.pango_layout, enable_hinting)
     values = textbox.style.text_decoration
 
-    metrics = textbox.pango_layout.get_font_metrics()
     thickness = textbox.style.font_size / 18  # That's what other browsers do
     if enable_hinting and thickness < 1:
         thickness = 1
 
+    if ('overline' in values or
+            'line-through' in values or
+            'underline' in values):
+        metrics = textbox.pango_layout.get_font_metrics()
     if 'overline' in values:
         draw_text_decoration(
             context, textbox,
diff --git a/weasyprint/fonts.py b/weasyprint/fonts.py
index 460cf80e..45bb552c 100644
--- a/weasyprint/fonts.py
+++ b/weasyprint/fonts.py
@@ -63,6 +63,7 @@ else:
         } FcMatchKind;
 
         FcConfig * FcInitLoadConfigAndFonts (void);
+        FcPattern * FcConfigDestroy (FcConfig *config);
         FcBool FcConfigAppFontAddFile (
             FcConfig *config, const FcChar8 *file);
         FcConfig * FcConfigGetCurrent (void);
@@ -75,6 +76,7 @@ else:
             FcConfig *config, FcPattern *p, FcMatchKind kind);
 
         FcPattern * FcPatternCreate (void);
+        FcPattern * FcPatternDestroy (FcPattern *p);
         FcBool FcPatternAddString (
             FcPattern *p, const char *object, const FcChar8 *s);
         FcResult FcPatternGetString (
@@ -88,6 +90,7 @@ else:
 
         void pango_fc_font_map_set_config (
             PangoFcFontMap *fcfontmap, FcConfig *fcconfig);
+        void pango_fc_font_map_shutdown (PangoFcFontMap *fcfontmap);
 
 
         // PangoCairo
@@ -145,7 +148,9 @@ else:
                     how-to-use-custom-application-fonts.html
 
             """
-            self._fontconfig_config = fontconfig.FcInitLoadConfigAndFonts()
+            self._fontconfig_config = ffi.gc(
+                fontconfig.FcInitLoadConfigAndFonts(),
+                fontconfig.FcConfigDestroy)
             self.font_map = ffi.gc(
                 pangocairo.pango_cairo_font_map_new_for_font_type(
                     cairo.FONT_TYPE_FT),
@@ -153,6 +158,8 @@ else:
             pangoft2.pango_fc_font_map_set_config(
                 ffi.cast('PangoFcFontMap *', self.font_map),
                 self._fontconfig_config)
+            # pango_fc_font_map_set_config keeps a reference to config
+            fontconfig.FcConfigDestroy(self._fontconfig_config)
             self._filenames = []
 
         def add_font_face(self, rule_descriptors, url_fetcher):
@@ -161,7 +168,9 @@ else:
                     config = self._fontconfig_config
                     if font_type == 'local':
                         font_name = url.encode('utf-8')
-                        pattern = fontconfig.FcPatternCreate()
+                        pattern = ffi.gc(
+                            fontconfig.FcPatternCreate(),
+                            fontconfig.FcPatternDestroy)
                         fontconfig.FcConfigSubstitute(
                             config, pattern, fontconfig.FcMatchFont)
                         fontconfig.FcDefaultSubstitute(pattern)
diff --git a/weasyprint/formatting_structure/boxes.py b/weasyprint/formatting_structure/boxes.py
index eb9a1f71..38e49838 100644
--- a/weasyprint/formatting_structure/boxes.py
+++ b/weasyprint/formatting_structure/boxes.py
@@ -88,9 +88,7 @@ class Box(object):
     def __init__(self, element_tag, sourceline, style):
         self.element_tag = element_tag
         self.sourceline = sourceline  # for debugging only
-        # Copying might not be needed, but let’s be careful with mutable
-        # objects.
-        self.style = style.copy()
+        self.style = style
 
     def __repr__(self):
         return '<%s %s %s>' % (
@@ -103,7 +101,7 @@ class Box(object):
                    parent.style.inherit_from(),
                    *args, **kwargs)
 
-    def copy(self):
+    def copy(self, copy_style=True):
         """Return shallow copy of the box."""
         cls = type(self)
         # Create a new instance without calling __init__: initializing
@@ -111,7 +109,10 @@ class Box(object):
         new_box = cls.__new__(cls)
         # Copy attributes
         new_box.__dict__.update(self.__dict__)
-        new_box.style = self.style.copy()
+        if copy_style:
+            new_box.style = self.style.copy()
+        else:
+            new_box.style = self.style
         return new_box
 
     def translate(self, dx=0, dy=0):
@@ -458,7 +459,7 @@ class TextBox(InlineLevelBox):
     def copy_with_text(self, text):
         """Return a new TextBox identical to this one except for the text."""
         assert text
-        new_box = self.copy()
+        new_box = self.copy(copy_style=False)
         new_box.text = text
         return new_box
 
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index 1bab736b..7bdd460d 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -173,7 +173,7 @@ def element_to_box(element, style_for, get_image_from_uri, state=None):
         if not counter_values[name]:
             counter_values.pop(name)
 
-    box = box.copy_with_children(children)
+    box.children = children
     replace_content_lists(element, box, style, counter_values)
 
     # Specific handling for the element. (eg. replaced element)
@@ -208,7 +208,8 @@ def pseudo_to_box(element, pseudo_type, state, style_for, get_image_from_uri):
     children.extend(content_to_boxes(
         style, box, quote_depth, counter_values, get_image_from_uri))
 
-    yield box.copy_with_children(children)
+    box.children = children
+    yield box
 
 
 def content_to_boxes(style, parent_box, quote_depth, counter_values,
@@ -294,12 +295,12 @@ def replace_content_lists(element, box, style, counter_values):
         for i, (string_name, string_values) in enumerate(style.string_set):
             string_set.append((string_name, compute_content_list_string(
                 element, box, counter_values, string_values)))
-    style.string_set = string_set
+    box.style['string_set'] = string_set
 
     if style.bookmark_label == 'none':
-        style.bookmark_label = ''
+        box.style['bookmark_label'] = ''
     else:
-        style.bookmark_label = compute_content_list_string(
+        box.style['bookmark_label'] = compute_content_list_string(
             element, box, counter_values, style.bookmark_label)
 
 
@@ -516,7 +517,8 @@ def table_boxes_children(box, children):
     if isinstance(box, boxes.TableBox):
         return wrap_table(box, children)
     else:
-        return box.copy_with_children(children)
+        box.children = list(children)
+        return box
 
 
 def wrap_table(box, children):
@@ -904,7 +906,8 @@ def inline_in_block(box):
                 if not (isinstance(child, boxes.TextBox) and not child.text)]
 
     if not isinstance(box, boxes.BlockContainerBox):
-        return box.copy_with_children(children)
+        box.children = children
+        return box
 
     new_line_children = []
     new_children = []
@@ -943,7 +946,8 @@ def inline_in_block(box):
             # Only inline-level children: one line box
             new_children.append(line_box)
 
-    return box.copy_with_children(new_children)
+    box.children = new_children
+    return box
 
 
 def block_in_inline(box):
@@ -1044,9 +1048,8 @@ def block_in_inline(box):
         new_children.append(new_child)
 
     if changed:
-        return box.copy_with_children(new_children)
-    else:
-        return box
+        box.children = new_children
+    return box
 
 
 def _inner_block_in_inline(box, skip_stack=None):
@@ -1121,7 +1124,8 @@ def set_viewport_overflow(root_box):
                 break
 
     root_box.viewport_overflow = chosen_box.style.overflow
-    chosen_box.style = chosen_box.style.updated_copy({'overflow': 'visible'})
+    chosen_box.style = chosen_box.style.copy()
+    chosen_box.style.update({'overflow': 'visible'})
     return root_box
 
 
diff --git a/weasyprint/html.py b/weasyprint/html.py
index 818ccf12..897f3de8 100644
--- a/weasyprint/html.py
+++ b/weasyprint/html.py
@@ -138,8 +138,8 @@ def handle_img(element, box, get_image_from_uri):
         else:
             # Invalid image, use the alt-text.
             if alt:
-                return [box.copy_with_children(
-                    [boxes.TextBox.anonymous_from(box, alt)])]
+                box.children = [boxes.TextBox.anonymous_from(box, alt)]
+                return [box]
             elif alt == '':
                 # The element represents nothing
                 return []
@@ -150,8 +150,8 @@ def handle_img(element, box, get_image_from_uri):
                 return []
     else:
         if alt:
-            return [box.copy_with_children(
-                [boxes.TextBox.anonymous_from(box, alt)])]
+            box.children = [boxes.TextBox.anonymous_from(box, alt)]
+            return [box]
         else:
             return []
 
diff --git a/weasyprint/layout/__init__.py b/weasyprint/layout/__init__.py
index 11d156db..6979f76f 100644
--- a/weasyprint/layout/__init__.py
+++ b/weasyprint/layout/__init__.py
@@ -59,7 +59,7 @@ def layout_document(enable_hinting, style_for, get_image_from_uri, root_box,
         root_children.extend(layout_fixed_boxes(context, pages[:i]))
         root_children.extend(root.children)
         root_children.extend(layout_fixed_boxes(context, pages[i+1:]))
-        root = root.copy_with_children(root_children)
+        root.children = root_children
         context.current_page = page_counter[0]
         page.children = (root,) + tuple(
             make_margin_boxes(context, page, counter_values))
@@ -79,6 +79,7 @@ class LayoutContext(object):
         self.excluded_shapes = None  # Not initialized yet
         self.string_set = defaultdict(lambda: defaultdict(lambda: list()))
         self.current_page = None
+        self.strut_layouts = {}
 
     def create_block_formatting_context(self):
         self.excluded_shapes = []
diff --git a/weasyprint/layout/blocks.py b/weasyprint/layout/blocks.py
index 27808651..233d1828 100644
--- a/weasyprint/layout/blocks.py
+++ b/weasyprint/layout/blocks.py
@@ -389,7 +389,7 @@ def block_container_layout(context, box, max_position_y, skip_stack,
     is_start = skip_stack is None
     if not is_start:
         # Remove top margin, border and padding:
-        box = box.copy_with_children(box.children, is_start=False)
+        box._remove_decoration(start=True, end=False)
 
     if adjoining_margins is None:
         adjoining_margins = []
diff --git a/weasyprint/layout/float.py b/weasyprint/layout/float.py
index 9a5e0e65..ed6951ae 100644
--- a/weasyprint/layout/float.py
+++ b/weasyprint/layout/float.py
@@ -118,7 +118,7 @@ def get_clearance(context, box, collapsed_margin=0):
     hypothetical_position = box.position_y + collapsed_margin
     # Hypothetical position is the position of the top border edge
     for excluded_shape in context.excluded_shapes:
-        if box.style.clear in (excluded_shape.style.float, 'both'):
+        if box.style['clear'] in (excluded_shape.style.float, 'both'):
             y, h = excluded_shape.position_y, excluded_shape.margin_height()
             if hypothetical_position < y + h:
                 clearance = max(
diff --git a/weasyprint/layout/inlines.py b/weasyprint/layout/inlines.py
index 2096adc1..41852951 100644
--- a/weasyprint/layout/inlines.py
+++ b/weasyprint/layout/inlines.py
@@ -155,8 +155,7 @@ def get_next_linebox(context, linebox, position_y, skip_stack,
             absolute_boxes, fixed_boxes)
         float_children.append(waiting_float)
     if float_children:
-        line = line.copy_with_children(
-            line.children + tuple(float_children))
+        line.children += tuple(float_children)
 
     return line, resume_at
 
@@ -920,18 +919,18 @@ def count_spaces(box):
 def add_word_spacing(context, box, extra_word_spacing, x_advance):
     if isinstance(box, boxes.TextBox):
         box.position_x += x_advance
-        box.style.word_spacing += extra_word_spacing
+        style = box.style.copy()
+        style.word_spacing += extra_word_spacing
         nb_spaces = count_spaces(box)
         if nb_spaces > 0:
-            new_box, resume_at, _ = split_text_box(
-                context, box, 1e10, None, 0)
-            assert new_box is not None
+            layout, _, resume_at, width, _, _ = split_first_line(
+                box.text, style, context, float('inf'), None)
             assert resume_at is None
             # XXX new_box.width - box.width is always 0???
             # x_advance +=  new_box.width - box.width
             x_advance += extra_word_spacing * nb_spaces
-            box.width = new_box.width
-            box.pango_layout = new_box.pango_layout
+            box.width = width
+            box.pango_layout = layout
     elif isinstance(box, (boxes.LineBox, boxes.InlineBox)):
         box.position_x += x_advance
         previous_x_advance = x_advance
diff --git a/weasyprint/layout/pages.py b/weasyprint/layout/pages.py
index cc3b9c7f..964481c5 100644
--- a/weasyprint/layout/pages.py
+++ b/weasyprint/layout/pages.py
@@ -303,10 +303,9 @@ def make_margin_boxes(context, page, counter_values):
         # TODO: get actual counter values at the time of the last page break
         if box.is_generated:
             quote_depth = [0]
-            children = build.content_to_boxes(
+            box.children = build.content_to_boxes(
                 box.style, box, quote_depth, counter_values,
                 context.get_image_from_uri, context)
-            box = box.copy_with_children(children)
             # content_to_boxes() only produces inline-level boxes, no need to
             # run other post-processors from build.build_formatting_structure()
             box = build.inline_in_block(box)
@@ -479,7 +478,7 @@ def make_page(context, root_box, page_type, resume_at, content_empty,
     """
     style = context.style_for(page_type)
     # Propagated from the root or <body>.
-    style.overflow = root_box.viewport_overflow
+    style['overflow'] = root_box.viewport_overflow
     page = boxes.PageBox(page_type, style)
 
     device_size = page.style.size
@@ -521,7 +520,7 @@ def make_page(context, root_box, page_type, resume_at, content_empty,
         absolute_layout(context, absolute_box, page, positioned_boxes)
     context.finish_block_formatting_context(root_box)
 
-    page = page.copy_with_children([root_box])
+    page.children = [root_box]
     descendants = page.descendants()
     for child in descendants:
         string_sets = child.style.string_set
diff --git a/weasyprint/layout/tables.py b/weasyprint/layout/tables.py
index a5970df1..f3429a4d 100644
--- a/weasyprint/layout/tables.py
+++ b/weasyprint/layout/tables.py
@@ -58,7 +58,7 @@ def table_layout(context, table, max_position_y, skip_stack,
             skipped_rows = 0
         _, horizontal_borders = table.collapsed_border_grid
         if horizontal_borders:
-            table.style.border_top_width = table.border_top_width = max(
+            table.style['border_top_width'] = table.border_top_width = max(
                 width for _, (_, width, _)
                 in horizontal_borders[skipped_rows]) / 2
 
@@ -738,7 +738,7 @@ def table_wrapper_width(context, wrapper, containing_block):
         auto_table_layout(context, wrapper, containing_block)
 
     wrapper.width = table.border_width()
-    wrapper.style.width = Dimension(wrapper.width, 'px')
+    wrapper.style['width'] = Dimension(wrapper.width, 'px')
 
 
 def cell_baseline(cell):
diff --git a/weasyprint/stacking.py b/weasyprint/stacking.py
index 9ee43080..3f094c8b 100644
--- a/weasyprint/stacking.py
+++ b/weasyprint/stacking.py
@@ -138,7 +138,8 @@ class StackingContext(object):
                 result = dispatch(child)
                 if result is not None:
                     new_children.append(result)
-            return box.copy_with_children(new_children)
+            box.children = new_children
+            return box
 
         box = dispatch_children(box)
 
diff --git a/weasyprint/text.py b/weasyprint/text.py
index e175bfe5..361bc676 100644
--- a/weasyprint/text.py
+++ b/weasyprint/text.py
@@ -612,8 +612,7 @@ class Layout(object):
         hinting = context.enable_hinting if context else False
         cairo_dummy_context = (
             cairo.Context(cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1))
-            if hinting else
-            cairo.Context(cairo.PDFSurface(None, 1, 1)))
+            if hinting else cairo.Context(cairo.PDFSurface(None, 1, 1)))
         self.layout = ffi.gc(
             pangocairo.pango_cairo_create_layout(ffi.cast(
                 'cairo_t *', cairo_dummy_context._pointer)),
@@ -888,7 +887,6 @@ def create_layout(text, style, context, max_width):
             attr_list = pango.pango_attr_list_new()
             pango.pango_attr_list_insert(attr_list, attr)
             pango.pango_layout_set_attributes(layout.layout, attr_list)
-            pango.pango_attr_list_unref(attr_list)
 
     # Tabs width
     if style.tab_size != 8:  # Default Pango value is 8
@@ -928,25 +926,10 @@ def split_first_line(text, style, context, max_width, line_width):
         max_width *= 1 + 1e-9
 
     # Step #1: Get a draft layout with the first line
-    layout = None
-    if max_width:
-        expected_length = int(max_width / style.font_size * 2.5)
-        if expected_length < len(text):
-            # Try to use a small amount of text instead of the whole text
-            layout = create_layout(
-                text[:expected_length], style, context, max_width)
-            lines = layout.iter_lines()
-            first_line = next(lines, None)
-            second_line = next(lines, None)
-            if second_line is None:
-                # The small amount of text fits in one line, give up and use
-                # the whole text
-                layout = None
-    if layout is None:
-        layout = create_layout(text, style, context, max_width)
-        lines = layout.iter_lines()
-        first_line = next(lines, None)
-        second_line = next(lines, None)
+    layout = create_layout(text, style, context, max_width)
+    lines = layout.iter_lines()
+    first_line = next(lines, None)
+    second_line = next(lines, None)
     resume_at = None if second_line is None else second_line.start_index
 
     # Step #2: Don't hyphenize when it's not needed
@@ -1021,8 +1004,7 @@ def split_first_line(text, style, context, max_width, line_width):
                         first_line_text, next_word = (
                             first_line_text.rsplit(u' ', 1))
                         next_word = u' ' + next_word
-                        layout = create_layout(
-                            first_line_text, style, context, max_width)
+                        layout.set_text(first_line_text)
                         lines = layout.iter_lines()
                         first_line = next(lines, None)
                         second_line = next(lines, None)
@@ -1077,8 +1059,9 @@ def split_first_line(text, style, context, max_width, line_width):
                     # Recreate the layout with no max_width to be sure that
                     # we don't break inside the hyphenate-character string
                     hyphenated = True
-                    layout = create_layout(
-                        hyphenated_first_line_text, style, context, None)
+                    layout.set_text(hyphenated_first_line_text)
+                    pango.pango_layout_set_width(
+                        layout.layout, units_from_double(-1))
                     lines = layout.iter_lines()
                     first_line = next(lines, None)
                     second_line = next(lines, None)
@@ -1092,8 +1075,9 @@ def split_first_line(text, style, context, max_width, line_width):
         hyphenated = True
         hyphenated_first_line_text = (
             first_line_text + style.hyphenate_character)
-        layout = create_layout(
-            hyphenated_first_line_text, style, context, None)
+        layout.set_text(hyphenated_first_line_text)
+        pango.pango_layout_set_width(
+            layout.layout, units_from_double(-1))
         lines = layout.iter_lines()
         first_line = next(lines, None)
         second_line = next(lines, None)
@@ -1113,9 +1097,11 @@ def split_first_line(text, style, context, max_width, line_width):
         # memory of the last) prevents shaping characters (arabic, for
         # instance) from keeping their shape when wrapped on the next line with
         # pango layout.  Maybe insert Unicode shaping characters in text ?
-        temp_layout = create_layout(text, style, context, max_width)
-        temp_layout.set_wrap(PANGO_WRAP_MODE['WRAP_WORD_CHAR'])
-        temp_lines = temp_layout.iter_lines()
+        layout.set_text(text)
+        pango.pango_layout_set_width(
+            layout.layout, units_from_double(max_width))
+        layout.set_wrap(PANGO_WRAP_MODE['WRAP_WORD_CHAR'])
+        temp_lines = layout.iter_lines()
         next(temp_lines, None)
         temp_second_line = next(temp_lines, None)
         temp_second_line_index = (
@@ -1123,7 +1109,7 @@ def split_first_line(text, style, context, max_width, line_width):
             else temp_second_line.start_index)
         resume_at = temp_second_line_index
         first_line_text = utf8_slice(text, slice(temp_second_line_index))
-        layout = create_layout(first_line_text, style, context, max_width)
+        layout.set_text(first_line_text)
         lines = layout.iter_lines()
         first_line = next(lines, None)
 


Test patch:
diff --git a/weasyprint/tests/test_boxes.py b/weasyprint/tests/test_boxes.py
index 472e907d..a49ed319 100644
--- a/weasyprint/tests/test_boxes.py
+++ b/weasyprint/tests/test_boxes.py
@@ -50,7 +50,8 @@ def serialize(box_list):
          # All concrete boxes are either text, replaced, column or parent.
          (box.text if isinstance(box, boxes.TextBox)
           else '<replaced>' if isinstance(box, boxes.ReplacedBox)
-          else serialize(getattr(box, 'column_groups', ()) + box.children)))
+          else serialize(
+            getattr(box, 'column_groups', ()) + tuple(box.children))))
         for box in box_list]
 
 
diff --git a/weasyprint/tests/test_presentational_hints.py b/weasyprint/tests/test_presentational_hints.py
index b72faf62..32743858 100644
--- a/weasyprint/tests/test_presentational_hints.py
+++ b/weasyprint/tests/test_presentational_hints.py
@@ -102,10 +102,10 @@ def test_ph_phrasing():
     br3, = line3.children
     br4, = line4.children
     font1, font2, font3, font4 = line5.children
-    assert br1.style.clear == 'left'
-    assert br2.style.clear == 'right'
-    assert br3.style.clear == 'both'
-    assert br4.style.clear == 'both'
+    assert br1.style['clear'] == 'left'
+    assert br2.style['clear'] == 'right'
+    assert br3.style['clear'] == 'both'
+    assert br4.style['clear'] == 'both'
     assert font1.style.color == (1, 0, 0, 1)
     assert font1.style.font_family == ['ahem']
     assert font1.style.font_size == 1.5 * 2 * 16
diff --git a/weasyprint/tests/test_text.py b/weasyprint/tests/test_text.py
index 472e30c8..1bc299b0 100644
--- a/weasyprint/tests/test_text.py
+++ b/weasyprint/tests/test_text.py
@@ -24,12 +24,12 @@ FONTS = FONTS.split(', ')
 
 def make_text(text, width=None, **style):
     """Wrapper for split_first_line() creating a StyleDict."""
-    style = StyleDict({
-        'font_family': ['Nimbus Mono L', 'Liberation Mono', 'FreeMono',
-                        'monospace'],
-    }, INITIAL_VALUES).updated_copy(style)
+    new_style = StyleDict(INITIAL_VALUES)
+    new_style['font_family'] = [
+        'Nimbus Mono L', 'Liberation Mono', 'FreeMono', 'monospace']
+    new_style.update(style)
     return split_first_line(
-        text, style, context=None, max_width=width, line_width=None)
+        text, new_style, context=None, max_width=width, line_width=None)
 
 
 @assert_no_logs