Repository: Kozea/WeasyPrint
Pull request #407 (2017-01-02 14:12:35)

Handle ::first-letter
The feature comes with a couple of small fixes:
- always use `::` instead of `:` for internal representation of pseudo-classes, and
- rename `pseudo_to_box` into `before_after_to_box` to avoid confusion with other pseudo classes.

If you're interested in this feature, please review before I merge!


Patch:
diff --git a/weasyprint/css/html5_ua.css b/weasyprint/css/html5_ua.css
index 2c3c1c44..8037d383 100644
--- a/weasyprint/css/html5_ua.css
+++ b/weasyprint/css/html5_ua.css
@@ -213,7 +213,7 @@ big { font-size: larger; }
 blink { text-decoration: blink; }
 blockquote { display: block; margin: 1em 40px; unicode-bidi: isolate; }
 body { display: block; margin: 8px; }
-br:before { content: '\A'; white-space: pre-line; }
+br::before { content: '\A'; white-space: pre-line; }
 button { display: inline-block; text-align: center; text-indent: 0; }
 caption { display: table-caption; unicode-bidi: isolate; }
 center { display: block; text-align: center; unicode-bidi: isolate; }
@@ -419,8 +419,8 @@ p { display: block; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate;
 param { display: none; }
 plaintext { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
 pre { display: block; font-family: monospace; margin-bottom: 1em; margin-top: 1em; unicode-bidi: isolate; white-space: pre; }
-q:after { content: close-quote; }
-q:before { content: open-quote; }
+q::after { content: close-quote; }
+q::before { content: open-quote; }
 rp { display: none; }
 rt { display: ruby-text; }
 ruby { display: ruby; }
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index 7bdd460d..99cf89ed 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -148,7 +148,10 @@ def element_to_box(element, style_for, get_image_from_uri, state=None):
     # names will be in this new list
     counter_scopes.append(set())
 
-    children.extend(pseudo_to_box(
+    box.first_letter_style = style_for(element, 'first-letter')
+    box.first_line_style = style_for(element, 'first-line')
+
+    children.extend(before_after_to_box(
         element, 'before', state, style_for, get_image_from_uri))
     text = element.text
     if text:
@@ -164,7 +167,7 @@ def element_to_box(element, style_for, get_image_from_uri, state=None):
                 children[-1].text += text_box.text
             else:
                 children.append(text_box)
-    children.extend(pseudo_to_box(
+    children.extend(before_after_to_box(
         element, 'after', state, style_for, get_image_from_uri))
 
     # Scopes created by this element’s children stop here.
@@ -174,14 +177,16 @@ def element_to_box(element, style_for, get_image_from_uri, state=None):
             counter_values.pop(name)
 
     box.children = children
+    box.first_letter_style = style_for(element, 'first-letter')
     replace_content_lists(element, box, style, counter_values)
 
     # Specific handling for the element. (eg. replaced element)
     return html.handle_element(element, box, get_image_from_uri)
 
 
-def pseudo_to_box(element, pseudo_type, state, style_for, get_image_from_uri):
-    """Yield the box for a :before or :after pseudo-element if there is one."""
+def before_after_to_box(element, pseudo_type, state, style_for,
+                        get_image_from_uri):
+    """Yield the box for ::before or ::after pseudo-element if there is one."""
     style = style_for(element, pseudo_type)
     if pseudo_type and style is None:
         # Pseudo-elements with no style at all do not get a StyleDict
@@ -196,7 +201,7 @@ def pseudo_to_box(element, pseudo_type, state, style_for, get_image_from_uri):
         return
 
     box = make_box(
-        '%s:%s' % (element.tag, pseudo_type), element.sourceline, style, [],
+        '%s::%s' % (element.tag, pseudo_type), element.sourceline, style, [],
         get_image_from_uri)
 
     quote_depth, counter_values, _counter_scopes = state
@@ -1135,8 +1140,8 @@ def box_text(box):
     elif isinstance(box, boxes.ParentBox):
         return ''.join(
             child.text for child in box.descendants()
-            if not child.element_tag.endswith(':before') and
-            not child.element_tag.endswith(':after') and
+            if not child.element_tag.endswith('::before') and
+            not child.element_tag.endswith('::after') and
             isinstance(child, boxes.TextBox))
     else:
         return ''
@@ -1146,7 +1151,7 @@ def box_text_before(box):
     if isinstance(box, boxes.ParentBox):
         return ''.join(
             box_text(child) for child in box.descendants()
-            if child.element_tag.endswith(':before') and
+            if child.element_tag.endswith('::before') and
             not isinstance(child, boxes.ParentBox))
     else:
         return ''
@@ -1156,7 +1161,7 @@ def box_text_after(box):
     if isinstance(box, boxes.ParentBox):
         return ''.join(
             box_text(child) for child in box.descendants()
-            if child.element_tag.endswith(':after') and
+            if child.element_tag.endswith('::after') and
             not isinstance(child, boxes.ParentBox))
     else:
         return ''
diff --git a/weasyprint/layout/blocks.py b/weasyprint/layout/blocks.py
index 301445bd..cca7040b 100644
--- a/weasyprint/layout/blocks.py
+++ b/weasyprint/layout/blocks.py
@@ -418,8 +418,10 @@ def block_container_layout(context, box, max_position_y, skip_stack,
 
     if is_start:
         skip = 0
+        first_letter_style = getattr(box, 'first_letter_style', None)
     else:
         skip, skip_stack = skip_stack
+        first_letter_style = None
     for index, child in box.enumerate_skip(skip):
         child.position_x = position_x
         # XXX does not count margins in adjoining_margins:
@@ -473,7 +475,8 @@ def block_container_layout(context, box, max_position_y, skip_stack,
             new_containing_block = box
             lines_iterator = iter_line_boxes(
                 context, child, position_y, skip_stack,
-                new_containing_block, device_size, absolute_boxes, fixed_boxes)
+                new_containing_block, device_size, absolute_boxes, fixed_boxes,
+                first_letter_style)
             is_page_break = False
             for line, resume_at in lines_iterator:
                 line.resume_at = resume_at
@@ -590,6 +593,8 @@ def block_container_layout(context, box, max_position_y, skip_stack,
                 position_y += collapsed_margin
                 adjoining_margins = []
 
+            if not getattr(child, 'first_letter_style', None):
+                child.first_letter_style = first_letter_style
             (new_child, resume_at, next_page, next_adjoining_margins,
                 collapsing_through) = block_level_layout(
                     context, child, max_position_y, skip_stack,
diff --git a/weasyprint/layout/inlines.py b/weasyprint/layout/inlines.py
index 41852951..89aa509c 100644
--- a/weasyprint/layout/inlines.py
+++ b/weasyprint/layout/inlines.py
@@ -12,6 +12,8 @@
 
 from __future__ import division, unicode_literals
 
+import unicodedata
+
 from .absolute import absolute_layout, AbsolutePlaceholder
 from .float import avoid_collisions, float_layout
 from .replaced import image_marker_layout
@@ -26,7 +28,8 @@ from ..css.computed_values import strut_layout, ex_ratio
 
 
 def iter_line_boxes(context, box, position_y, skip_stack, containing_block,
-                    device_size, absolute_boxes, fixed_boxes):
+                    device_size, absolute_boxes, fixed_boxes,
+                    first_letter_style):
     """Return an iterator of ``(line, resume_at)``.
 
     ``line`` is a laid-out LineBox with as much content as possible that
@@ -45,7 +48,7 @@ def iter_line_boxes(context, box, position_y, skip_stack, containing_block,
     while 1:
         line, resume_at = get_next_linebox(
             context, box, position_y, skip_stack, containing_block,
-            device_size, absolute_boxes, fixed_boxes)
+            device_size, absolute_boxes, fixed_boxes, first_letter_style)
         if line:
             position_y = line.position_y + line.height
         if line is None:
@@ -54,11 +57,12 @@ def iter_line_boxes(context, box, position_y, skip_stack, containing_block,
         if resume_at is None:
             return
         skip_stack = resume_at
+        first_letter_style = None
 
 
 def get_next_linebox(context, linebox, position_y, skip_stack,
                      containing_block, device_size, absolute_boxes,
-                     fixed_boxes):
+                     fixed_boxes, first_letter_style):
     """Return ``(line, resume_at)``."""
     resolve_percentages(linebox, containing_block)
     if skip_stack is None:
@@ -67,11 +71,14 @@ def get_next_linebox(context, linebox, position_y, skip_stack,
         resolve_one_percentage(linebox, 'text_indent', containing_block.width)
     else:
         linebox.text_indent = 0
+        first_letter_style = None
 
     skip_stack = skip_first_whitespace(linebox, skip_stack)
     if skip_stack == 'continue':
         return None, None
 
+    skip_stack = first_letter_to_box(linebox, skip_stack, first_letter_style)
+
     linebox.width = inline_min_content_width(
         context, linebox, skip_stack=skip_stack, first_line=True)
 
@@ -242,6 +249,81 @@ def remove_last_whitespace(context, box):
     # 'white-space' set to 'pre-wrap', UAs may visually collapse them.
 
 
+def first_letter_to_box(box, skip_stack, first_letter_style):
+    """Create a box for the ::first-letter selector."""
+    if first_letter_style and box.children:
+        first_letter = ''
+        child = box.children[0]
+        if isinstance(child, boxes.TextBox):
+            if child.element_tag.endswith('::first-letter'):
+                letter_box = boxes.Inbox(
+                    '%s::first-letter' % box.element_tag,
+                    box.sourceline, first_letter_style.inherit_from(),
+                    [child])
+                box.children = (
+                    (letter_box,) + tuple(box.children[1:]))
+            elif child.text:
+                character_found = False
+                if skip_stack:
+                    child_skip_stack = skip_stack[1]
+                    if child_skip_stack:
+                        index = child_skip_stack[0]
+                        child.text = child.text[index:]
+                        skip_stack = None
+                while child.text:
+                    next_letter = child.text[0]
+                    category = unicodedata.category(next_letter)
+                    if category not in ('Ps', 'Pe', 'Pi', 'Pf', 'Po'):
+                        if character_found:
+                            break
+                        character_found = True
+                    first_letter += next_letter
+                    child.text = child.text[1:]
+                if first_letter.lstrip('\n'):
+                    # "This type of initial letter is similar to an
+                    # inline-level element if its 'float' property is 'none',
+                    # otherwise it is similar to a floated element."
+                    if first_letter_style['float'] == 'none':
+                        letter_box = boxes.InlineBox(
+                            '%s::first-letter' % box.element_tag,
+                            box.sourceline, first_letter_style, [])
+                        text_box = boxes.TextBox(
+                            '%s::first-letter' % box.element_tag,
+                            box.sourceline, letter_box.style.inherit_from(),
+                            first_letter)
+                        letter_box.children = (text_box,)
+                        box.children = (letter_box,) + tuple(box.children)
+                    else:
+                        letter_box = boxes.BlockBox(
+                            '%s::first-letter' % box.element_tag,
+                            box.sourceline, first_letter_style, [])
+                        letter_box.first_letter_style = None
+                        line_box = boxes.LineBox(
+                            '%s::first-letter' % box.element_tag,
+                            box.sourceline, letter_box.style.inherit_from(),
+                            [])
+                        letter_box.children = (line_box,)
+                        text_box = boxes.TextBox(
+                            '%s::first-letter' % box.element_tag,
+                            box.sourceline, letter_box.style.inherit_from(),
+                            first_letter)
+                        line_box.children = (text_box,)
+                        box.children = (letter_box,) + tuple(box.children)
+                    if skip_stack and child_skip_stack:
+                        skip_stack = (skip_stack[0], (
+                            child_skip_stack[0] + 1, child_skip_stack[1]))
+        elif isinstance(child, boxes.ParentBox):
+            if skip_stack:
+                child_skip_stack = skip_stack[1]
+            else:
+                child_skip_stack = None
+            child_skip_stack = first_letter_to_box(
+                child, child_skip_stack, first_letter_style)
+            if skip_stack:
+                skip_stack = (skip_stack[0], child_skip_stack)
+    return skip_stack
+
+
 @handle_min_max_width
 def replaced_box_width(box, device_size):
     """


Test patch:
diff --git a/weasyprint/tests/test_boxes.py b/weasyprint/tests/test_boxes.py
index a49ed319..2d3da176 100644
--- a/weasyprint/tests/test_boxes.py
+++ b/weasyprint/tests/test_boxes.py
@@ -762,12 +762,12 @@ def test_colspan_rowspan():
 
 @assert_no_logs
 def test_before_after():
-    """Test the :before and :after pseudo-elements."""
+    """Test the ::before and ::after pseudo-elements."""
     assert_tree(parse_all('''
         <style>
             p:before { content: normal }
             div:before { content: none }
-            section:before { color: black }
+            section::before { color: black }
         </style>
         <p></p>
         <div></div>
@@ -781,7 +781,7 @@ def test_before_after():
     assert_tree(parse_all('''
         <style>
             p:before { content: 'a' 'b' }
-            p:after { content: 'd' 'e' }
+            p::after { content: 'd' 'e' }
         </style>
         <p>
             c
@@ -789,11 +789,11 @@ def test_before_after():
     '''), [
         ('p', 'Block', [
             ('p', 'Line', [
-                ('p:before', 'Inline', [
-                    ('p:before', 'Text', 'ab')]),
+                ('p::before', 'Inline', [
+                    ('p::before', 'Text', 'ab')]),
                 ('p', 'Text', ' c '),
-                ('p:after', 'Inline', [
-                    ('p:after', 'Text', 'de')])])])])
+                ('p::after', 'Inline', [
+                    ('p::after', 'Text', 'de')])])])])
 
     assert_tree(parse_all('''
         <style>
@@ -804,8 +804,8 @@ def test_before_after():
         ('p', 'Block', [
             ('p', 'Line', [
                 ('a', 'Inline', [
-                    ('a:before', 'Inline', [
-                        ('a:before', 'Text', '[some url] ')]),
+                    ('a::before', 'Inline', [
+                        ('a::before', 'Text', '[some url] ')]),
                     ('a', 'Text', 'some text')])])])])
 
     assert_tree(parse_all('''
@@ -819,18 +819,18 @@ def test_before_after():
         ('p', 'Block', [
             ('p', 'Line', [
                 ('q', 'Inline', [
-                    ('q:before', 'Inline', [
-                        ('q:before', 'Text', '« ')]),
+                    ('q::before', 'Inline', [
+                        ('q::before', 'Text', '« ')]),
                     ('q', 'Text', 'Lorem ipsum '),
                     ('q', 'Inline', [
-                        ('q:before', 'Inline', [
-                            ('q:before', 'Text', '“ ')]),
+                        ('q::before', 'Inline', [
+                            ('q::before', 'Text', '“ ')]),
                         ('q', 'Text', 'dolor'),
-                        ('q:after', 'Inline', [
-                            ('q:after', 'Text', ' ”')])]),
+                        ('q::after', 'Inline', [
+                            ('q::after', 'Text', ' ”')])]),
                     ('q', 'Text', ' sit amet'),
-                    ('q:after', 'Inline', [
-                        ('q:after', 'Text', ' »')])])])])])
+                    ('q::after', 'Inline', [
+                        ('q::after', 'Text', ' »')])])])])])
     with capture_logs() as logs:
         assert_tree(parse_all('''
             <style>
@@ -846,10 +846,10 @@ def test_before_after():
         '''), [
             ('p', 'Block', [
                 ('p', 'Line', [
-                    ('p:before', 'Inline', [
-                        ('p:before', 'Text', 'a'),
-                        ('p:before', 'AnonInlineReplaced', '<replaced>'),
-                        ('p:before', 'Text', 'b')]),
+                    ('p::before', 'Inline', [
+                        ('p::before', 'Text', 'a'),
+                        ('p::before', 'AnonInlineReplaced', '<replaced>'),
+                        ('p::before', 'Text', 'b')]),
                     ('p', 'Text', 'c')])])])
     assert len(logs) == 1
     assert 'nested-function(' in logs[0]
@@ -880,8 +880,8 @@ def test_counters():
         <p></p>'''), [
         ('p', 'Block', [
             ('p', 'Line', [
-                ('p:before', 'Inline', [
-                    ('p:before', 'Text', counter)])])])
+                ('p::before', 'Inline', [
+                    ('p::before', 'Text', counter)])])])
         for counter in '0 1 3  2 4 6  -11 -9 -7  44 46 48'.split()])
 
     assert_tree(parse_all('''
@@ -965,31 +965,31 @@ def test_counters():
         ('section', 'Block', [
             ('section', 'AnonBlock', [
                 ('section', 'Line', [
-                    ('section:before', 'Inline', [])])]),
+                    ('section::before', 'Inline', [])])]),
             ('h1', 'Block', [
                 ('h1', 'Line', [
-                    ('h1:before', 'Inline', [
-                        ('h1:before', 'Text', '1')])])]),
+                    ('h1::before', 'Inline', [
+                        ('h1::before', 'Text', '1')])])]),
             ('h1', 'Block', [
                 ('h1', 'Line', [
-                    ('h1:before', 'Inline', [
-                        ('h1:before', 'Text', '2')])])]),
+                    ('h1::before', 'Inline', [
+                        ('h1::before', 'Text', '2')])])]),
             ('section', 'Block', [
                 ('section', 'AnonBlock', [
                     ('section', 'Line', [
-                        ('section:before', 'Inline', [])])]),
+                        ('section::before', 'Inline', [])])]),
                 ('h1', 'Block', [
                     ('h1', 'Line', [
-                        ('h1:before', 'Inline', [
-                            ('h1:before', 'Text', '2.1')])])]),
+                        ('h1::before', 'Inline', [
+                            ('h1::before', 'Text', '2.1')])])]),
                 ('h1', 'Block', [
                     ('h1', 'Line', [
-                        ('h1:before', 'Inline', [
-                            ('h1:before', 'Text', '2.2')])])])]),
+                        ('h1::before', 'Inline', [
+                            ('h1::before', 'Text', '2.2')])])])]),
             ('h1', 'Block', [
                 ('h1', 'Line', [
-                    ('h1:before', 'Inline', [
-                        ('h1:before', 'Text', '3')])])])])])
+                    ('h1::before', 'Inline', [
+                        ('h1::before', 'Text', '3')])])])])])
 
     assert_tree(parse_all('''
         <style>
@@ -1008,8 +1008,8 @@ def test_counters():
                      'Scope created now, deleted after the div ')])])]),
         ('p', 'Block', [
             ('p', 'Line', [
-                ('p:before', 'Inline', [
-                    ('p:before', 'Text', '0')])])])])
+                ('p::before', 'Inline', [
+                    ('p::before', 'Text', '0')])])])])
 
     # counter-increment may interfere with display: list-item
     assert_tree(parse_all('''
@@ -1041,14 +1041,14 @@ def test_counter_styles():
     '''), [
         ('p', 'Block', [
             ('p', 'Line', [
-                ('p:before', 'Inline', [
-                    ('p:before', 'Text', counter)])])])
+                ('p::before', 'Inline', [
+                    ('p::before', 'Text', counter)])])])
         for counter in '--  •  ◦  ▪  -7'.split()])
 
     assert_tree(parse_all('''
         <style>
             p { counter-increment: p }
-            p:before { content: counter(p, decimal-leading-zero); }
+            p::before { content: counter(p, decimal-leading-zero); }
         </style>
         <p style="counter-reset: p -1987"></p>
         <p></p>
@@ -1071,8 +1071,8 @@ def test_counter_styles():
     '''), [
         ('p', 'Block', [
             ('p', 'Line', [
-                ('p:before', 'Inline', [
-                    ('p:before', 'Text', counter)])])])
+                ('p::before', 'Inline', [
+                    ('p::before', 'Text', counter)])])])
         for counter in '''-1986 -1985  -11 -10 -09 -08  -01 00 01 02  09 10 11
                             99 100 101  4135 4136'''.split()])