Repository: Kozea/WeasyPrint

Issue:
Add support for CSS Grid Layout Module
Could this be added to supported CSS specs? It makes layout design sooo much easier.

https://www.w3.org/TR/css-grid-1/


Discussion:
@LucienD You're lucky, [the algorithm has already been done for you](https://www.w3.org/TR/css-grid-1/#algo-overview) :wink:.
Hi @liZe, where in the project would I start to begin figuring out how I may contribute this?
I haven't worked on complex python projects such as this, but I'd be keen to try my hand.
Hello @MindFluid!

> where in the project would I start to begin figuring out how I may contribute this?
> I haven't worked on complex python projects such as this, but I'd be keen to try my hand.

Good news, thank you!

First of all: this issue needs a lot of work and requires a lot of patience. The first steps are not that hard and are a good way to understand WeasyPrint, but implementing grid's algorithm is really complex. So: if you don't feel like you can spend days before getting a simple grid on a PDF, you can try other [issues with the "Good first issue" label](https://github.com/Kozea/WeasyPrint/issues?q=is:issue+is:open+label:"good+first+issue"). I can help you a lot on these issues, just ask.

If you're not afraid and want to work on this issue, no problem! As I said, the first steps are a good way to discover WeasyPrint. Even if you will not get a grid quickly, there's a lot of fun problems to solve and of small rewards to get.

Here are the steps you can follow:

## Define your test case

The first way to test during development (the one I prefer) is to create a small HTML file with only CSS and to launch `weasyprint` from the command line. Logs will tell you that your properties are unknown.

For example, a good first file for you could be:
```html
<style>
  article {
    grid-template-columns: auto 1fr;
    grid-template-rows: auto 50px;
  }
  div {
    grid-column-start: 1;
    grid-row-start: 1;
  }
</style>
```

For the moment, you get:
```
$ python -m weasyprint /tmp/grid.html /tmp/grid.pdf
WARNING: Ignored `grid-template-columns: auto 1fr` at 3:5, unknown property.
WARNING: Ignored `grid-template-rows: auto 50px` at 4:5, unknown property.
WARNING: Ignored `grid-column-start: 1` at 7:5, unknown property.
WARNING: Ignored `grid-row-start: 1` at 8:5, unknown property.
```

Your first mission is to make these messages disappear. You can only focus on these 4 properties for now. How to do that? Go to the next step!

## Validate the properties

[CSS properties related to grid](https://www.w3.org/TR/css-grid-1/#property-index) are currently unknown for WeasyPrint. You must add a validator to check that the values are correct. Validators are in [`css/validation/properties.py`](https://github.com/Kozea/WeasyPrint/blob/master/weasyprint/css/validation/properties.py), you can add your functions here.

The syntax of the values allowed for the properties are defined in the "Value" line of each property in the specification (for example, it is `<grid-line>` for [`grid-row-start`](https://www.w3.org/TR/css-grid-1/#propdef-grid-row-start)). If the syntax looks too complicated, don't panic! It's normal at the beginning, but it's not that difficult. You can just allow a subset (for example, just allow numbers in `grid-column-start`) and then handle more and more cases.

The `@property` decorator is needed to declare the property, its name is automatically designed by the function name (hyphens are transformed into underscores). Other decorators (`single_keyword`, `single_token`) may be needed depending on the type of values the property is waiting for. Looking at the other validators should give you a good idea about how they work.

A validator must return the pythonic representation of the values. For example, if your property needs a string, you return a `str`, if the property needs a list, you return a `tuple`, etc. If the value is invalid, the validator must return `None` (returning nothing in a function automatically returns `None` in Python). Validators decorated by the `single_keyword` decorator only need to return `True` (validation OK) or `False`.

---

If you reach this point, congratulations! This comment is already too long, you'll discover the next steps in the next comment!

**If you need help or have any questions, just ask, I'll do my best to help!**
Thanks for the detailed answer @liZe, I'll try find some time this year to get around this!
Is any work being done on this?
> Is any work being done on this?

Nobody's working on that as far as I know.
I just ran into this.  Definitely would be awesome if this could be used.
> I need this. Send help.

Hello @rarenatoe.

I’d love to have this feature too. Everybody does. But unfortunately I don’t have the time to implement it: the amount of work needed to support this is important, and there are lots of other bugs to fix and features to add.

You can help us by writing some code (if you have Python skills), we can help you if you want. If you’re not a Python dev, you can also support us on [OpenCollective](https://opencollective.com/courtbouillon), it really gives us more time to work on WeasyPrint, and hopefully to solve this issue more quickly.
hi can you guide me on it so that i can implement it?
Hello. What about organizing a google summer of code to handle those kind of issue ?
BTW, the recruiting date goes from mi-january to mi-feb, nearly now ...

---
EDIT

PS here is the 2020 org list https://summerofcode.withgoogle.com/archive/2020/organizations/

ffmeg, opencv inkscape, processing foundation, purr data, videolan
Profiles are not that different of Kozea.
A lot of graphics orgs, note enough web ones. Bring back the balance !
> hi can you guide me on it so that i can implement it?

Hello @keinagae!

The best way for you to start working on Grid support is to follow [this comment](https://github.com/Kozea/WeasyPrint/issues/543#issuecomment-474904286). If you reach the end of the comment, I’ll write the next one :wink:.

> Hello. What about organizing a google summer of code to handle those kind of issue ?

Hello @o314.

Getting money from Google to develop a web engine that can help users to get rid of Google products is a bit awkward for us :wink:.
Hi. As google is the uncontested master of the universe with chrome / chromium, i don't believe there is such an appetency when we go down to the pdf side of the rendering eg. pdium. So, fortunately, weasyprint might not be snipped soon, hurrah and happy new year.

One can always "borrow" them some code since the license of the chromium blink engine is bsd and the code source is here :

- https://www.chromestatus.com/feature/4589636412243968
- https://github.com/chromium/chromium/tree/master/third_party/blink/renderer/core/layout
- https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/layout/grid.h

The mozilla ones is less business friendly 

- https://github.com/mozilla/gecko-dev/search?q=GridTemplateComponent
- https://github.com/mozilla/gecko-dev/blob/master/servo/components/style/properties/shorthands/position.mako.rs
- https://github.com/mozilla/gecko-dev/blob/master/servo/components/style/values/specified/grid.rs
- https://github.com/mozilla/gecko-dev/blob/master/servo/components/style/values/computed/position.rs

But docs are good :)

- https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout
- https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout/CSS_Grid_and_Progressive_Enhancement


IMO, expect some hundred hours of work, it may be a bit hard for a newcomer, but taught by the best

Hello @liZe,

CSS grid support would be very nice to have, so I decided to try my hand at this, based on your [comment above](https://github.com/Kozea/WeasyPrint/issues/543#issuecomment-474904286).

I only just started playing around with it, but maybe you could have a look, to see if I'm at least moving in the right direction?

[Here's what I have up to now.](https://github.com/dennisvang/WeasyPrint/commit/c6ffce782e346d8c229d5c6a1febd49403611d76) 

It's a work in progress...

EDIT:

After re-reading the original comment, just now, I see it already answers some of my questions.

Hi @dennisvang,

That’s a good start! When your validations are done, just ask me, I’ll add a comment with the next steps!
Hello!

(The survey is now closed. Thanks for all your answers! We’ll share the results soon 😉)

If you’re interested in grid, we created a [short survey](https://app.evalandgo.com/s/index.php?id=JTk4cCU5NG8lOUQlQUQ%3D&a=JTk3biU5M2glOUMlQUI%3D) where you can give a boost to this feature and help us to improve WeasyPrint 😉

Vote for it!
> Hi @dennisvang,
> 
> That’s a good start! When your validations are done, just ask me, I’ll add a comment with the next steps!

@liZe I want the next steps.
i dont want to receive this mails any more

El vie, 18 jun 2021 a las 11:50, Khalid Muhammad ***@***.***>)
escribió:

> Hi @dennisvang <https://github.com/dennisvang>,
>
> That’s a good start! When your validations are done, just ask me, I’ll add
> a comment with the next steps!
>
> @liZe <https://github.com/liZe> I want the next steps.
>
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/Kozea/WeasyPrint/issues/543#issuecomment-864130490>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AJ32JM2QCPZQD5QA72VZ4B3TTNTLJANCNFSM4EEWX6NQ>
> .
>


-- 
[image: Avatar]
JAVIER RODRIGUEZ AGUILERA
MACHINE LEARNING ANALYST
GERENCIA INTELIGENCIA ARTIFICIAL
*email:* ***@***.***
ALCANTARA 99, Piso 6,SANTIAGO
Teléfono: 02 26927000 Ext: 9633

Te invitamos a ingresar a nuestros *Canales Digitales, Bci.cl y App Bci*,
en donde podrás realizar consultas y hacer tus operaciones bancarias las *24
horas del día*, todos los días del año.

*Bci.cl*, realiza todas tus operaciones bancarias y de inversión, en
cualquier momento.

*App Bci*, descárgala desde App Store o Google Play y accede a tu
información de manera rápida, fácil y segura.

*Chat Digital*, busca el ícono en tu sitio privado y contacta a un
ejecutivo virtual que te ayudará en línea.

*Clientes empresas*, ingresa a nuestra plataforma digital 360 | connect, en
www.bci/empresas o con la Mesa de ayuda
al 2 2697 5000.

-- 


  * Nunca y bajo ninguna circunstancia, te solicitaremos de manera 
escrita o verbal, tus claves personales, tampoco en links enviados por 
sitios con apariencia legítima, vía SMS, correos electrónicos o emails..
  
* Protege y nunca compartas tus claves de acceso, BciPass, Multipass.
  * 
Accede directo ingresando la dirección electrónica de Bci en la barra de tu 
navegador.
  * No accedas a la página del banco a través de links 
entregados por buscadores (Google, otros) o a través de enlaces que 
aparezcan en otras web, porque pueden llevarte a sitios falsos y tener 
links maliciosos.
  * Nunca bloquearemos tu cuenta como argumento para 
solicitar actualización de datos

CONFIDENCIALIDAD: Toda la información 
contenida en este mensaje es confidencial y destinada al uso exclusivo del 
emisor y/o del destinatario.  Cualquier tratamiento no autorizado de esta 
información podrá ser sancionado conforme a la Ley. Si recibió este mensaje 
por error, por favor infórmenos, borre el mensaje y sus archivos adjuntos


Hi @jrodria!
You can click on "unsubscribe" on the right panel of the issue to stop receiving notifications about this issue. And also there is a link in the mails.
@jrodria No te vayas :(
Hello!

As it’s soon our 2-year anniversary as CourtBouillon, we opened a [short survey](https://thym.courtbouillon.org/1) to know more about your expectations.
Don’t hesitate to support this feature and give it a boost 🚀!

The survey will be opened until October 10th.

Update: the survey is now closed. You can find the results [here](https://www.courtbouillon.org/blog/00033-two-years-of-courtbouillon).

Hello!

As you may know, two weeks ago was [CourtBouillon 3-year anniversary](https://www.courtbouillon.org/blog/00046-three-years-of-courtbouillon) 🎉.

For this occasion, we prepared [a short survey](https://thym.courtbouillon.org/4) to have your opinion on this year’s features and to know what you’d like to see in the future!
Don’t hesitate to give a boost to this feature ✨️

 The survey is opened until November 19.
Hi!, I'm interested in supporting this issue, is there a `get involved` guide for code, test, and building?
> Hi!, I'm interested in supporting this issue, is there a `get involved` guide for code, test, and building?

Hi!

You can read the [contribute](https://doc.courtbouillon.org/weasyprint/stable/contribute.html) page of the documentation.

To be honest, this feature is quite hard to implement. Maybe you can dive into WeasyPrint’s code by trying to solve a [good first issue](https://github.com/Kozea/WeasyPrint/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)?



Patch:
diff --git a/docs/api_reference.rst b/docs/api_reference.rst
index 6826269d0..806c397b5 100644
--- a/docs/api_reference.rst
+++ b/docs/api_reference.rst
@@ -757,6 +757,56 @@ supported. The ``flex`` and ``flex-flow`` shorthands are supported too.
 
 .. _CSS Flexible Box Layout Module Level 1: https://www.w3.org/TR/css-flexbox-1/
 
+CSS Grid Layout Module Level 2
+++++++++++++++++++++++++++++++
+
+The `CSS Grid Layout Module Level 2`_ "defines a two-dimensional grid-based layout
+system, optimized for user interface design".
+
+This module works for simple cases, but has some limitations. Here are
+non-exhaustive lists of supported/unsupported features.
+
+Supported:
+
+- ``display: grid``,
+- ``grid-auto-*``, ``grid-template-*`` and other ``grid-*`` properties,
+- ``grid`` and other ``grid-*`` shorthands,
+- flexible lengths (``fr`` unit),
+- line names,
+- grid areas,
+- auto rows and auto columns,
+- ``z-index``,
+- ``repeat(X, *)``,
+- ``minmax()``,
+- ``align-*`` and ``justify-*`` alignment properties,
+- ``gap`` and ``*-gap`` properties for gutters,
+- dense auto flow,
+- ``order``,
+- margins, borders, padding on grid containers and grid items,
+- fragmentation between rows.
+
+Unsupported or untested:
+
+- ``display: inline-grid``,
+- auto content size for grid containers,
+- ``grid-auto-flow: column``,
+- subgrids,
+- ``repeat(auto-fill, *)`` and ``repeat(auto-fit, *)``,
+- auto margins for grid items,
+- ``span`` with line names,
+- ``span`` for flexible tracks,
+- ``safe`` and ``unsafe`` alignments,
+- baseline alignment,
+- grid items with intrinsic size (images),
+- distribute space beyond limits,
+- grid items larger than grid containers,
+- ``min-width``, ``max-width``, ``min-height``, ``max-height`` on grid items,
+- complex ``min-content`` and ``max-content`` cases,
+- absolutely positioned and floating grid items,
+- fragmentation in rows.
+
+.. _CSS Grid Layout Module Level 2: https://www.w3.org/TR/css-grid-2/
+
 CSS Basic User Interface Module Level 3/4
 +++++++++++++++++++++++++++++++++++++++++
 
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index a9f833753..bd9883468 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -440,10 +440,11 @@ def border_radius(style, name, values):
 
 
 @register_computer('column-gap')
-def column_gap(style, name, value):
-    """Compute the ``column-gap`` property."""
+@register_computer('row-gap')
+def gap(style, name, value):
+    """Compute the ``*-gap`` properties."""
     if value == 'normal':
-        value = Dimension(1, 'em')
+        return value
     return length(style, name, value, pixels_only=True)
 
 
@@ -589,6 +590,72 @@ def font_weight(style, name, value):
         return value
 
 
+def _compute_track_breadth(style, name, value):
+    """Compute track breadth."""
+    if value in ('auto', 'min-content', 'max-content'):
+        return value
+    elif isinstance(value, Dimension):
+        if value.unit == 'fr':
+            return value
+        else:
+            return length(style, name, value)
+
+
+def _track_size(style, name, values):
+    """Compute track size."""
+    return_values = []
+    for i, value in enumerate(values):
+        if i % 2 == 0:
+            # line name
+            return_values.append(value)
+        else:
+            # track section
+            track_breadth = _compute_track_breadth(style, name, value)
+            if track_breadth:
+                return_values.append(track_breadth)
+            elif value[0] == 'minmax()':
+                return_values.append((
+                    'minmax()',
+                    _compute_track_breadth(style, name, value[1]),
+                    _compute_track_breadth(style, name, value[2])))
+            elif value[0] == 'fit-content()':
+                return_values.append((
+                    'fit-content()', length(style, name, value[1])))
+            elif value[0] == 'repeat()':
+                return_values.append((
+                    'repeat()', value[1], _track_size(style, name, value[2])))
+    return tuple(return_values)
+
+
+@register_computer('grid-template-columns')
+@register_computer('grid-template-rows')
+def grid_template(style, name, values):
+    """Compute the ``grid-template-*`` properties."""
+    if values == 'none' or values[0] == 'subgrid':
+        return values
+    else:
+        return _track_size(style, name, values)
+
+
+@register_computer('grid-auto-columns')
+@register_computer('grid-auto-rows')
+def grid_auto(style, name, values):
+    """Compute the ``grid-auto-*`` properties."""
+    return_values = []
+    for value in values:
+        track_breadth = _compute_track_breadth(style, name, value)
+        if track_breadth:
+            return_values.append(track_breadth)
+        elif value[0] == 'minmax()':
+            return_values.append((
+                'minmax()', grid_auto(style, name, [value[1]])[0],
+                grid_auto(style, name, [value[2]])[0]))
+        elif value[0] == 'fit-content()':
+            return_values.append((
+                'fit-content()', grid_auto(style, name, [value[1]])[0]))
+    return tuple(return_values)
+
+
 @register_computer('line-height')
 def line_height(style, name, value):
     """Compute the ``line-height`` property."""
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 1917fe122..3c439c185 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -85,7 +85,6 @@
     # Multi-column Layout (WD): https://www.w3.org/TR/css-multicol-1/
     'column_width': 'auto',
     'column_count': 'auto',
-    'column_gap': Dimension(1, 'em'),
     'column_rule_color': 'currentColor',
     'column_rule_style': 'none',
     'column_rule_width': 'medium',
@@ -183,16 +182,34 @@
     'width': 'auto',
 
     # Flexible Box Layout Module 1 (CR): https://www.w3.org/TR/css-flexbox-1/
-    'align_content': 'stretch',
-    'align_items': 'stretch',
-    'align_self': 'auto',
     'flex_basis': 'auto',
     'flex_direction': 'row',
     'flex_grow': 0,
     'flex_shrink': 1,
     'flex_wrap': 'nowrap',
-    'justify_content': 'flex-start',
+
+    # Grid Layout Module Level 2 (CR): https://www.w3.org/TR/css-grid-2/
+    'grid_auto_columns': ('auto',),
+    'grid_auto_flow': ('row',),
+    'grid_auto_rows': ('auto',),
+    'grid_template_areas': 'none',
+    'grid_template_columns': 'none',
+    'grid_template_rows': 'none',
+    'grid_row_start': 'auto',
+    'grid_column_start': 'auto',
+    'grid_row_end': 'auto',
+    'grid_column_end': 'auto',
+
+    # CSS Box Alignment Module Level 3 (WD): https://www.w3.org/TR/css-align-3/
+    'align_content': ('normal',),
+    'align_items': ('normal',),
+    'align_self': ('auto',),
+    'justify_content': ('normal',),
+    'justify_items': ('normal',),
+    'justify_self': ('auto',),
     'order': 0,
+    'column_gap': 'normal',
+    'row_gap': 'normal',
 
     # Text Decoration Module 3 (CR): https://www.w3.org/TR/css-text-decor-3/
     'text_decoration_line': 'none',
diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py
index 4a9de12fa..cde3807dd 100644
--- a/weasyprint/css/validation/expanders.py
+++ b/weasyprint/css/validation/expanders.py
@@ -16,9 +16,10 @@
     background_size, block_ellipsis, border_image_source, border_image_slice,
     border_image_width, border_image_outset, border_image_repeat, border_style,
     border_width, box, column_count, column_width, flex_basis, flex_direction,
-    flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch, font_style,
-    font_weight, line_height, list_style_image, list_style_position, list_style_type,
-    other_colors, overflow_wrap, validate_non_shorthand)
+    flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch,
+    font_style, font_weight, gap, grid_line, grid_template, line_height,
+    list_style_image, list_style_position, list_style_type, other_colors,
+    overflow_wrap, validate_non_shorthand)
 
 EXPANDERS = {}
 
@@ -754,6 +755,220 @@ def expand_flex_flow(tokens, name):
         raise InvalidValues
 
 
+def _expand_grid_template(tokens, name):
+    line, column = tokens[0].source_line, tokens[0].source_column
+    none = IdentToken(line, column, 'none')
+    if len(tokens) == 1 and get_keyword(tokens[0]) == 'none':
+        yield '-columns', [none]
+        yield '-rows', [none]
+        yield '-areas', [none]
+        return
+    slash_separated = [[]]
+    for token in tokens:
+        if token.type == 'literal' and token.value == '/':
+            slash_separated.append([])
+        else:
+            slash_separated[-1].append(token)
+    if len(slash_separated) == 2:
+        rows = grid_template(slash_separated[0])
+        columns = grid_template(slash_separated[1])
+        if columns:
+            if rows:
+                yield '-columns', slash_separated[1]
+                yield '-rows', slash_separated[0]
+                yield '-areas', [none]
+                return
+            columns = slash_separated[1]
+        else:
+            raise InvalidValues
+    elif len(slash_separated) == 1:
+        columns = [none]
+    else:
+        raise InvalidValues
+    # TODO: Handle last syntax.
+    raise InvalidValues
+
+
+@expander('grid-template')
+@generic_expander('-columns', '-rows', '-areas')
+def expand_grid_template(tokens, name):
+    """Expand the ``grid-template`` property."""
+    yield from _expand_grid_template(tokens, name)
+
+
+@expander('grid')
+@generic_expander('-template-columns', '-template-rows', '-template-areas',
+                  '-auto-columns', '-auto-rows', '-auto-flow')
+def expand_grid(tokens, name):
+    """Expand the ``grid`` property."""
+    line, column = tokens[0].source_line, tokens[0].source_column
+    auto = IdentToken(line, column, 'auto')
+    none = IdentToken(line, column, 'none')
+    row = IdentToken(line, column, 'row')
+    column = IdentToken(line, column, 'column')
+    try:
+        template = tuple(_expand_grid_template(tokens, 'grid-template'))
+    except InvalidValues:
+        pass
+    else:
+        for key, value in template:
+            yield f'-template-{key.split("-")[-1]}', value
+        yield '-auto-columns', [auto]
+        yield '-auto-rows', [auto]
+        yield '-auto-flow', [row]
+        return
+    split_tokens = [[]]
+    for token in tokens:
+        if token.type == 'literal' and token.value == '/':
+            split_tokens.append([])
+            continue
+        split_tokens[-1].append(token)
+    if len(split_tokens) != 2:
+        raise InvalidValues
+    auto_track = None
+    dense = None
+    templates = {'row': [], 'column': []}
+    iterable = zip(split_tokens, templates.items())
+    for tokens, (track, track_templates) in iterable:
+        for token in tokens:
+            if get_keyword(token) == 'dense':
+                if dense or (auto_track and auto_track != track):
+                    raise InvalidValues
+                dense = token
+            elif get_keyword(token) == 'auto-flow':
+                if auto_track:
+                    raise InvalidValues
+                auto_track = track
+            else:
+                track_templates.append(token)
+    if not auto_track:
+        raise InvalidValues
+    non_auto_track = 'row' if auto_track == 'column' else 'column'
+    auto_track_token = column if auto_track == 'column' else row
+    yield '-auto-flow', (
+        (auto_track_token, dense) if dense else (auto_track_token,))
+    yield f'-auto-{auto_track}s', tuple(templates[auto_track])
+    yield f'-auto-{non_auto_track}s', [auto]
+    yield f'-template-{auto_track}s', [none]
+    yield f'-template-{non_auto_track}s', tuple(templates[non_auto_track])
+    yield '-template-areas', [none]
+
+
+def _expand_grid_column_row_area(tokens, max_number):
+    grid_lines = [[]]
+    for token in tokens:
+        if token.type == 'literal' and token.value == '/':
+            grid_lines.append([])
+            continue
+        grid_lines[-1].append(token)
+    if not 1 <= len(grid_lines) <= max_number:
+        raise InvalidValues
+    validations = []
+    for tokens in grid_lines:
+        if not (validation := grid_line(tokens)):
+            raise InvalidValues
+        validations.append(validation)
+        yield tuple(tokens)
+    auto = IdentToken(token.source_line, token.source_column, 'auto')
+    if (lines := len(grid_lines)) <= 1:
+        custom_ident = set(validations[0][:2]) == {None}
+        value = tuple(grid_lines[0]) if custom_ident else (auto,)
+        grid_lines.append(tokens)
+        validations.append(validations[0])
+        yield value
+    if lines <= 2 < max_number:
+        custom_ident = set(validations[0][:2]) == {None}
+        yield tuple(grid_lines[0]) if custom_ident else (auto,)
+    if lines <= 3 < max_number:
+        custom_ident = set(validations[1][:2]) == {None}
+        yield tuple(grid_lines[1]) if custom_ident else (auto,)
+
+
+@expander('grid-column')
+@expander('grid-row')
+@generic_expander('-start', '-end')
+def expand_grid_column_row(tokens, name):
+    """Expand the ``grid-[column|row]`` properties."""
+    tokens_list = _expand_grid_column_row_area(tokens, 2)
+    for tokens, side in zip(tokens_list, ('start', 'end')):
+        yield f'-{side}', tokens
+
+
+@expander('grid-area')
+@generic_expander('grid-row-start', 'grid-row-end',
+                  'grid-column-start', 'grid-column-end')
+def expand_grid_area(tokens, name):
+    """Expand the ``grid-area`` property."""
+    tokens_list = _expand_grid_column_row_area(tokens, 4)
+    sides = ('row-start', 'row-end', 'column-start', 'column-end')
+    for tokens, side in zip(tokens_list, sides):
+        yield f'grid-{side}', tokens
+
+
+@expander('grid-gap')
+@expander('gap')
+@generic_expander('column-gap', 'row-gap')
+def expand_gap(tokens, name):
+    """Expand the ``gap`` property."""
+    if len(tokens) == 1:
+        if gap(tokens) is None:
+            raise InvalidValues
+        yield 'row-gap', tokens
+        yield 'column-gap', tokens
+    elif len(tokens) == 2:
+        column_gap, row_gap = gap(tokens[0:1]), gap(tokens[1:2])
+        if None in (column_gap, row_gap):
+            raise InvalidValues
+        yield 'row-gap', tokens[0:1]
+        yield 'column-gap', tokens[1:2]
+    else:
+        raise InvalidValues
+
+
+@expander('grid-column-gap')
+@generic_expander('column-gap')
+def expand_legacy_column_gap(tokens, name):
+    """Expand legacy ``grid-column-gap`` property."""
+    keyword = gap(tokens)
+    if keyword is None:
+        raise InvalidValues
+    yield 'column-gap', tokens
+
+
+@expander('grid-row-gap')
+@generic_expander('row-gap')
+def expand_legacy_row_gap(tokens, name):
+    """Expand legacy ``grid-row-gap`` property."""
+    keyword = gap(tokens)
+    if keyword is None:
+        raise InvalidValues
+    yield 'row-gap', tokens
+
+
+@expander('place-content')
+@generic_expander('align-content', 'justify-content')
+def expand_place_content(tokens, name):
+    """Expand the ``place-content`` property."""
+    # TODO
+    raise InvalidValues
+
+
+@expander('place-items')
+@generic_expander('align-items', 'justify-items')
+def expand_place_items(tokens, name):
+    """Expand the ``place-items`` property."""
+    # TODO
+    raise InvalidValues
+
+
+@expander('place-self')
+@generic_expander('align-self', 'justify-self')
+def expand_place_self(tokens, name):
+    """Expand the ``place-self`` property."""
+    # TODO
+    raise InvalidValues
+
+
 @expander('line-clamp')
 @generic_expander('max-lines', 'continue', 'block-ellipsis')
 def expand_line_clamp(tokens, name):
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index 377a75ac1..6009d5ab3 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -6,6 +6,7 @@
 
 from math import inf
 
+from tinycss2 import parse_component_value_list
 from tinycss2.color3 import parse_color
 
 from .. import computed_values
@@ -658,10 +659,11 @@ def width_height(token):
         return 'auto'
 
 
-@property(unstable=True)
+@property('column-gap', unstable=True)
+@property('row-gap', unstable=True)
 @single_token
-def column_gap(token):
-    """Validation for the ``column-gap`` property."""
+def gap(token):
+    """Validation for the ``column-gap`` and ``row-gap`` properties."""
     length = get_length(token, negative=False)
     if length:
         return length
@@ -698,7 +700,7 @@ def display(tokens):
                 'table-header-group', 'table-footer-group', 'table-row',
                 'table-column-group', 'table-column'):
             return (value,)
-        elif value in ('inline-table', 'inline-flex'):
+        elif value in ('inline-table', 'inline-flex', 'inline-grid'):
             return tuple(value.split('-'))
         elif value == 'inline-block':
             return ('inline', 'flow-root')
@@ -710,7 +712,7 @@ def display(tokens):
             if outside:
                 return
             outside = value
-        elif value in ('flow', 'flow-root', 'table', 'flex'):
+        elif value in ('flow', 'flow-root', 'table', 'flex', 'grid'):
             if inside:
                 return
             inside = value
@@ -1298,11 +1300,326 @@ def flex_grow_shrink(token):
         return token.value
 
 
+def _inflexible_breadth(token):
+    """Parse ``inflexible-breadth``."""
+    keyword = get_keyword(token)
+    if keyword in ('auto', 'min-content', 'max-content'):
+        return keyword
+    elif keyword:
+        return
+    length = get_length(token, negative=False, percentage=True)
+    if length:
+        return length
+
+
+def _track_breadth(token):
+    """Parse ``track-breadth``."""
+    if token.type == 'dimension' and token.value >= 0 and token.unit == 'fr':
+        return Dimension(token.value, token.unit)
+    return _inflexible_breadth(token)
+
+
+def _track_size(token):
+    """Parse ``track-size``."""
+    track_breadth = _track_breadth(token)
+    if track_breadth:
+        return track_breadth
+    function = parse_function(token)
+    if function:
+        name, args = function
+        if name == 'minmax':
+            if len(args) == 2:
+                inflexible_breadth = _inflexible_breadth(args[0])
+                track_breadth = _track_breadth(args[1])
+                if inflexible_breadth and track_breadth:
+                    return ('minmax()', inflexible_breadth, track_breadth)
+        elif name == 'fit-content':
+            if len(args) == 1:
+                length = get_length(args[0], negative=False, percentage=True)
+                if length:
+                    return ('fit-content()', length)
+
+
+def _fixed_size(token):
+    """Parse ``fixed-size``."""
+    length = get_length(token, negative=False, percentage=True)
+    if length:
+        return length
+    function = parse_function(token)
+    if function:
+        name, args = function
+        if name == 'minmax' and len(args) == 2:
+            length = get_length(args[0], negative=False, percentage=True)
+            if length:
+                track_breadth = _track_breadth(args[1])
+                if track_breadth:
+                    return ('minmax()', length, track_breadth)
+            keyword = get_keyword(args[0])
+            if keyword in ('min-content', 'max-content', 'auto') or length:
+                fixed_breadth = get_length(
+                    args[1], negative=False, percentage=True)
+                if fixed_breadth:
+                    return ('minmax()', length or keyword, fixed_breadth)
+
+
+def _line_names(arg):
+    """Parse ``line-names``."""
+    return_line_names = []
+    if arg.type == '[] block':
+        for token in arg.content:
+            if token.type == 'ident':
+                return_line_names.append(token.value)
+            elif token.type != 'whitespace':
+                return
+        return tuple(return_line_names)
+
+
+@property('grid-auto-columns')
+@property('grid-auto-rows')
+def grid_auto(tokens):
+    """``grid-auto-columns`` and ``grid-auto-rows`` properties validation."""
+    return_tokens = []
+    for token in tokens:
+        track_size = _track_size(token)
+        if track_size:
+            return_tokens.append(track_size)
+            continue
+        return
+    return tuple(return_tokens)
+
+
 @property()
-@single_token
-def order(token):
-    if token.type == 'number' and token.int_value is not None:
-        return token.int_value
+def grid_auto_flow(tokens):
+    """``grid-auto-flow`` property validation."""
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in ('row', 'column'):
+            return (keyword,)
+        elif keyword == 'dense':
+            return (keyword, 'row')
+    elif len(tokens) == 2:
+        keywords = [get_keyword(token) for token in tokens]
+        if 'dense' in keywords and ('row' in keywords or 'column' in keywords):
+            return tuple(keywords)
+
+
+@property('grid-template-columns')
+@property('grid-template-rows')
+def grid_template(tokens):
+    """``grid-template-columns`` and ``grid-template-rows`` validation."""
+    return_tokens = []
+    if len(tokens) == 1 and get_keyword(tokens[0]) == 'none':
+        return 'none'
+    if get_keyword(tokens[0]) == 'subgrid':
+        return_tokens.append('subgrid')
+        subgrid_tokens = []
+        for token in tokens[1:]:
+            line_names = _line_names(token)
+            if line_names is not None:
+                subgrid_tokens.append(line_names)
+                continue
+            function = parse_function(token)
+            if function:
+                name, args = function
+                if name == 'repeat' and len(args) >= 2:
+                    if (args[0].type == 'number' and
+                            args[0].is_integer and args[0].value >= 1):
+                        number = args[0].int_value
+                    elif get_keyword(args[0]) == 'auto-fill':
+                        number = 'auto-fill'
+                    else:
+                        return
+                    line_names_list = []
+                    for arg in args[1:]:
+                        line_names = _line_names(arg)
+                        if line_names is not None:
+                            line_names_list.append(line_names)
+                    subgrid_tokens.append(
+                        ('repeat()', number, tuple(line_names_list)))
+                    continue
+            return
+        return_tokens.append(tuple(subgrid_tokens))
+    else:
+        includes_auto_repeat = False
+        includes_track = False
+        last_is_line_name = False
+        for token in tokens:
+            line_names = _line_names(token)
+            if line_names is not None:
+                if last_is_line_name:
+                    return
+                last_is_line_name = True
+                return_tokens.append(line_names)
+                continue
+            fixed_size = _fixed_size(token)
+            if fixed_size:
+                if not last_is_line_name:
+                    return_tokens.append(())
+                last_is_line_name = False
+                return_tokens.append(fixed_size)
+                continue
+            track_size = _track_size(token)
+            if track_size:
+                if not last_is_line_name:
+                    return_tokens.append(())
+                last_is_line_name = False
+                return_tokens.append(track_size)
+                includes_track = True
+                continue
+            function = parse_function(token)
+            if function:
+                name, args = function
+                if name == 'repeat' and len(args) >= 2:
+                    if (args[0].type == 'number' and
+                            args[0].is_integer and args[0].value >= 1):
+                        number = args[0].int_value
+                    elif get_keyword(args[0]) in ('auto-fill', 'auto-fit'):
+                        # auto-repeat
+                        if includes_auto_repeat:
+                            return
+                        number = args[0].value
+                        includes_auto_repeat = True
+                    else:
+                        return
+                    names_and_sizes = []
+                    repeat_last_is_line_name = False
+                    for arg in args[1:]:
+                        line_names = _line_names(arg)
+                        if line_names is not None:
+                            if repeat_last_is_line_name:
+                                return
+                            names_and_sizes.append(line_names)
+                            repeat_last_is_line_name = True
+                            continue
+                        # fixed-repead
+                        fixed_size = _fixed_size(arg)
+                        if fixed_size:
+                            if not repeat_last_is_line_name:
+                                names_and_sizes.append(())
+                            repeat_last_is_line_name = False
+                            names_and_sizes.append(fixed_size)
+                            continue
+                        # track-repeat
+                        track_size = _track_size(arg)
+                        if track_size:
+                            includes_track = True
+                            if not repeat_last_is_line_name:
+                                names_and_sizes.append(())
+                            repeat_last_is_line_name = False
+                            names_and_sizes.append(track_size)
+                            continue
+                        return
+                    if not last_is_line_name:
+                        return_tokens.append(())
+                    last_is_line_name = False
+                    if not repeat_last_is_line_name:
+                        names_and_sizes.append(())
+                    return_tokens.append(
+                        ('repeat()', number, tuple(names_and_sizes)))
+                    continue
+            return
+        if includes_auto_repeat and includes_track:
+            return
+        if not last_is_line_name:
+            return_tokens.append(())
+    return tuple(return_tokens)
+
+
+@property()
+def grid_template_areas(tokens):
+    """``grid-template-areas`` property validation."""
+    if len(tokens) == 1 and get_keyword(tokens[0]) == 'none':
+        return 'none'
+    grid_areas = []
+    for token in tokens:
+        if token.type != 'string':
+            return
+        component_values = parse_component_value_list(token.value)
+        row = []
+        last_is_dot = False
+        for value in component_values:
+            if value.type == 'ident':
+                row.append(value.value)
+                last_is_dot = False
+            elif value.type == 'literal' and value.value == '.':
+                if last_is_dot:
+                    continue
+                row.append(None)
+                last_is_dot = True
+            elif value.type == 'whitespace':
+                last_is_dot = False
+            else:
+                return
+        if not row:
+            return
+        grid_areas.append(tuple(row))
+    # check row / column have the same sizes
+    if len(set(len(row) for row in grid_areas)) == 1:
+        # check areas are continuous rectangles
+        coordinates = set()
+        areas = set()
+        for y, row in enumerate(grid_areas):
+            for x, area in enumerate(row):
+                if (x, y) in coordinates or area is None:
+                    continue
+                if area in areas:
+                    return
+                areas.add(area)
+                coordinates.add((x, y))
+                nx = x + 1
+                for nx, narea in enumerate(row[x+1:], start=x+1):
+                    if narea != area:
+                        break
+                    coordinates.add((nx, y))
+                for ny, nrow in enumerate(grid_areas[y+1:], start=y+1):
+                    if set(nrow[x:nx]) == {area}:
+                        for nnx in range(x, nx):
+                            coordinates.add((nnx, ny))
+                    else:
+                        break
+        return tuple(grid_areas)
+
+
+@property('grid-row-start')
+@property('grid-row-end')
+@property('grid-column-start')
+@property('grid-column-end')
+def grid_line(tokens):
+    """``grid-[row|column]-[start—end]`` properties validation."""
+    if len(tokens) == 1:
+        token = tokens[0]
+        if keyword := get_keyword(token):
+            if keyword == 'auto':
+                return keyword
+            elif keyword != 'span':
+                return (None, None, keyword)
+        elif token.type == 'number' and token.is_integer and token.value:
+            return (None, token.int_value, None)
+        return
+    number = ident = span = None
+    for token in tokens:
+        if keyword := get_keyword(token):
+            if keyword == 'auto':
+                return
+            if keyword == 'span':
+                if span is None:
+                    span = 'span'
+                    continue
+            elif keyword and ident is None:
+                ident = keyword
+                continue
+        elif token.type == 'number' and token.is_integer and token.value:
+            if number is None:
+                number = token.int_value
+                continue
+        return
+    if span:
+        if number and number < 0:
+            return
+        elif ident or number:
+            return (span, number, ident)
+    elif number:
+        return (span, number, ident)
 
 
 @property()
@@ -1313,37 +1630,149 @@ def flex_wrap(keyword):
 
 
 @property()
-@single_keyword
-def justify_content(keyword):
+def justify_content(tokens):
     """``justify-content`` property validation."""
-    return keyword in (
-        'flex-start', 'flex-end', 'center', 'space-between', 'space-around',
-        'space-evenly', 'stretch')
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'center', 'space-between', 'space-around', 'space-evenly',
+                'stretch', 'normal', 'flex-start', 'flex-end',
+                'start', 'end', 'left', 'right'):
+            return (keyword,)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'flex-start', 'flex-end', 'left',
+                    'right'):
+                return keywords
 
 
 @property()
-@single_keyword
-def align_items(keyword):
+def justify_items(tokens):
+    """``justify-items`` property validation."""
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'normal', 'stretch', 'center', 'start', 'end', 'self-start',
+                'self-end', 'flex-start', 'flex-end', 'left', 'right',
+                'legacy'):
+            return (keyword,)
+        elif keyword == 'baseline':
+            return ('first', keyword)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'self-start', 'self-end',
+                    'flex-start', 'flex-end', 'left', 'right'):
+                return keywords
+        elif 'baseline' in keywords:
+            if 'first' in keywords or 'last' in keywords:
+                return keywords
+        elif 'legacy' in keywords:
+            if set(keywords) & {'left', 'right', 'center'}:
+                return keywords
+
+
+@property()
+def justify_self(tokens):
+    """``justify-self`` property validation."""
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'auto', 'normal', 'stretch', 'center', 'start', 'end',
+                'self-start', 'self-end', 'flex-start', 'flex-end', 'left',
+                'right'):
+            return (keyword,)
+        elif keyword == 'baseline':
+            return ('first', keyword)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'self-start', 'self-end',
+                    'flex-start', 'flex-end', 'left', 'right'):
+                return keywords
+        elif 'baseline' in keywords:
+            if 'first' in keywords or 'last' in keywords:
+                return keywords
+
+
+@property()
+def align_items(tokens):
     """``align-items`` property validation."""
-    return keyword in (
-        'flex-start', 'flex-end', 'center', 'baseline', 'stretch')
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'normal', 'stretch', 'center', 'start', 'end', 'self-start',
+                'self-end', 'flex-start', 'flex-end'):
+            return (keyword,)
+        elif keyword == 'baseline':
+            return ('first', keyword)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'self-start', 'self-end',
+                    'flex-start', 'flex-end'):
+                return keywords
+        elif 'baseline' in keywords:
+            if 'first' in keywords or 'last' in keywords:
+                return keywords
 
 
 @property()
-@single_keyword
-def align_self(keyword):
+def align_self(tokens):
     """``align-self`` property validation."""
-    return keyword in (
-        'auto', 'flex-start', 'flex-end', 'center', 'baseline', 'stretch')
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'auto', 'normal', 'stretch', 'center', 'start', 'end',
+                'self-start', 'self-end', 'flex-start', 'flex-end'):
+            return (keyword,)
+        elif keyword == 'baseline':
+            return ('first', keyword)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'self-start', 'self-end',
+                    'flex-start', 'flex-end'):
+                return keywords
+        elif 'baseline' in keywords:
+            if 'first' in keywords or 'last' in keywords:
+                return keywords
 
 
 @property()
-@single_keyword
-def align_content(keyword):
+def align_content(tokens):
     """``align-content`` property validation."""
-    return keyword in (
-        'flex-start', 'flex-end', 'center', 'space-between', 'space-around',
-        'space-evenly', 'stretch')
+    if len(tokens) == 1:
+        keyword = get_keyword(tokens[0])
+        if keyword in (
+                'center', 'space-between', 'space-around', 'space-evenly',
+                'stretch', 'normal', 'flex-start', 'flex-end',
+                'start', 'end'):
+            return (keyword,)
+        elif keyword == 'baseline':
+            return ('first', keyword)
+    elif len(tokens) == 2:
+        keywords = tuple(get_keyword(token) for token in tokens)
+        if keywords[0] in ('safe', 'unsafe'):
+            if keywords[1] in (
+                    'center', 'start', 'end', 'flex-start', 'flex-end'):
+                return keywords
+        elif 'baseline' in keywords:
+            if 'first' in keywords or 'last' in keywords:
+                return keywords
+
+
+@property()
+@single_token
+def order(token):
+    if token.type == 'number' and token.int_value is not None:
+        return token.int_value
 
 
 @property(unstable=True)
diff --git a/weasyprint/draw.py b/weasyprint/draw.py
index dbacd4fa5..dd4b4a5ca 100644
--- a/weasyprint/draw.py
+++ b/weasyprint/draw.py
@@ -1211,8 +1211,8 @@ def draw_inline_level(stream, page, box, offset_x=0, text_overflow='clip',
                       block_ellipsis='none'):
     if isinstance(box, StackingContext):
         stacking_context = box
-        assert isinstance(
-            stacking_context.box, (boxes.InlineBlockBox, boxes.InlineFlexBox))
+        assert isinstance(stacking_context.box, (
+            boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox))
         draw_stacking_context(stream, stacking_context)
     else:
         draw_background(stream, box.background)
diff --git a/weasyprint/formatting_structure/boxes.py b/weasyprint/formatting_structure/boxes.py
index 2dece56e5..f755e5762 100644
--- a/weasyprint/formatting_structure/boxes.py
+++ b/weasyprint/formatting_structure/boxes.py
@@ -68,6 +68,7 @@ class Box:
     # Default, may be overriden on instances.
     is_table_wrapper = False
     is_flex_item = False
+    is_grid_item = False
     is_for_root_element = False
     is_column = False
     is_leader = False
@@ -775,3 +776,23 @@ class InlineFlexBox(FlexContainerBox, InlineLevelBox):
     It behaves as inline on the outside and as a flex container on the inside.
 
     """
+
+
+class GridContainerBox(ParentBox):
+    """A box that contains only grid-items."""
+
+
+class GridBox(GridContainerBox, BlockLevelBox):
+    """A box that is both block-level and a grid container.
+
+    It behaves as block on the outside and as a grid container on the inside.
+
+    """
+
+
+class InlineGridBox(GridContainerBox, InlineLevelBox):
+    """A box that is both inline-level and a grid container.
+
+    It behaves as inline on the outside and as a grid container on the inside.
+
+    """
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index e6ec22c1d..c09345524 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -28,6 +28,9 @@
     ('block', 'flex'): boxes.FlexBox,
     ('inline', 'flex'): boxes.InlineFlexBox,
 
+    ('block', 'grid'): boxes.GridBox,
+    ('inline', 'grid'): boxes.InlineGridBox,
+
     ('table-row',): boxes.TableRowBox,
     ('table-row-group',): boxes.TableRowGroupBox,
     ('table-header-group',): boxes.TableRowGroupBox,
@@ -51,6 +54,7 @@ def create_anonymous_boxes(box):
     """Create anonymous boxes in box descendants according to layout rules."""
     box = anonymous_table_boxes(box)
     box = flex_boxes(box)
+    box = grid_boxes(box)
     box = inline_in_block(box)
     box = block_in_inline(box)
     return box
@@ -1038,6 +1042,47 @@ def flex_children(box, children):
         return children
 
 
+def grid_boxes(box):
+    """Remove and add boxes according to the grid model.
+
+    Take and return a ``Box`` object.
+
+    See https://drafts.csswg.org/css-grid-2/#grid-item
+
+    """
+    if not isinstance(box, boxes.ParentBox) or box.is_running():
+        return box
+
+    # Do recursion.
+    children = [grid_boxes(child) for child in box.children]
+    box.children = grid_children(box, children)
+    return box
+
+
+def grid_children(box, children):
+    if isinstance(box, boxes.GridContainerBox):
+        grid_children = []
+        for child in children:
+            if not child.is_absolutely_positioned():
+                child.is_grid_item = True
+            if isinstance(child, boxes.TextBox) and not child.text.strip(' '):
+                # TODO: ignore texts only containing "characters that can be
+                # affected by the white-space property"
+                # https://drafts.csswg.org/css-grid-2/#grid-item
+                continue
+            if isinstance(child, boxes.InlineLevelBox):
+                anonymous = boxes.BlockBox.anonymous_from(child, [child])
+                anonymous.style = child.style
+                child.is_grid_item = False
+                anonymous.is_grid_item = True
+                grid_children.append(anonymous)
+            else:
+                grid_children.append(child)
+        return grid_children
+    else:
+        return children
+
+
 def process_whitespace(box, following_collapsible_space=False):
     """First part of "The 'white-space' processing model".
 
diff --git a/weasyprint/layout/absolute.py b/weasyprint/layout/absolute.py
index fc63679de..dc89af214 100644
--- a/weasyprint/layout/absolute.py
+++ b/weasyprint/layout/absolute.py
@@ -178,6 +178,7 @@ def absolute_block(context, box, containing_block, fixed_boxes, bottom_space,
                    skip_stack, cb_x, cb_y, cb_width, cb_height):
     from .block import block_container_layout
     from .flex import flex_layout
+    from .grid import grid_layout
 
     translate_box_width, translate_x = absolute_width(
         box, context, cb_x, cb_y, cb_width, cb_height)
@@ -200,11 +201,16 @@ def absolute_block(context, box, containing_block, fixed_boxes, bottom_space,
             context, box, bottom_space, skip_stack, page_is_empty=True,
             absolute_boxes=absolute_boxes, fixed_boxes=fixed_boxes,
             adjoining_margins=None, discard=False, max_lines=None)
-    else:
+    elif isinstance(box, (boxes.FlexContainerBox)):
         new_box, resume_at, _, _, _ = flex_layout(
             context, box, bottom_space, skip_stack, containing_block,
             page_is_empty=True, absolute_boxes=absolute_boxes,
             fixed_boxes=fixed_boxes)
+    elif isinstance(box, (boxes.GridContainerBox)):
+        new_box, resume_at, _, _, _ = grid_layout(
+            context, box, bottom_space, skip_stack, containing_block,
+            page_is_empty=True, absolute_boxes=absolute_boxes,
+            fixed_boxes=fixed_boxes)
 
     for child_placeholder in absolute_boxes:
         absolute_layout(
@@ -252,16 +258,15 @@ def absolute_box_layout(context, box, containing_block, fixed_boxes,
     resolve_position_percentages(box, (cb_width, cb_height))
 
     context.create_block_formatting_context()
-    # Absolute tables are wrapped into block boxes
-    if isinstance(box, (boxes.BlockBox, boxes.FlexContainerBox)):
-        new_box, resume_at = absolute_block(
-            context, box, containing_block, fixed_boxes, bottom_space,
-            skip_stack, cb_x, cb_y, cb_width, cb_height)
-    else:
-        assert isinstance(box, boxes.BlockReplacedBox)
+    if isinstance(box, boxes.BlockReplacedBox):
         new_box = absolute_replaced(
             context, box, cb_x, cb_y, cb_width, cb_height)
         resume_at = None
+    else:
+        # Absolute tables are wrapped into block boxes
+        new_box, resume_at = absolute_block(
+            context, box, containing_block, fixed_boxes, bottom_space,
+            skip_stack, cb_x, cb_y, cb_width, cb_height)
     context.finish_block_formatting_context(new_box)
     return new_box, resume_at
 
diff --git a/weasyprint/layout/block.py b/weasyprint/layout/block.py
index 422c03167..8a993cd52 100644
--- a/weasyprint/layout/block.py
+++ b/weasyprint/layout/block.py
@@ -7,6 +7,7 @@
 from .column import columns_layout
 from .flex import flex_layout
 from .float import avoid_collisions, float_layout, get_clearance
+from .grid import grid_layout
 from .inline import iter_line_boxes
 from .min_max import handle_min_max_width
 from .percent import resolve_percentages, resolve_position_percentages
@@ -81,6 +82,10 @@ def block_level_layout_switch(context, box, bottom_space, skip_stack,
         result = flex_layout(
             context, box, bottom_space, skip_stack, containing_block,
             page_is_empty, absolute_boxes, fixed_boxes)
+    elif isinstance(box, boxes.GridBox):
+        result = grid_layout(
+            context, box, bottom_space, skip_stack, containing_block,
+            page_is_empty, absolute_boxes, fixed_boxes)
     else:  # pragma: no cover
         raise TypeError(f'Layout for {type(box).__name__} not handled yet')
     return (*result, None)
@@ -644,7 +649,8 @@ def block_container_layout(context, box, bottom_space, skip_stack,
 
     collapsing_with_children = not (
         box.border_top_width or box.padding_top or box.is_flex_item or
-        establishes_formatting_context(box) or box.is_for_root_element)
+        box.is_grid_item or establishes_formatting_context(box) or
+        box.is_for_root_element)
     if collapsing_with_children:
         # Not counting margins in adjoining_margins, if any
         # (there are not padding or borders, see above)
diff --git a/weasyprint/layout/column.py b/weasyprint/layout/column.py
index 9f5ac5359..bd77e0b6c 100644
--- a/weasyprint/layout/column.py
+++ b/weasyprint/layout/column.py
@@ -26,6 +26,10 @@ def columns_layout(context, box, bottom_space, skip_stack, containing_block,
         # New containing block, use a new absolute list
         absolute_boxes = []
 
+    if gap == 'normal':
+        # 1em because in column context
+        gap = style['font_size']
+
     box = box.copy_with_children(box.children)
     box.position_y += collapse_margin(adjoining_margins) - box.margin_top
 
diff --git a/weasyprint/layout/flex.py b/weasyprint/layout/flex.py
index 330538cfd..e7b6c9a48 100644
--- a/weasyprint/layout/flex.py
+++ b/weasyprint/layout/flex.py
@@ -489,7 +489,7 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
             for i, child in line:
                 align_self = child.style['align_self']
                 if (box.style['flex_direction'].startswith('row') and
-                        align_self == 'baseline' and
+                        'baseline' in align_self and
                         child.margin_top != 'auto' and
                         child.margin_bottom != 'auto'):
                     collected_items.append(child)
@@ -536,7 +536,10 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
             min_cross_size, min(line.cross_size, max_cross_size))
 
     # Step 9
-    if box.style['align_content'] == 'stretch':
+    align_content = box.style['align_content']
+    if 'normal' in align_content:
+        align_content = ('stretch',)
+    if 'stretch' in align_content:
         definite_cross_size = None
         if cross == 'height' and box.style['height'] != 'auto':
             definite_cross_size = box.style['height'].value
@@ -556,12 +559,17 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
     # TODO: Step 10
 
     # Step 11
+    align_items = box.style['align_items']
+    if 'normal' in align_items:
+        align_items = ('stretch',)
     for line in flex_lines:
         for i, child in line:
             align_self = child.style['align_self']
-            if align_self == 'auto':
-                align_self = box.style['align_items']
-            if align_self == 'stretch' and child.style[cross] == 'auto':
+            if 'normal' in align_self:
+                align_self = ('stretch',)
+            elif 'auto' in align_self:
+                align_self = align_items
+            if 'stretch' in align_self and child.style[cross] == 'auto':
                 cross_margins = (
                     (child.margin_top, child.margin_bottom)
                     if cross == 'height'
@@ -590,11 +598,13 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
         box.content_box_x() if axis == 'width'
         else box.content_box_y())
     justify_content = box.style['justify_content']
+    if 'normal' in justify_content:
+        justify_content = ('flex-start',)
     if box.style['flex_direction'].endswith('-reverse'):
-        if justify_content == 'flex-start':
-            justify_content = 'flex-end'
+        if 'flex-start' in justify_content:
+            justify_content = ('flex-end',)
         elif justify_content == 'flex-end':
-            justify_content = 'flex-start'
+            justify_content = ('flex-start',)
 
     for line in flex_lines:
         position_axis = original_position_axis
@@ -645,19 +655,19 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
         if box.style['direction'] == 'rtl' and axis == 'width':
             free_space *= -1
 
-        if justify_content == 'flex-end':
+        if {'end', 'flex-end', 'right'} & set(justify_content):
             position_axis += free_space
-        elif justify_content == 'center':
+        elif 'center' in justify_content:
             position_axis += free_space / 2
-        elif justify_content == 'space-around':
+        elif 'space-around' in justify_content:
             position_axis += free_space / len(line) / 2
-        elif justify_content == 'space-evenly':
+        elif 'space-evenly' in justify_content:
             position_axis += free_space / (len(line) + 1)
 
         for i, child in line:
             if axis == 'width':
                 child.position_x = position_axis
-                if justify_content == 'stretch':
+                if 'stretch' in justify_content:
                     child.width += free_space / len(line)
             else:
                 child.position_y = position_axis
@@ -667,12 +677,12 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
             if box.style['direction'] == 'rtl' and axis == 'width':
                 margin_axis *= -1
             position_axis += margin_axis
-            if justify_content == 'space-around':
+            if 'space-around' in justify_content:
                 position_axis += free_space / len(line)
-            elif justify_content == 'space-between':
+            elif 'space-between' in justify_content:
                 if len(line) > 1:
                     position_axis += free_space / (len(line) - 1)
-            elif justify_content == 'space-evenly':
+            elif 'space-evenly' in justify_content:
                 position_axis += free_space / (len(line) + 1)
 
     # Step 13
@@ -684,9 +694,9 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
         # TODO: don't duplicate this loop
         for i, child in line:
             align_self = child.style['align_self']
-            if align_self == 'auto':
-                align_self = box.style['align_items']
-            if align_self == 'baseline' and axis == 'width':
+            if 'auto' in align_self:
+                align_self = align_items
+            if 'baseline' in align_self and axis == 'width':
                 # TODO: handle vertical text
                 child.baseline = child._baseline - position_cross
                 line.lower_baseline = max(line.lower_baseline, child.baseline)
@@ -735,32 +745,34 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
             else:
                 # Step 14
                 align_self = child.style['align_self']
-                if align_self == 'auto':
-                    align_self = box.style['align_items']
+                if 'normal' in align_self:
+                    align_self = ('stretch',)
+                elif 'auto' in align_self:
+                    align_self = align_items
                 position = 'position_y' if cross == 'height' else 'position_x'
                 setattr(child, position, position_cross)
-                if align_self == 'flex-end':
+                if {'end', 'self-end', 'flex-end'} & set(align_self):
                     if cross == 'height':
                         child.position_y += (
                             line.cross_size - child.margin_height())
                     else:
                         child.position_x += (
                             line.cross_size - child.margin_width())
-                elif align_self == 'center':
+                elif 'center' in align_self:
                     if cross == 'height':
                         child.position_y += (
                             line.cross_size - child.margin_height()) / 2
                     else:
                         child.position_x += (
                             line.cross_size - child.margin_width()) / 2
-                elif align_self == 'baseline':
+                elif 'baseline' in align_self:
                     if cross == 'height':
                         child.position_y += (
                             line.lower_baseline - child.baseline)
                     else:
                         # Handle vertical text
                         pass
-                elif align_self == 'stretch':
+                elif 'stretch' in align_self:
                     if child.style[cross] == 'auto':
                         if cross == 'height':
                             margins = child.margin_top + child.margin_bottom
@@ -801,29 +813,29 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block,
                         current_value = getattr(child, direction)
                         current_value += cross_translate
                         setattr(child, direction, current_value)
-                        if box.style['align_content'] == 'flex-end':
+                        if {'flex-end', 'end'} & set(align_content):
                             setattr(
                                 child, direction,
                                 current_value + extra_cross_size)
-                        elif box.style['align_content'] == 'center':
+                        elif 'center' in align_content:
                             setattr(
                                 child, direction,
                                 current_value + extra_cross_size / 2)
-                        elif box.style['align_content'] == 'space-around':
+                        elif 'space-around' in align_content:
                             setattr(
                                 child, direction,
                                 current_value + extra_cross_size /
                                 len(flex_lines) / 2)
-                        elif box.style['align_content'] == 'space-evenly':
+                        elif 'space-evenly' in align_content:
                             setattr(
                                 child, direction,
                                 current_value + extra_cross_size /
                                 (len(flex_lines) + 1))
-                if box.style['align_content'] == 'space-between':
+                if 'space-between' in align_content:
                     cross_translate += extra_cross_size / (len(flex_lines) - 1)
-                elif box.style['align_content'] == 'space-around':
+                elif 'space-around' in align_content:
                     cross_translate += extra_cross_size / len(flex_lines)
-                elif box.style['align_content'] == 'space-evenly':
+                elif 'space-evenly' in align_content:
                     cross_translate += extra_cross_size / (len(flex_lines) + 1)
 
     # TODO: don't use block_box_layout, see TODOs in Step 14 and
diff --git a/weasyprint/layout/float.py b/weasyprint/layout/float.py
index daff903aa..0f2107e03 100644
--- a/weasyprint/layout/float.py
+++ b/weasyprint/layout/float.py
@@ -21,6 +21,7 @@ def float_layout(context, box, containing_block, absolute_boxes, fixed_boxes,
     """Set the width and position of floating ``box``."""
     from .block import block_container_layout
     from .flex import flex_layout
+    from .grid import grid_layout
 
     cb_width, cb_height = (containing_block.width, containing_block.height)
     resolve_percentages(box, (cb_width, cb_height))
@@ -68,6 +69,12 @@ def float_layout(context, box, containing_block, absolute_boxes, fixed_boxes,
             skip_stack=skip_stack, containing_block=containing_block,
             page_is_empty=True, absolute_boxes=absolute_boxes,
             fixed_boxes=fixed_boxes)
+    elif isinstance(box, boxes.GridContainerBox):
+        box, resume_at, _, _, _ = grid_layout(
+            context, box, bottom_space=bottom_space,
+            skip_stack=skip_stack, containing_block=containing_block,
+            page_is_empty=True, absolute_boxes=absolute_boxes,
+            fixed_boxes=fixed_boxes)
     else:
         assert isinstance(box, boxes.BlockReplacedBox)
         resume_at = None
diff --git a/weasyprint/layout/grid.py b/weasyprint/layout/grid.py
new file mode 100644
index 000000000..a003f1768
--- /dev/null
+++ b/weasyprint/layout/grid.py
@@ -0,0 +1,1181 @@
+"""Layout for grid containers and grid-items."""
+
+from itertools import count, cycle
+from math import inf
+
+from ..css.properties import Dimension
+from ..formatting_structure import boxes
+from ..logger import LOGGER
+from .percent import percentage, resolve_percentages
+from .preferred import max_content_width, min_content_width
+from .table import find_in_flow_baseline
+
+
+def _is_length(sizing):
+    return isinstance(sizing, Dimension) and sizing.unit != 'fr'
+
+
+def _is_fr(sizing):
+    return isinstance(sizing, Dimension) and sizing.unit == 'fr'
+
+
+def _intersect(position_1, size_1, position_2, size_2):
+    return (
+        position_1 < position_2 + size_2 and
+        position_2 < position_1 + size_1)
+
+
+def _intersect_with_children(x, y, width, height, positions):
+    for full_x, full_y, full_width, full_height in positions:
+        x_intersect = _intersect(x, width, full_x, full_width)
+        y_intersect = _intersect(y, height, full_y, full_height)
+        if x_intersect and y_intersect:
+            return True
+    return False
+
+
+def _get_line(line, lines, side):
+    span, number, ident = line
+    if ident and span is None and number is None:
+        for coord, line in enumerate(lines):
+            if f'{ident}-{side}' in line:
+                break
+        else:
+            number = 1
+    if number is not None and span is None:
+        if ident is None:
+            coord = number - 1
+        else:
+            step = 1 if number > 0 else -1
+            for coord, line in enumerate(lines[::step]):
+                if ident in line:
+                    number -= step
+                    break
+                if number == 0:
+                    break
+            else:
+                coord += abs(number)
+            if step == -1:
+                coord = len(lines) - 1 - coord
+    if span is not None:
+        coord = None
+    return span, number, ident, coord
+
+
+def _get_placement(start, end, lines):
+    # Input coordinates are 1-indexed, returned coordinates are 0-indexed.
+    if start == 'auto' or start[0] == 'span':
+        if end == 'auto' or end[0] == 'span':
+            return
+    if start != 'auto':
+        span, number, ident, coord = _get_line(start, lines, 'start')
+        if span is not None:
+            size = number or 1
+            span_ident = ident
+    else:
+        size = 1
+        span_ident = coord = None
+    if end != 'auto':
+        span, number, ident, coord_end = _get_line(end, lines, 'end')
+        if span is not None:
+            size = span_number = number or 1
+            span_ident = ident
+            if span_ident is not None:
+                for size, line in enumerate(lines[coord+1:], start=1):
+                    if span_ident in line:
+                        span_number -= 1
+                    if span_number == 0:
+                        break
+                else:
+                    size += span_number
+        elif coord is not None:
+            size = coord_end - coord
+        if coord is None:
+            if span_ident is None:
+                coord = coord_end - size
+            else:
+                number = number or 1
+                if coord_end > 0:
+                    iterable = enumerate(lines[coord_end-1::-1])
+                    for coord, line in iterable:
+                        if span_ident in line:
+                            number -= 1
+                        if number == 0:
+                            coord = coord_end - 1 - coord
+                            break
+                    else:
+                        coord = -number
+                else:
+                    coord = -number
+            size = coord_end - coord
+    else:
+        size = 1
+    if size < 0:
+        size = -size
+        coord -= size
+    if size == 0:
+        size = 1
+    return (coord, size)
+
+
+def _get_span(place):
+    # TODO: Handle lines.
+    span = 1
+    if place[0] == 'span':
+        span = place[1] or 1
+    return span
+
+
+def _get_column_placement(row_placement, column_start, column_end,
+                          columns, children_positions, dense):
+    occupied_columns = set()
+    for x, y, width, height in children_positions.values():
+        # Test whether cells overlap.
+        if _intersect(y, height, *row_placement):
+            for x in range(x, x + width):
+                occupied_columns.add(x)
+    if dense:
+        for x in count():
+            if x in occupied_columns:
+                continue
+            if column_start == 'auto':
+                placement = _get_placement(
+                    (None, x + 1, None), column_end, columns)
+            else:
+                assert column_start[0] == 'span'
+                # If the placement contains two spans, remove the one
+                # contributed by the end grid-placement property.
+                # https://drafts.csswg.org/css-grid/#grid-placement-errors
+                assert column_start == 'auto' or column_start[0] == 'span'
+                span = _get_span(column_start)
+                placement = _get_placement(
+                    column_start, (None, x + 1 + span, None), columns)
+            columns = range(placement[0], placement[0] + placement[1])
+            if not set(columns) & occupied_columns:
+                return placement
+    else:
+        y = max(occupied_columns or [0]) + 1
+        if column_start == 'auto':
+            return _get_placement(
+                (None, y + 1, None), column_end, columns)
+        else:
+            assert column_start[0] == 'span'
+            # If the placement contains two spans, remove the one contributed
+            # by the end grid-placement property.
+            # https://drafts.csswg.org/css-grid/#grid-placement-errors
+            assert column_start == 'auto' or column_start[0] == 'span'
+            for end_y in count(y + 1):
+                placement = _get_placement(
+                    column_start, (None, end_y + 1, None), columns)
+                if placement[0] >= y:
+                    return placement
+
+
+def _get_sizing_functions(size):
+    min_sizing = max_sizing = size
+    if size[0] == 'minmax()':
+        min_sizing, max_sizing = size[1:]
+    if min_sizing[0] == 'fit-content()':
+        min_sizing = 'auto'
+    elif _is_fr(min_sizing):
+        min_sizing = 'auto'
+    return (min_sizing, max_sizing)
+
+
+def _get_template_tracks(tracks):
+    if tracks == 'none':
+        tracks = ((),)
+    if 'subgrid' in tracks:
+        # TODO: Support subgrids.
+        LOGGER.warning('Subgrids are unsupported')
+        return [[]]
+    tracks_list = []
+    for i, track in enumerate(tracks):
+        if i % 2:
+            # Track size.
+            if track[0] == 'repeat()':
+                repeat_number, repeat_track_list = track[1:]
+                if not isinstance(repeat_number, int):
+                    # TODO: Respect auto-fit and auto-fill.
+                    LOGGER.warning(
+                        '"auto-fit" and "auto-fill" are unsupported in repeat()')
+                    repeat_number = 1
+                for _ in range(repeat_number):
+                    for j, repeat_track in enumerate(repeat_track_list):
+                        if j % 2:
+                            # Track size in repeat.
+                            tracks_list.append(repeat_track)
+                        else:
+                            # Line names in repeat.
+                            if len(tracks_list) % 2:
+                                tracks_list[-1].extend(repeat_track)
+                            else:
+                                tracks_list.append(list(repeat_track))
+            else:
+                tracks_list.append(track)
+        else:
+            # Line names.
+            if len(tracks_list) % 2:
+                tracks_list[-1].extend(track)
+            else:
+                tracks_list.append(list(track))
+    return tracks_list
+
+
+def _distribute_extra_space(affected_sizes, affected_tracks_types,
+                            size_contribution, tracks_children,
+                            sizing_functions, tracks_sizes, span, direction,
+                            context, containing_block):
+    assert affected_sizes in ('min', 'max')
+    assert affected_tracks_types in (
+        'intrinsic', 'content-based', 'max-content')
+    assert size_contribution in ('mininum', 'min-content', 'max-content')
+    assert direction in 'xy'
+
+    # 1. Maintain separately for each affected track a planned increase.
+    planned_increases = [0] * len(tracks_sizes)
+
+    # 2. Distribute space.
+    affected_tracks = []
+    affected_size_index = 0 if affected_sizes == 'min' else 1
+    for functions in sizing_functions:
+        function = functions[affected_size_index]
+        if affected_tracks_types == 'intrinsic':
+            if (function in ('min-content', 'max-content', 'auto') or
+                    function[0] == 'fit-content()'):
+                affected_tracks.append(True)
+                continue
+        elif affected_tracks_types == 'content-based':
+            if function in ('min-content', 'max-content'):
+                affected_tracks.append(True)
+                continue
+        elif affected_tracks_types == 'max-content':
+            if function in ('max-content', 'auto'):
+                affected_tracks.append(True)
+                continue
+        affected_tracks.append(False)
+    for i, children in enumerate(tracks_children):
+        if not children:
+            continue
+        for item in children:
+            # 2.1 Find the space distribution.
+            # TODO: Differenciate minimum and min-content values.
+            # TODO: Find a better way to get height.
+            if direction == 'x':
+                if size_contribution in ('minimum', 'min-content'):
+                    space = min_content_width(context, item)
+                else:
+                    space = max_content_width(context, item)
+            else:
+                from .block import block_level_layout
+                item = item.deepcopy()
+                item.position_x = 0
+                item.position_y = 0
+                item, _, _, _, _, _ = block_level_layout(
+                    context, item, bottom_space=-inf, skip_stack=None,
+                    containing_block=containing_block, page_is_empty=True,
+                    absolute_boxes=[], fixed_boxes=[])
+                space = item.margin_height()
+            for sizes in tracks_sizes[i:i+span]:
+                space -= sizes[affected_size_index]
+            space = max(0, space)
+            # 2.2 Distribute space up to limits.
+            tracks_numbers = list(
+                enumerate(affected_tracks[i:i+span], start=i))
+            item_incurred_increases = [0] * len(sizing_functions)
+            affected_tracks_numbers = [
+                j for j, affected in tracks_numbers if affected]
+            distributed_space = space / (len(affected_tracks_numbers) or 1)
+            for track_number in affected_tracks_numbers:
+                base_size, growth_limit = tracks_sizes[track_number]
+                item_incurred_increase = distributed_space
+                affected_size = tracks_sizes[track_number][affected_size_index]
+                limit = tracks_sizes[track_number][1]
+                if affected_size + item_incurred_increase >= limit:
+                    extra = (
+                        item_incurred_increase + affected_size - limit)
+                    item_incurred_increase -= extra
+                space -= item_incurred_increase
+                item_incurred_increases[track_number] = item_incurred_increase
+            # 2.3 Distribute space to non-affected tracks.
+            if space and affected_tracks_numbers:
+                unaffected_tracks_numbers = [
+                    j for j, affected in tracks_numbers if not affected]
+                distributed_space = (
+                    space / (len(unaffected_tracks_numbers) or 1))
+                for track_number in unaffected_tracks_numbers:
+                    base_size, growth_limit = tracks_sizes[track_number]
+                    item_incurred_increase = distributed_space
+                    affected_size = (
+                        tracks_sizes[track_number][affected_size_index])
+                    limit = tracks_sizes[track_number][1]
+                    if affected_size + item_incurred_increase >= limit:
+                        extra = (
+                            item_incurred_increase + affected_size - limit)
+                        item_incurred_increase -= extra
+                    space -= item_incurred_increase
+                    item_incurred_increases[track_number] = (
+                        item_incurred_increase)
+            # 2.4 Distribute space beyond limits.
+            if space:
+                # TODO: Distribute space beyond limits.
+                pass
+            # 2.5. Set the track’s planned increase.
+            for k, extra in enumerate(item_incurred_increases):
+                if extra > planned_increases[k]:
+                    planned_increases[k] = extra
+    # 3. Update the tracks’ affected size.
+    for i, increase in enumerate(planned_increases):
+        if affected_sizes == 'max' and tracks_sizes[i][1] is inf:
+            tracks_sizes[i][1] = tracks_sizes[i][0] + increase
+        else:
+            tracks_sizes[i][affected_size_index] += increase
+
+
+def _resolve_tracks_sizes(sizing_functions, box_size, children_positions,
+                          implicit_start, direction, gap, context,
+                          containing_block, orthogonal_sizes=None):
+    assert direction in 'xy'
+    tracks_sizes = []
+    # TODO: Check that auto box size is 0 for percentages.
+    percent_box_size = 0 if box_size == 'auto' else box_size
+    # 1.1 Initialize track sizes.
+    for min_function, max_function in sizing_functions:
+        base_size = None
+        if _is_length(min_function):
+            base_size = percentage(min_function, percent_box_size)
+        elif (min_function in ('min-content', 'max-content', 'auto') or
+              min_function[0] == 'fit-content()'):
+            base_size = 0
+        growth_limit = None
+        if _is_length(max_function):
+            growth_limit = percentage(max_function, percent_box_size)
+        elif (max_function in ('min-content', 'max-content', 'auto') or
+              max_function[0] == 'fit-content()' or _is_fr(max_function)):
+            growth_limit = inf
+        if None not in (base_size, growth_limit):
+            growth_limit = max(base_size, growth_limit)
+        tracks_sizes.append([base_size, growth_limit])
+
+    # 1.2 Resolve intrinsic track sizes.
+    # 1.2.1 Shim baseline-aligned items.
+    # TODO: Shim items.
+    # 1.2.2 Size tracks to fit non-spanning items.
+    tracks_children = [[] for _ in range(len(tracks_sizes))]
+    for child, (x, y, width, height) in children_positions.items():
+        coord, size = (x, width) if direction == 'x' else (y, height)
+        if size != 1:
+            continue
+        tracks_children[coord - implicit_start].append(child)
+    iterable = zip(tracks_children, sizing_functions, tracks_sizes)
+    for children, (min_function, max_function), sizes in iterable:
+        if not children:
+            continue
+        if direction == 'y':
+            # TODO: Find a better way to get height.
+            from .block import block_level_layout
+            height = 0
+            for child in children:
+                x, _, width, _ = children_positions[child]
+                width = sum(orthogonal_sizes[x:x+width])
+                child = child.deepcopy()
+                child.position_x = 0
+                child.position_y = 0
+                parent = boxes.BlockContainerBox.anonymous_from(
+                    containing_block, ())
+                resolve_percentages(parent, containing_block)
+                parent.position_x = child.position_x
+                parent.position_y = child.position_y
+                parent.width = width
+                parent.height = height
+                bottom_space = -inf
+                child, _, _, _, _, _ = block_level_layout(
+                    context, child, bottom_space, skip_stack=None,
+                    containing_block=parent, page_is_empty=True,
+                    absolute_boxes=[], fixed_boxes=[])
+                height = max(height, child.margin_height())
+            if min_function in ('min-content', 'max_content', 'auto'):
+                sizes[0] = height
+            if max_function in ('min-content', 'max_content'):
+                sizes[1] = height
+            if None not in sizes:
+                sizes[1] = max(sizes)
+            continue
+        if min_function == 'min-content':
+            sizes[0] = max(0, *(
+                min_content_width(context, child) for child in children))
+        elif min_function == 'max-content':
+            sizes[0] = max(0, *(
+                max_content_width(context, child) for child in children))
+        elif min_function == 'auto':
+            # TODO: Handle min-/max-content constrained parents.
+            # TODO: Use real "minimum contributions".
+            sizes[0] = max(0, *(
+                min_content_width(context, child) for child in children))
+        if max_function == 'min-content':
+            sizes[1] = max(
+                min_content_width(context, child) for child in children)
+        elif (max_function in ('auto', 'max-content') or
+              max_function[0] == 'fit_content()'):
+            sizes[1] = max(
+                max_content_width(context, child) for child in children)
+        if None not in sizes:
+            sizes[1] = max(sizes)
+    # 1.2.3 Increase sizes to accommodate items spanning content-sized tracks.
+    spans = sorted({
+        width if direction == 'x' else height
+        for (_, _, width, height) in children_positions.values()
+        if (width if direction == 'x' else height) >= 2})
+    for span in spans:
+        tracks_children = [[] for _ in range(len(sizing_functions))]
+        iterable = enumerate(children_positions.items())
+        for i, (child, (x, y, width, height)) in iterable:
+            coord, size = (x, width) if direction == 'x' else (y, height)
+            if size != span:
+                continue
+            for _, max_function in sizing_functions[i:i+span+1]:
+                if _is_fr(max_function):
+                    break
+            else:
+                tracks_children[coord - implicit_start].append(child)
+        # 1.2.3.1 For intrinsic minimums.
+        # TODO: Respect min-/max-content constraint.
+        _distribute_extra_space(
+            'min', 'intrinsic', 'mininum', tracks_children,
+            sizing_functions, tracks_sizes, span, direction, context,
+            containing_block)
+        # 1.2.3.2 For content-based minimums.
+        _distribute_extra_space(
+            'min', 'content-based', 'min-content', tracks_children,
+            sizing_functions, tracks_sizes, span, direction, context,
+            containing_block)
+        # 1.2.3.3 For max-content minimums.
+        # TODO: Respect max-content constraint.
+        _distribute_extra_space(
+            'min', 'max-content', 'max-content', tracks_children,
+            sizing_functions, tracks_sizes, span, direction, context,
+            containing_block)
+        # 1.2.3.4 Increase growth limit.
+        for sizes in tracks_sizes:
+            if None not in sizes:
+                sizes[1] = max(sizes)
+        iterable = enumerate(children_positions.items())
+        for i, (child, (x, y, width, height)) in iterable:
+            coord, size = (x, width) if direction == 'x' else (y, height)
+            if size != span:
+                continue
+            for _, max_function in sizing_functions[i:i+span+1]:
+                if _is_fr(max_function):
+                    break
+            else:
+                tracks_children[coord - implicit_start].append(child)
+        # 1.2.3.5 For intrinsic maximums.
+        _distribute_extra_space(
+            'max', 'intrinsic', 'min-content', tracks_children,
+            sizing_functions, tracks_sizes, span, direction, context,
+            containing_block)
+        # 1.2.3.6 For max-content maximums.
+        _distribute_extra_space(
+            'max', 'max-content', 'max-content', tracks_children,
+            sizing_functions, tracks_sizes, span, direction, context,
+            containing_block)
+    # 1.2.4 Increase sizes to accommodate items spanning flexible tracks.
+    # TODO: Support spans for flexible tracks.
+    # 1.2.5 Fix infinite growth limits.
+    for sizes in tracks_sizes:
+        if sizes[1] is inf:
+            sizes[1] = sizes[0]
+    # 1.3 Maximize tracks.
+    if box_size == 'auto':
+        free_space = None
+    else:
+        free_space = (
+            box_size -
+            sum(size[0] for size in tracks_sizes) -
+            (len(tracks_sizes) - 1) * gap)
+    if free_space is not None and free_space > 0:
+        distributed_free_space = free_space / len(tracks_sizes)
+        for i, sizes in enumerate(tracks_sizes):
+            base_size, growth_limit = sizes
+            if base_size + distributed_free_space > growth_limit:
+                sizes[0] = growth_limit
+                free_space -= growth_limit - base_size
+            else:
+                sizes[0] += distributed_free_space
+                free_space -= distributed_free_space
+    # TODO: Respect max-width/-height.
+    # 1.4 Expand flexible tracks.
+    if free_space is not None and free_space <= 0:
+        # TODO: Respect min-content constraint.
+        flex_fraction = 0
+    elif free_space is not None:
+        stop = False
+        inflexible_tracks = set()
+        while not stop:
+            leftover_space = free_space
+            flex_factor_sum = 0
+            iterable = enumerate(zip(tracks_sizes, sizing_functions))
+            for i, (sizes, (_, max_function)) in iterable:
+                if _is_fr(max_function):
+                    leftover_space += sizes[0]
+                    if i not in inflexible_tracks:
+                        flex_factor_sum += max_function.value
+            flex_factor_sum = max(1, flex_factor_sum)
+            hypothetical_fr_size = leftover_space / flex_factor_sum
+            stop = True
+            iterable = enumerate(zip(tracks_sizes, sizing_functions))
+            for i, (sizes, (_, max_function)) in iterable:
+                if i not in inflexible_tracks and _is_fr(max_function):
+                    if hypothetical_fr_size * max_function.value < sizes[0]:
+                        inflexible_tracks.add(i)
+                        stop = False
+        flex_fraction = hypothetical_fr_size
+    else:
+        flex_fraction = 0
+        iterable = zip(tracks_sizes, sizing_functions)
+        for sizes, (_, max_function) in iterable:
+            if _is_fr(max_function):
+                if max_function.value > 1:
+                    flex_fraction = max(
+                        flex_fraction, max_function.value * sizes[0])
+                else:
+                    flex_fraction = max(flex_fraction, sizes[0])
+        # TODO: Respect grid items max-content contribution.
+        # TODO: Respect min-* constraint.
+    iterable = enumerate(zip(tracks_sizes, sizing_functions))
+    for i, (sizes, (_, max_function)) in iterable:
+        if _is_fr(max_function):
+            if flex_fraction * max_function.value > sizes[0]:
+                if free_space is not None:
+                    free_space -= flex_fraction * max_function.value
+                sizes[0] = flex_fraction * max_function.value
+    # 1.5 Expand stretched auto tracks.
+    justify_content = containing_block.style['justify_content']
+    align_content = containing_block.style['align_content']
+    x_stretch = (
+        direction == 'x' and set(justify_content) & {'normal', 'stretch'})
+    y_stretch = (
+        direction == 'y' and set(align_content) & {'normal', 'stretch'})
+    if (x_stretch or y_stretch) and free_space is not None and free_space > 0:
+        auto_tracks_sizes = [
+            sizes for sizes, (min_function, _)
+            in zip(tracks_sizes, sizing_functions)
+            if min_function == 'auto']
+        if auto_tracks_sizes:
+            distributed_free_space = free_space / len(auto_tracks_sizes)
+            for sizes in auto_tracks_sizes:
+                sizes[0] += distributed_free_space
+
+    return tracks_sizes
+
+
+def grid_layout(context, box, bottom_space, skip_stack, containing_block,
+                page_is_empty, absolute_boxes, fixed_boxes):
+    context.create_block_formatting_context()
+
+    # Define explicit grid
+    grid_areas = box.style['grid_template_areas']
+    flow = box.style['grid_auto_flow']
+    auto_rows = cycle(box.style['grid_auto_rows'])
+    auto_columns = cycle(box.style['grid_auto_columns'])
+    auto_rows_back = cycle(box.style['grid_auto_rows'][::-1])
+    auto_columns_back = cycle(box.style['grid_auto_columns'][::-1])
+    column_gap = box.style['column_gap']
+    if column_gap == 'normal':
+        column_gap = 0
+    row_gap = box.style['row_gap']
+    if row_gap == 'normal':
+        row_gap = 0
+
+    # TODO: Support 'column' value in grid-auto-flow.
+    if 'column' in flow:
+        LOGGER.warning('"column" is not supported in grid-auto-flow')
+
+    if grid_areas == 'none':
+        grid_areas = ((None,),)
+    grid_areas = [list(row) for row in grid_areas]
+
+    rows = _get_template_tracks(box.style['grid_template_rows'])
+    columns = _get_template_tracks(box.style['grid_template_columns'])
+
+    # Adjust rows number
+    grid_areas_columns = len(grid_areas[0]) if grid_areas else 0
+    rows_diff = int((len(rows) - 1) / 2) - len(grid_areas)
+    if rows_diff > 0:
+        for _ in range(rows_diff):
+            grid_areas.append([None] * grid_areas_columns)
+    elif rows_diff < 0:
+        for _ in range(-rows_diff):
+            rows.append(next(auto_rows))
+            rows.append([])
+
+    # Adjust columns number
+    columns_diff = int((len(columns) - 1) / 2) - grid_areas_columns
+    if columns_diff > 0:
+        for row in grid_areas:
+            for _ in range(columns_diff):
+                row.append(None)
+    elif columns_diff < 0:
+        for _ in range(-columns_diff):
+            columns.append(next(auto_columns))
+            columns.append([])
+
+    # Add implicit line names
+    for y, row in enumerate(grid_areas):
+        for x, area_name in enumerate(row):
+            if area_name is None:
+                continue
+            start_name = f'{area_name}-start'
+            names = [name for row in rows[::2] for name in row]
+            if start_name not in names:
+                rows[2*y].append(start_name)
+            names = [name for column in columns[::2] for name in column]
+            if start_name not in names:
+                columns[2*x].append(start_name)
+    for y, row in enumerate(grid_areas[::-1]):
+        for x, area_name in enumerate(row[::-1]):
+            if area_name is None:
+                continue
+            end_name = f'{area_name}-end'
+            names = [name for row in rows[::2] for name in row]
+            if end_name not in names:
+                rows[-2*y-1].append(end_name)
+            names = [name for column in columns[::2] for name in column]
+            if end_name not in names:
+                columns[-2*x-1].append(end_name)
+
+    # 1. Run the grid placement algorithm.
+
+    # 1.1 Position anything that’s not auto-positioned.
+    children_positions = {}
+    for child in box.children:
+        column_start = child.style['grid_column_start']
+        column_end = child.style['grid_column_end']
+        row_start = child.style['grid_row_start']
+        row_end = child.style['grid_row_end']
+
+        column_placement = _get_placement(
+            column_start, column_end, columns[::2])
+        row_placement = _get_placement(row_start, row_end, rows[::2])
+
+        if column_placement and row_placement:
+            x, width = column_placement
+            y, height = row_placement
+            children_positions[child] = (x, y, width, height)
+
+    # 1.2 Process the items locked to a given row.
+    children = sorted(box.children, key=lambda item: item.style['order'])
+    for child in children:
+        if child in children_positions:
+            continue
+        row_start = child.style['grid_row_start']
+        row_end = child.style['grid_row_end']
+        row_placement = _get_placement(row_start, row_end, rows[::2])
+        if not row_placement:
+            continue
+        y, height = row_placement
+        column_start = child.style['grid_column_start']
+        column_end = child.style['grid_column_end']
+        x, width = _get_column_placement(
+            row_placement, column_start, column_end, columns,
+            children_positions, 'dense' in flow)
+        children_positions[child] = (x, y, width, height)
+
+    # 1.3 Determine the columns in the implicit grid.
+    # 1.3.1 Start with the columns from the explicit grid.
+    implicit_x1 = 0
+    implicit_x2 = len(grid_areas[0]) if grid_areas else 0
+    # 1.3.2 Add columns to the beginning and end of the implicit grid.
+    remaining_grid_items = []
+    for child in children:
+        if child in children_positions:
+            x, _, width, _ = children_positions[child]
+        else:
+            column_start = child.style['grid_column_start']
+            column_end = child.style['grid_column_end']
+            column_placement = _get_placement(
+                column_start, column_end, columns[::2])
+            remaining_grid_items.append(child)
+            if column_placement:
+                x, width = column_placement
+            else:
+                continue
+        implicit_x1 = min(x, implicit_x1)
+        implicit_x2 = max(x + width, implicit_x2)
+    # 1.3.3 Add columns to accommodate max column span.
+    for child in remaining_grid_items:
+        column_start = child.style['grid_column_start']
+        column_end = child.style['grid_column_end']
+        span = 1
+        if column_start != 'auto' and column_start[0] == 'span':
+            span = column_start[1]
+        elif column_end != 'auto' and column_end[0] == 'span':
+            span = column_end[1]
+        implicit_x2 = max(implicit_x1 + (span or 1), implicit_x2)
+
+    # 1.4 Position the remaining grid items.
+    implicit_y1 = 0
+    implicit_y2 = len(grid_areas)
+    for position in children_positions.values():
+        _, y, _, height = position
+        implicit_y1 = min(y, implicit_y1)
+        implicit_y2 = max(y + height, implicit_y2)
+    cursor_x, cursor_y = implicit_x1, implicit_y1
+    if 'dense' in flow:
+        for child in remaining_grid_items:
+            column_start = child.style['grid_column_start']
+            column_end = child.style['grid_column_end']
+            column_placement = _get_placement(
+                column_start, column_end, columns[::2])
+            if column_placement:
+                # 1. Set the row position of the cursor.
+                cursor_y = implicit_y1
+                x, width = column_placement
+                cursor_x = x
+                # 2. Increment the cursor’s row position.
+                row_start = child.style['grid_row_start']
+                row_end = child.style['grid_row_end']
+                for y in count(cursor_y):
+                    if row_start == 'auto':
+                        y, height = _get_placement(
+                            (None, y + 1, None), row_end, rows[::2])
+                    else:
+                        assert row_start[0] == 'span'
+                        assert row_start == 'auto' or row_start[0] == 'span'
+                        span = _get_span(row_start)
+                        y, height = _get_placement(
+                            row_start, (None, y + 1 + span, None), rows[::2])
+                    if y < cursor_y:
+                        continue
+                    for row in range(y, y + height):
+                        intersect = _intersect_with_children(
+                            x, y, width, height, children_positions.values())
+                        if intersect:
+                            # Child intersects with a positioned child on
+                            # current row.
+                            break
+                    else:
+                        # Child doesn’t intersect with any positioned child on
+                        # any row.
+                        break
+                y_diff = y + height - implicit_y2
+                if y_diff > 0:
+                    for _ in range(y_diff):
+                        rows.append(next(auto_rows))
+                        rows.append([])
+                    implicit_y2 = y + height
+                # 3. Set the item’s row-start line.
+                children_positions[child] = (x, y, width, height)
+            else:
+                # 1. Set the cursor’s row and column positions.
+                cursor_x, cursor_y = implicit_x1, implicit_y1
+                while True:
+                    # 2. Increment the column position of the cursor.
+                    y = cursor_y
+                    row_start = child.style['grid_row_start']
+                    row_end = child.style['grid_row_end']
+                    column_start = child.style['grid_column_start']
+                    column_end = child.style['grid_column_end']
+                    for x in range(cursor_x, implicit_x2):
+                        if row_start == 'auto':
+                            y, height = _get_placement(
+                                (None, y + 1, None), row_end, rows[::2])
+                        else:
+                            span = _get_span(row_start)
+                            y, height = _get_placement(
+                                row_start, (None, y + 1 + span, None),
+                                rows[::2])
+                        if column_start == 'auto':
+                            x, width = _get_placement(
+                                (None, x + 1, None), column_end, columns[::2])
+                        else:
+                            span = _get_span(column_start)
+                            x, width = _get_placement(
+                                column_start, (None, x + 1 + span, None),
+                                columns[::2])
+                        intersect = _intersect_with_children(
+                            x, y, width, height, children_positions.values())
+                        if intersect:
+                            # Child intersects with a positioned child.
+                            continue
+                        else:
+                            # Free place found.
+                            # 3. Set the item’s row-/column-start lines.
+                            children_positions[child] = (x, y, width, height)
+                            y_diff = cursor_y + height - 1 - implicit_y2
+                            if y_diff > 0:
+                                for _ in range(y_diff):
+                                    rows.append(next(auto_rows))
+                                    rows.append([])
+                                implicit_y2 = cursor_y + height - 1
+                            break
+                    else:
+                        # No room found.
+                        # 2. Return to the previous step.
+                        cursor_y += 1
+                        y_diff = cursor_y + 1 - implicit_y2
+                        if y_diff > 0:
+                            for _ in range(y_diff):
+                                rows.append(next(auto_rows))
+                                rows.append([])
+                            implicit_y2 = cursor_y
+                        cursor_x = implicit_x1
+                        continue
+                    break
+    else:
+        for child in remaining_grid_items:
+            column_start = child.style['grid_column_start']
+            column_end = child.style['grid_column_end']
+            column_placement = _get_placement(
+                column_start, column_end, columns[::2])
+            if column_placement:
+                # 1. Set the column position of the cursor.
+                x, width = column_placement
+                if x < cursor_x:
+                    cursor_y += 1
+                cursor_x = x
+                # 2. Increment the cursor’s row position.
+                row_start = child.style['grid_row_start']
+                row_end = child.style['grid_row_end']
+                for cursor_y in count(cursor_y):
+                    if row_start == 'auto':
+                        y, height = _get_placement(
+                            (None, cursor_y + 1, None), row_end, rows[::2])
+                    else:
+                        assert row_start[0] == 'span'
+                        assert row_start == 'auto' or row_start[0] == 'span'
+                        span = _get_span(row_start)
+                        y, height = _get_placement(
+                            row_start, (None, cursor_y + 1 + span, None),
+                            rows[::2])
+                    if y < cursor_y:
+                        continue
+                    for row in range(y, y + height):
+                        intersect = _intersect_with_children(
+                            x, y, width, height, children_positions.values())
+                        if intersect:
+                            # Child intersects with a positioned child on
+                            # current row.
+                            break
+                    else:
+                        # Child doesn’t intersect with any positioned child on
+                        # any row.
+                        break
+                y_diff = y + height - implicit_y2
+                if y_diff > 0:
+                    for _ in range(y_diff):
+                        rows.append(next(auto_rows))
+                        rows.append([])
+                    implicit_y2 = y + height
+                # 3. Set the item’s row-start line.
+                children_positions[child] = (x, y, width, height)
+            else:
+                while True:
+                    # 1. Increment the column position of the cursor.
+                    y = cursor_y
+                    row_start = child.style['grid_row_start']
+                    row_end = child.style['grid_row_end']
+                    column_start = child.style['grid_column_start']
+                    column_end = child.style['grid_column_end']
+                    for x in range(cursor_x, implicit_x2):
+                        if row_start == 'auto':
+                            y, height = _get_placement(
+                                (None, y + 1, None), row_end, rows[::2])
+                        else:
+                            span = _get_span(row_start)
+                            y, height = _get_placement(
+                                row_start, (None, y + 1 + span, None),
+                                rows[::2])
+                        if column_start == 'auto':
+                            x, width = _get_placement(
+                                (None, x + 1, None), column_end, columns[::2])
+                        else:
+                            span = _get_span(column_start)
+                            x, width = _get_placement(
+                                column_start, (None, x + 1 + span, None),
+                                columns[::2])
+                        intersect = _intersect_with_children(
+                            x, y, width, height, children_positions.values())
+                        if intersect:
+                            # Child intersects with a positioned child.
+                            continue
+                        else:
+                            # Free place found.
+                            # 2. Set the item’s row-/column-start lines.
+                            children_positions[child] = (x, y, width, height)
+                            break
+                    else:
+                        # No room found.
+                        # 2. Return to the previous step.
+                        cursor_y += 1
+                        y_diff = cursor_y + 1 - implicit_y2
+                        if y_diff > 0:
+                            for _ in range(y_diff):
+                                rows.append(next(auto_rows))
+                                rows.append([])
+                            implicit_y2 = cursor_y
+                        cursor_x = implicit_x1
+                        continue
+                    break
+
+    for _ in range(0 - implicit_x1):
+        columns.insert(0, next(auto_columns_back))
+        columns.insert(0, [])
+    for _ in range(len(grid_areas[0]) if grid_areas else 0, implicit_x2):
+        columns.append(next(auto_columns))
+        columns.append([])
+    for _ in range(0 - implicit_y1):
+        rows.insert(0, next(auto_rows_back))
+        rows.insert(0, [])
+    for _ in range(len(grid_areas), implicit_y2):
+        rows.append(next(auto_rows))
+        rows.append([])
+
+    # 2. Find the size of the grid container.
+
+    if isinstance(box, boxes.GridBox):
+        from .block import block_level_width
+        block_level_width(box, containing_block)
+    else:
+        assert isinstance(box, boxes.InlineGridBox)
+        from .inline import inline_block_width
+        inline_block_width(box, context, containing_block)
+    if box.width == 'auto':
+        # TODO: Calculate max-width.
+        box.width = containing_block.width
+
+    # 3. Run the grid sizing algorithm.
+
+    # 3.0 List min/max sizing functions.
+    row_sizing_functions = [_get_sizing_functions(row) for row in rows[1::2]]
+    column_sizing_functions = [
+        _get_sizing_functions(column) for column in columns[1::2]]
+
+    # 3.1 Resolve the sizes of the grid columns.
+    columns_sizes = _resolve_tracks_sizes(
+        column_sizing_functions, box.width, children_positions, implicit_x1,
+        'x', column_gap, context, box)
+
+    # 3.2 Resolve the sizes of the grid rows.
+    rows_sizes = _resolve_tracks_sizes(
+        row_sizing_functions, box.height, children_positions, implicit_y1, 'y',
+        row_gap, context, box, [size for size, _ in columns_sizes])
+
+    # 3.3 Re-resolve the sizes of the grid columns with min-/max-content.
+    # TODO: Re-resolve.
+
+    # 3.4 Re-re-resolve the sizes of the grid columns with min-/max-content.
+    # TODO: Re-re-resolve.
+
+    # 3.5 Align the tracks within the grid container.
+    # TODO: Support safe/unsafe.
+    justify_content = set(box.style['justify_content'])
+    x = box.content_box_x()
+    free_width = max(0, box.width - sum(size for size, _ in columns_sizes))
+    columns_positions = []
+    columns_number = len(columns_sizes)
+    if justify_content & {'center'}:
+        x += free_width / 2
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            x += size + column_gap
+    elif justify_content & {'right', 'end', 'flex-end'}:
+        x += free_width
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            x += size + column_gap
+    elif justify_content & {'space-around'}:
+        x += free_width / 2 / columns_number
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            x += size + free_width / columns_number + column_gap
+    elif justify_content & {'space-between'}:
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            if columns_number >= 2:
+                x += size + free_width / (columns_number - 1) + column_gap
+    elif justify_content & {'space-evenly'}:
+        x += free_width / (columns_number + 1)
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            x += size + free_width / (columns_number + 1) + column_gap
+    else:
+        for size, _ in columns_sizes:
+            columns_positions.append(x)
+            x += size + column_gap
+
+    align_content = set(box.style['align_content'])
+    y = box.content_box_y()
+    if box.height == 'auto':
+        free_height = 0
+    else:
+        free_height = (
+            box.height -
+            sum(size for size, _ in rows_sizes) -
+            (len(rows_sizes) - 1) * row_gap)
+        free_height = max(0, free_height)
+    rows_positions = []
+    rows_number = len(rows_sizes)
+    if align_content & {'center'}:
+        y += free_height / 2
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            y += size + row_gap
+    elif align_content & {'right', 'end', 'flex-end'}:
+        y += free_height
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            y += size + row_gap
+    elif align_content & {'space-around'}:
+        y += free_height / 2 / rows_number
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            y += size + free_height / rows_number + row_gap
+    elif align_content & {'space-between'}:
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            if rows_number >= 2:
+                y += size + free_height / (rows_number - 1) + row_gap
+    elif align_content & {'space-evenly'}:
+        y += free_height / (rows_number + 1)
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            y += size + free_height / (rows_number + 1) + row_gap
+    else:
+        if align_content & {'baseline'}:
+            # TODO: Support baseline value.
+            LOGGER.warning('Baseline alignment is not supported for grid layout')
+        for size, _ in rows_sizes:
+            rows_positions.append(y)
+            y += size + row_gap
+
+    # 4. Lay out the grid items into their respective containing blocks.
+    # Find resume_at row.
+    this_page_children = []
+    resume_row = None
+    if skip_stack:
+        skip_row = next(iter(skip_stack))
+        skip_height = (
+            sum(size for size, _ in rows_sizes[:skip_row]) +
+            (len(rows_sizes[:skip_row]) - 1) * row_gap)
+    else:
+        skip_row = 0
+        skip_height = 0
+    resume_at = None
+    for i, row_y in enumerate(rows_positions[skip_row:], start=skip_row):
+        # TODO: Check that page is not empty.
+        if context.overflows_page(bottom_space, row_y - skip_height):
+            if i == 0:
+                return None, None, {'break': 'any', 'page': None}, [], False
+            resume_row = i - 1
+            resume_at = {i-1: None}
+            for child in children:
+                _, y, _, _ = children_positions[child]
+                if skip_row <= y <= i-2:
+                    this_page_children.append(child)
+            break
+    else:
+        for child in children:
+            _, y, _, _ = children_positions[child]
+            if skip_row <= y:
+                this_page_children.append(child)
+    if box.height == 'auto':
+        box.height = (
+            sum(size for size, _ in rows_sizes[skip_row:resume_row]) +
+            (len(rows_sizes[skip_row:resume_row]) - 1) * row_gap)
+    # Lay out grid items.
+    justify_items = set(box.style['justify_items'])
+    align_items = set(box.style['align_items'])
+    new_children = []
+    baseline = None
+    next_page = {'break': 'any', 'page': None}
+    from .block import block_level_layout
+    for child in this_page_children:
+        x, y, width, height = children_positions[child]
+        index = box.children.index(child)
+        if skip_stack and skip_stack.get(y) and index in skip_stack[y]:
+            child_skip_stack = skip_stack[y][index]
+        else:
+            child_skip_stack = None
+        child = child.deepcopy()
+        child.position_x = columns_positions[x]
+        child.position_y = rows_positions[y] - skip_height
+        resolve_percentages(child, box)
+        width = (
+            sum(size for size, _ in columns_sizes[x:x+width]) +
+            (width - 1) * column_gap)
+        height = (
+            sum(size for size, _ in rows_sizes[y:y+height]) +
+            (height - 1) * row_gap)
+        child_width = width - (
+            child.margin_left + child.border_left_width + child.padding_left +
+            child.margin_right + child.border_right_width + child.padding_right)
+        child_height = height - (
+            child.margin_top + child.border_top_width + child.padding_top +
+            child.margin_bottom + child.border_bottom_width + child.padding_bottom)
+
+        justify_self = set(child.style['justify_self'])
+        if justify_self & {'auto'}:
+            justify_self = justify_items
+        if justify_self & {'normal', 'stretch'}:
+            if child.style['width'] == 'auto':
+                child.style['width'] = Dimension(child_width, 'px')
+        align_self = set(child.style['align_self'])
+        if align_self & {'auto'}:
+            align_self = align_items
+        if align_self & {'normal', 'stretch'}:
+            if child.style['height'] == 'auto':
+                child.style['height'] = Dimension(child_height, 'px')
+
+        # TODO: Find a better solution for the layout.
+        parent = boxes.BlockContainerBox.anonymous_from(box, ())
+        resolve_percentages(parent, containing_block)
+        parent.position_x = child.position_x
+        parent.position_y = child.position_y
+        parent.width = width
+        parent.height = height
+        new_child, child_resume_at, child_next_page = block_level_layout(
+            context, child, bottom_space, child_skip_stack, parent,
+            page_is_empty, absolute_boxes, fixed_boxes)[:3]
+        if new_child:
+            page_is_empty = False
+            # TODO: Support fragmentation in grid items.
+        else:
+            # TODO: Support fragmentation in grid rows.
+            continue
+
+        # TODO: Apply auto margins.
+        if justify_self & {'normal', 'stretch'}:
+            new_child.width = max(child_width, new_child.width)
+        else:
+            new_child.width = max_content_width(context, new_child)
+            diff = child_width - new_child.width
+            if justify_self & {'center'}:
+                new_child.translate(diff / 2, 0)
+            elif justify_self & {'right', 'end', 'flex-end', 'self-end'}:
+                new_child.translate(diff, 0)
+
+        # TODO: Apply auto margins.
+        if align_self & {'normal', 'stretch'}:
+            new_child.height = max(child_height, new_child.height)
+        else:
+            diff = child_height - new_child.height
+            if align_self & {'center'}:
+                new_child.translate(0, diff / 2)
+            elif align_self & {'end', 'flex-end', 'self-end'}:
+                new_child.translate(0, diff)
+
+        # TODO: Take care of page fragmentation.
+        new_children.append(new_child)
+        if baseline is None and y == implicit_y1:
+            baseline = find_in_flow_baseline(new_child)
+
+    box = box.copy_with_children(new_children)
+    if isinstance(box, boxes.InlineGridBox):
+        # TODO: Synthetize a real baseline value.
+        LOGGER.warning('Inline grids are not supported')
+        box.baseline = baseline or 0
+
+    context.finish_block_formatting_context(box)
+
+    return box, resume_at, next_page, [], False
diff --git a/weasyprint/layout/inline.py b/weasyprint/layout/inline.py
index 9e264a6ed..4dc24b533 100644
--- a/weasyprint/layout/inline.py
+++ b/weasyprint/layout/inline.py
@@ -10,6 +10,7 @@
 from .absolute import AbsolutePlaceholder, absolute_layout
 from .flex import flex_layout
 from .float import avoid_collisions, float_layout
+from .grid import grid_layout
 from .leader import handle_leader
 from .min_max import handle_min_max_width
 from .percent import resolve_one_percentage, resolve_percentages
@@ -516,6 +517,18 @@ def split_inline_level(context, box, position_x, max_x, bottom_space,
         preserved_line_break = False
         first_letter = '\u2e80'
         last_letter = '\u2e80'
+    elif isinstance(box, boxes.InlineGridBox):
+        box.position_x = position_x
+        box.position_y = 0
+        for side in ('top', 'right', 'bottom', 'left'):
+            if getattr(box, f'margin_{side}') == 'auto':
+                setattr(box, f'margin_{side}', 0)
+        new_box, resume_at, _, _, _ = grid_layout(
+            context, box, -inf, skip_stack, containing_block, False,
+            absolute_boxes, fixed_boxes)
+        preserved_line_break = False
+        first_letter = '\u2e80'
+        last_letter = '\u2e80'
     else:  # pragma: no cover
         raise TypeError(f'Layout for {type(box).__name__} not handled yet')
     return (
@@ -1036,7 +1049,9 @@ def inline_box_verticality(box, top_bottom_subtrees, baseline_y):
 
         # the child’s `top` is `child.baseline` above (lower y) its baseline.
         top = child_baseline_y - child.baseline
-        if isinstance(child, (boxes.InlineBlockBox, boxes.InlineFlexBox)):
+        box_types = (
+            boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)
+        if isinstance(child, box_types):
             # This also includes table wrappers for inline tables.
             child.translate(dy=top - child.position_y)
         else:
diff --git a/weasyprint/layout/page.py b/weasyprint/layout/page.py
index 9c042f825..2f98c9fd2 100644
--- a/weasyprint/layout/page.py
+++ b/weasyprint/layout/page.py
@@ -604,7 +604,8 @@ def make_page(context, root_box, page_type, resume_at, page_number,
 
     # TODO: handle cases where the root element is something else.
     # See https://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
-    assert isinstance(root_box, (boxes.BlockBox, boxes.FlexContainerBox))
+    assert isinstance(root_box, (
+        boxes.BlockBox, boxes.FlexContainerBox, boxes.GridContainerBox))
     context.create_block_formatting_context()
     context.current_page = page_number
     context.current_page_footnotes = []
diff --git a/weasyprint/layout/preferred.py b/weasyprint/layout/preferred.py
index d4385d117..8d8eddbda 100644
--- a/weasyprint/layout/preferred.py
+++ b/weasyprint/layout/preferred.py
@@ -54,6 +54,9 @@ def min_content_width(context, box, outer=True):
         return replaced_min_content_width(box, outer)
     elif isinstance(box, boxes.FlexContainerBox):
         return flex_min_content_width(context, box, outer)
+    elif isinstance(box, boxes.GridContainerBox):
+        # TODO: Get real grid size.
+        return block_min_content_width(context, box, outer)
     else:
         raise TypeError(
             f'min-content width for {type(box).__name__} not handled yet')
@@ -81,6 +84,9 @@ def max_content_width(context, box, outer=True):
         return replaced_max_content_width(box, outer)
     elif isinstance(box, boxes.FlexContainerBox):
         return flex_max_content_width(context, box, outer)
+    elif isinstance(box, boxes.GridContainerBox):
+        # TODO: Get real grid size.
+        return block_max_content_width(context, box, outer)
     else:
         raise TypeError(
             f'max-content width for {type(box).__name__} not handled yet')
diff --git a/weasyprint/stacking.py b/weasyprint/stacking.py
index 74602caaa..cce668110 100644
--- a/weasyprint/stacking.py
+++ b/weasyprint/stacking.py
@@ -74,6 +74,7 @@ def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells):
     # Remove boxes defining a new stacking context from the children list.
     defines_stacking_context = (
         (style['position'] != 'static' and style['z_index'] != 'auto') or
+        (box.is_grid_item and style['z_index'] != 'auto') or
         style['opacity'] < 1 or
         style['transform'] or  # 'transform: none' gives a "falsy" empty list
         style['overflow'] != 'visible')
@@ -90,7 +91,8 @@ def _dispatch(box, page, child_contexts, blocks, floats, blocks_and_cells):
         child_contexts.insert(index, stacking_context)
     elif box.is_floated():
         floats.append(StackingContext.from_box(box, page, child_contexts))
-    elif isinstance(box, (boxes.InlineBlockBox, boxes.InlineFlexBox)):
+    elif isinstance(box, (
+            boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)):
         # Have this fake stacking context be part of the "normal" box tree,
         # because we need its position in the middle of a tree of inline boxes.
         return StackingContext.from_box(box, page, child_contexts)


Test patch:
diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py
index c41ff5ecf..81ae3f5f8 100644
--- a/tests/css/test_expanders.py
+++ b/tests/css/test_expanders.py
@@ -623,6 +623,91 @@ def test_flex_flow_invalid(rule):
     assert_invalid(f'flex-flow: {rule}')
 
 
+@assert_no_logs
+@pytest.mark.parametrize('rule, result', (
+    ('auto', {'start': 'auto', 'end': 'auto'}),
+    ('auto / auto', {'start': 'auto', 'end': 'auto'}),
+    ('4', {'start': (None, 4, None), 'end': 'auto'}),
+    ('c', {'start': (None, None, 'c'), 'end': (None, None, 'c')}),
+    ('4 / -4', {'start': (None, 4, None), 'end': (None, -4, None)}),
+    ('c / d', {'start': (None, None, 'c'), 'end': (None, None, 'd')}),
+    ('ab / cd 4', {'start': (None, None, 'ab'), 'end': (None, 4, 'cd')}),
+    ('ab 2 span', {'start': ('span', 2, 'ab'), 'end': 'auto'}),
+))
+def test_grid_column_row(rule, result):
+    assert expand_to_dict(f'grid-column: {rule}') == dict(
+        (f'grid_column_{key}', value) for key, value in result.items())
+    assert expand_to_dict(f'grid-row: {rule}') == dict(
+        (f'grid_row_{key}', value) for key, value in result.items())
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto auto',
+    '4 / 2 / c',
+    'span',
+    '4 / span',
+    'c /',
+    '/4',
+    'col / 2.1',
+))
+def test_grid_column_row_invalid(rule):
+    assert_invalid(f'grid-column: {rule}')
+    assert_invalid(f'grid-row: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, result', (
+    ('auto', {
+        'row_start': 'auto', 'row_end': 'auto',
+        'column_start': 'auto', 'column_end': 'auto'}),
+    ('auto / auto', {
+        'row_start': 'auto', 'row_end': 'auto',
+        'column_start': 'auto', 'column_end': 'auto'}),
+    ('auto / auto / auto', {
+        'row_start': 'auto', 'row_end': 'auto',
+        'column_start': 'auto', 'column_end': 'auto'}),
+    ('auto / auto / auto / auto', {
+        'row_start': 'auto', 'row_end': 'auto',
+        'column_start': 'auto', 'column_end': 'auto'}),
+    ('1/c/2 d/span 2 ab', {
+        'row_start': (None, 1, None), 'row_end': (None, None, 'c'),
+        'column_start': (None, 2, 'd'), 'column_end': ('span', 2, 'ab')}),
+    ('1  /  c', {
+        'row_start': (None, 1, None), 'row_end': (None, None, 'c'),
+        'column_start': 'auto', 'column_end': (None, None, 'c')}),
+    ('a / c 2', {
+        'row_start': (None, None, 'a'), 'row_end': (None, 2, 'c'),
+        'column_start': (None, None, 'a'), 'column_end': 'auto'}),
+    ('a', {
+        'row_start': (None, None, 'a'), 'row_end': (None, None, 'a'),
+        'column_start': (None, None, 'a'), 'column_end': (None, None, 'a')}),
+    ('span 2', {
+        'row_start': ('span', 2, None), 'row_end': 'auto',
+        'column_start': 'auto', 'column_end': 'auto'}),
+))
+def test_grid_area(rule, result):
+    assert expand_to_dict(f'grid-area: {rule}') == dict(
+        (f'grid_{key}', value) for key, value in result.items())
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto auto',
+    'auto / auto auto',
+    '4 / 2 / c / d / e',
+    'span',
+    '4 / span',
+    'c /',
+    '/4',
+    'c//4',
+    '/',
+    '1 / 2 / 4 / 0.5',
+))
+def test_grid_area_invalid(rule):
+    assert_invalid(f'grid-area: {rule}')
+
+
 @assert_no_logs
 @pytest.mark.parametrize('rule, result', (
     ('page-break-after: left', {'break_after': 'left'}),
diff --git a/tests/css/test_validation.py b/tests/css/test_validation.py
index 97718ad7c..cf01bba22 100644
--- a/tests/css/test_validation.py
+++ b/tests/css/test_validation.py
@@ -685,3 +685,451 @@ def test_radial_gradient(rule, value):
 def test_radial_gradient_invalid(rule):
     assert_invalid(f'background-image: radial-gradient({rule})')
     assert_invalid(f'background-image: repeating-radial-gradient({rule})')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('40px', ((40, 'px'),)),
+    ('2fr', ((2, 'fr'),)),
+    ('18%', ((18, '%'),)),
+    ('auto', ('auto',)),
+    ('min-content', ('min-content',)),
+    ('max-content', ('max-content',)),
+    ('fit-content(20%)', (('fit-content()', (20, '%')),)),
+    ('minmax(20px, 25px)', (('minmax()', (20, 'px'), (25, 'px')),)),
+    ('minmax(min-content, max-content)',
+     (('minmax()', 'min-content', 'max-content'),)),
+    ('min-content max-content', ('min-content', 'max-content')),
+))
+def test_grid_auto_columns_rows(rule, value):
+    assert get_value(f'grid-auto-columns: {rule}') == value
+    assert get_value(f'grid-auto-rows: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    '40',
+    'coucou',
+    'fit-content',
+    'fit-content(min-content)',
+    'minmax(40px)',
+    'minmax(2fr, 1fr)',
+    '1fr 1fr coucou',
+    'fit-content()',
+    'fit-content(2%, 18%)',
+))
+def test_grid_auto_columns_rows_invalid(rule):
+    assert_invalid(f'grid-auto-columns: {rule}')
+    assert_invalid(f'grid-auto-rows: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('row', ('row',)),
+    ('column', ('column',)),
+    ('row dense', ('row', 'dense')),
+    ('column dense', ('column', 'dense')),
+    ('dense row', ('dense', 'row')),
+    ('dense column', ('dense', 'column')),
+    ('dense', ('dense', 'row')),
+))
+def test_grid_auto_flow(rule, value):
+    assert get_value(f'grid-auto-flow: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'row row',
+    'column column',
+    'dense dense',
+    'coucou',
+    'row column',
+    'column row',
+    'row coucou',
+    'column coucou',
+    'coucou row',
+    'coucou column',
+    'row column dense',
+))
+def test_grid_auto_flow_invalid(rule):
+    assert_invalid(f'grid-auto-flow: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('none', 'none'),
+    ('subgrid', ('subgrid', ())),
+    ('subgrid [a] repeat(auto-fill, [b]) [c]',
+     ('subgrid', (('a',), ('repeat()', 'auto-fill', (('b',),)), ('c',)))),
+    ('subgrid [a] [a] [a] [a] repeat(auto-fill, [b]) [c] [c]',
+     ('subgrid', (('a',), ('a',), ('a',), ('a',),
+      ('repeat()', 'auto-fill', (('b',),)), ('c',), ('c',)))),
+    ('subgrid [] [a]', ('subgrid', ((), ('a',)))),
+    ('subgrid [a] [b] [c] [d] [e] [f]',
+     ('subgrid', (('a',), ('b',), ('c',), ('d',), ('e',), ('f',)))),
+    ('[outer-edge] 20px [main-start] 1fr [center] 1fr max-content [main-end]',
+     (('outer-edge',), (20, 'px'), ('main-start',), (1, 'fr'), ('center',),
+      (1, 'fr'), (), 'max-content', ('main-end',))),
+    ('repeat(auto-fill, minmax(25ch, 1fr))',
+     ((), ('repeat()', 'auto-fill', (
+         (), ('minmax()', (25, 'ch'), (1, 'fr')), ())), ())),
+    ('[a] auto [b] minmax(min-content, 1fr) [b c d] '
+     'repeat(2, [e] 40px) repeat(5, auto)',
+     (('a',), 'auto', ('b',), ('minmax()', 'min-content', (1, 'fr')),
+      ('b', 'c', 'd'), ('repeat()', 2, (('e',), (40, 'px'), ())),
+      (), ('repeat()', 5, ((), 'auto', ())), ())),
+))
+def test_grid_template_columns_rows(rule, value):
+    assert get_value(f'grid-template-columns: {rule}') == value
+    assert get_value(f'grid-template-rows: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'coucou',
+    'subgrid subgrid',
+    'subgrid coucou',
+    'subgrid [coucou] repeat(0, [wow])',
+    'subgrid [coucou] repeat(auto-fit [wow])',
+    'fit-content(18%) repeat(auto-fill, 15em)',
+    '[coucou] [wow]',
+))
+def test_grid_template_columns_rows_invalid(rule):
+    assert_invalid(f'grid-template-columns: {rule}')
+    assert_invalid(f'grid-template-rows: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('none', 'none'),
+    ('"head head" "nav main" "foot ...."',
+     (('head', 'head'), ('nav', 'main'), ('foot', None))),
+    ('"title board" "stats board"',
+     (('title', 'board'), ('stats', 'board'))),
+    ('". a" "b a" ".a"',
+     ((None, 'a'), ('b', 'a'), (None, 'a'))),
+))
+def test_grid_template_areas(rule, value):
+    assert get_value(f'grid-template-areas: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    '"head head coucou" "nav main" "foot ...."',
+    '". a" "b c" ". a"',
+    '". a" "b a" "a a"',
+    '"a a a a" "a b b a" "a a a a"',
+    '" "',
+))
+def test_grid_template_areas_invalid(rule):
+    assert_invalid(f'grid-template-areas: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('auto', 'auto'),
+    ('4', (None, 4, None)),
+    ('C', (None, None, 'c')),
+    ('4 c', (None, 4, 'c')),
+    ('col -4', (None, -4, 'col')),
+    ('span c 4', ('span', 4, 'c')),
+    ('span 4 c', ('span', 4, 'c')),
+    ('4 span c', ('span', 4, 'c')),
+    ('super 4 span', ('span', 4, 'super')),
+))
+def test_grid_line(rule, value):
+    assert get_value(f'grid-row-start: {rule}') == value
+    assert get_value(f'grid-row-end: {rule}') == value
+    assert get_value(f'grid-column-start: {rule}') == value
+    assert get_value(f'grid-column-end: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'span',
+    '0',
+    '1.1',
+    'span 0',
+    'span -1',
+    'span 2.1',
+    'span auto',
+    'auto auto',
+    '-4 cOL span',
+    'span 1.1 col',
+))
+def test_grid_line_invalid(rule):
+    assert_invalid(f'grid-row-start: {rule}')
+    assert_invalid(f'grid-row-end: {rule}')
+    assert_invalid(f'grid-column-start: {rule}')
+    assert_invalid(f'grid-column-end: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('normal', ('normal',)),
+    ('baseline', ('first', 'baseline')),
+    ('first baseline', ('first', 'baseline')),
+    ('last baseline', ('last', 'baseline')),
+    ('baseline last', ('baseline', 'last')),
+    ('space-between', ('space-between',)),
+    ('space-around', ('space-around',)),
+    ('space-evenly', ('space-evenly',)),
+    ('stretch', ('stretch',)),
+    ('center', ('center',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+))
+def test_align_content(rule, value):
+    assert get_value(f'align-content: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto',
+    'none',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+    'left',
+    'right',
+))
+def test_align_content_invalid(rule):
+    assert_invalid(f'align-content: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('normal', ('normal',)),
+    ('stretch', ('stretch',)),
+    ('baseline', ('first', 'baseline')),
+    ('first baseline', ('first', 'baseline')),
+    ('last baseline', ('last', 'baseline')),
+    ('baseline last', ('baseline', 'last')),
+    ('center', ('center',)),
+    ('self-start', ('self-start',)),
+    ('self-end', ('self-end',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('unsafe self-start', ('unsafe', 'self-start')),
+    ('safe self-end', ('safe', 'self-end')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+))
+def test_align_items(rule, value):
+    assert get_value(f'align-items: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto',
+    'none',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+    'left',
+    'right',
+    'space-between',
+))
+def test_align_items_invalid(rule):
+    assert_invalid(f'align-items: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('auto', ('auto',)),
+    ('normal', ('normal',)),
+    ('stretch', ('stretch',)),
+    ('baseline', ('first', 'baseline')),
+    ('first baseline', ('first', 'baseline')),
+    ('last baseline', ('last', 'baseline')),
+    ('baseline last', ('baseline', 'last')),
+    ('center', ('center',)),
+    ('self-start', ('self-start',)),
+    ('self-end', ('self-end',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('unsafe self-start', ('unsafe', 'self-start')),
+    ('safe self-end', ('safe', 'self-end')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+))
+def test_align_self(rule, value):
+    assert get_value(f'align-self: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+    'left',
+    'right',
+    'space-between',
+))
+def test_align_self_invalid(rule):
+    assert_invalid(f'align-self: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('normal', ('normal',)),
+    ('space-between', ('space-between',)),
+    ('space-around', ('space-around',)),
+    ('space-evenly', ('space-evenly',)),
+    ('stretch', ('stretch',)),
+    ('center', ('center',)),
+    ('left', ('left',)),
+    ('right', ('right',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('unsafe left', ('unsafe', 'left')),
+    ('safe right', ('safe', 'right')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+))
+def test_justify_content(rule, value):
+    assert get_value(f'justify-content: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto',
+    'none',
+    'baseline',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+))
+def test_justify_content_invalid(rule):
+    assert_invalid(f'justify-content: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('normal', ('normal',)),
+    ('stretch', ('stretch',)),
+    ('baseline', ('first', 'baseline')),
+    ('first baseline', ('first', 'baseline')),
+    ('last baseline', ('last', 'baseline')),
+    ('baseline last', ('baseline', 'last')),
+    ('center', ('center',)),
+    ('self-start', ('self-start',)),
+    ('self-end', ('self-end',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('left', ('left',)),
+    ('right', ('right',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('unsafe self-start', ('unsafe', 'self-start')),
+    ('safe self-end', ('safe', 'self-end')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+    ('legacy', ('legacy',)),
+    ('legacy left', ('legacy', 'left')),
+    ('left legacy', ('left', 'legacy')),
+    ('legacy center', ('legacy', 'center')),
+))
+def test_justify_items(rule, value):
+    assert get_value(f'justify-items: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'auto',
+    'none',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+    'space-between',
+))
+def test_justify_items_invalid(rule):
+    assert_invalid(f'justify-items: {rule}')
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule, value', (
+    ('auto', ('auto',)),
+    ('normal', ('normal',)),
+    ('stretch', ('stretch',)),
+    ('baseline', ('first', 'baseline')),
+    ('first baseline', ('first', 'baseline')),
+    ('last baseline', ('last', 'baseline')),
+    ('baseline last', ('baseline', 'last')),
+    ('center', ('center',)),
+    ('self-start', ('self-start',)),
+    ('self-end', ('self-end',)),
+    ('start', ('start',)),
+    ('end', ('end',)),
+    ('left', ('left',)),
+    ('right', ('right',)),
+    ('flex-start', ('flex-start',)),
+    ('flex-end', ('flex-end',)),
+    ('safe center', ('safe', 'center')),
+    ('unsafe start', ('unsafe', 'start')),
+    ('safe end', ('safe', 'end')),
+    ('unsafe left', ('unsafe', 'left')),
+    ('safe right', ('safe', 'right')),
+    ('unsafe self-start', ('unsafe', 'self-start')),
+    ('safe self-end', ('safe', 'self-end')),
+    ('safe flex-start', ('safe', 'flex-start')),
+    ('unsafe flex-start', ('unsafe', 'flex-start')),
+))
+def test_justify_self(rule, value):
+    assert get_value(f'justify-self: {rule}') == value
+
+
+@assert_no_logs
+@pytest.mark.parametrize('rule', (
+    'none',
+    'auto auto',
+    'first last',
+    'baseline baseline',
+    'start safe',
+    'start end',
+    'safe unsafe',
+    'space-between',
+))
+def test_justify_self_invalid(rule):
+    assert_invalid(f'justify-self: {rule}')
diff --git a/tests/layout/test_grid.py b/tests/layout/test_grid.py
new file mode 100644
index 000000000..539c2ff86
--- /dev/null
+++ b/tests/layout/test_grid.py
@@ -0,0 +1,802 @@
+"""Tests for grid layout."""
+
+from ..testing_utils import assert_no_logs, render_pages
+
+
+@assert_no_logs
+def test_grid_empty():
+    page, = render_pages('''
+      <article style="display: grid">
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    assert article.position_x == 0
+    assert article.position_y == 0
+    assert article.width == html.width
+    assert article.height == 0
+
+
+@assert_no_logs
+def test_grid_single_item():
+    page, = render_pages('''
+      <article style="display: grid">
+        <div>a</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div, = article.children
+    assert article.position_x == div.position_x == 0
+    assert article.position_y == div.position_y == 0
+    assert article.width == div.width == html.width
+
+
+@assert_no_logs
+def test_grid_rows():
+    page, = render_pages('''
+      <article style="display: grid">
+        <div>a</div>
+        <div>b</div>
+        <div>c</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c = article.children
+    assert div_a.position_x == div_b.position_x == div_c.position_x == 0
+    assert div_a.position_y < div_b.position_y < div_c.position_y
+    assert div_a.height == div_b.height == div_c.height
+    assert article.width == html.width
+    assert div_a.width == div_b.width == div_c.width == html.width
+
+
+@assert_no_logs
+def test_grid_template_fr():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-rows: auto 1fr;
+          grid-template-columns: auto 1fr;
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div> <div>b</div>
+        <div>c</div> <div>d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == 0
+    assert div_b.position_x == div_d.position_x == 2
+    assert div_a.height == div_b.height == div_c.height == div_d.height == 2
+    assert div_a.width == div_c.width == 2
+    assert div_b.width == div_d.width == 8
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'a b' 'c d';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div> <div>b</div>
+        <div>c</div> <div>d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == 0
+    assert div_b.position_x == div_d.position_x == 5
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == div_d.position_y == 2
+    assert div_a.height == div_b.height == div_c.height == div_d.height == 2
+    assert div_a.width == div_b.width == div_c.width == div_d.width == 5
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_grid_area():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'b a' 'd c';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div> <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div> <div style="grid-area: d">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_b.position_x == div_d.position_x == 0
+    assert div_a.position_x == div_c.position_x == 5
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == div_d.position_y == 2
+    assert div_a.height == div_b.height == div_c.height == div_d.height == 2
+    assert div_a.width == div_b.width == div_c.width == div_d.width == 5
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_empty_row():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'b a' 'd a' 'd c';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div> <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div> <div style="grid-area: d">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_b.position_x == div_d.position_x == 0
+    assert div_a.position_x == div_c.position_x == 5
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == div_d.position_y == 2
+    assert div_a.height == div_b.height == div_c.height == div_d.height == 2
+    assert div_a.width == div_b.width == div_c.width == div_d.width == 5
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_multiple_rows():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'b a' 'd a' '. c';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div> <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div> <div style="grid-area: d">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_b.position_x == div_d.position_x == 0
+    assert div_a.position_x == div_c.position_x == 5
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == 4
+    assert div_d.position_y == 2
+    assert div_a.height == 4
+    assert div_b.height == div_c.height == div_d.height == 2
+    assert div_a.width == div_b.width == div_c.width == div_d.width == 5
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_multiple_columns():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'b b' 'c a';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div>
+        <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c = article.children
+    assert div_b.position_x == div_c.position_x == 0
+    assert div_a.position_x == 5
+    assert div_a.position_y == div_c.position_y == 2
+    assert div_b.position_y == 0
+    assert div_a.height == div_b.height == div_c.height == 2
+    assert div_a.width == div_c.width == 5
+    assert div_b.width == 10
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_overlap():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'a b' 'c d';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div>
+        <div style="grid-area: a">a</div>
+        <div style="grid-area: a">a</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a1, div_a2, div_a3 = article.children
+    assert div_a1.position_x == div_a2.position_x == div_a3.position_x == 0
+    assert div_a1.position_y == div_a2.position_y == div_a3.position_y == 0
+    assert div_a1.width == div_a2.width == div_a3.width == 6  # 2 + (10-2) / 2
+    assert div_a1.height == div_a2.height == div_a3.height == 2
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_extra_span():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-areas: 'a . b' 'c d d';
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div>
+        <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div>
+        <div style="grid-area: d">d</div>
+        <div style="grid-row: span 2; grid-column: span 2">e</div>
+        <div>f</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d, div_e, div_f = article.children
+    assert div_a.position_x == div_c.position_x == div_e.position_x == 0
+    assert div_d.position_x == 4  # 2 + (10 - 2×3) / 2
+    assert div_b.position_x == div_f.position_x == 6
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == div_d.position_y == 2
+    assert div_e.position_y == div_f.position_y == 4
+    assert div_a.width == div_b.width == div_c.width == div_f.width == 4
+    assert div_d.width == div_e.width == 6
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_areas_extra_span_dense():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-auto-flow: dense;
+          grid-template-areas: 'a . b' 'c d d';
+          line-height: 1;
+          width: 9px;
+        }
+      </style>
+      <article>
+        <div style="grid-area: a">a</div>
+        <div style="grid-area: b">b</div>
+        <div style="grid-area: c">c</div>
+        <div style="grid-area: d">d</div>
+        <div style="grid-row: span 2; grid-column: span 2">e</div>
+        <div>f</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d, div_e, div_f = article.children
+    assert div_a.position_x == div_c.position_x == div_e.position_x == 0
+    assert div_d.position_x == div_f.position_x == 3
+    assert div_b.position_x == 6
+    assert div_a.position_y == div_b.position_y == div_f.position_y == 0
+    assert div_c.position_y == div_d.position_y == 2
+    assert div_e.position_y == 4
+    assert div_a.width == div_b.width == div_c.width == div_f.width == 3
+    assert div_d.width == div_e.width == 6
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 9
+
+
+@assert_no_logs
+def test_grid_template_repeat_fr():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-columns: repeat(2, 1fr 2fr);
+          line-height: 1;
+          width: 12px;
+        }
+      </style>
+      <article>
+        <div>a</div>
+        <div>b</div>
+        <div>c</div>
+        <div>d</div>
+        <div>e</div>
+        <div>f</div>
+        <div>g</div>
+        <div>h</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d, div_e, div_f, div_g, div_h = article.children
+    assert div_a.position_x == div_e.position_x == 0
+    assert div_b.position_x == div_f.position_x == 2
+    assert div_c.position_x == div_g.position_x == 6
+    assert div_d.position_x == div_h.position_x == 8
+    assert div_a.position_y == div_b.position_y == 0
+    assert div_c.position_y == div_d.position_y == 0
+    assert div_e.position_y == div_f.position_y == 2
+    assert div_g.position_y == div_h.position_y == 2
+    assert div_a.width == div_c.width == div_e.width == div_g.width == 2
+    assert div_b.width == div_d.width == div_f.width == div_h.width == 4
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 12
+
+
+@assert_no_logs
+def test_grid_template_shorthand_fr():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template: auto 1fr / auto 1fr auto;
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div>
+        <div>b</div>
+        <div>c</div>
+        <div>d</div>
+        <div>e</div>
+        <div>f</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d, div_e, div_f = article.children
+    assert div_a.position_x == div_d.position_x == 0
+    assert div_b.position_x == div_e.position_x == 2
+    assert div_c.position_x == div_f.position_x == 8
+    assert div_a.position_y == div_b.position_y == div_c.position_y == 0
+    assert div_d.position_y == div_e.position_y == div_f.position_y == 2
+    assert div_a.width == div_c.width == div_d.width == div_f.width == 2
+    assert div_b.width == div_e.width == 6
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_shorthand_auto_flow_rows_fr_size():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid: auto-flow 1fr / 6px;
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div>
+        <div>b</div>
+        <div>c</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c = article.children
+    assert div_a.position_x == div_b.position_x == div_c.position_x == 0
+    assert div_a.position_y == 0
+    assert div_b.position_y == 2
+    assert div_c.position_y == 4
+    assert div_a.width == div_b.width == div_c.width == 6
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 10
+
+
+def test_grid_shorthand_auto_flow_columns_none_dense():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid: none / auto-flow 1fr dense;
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div>
+        <div>b</div>
+        <div>c</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c = article.children
+    assert div_a.position_x == div_b.position_x == div_c.position_x == 0
+    assert div_a.position_y == 0
+    assert div_b.position_y == 2
+    assert div_c.position_y == 4
+    assert div_a.width == div_b.width == div_c.width == 10
+    assert {div.height for div in article.children} == {2}
+    assert article.width == 10
+
+
+@assert_no_logs
+def test_grid_template_fr_undefined_free_space():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-rows: 1fr 1fr;
+          grid-template-columns: 1fr 1fr;
+          line-height: 1;
+          width: 10px;
+        }
+      </style>
+      <article>
+        <div>a</div> <div>b<br>b<br>b<br>b</div>
+        <div>c</div> <div>d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == 0
+    assert div_b.position_x == div_d.position_x == 5
+    assert div_a.height == div_b.height == div_c.height == div_d.height == 8
+    assert div_a.width == div_c.width == 5
+    assert div_b.width == div_d.width == 5
+    assert article.width == 10
+    assert article.height == 16
+
+
+@assert_no_logs
+def test_grid_column_start():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        dl {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-columns: max-content auto;
+          line-height: 1;
+          width: 10px;
+        }
+        dt {
+          display: block;
+          grid-column-start: 1;
+        }
+        dd {
+          display: block;
+          grid-column-start: 2;
+        }
+      </style>
+      <dl>
+        <dt>A</dt>
+        <dd>A1</dd>
+        <dd>A2</dd>
+        <dt>B</dt>
+        <dd>B1</dd>
+        <dd>B2</dd>
+      </dl>
+    ''')
+    html, = page.children
+    body, = html.children
+    dl, = body.children
+    dt_a, dd_a1, dd_a2, dt_b, dd_b1, dd_b2 = dl.children
+    assert dt_a.position_y == dd_a1.position_y == 0
+    assert dd_a2.position_y == 2
+    assert dt_b.position_y == dd_b1.position_y == 4
+    assert dd_b2.position_y == 6
+    assert dt_a.position_x == dt_b.position_x == 0
+    assert dd_a1.position_x == dd_a2.position_x == 2
+    assert dd_b1.position_x == dd_b2.position_x == 2
+
+
+@assert_no_logs
+def test_grid_column_start_blockified():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        dl {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-columns: max-content auto;
+          line-height: 1;
+          width: 10px;
+        }
+        dt {
+          display: inline;
+          grid-column-start: 1;
+        }
+        dd {
+          display: inline;
+          grid-column-start: 2;
+        }
+      </style>
+      <dl>
+        <dt>A</dt>
+        <dd>A1</dd>
+        <dd>A2</dd>
+        <dt>B</dt>
+        <dd>B1</dd>
+        <dd>B2</dd>
+      </dl>
+    ''')
+    html, = page.children
+    body, = html.children
+    dl, = body.children
+    dt_a, dd_a1, dd_a2, dt_b, dd_b1, dd_b2 = dl.children
+    assert dt_a.position_y == dd_a1.position_y == 0
+    assert dd_a2.position_y == 2
+    assert dt_b.position_y == dd_b1.position_y == 4
+    assert dd_b2.position_y == 6
+    assert dt_a.position_x == dt_b.position_x == 0
+    assert dd_a1.position_x == dd_a2.position_x == 2
+    assert dd_b1.position_x == dd_b2.position_x == 2
+
+
+@assert_no_logs
+def test_grid_undefined_free_space():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        body {
+          font-family: weasyprint;
+          font-size: 2px;
+          line-height: 1;
+        }
+        .columns {
+          display: grid;
+          grid-template-columns: 1fr 1fr;
+          width: 8px;
+        }
+        .rows {
+          display: grid;
+          grid-template-rows: 1fr 1fr;
+        }
+      </style>
+      <div class="columns">
+        <div class="rows">
+          <div>aa</div>
+          <div>b</div>
+        </div>
+        <div class="rows">
+          <div>c<br>c</div>
+          <div>d</div>
+        </div>
+      </div>
+    ''')
+    html, = page.children
+    body, = html.children
+    div_c, = body.children
+    div_c1, div_c2 = div_c.children
+    div_r11, div_r12 = div_c1.children
+    div_r21, div_r22 = div_c2.children
+    assert div_r11.position_x == div_r12.position_x == 0
+    assert div_r21.position_x == div_r22.position_x == 4
+    assert div_r11.position_y == div_r21.position_y == 0
+    assert div_r12.position_y == div_r22.position_y == 4
+    assert div_r11.height == div_r12.height == div_r21.height == div_r22.height == 4
+    assert div_r11.width == div_r12.width == div_r21.width == div_r22.width == 4
+    assert div_c.width == 8
+
+
+@assert_no_logs
+def test_grid_padding():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-rows: auto 1fr;
+          grid-template-columns: auto 1fr;
+          line-height: 1;
+          width: 14px;
+        }
+      </style>
+      <article>
+        <div style="padding: 1px">a</div> <div>b</div>
+        <div>c</div> <div style="padding: 2px">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == div_c.content_box_x() == 0
+    assert div_a.content_box_x() == 1
+    assert div_b.position_x == div_b.content_box_x() == div_d.position_x == 4
+    assert div_d.content_box_x() == 6
+    assert div_a.width == 2
+    assert div_b.width == 10
+    assert div_c.width == 4
+    assert div_d.width == 6
+    assert article.width == 14
+    assert div_a.position_y == div_b.position_y == div_b.content_box_y() == 0
+    assert div_a.content_box_y() == 1
+    assert div_c.position_y == div_c.content_box_y() == div_d.position_y == 4
+    assert div_d.content_box_y() == 6
+    assert div_a.height == div_d.height == 2
+    assert div_b.height == 4
+    assert div_c.height == 6
+    assert article.height == 10
+
+
+@assert_no_logs
+def test_grid_border():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-rows: auto 1fr;
+          grid-template-columns: auto 1fr;
+          line-height: 1;
+          width: 14px;
+        }
+      </style>
+      <article>
+        <div style="border: 1px solid">a</div> <div>b</div>
+        <div>c</div> <div style="border: 2px solid">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == div_c.padding_box_x() == 0
+    assert div_a.padding_box_x() == 1
+    assert div_b.position_x == div_b.padding_box_x() == div_d.position_x == 4
+    assert div_d.padding_box_x() == 6
+    assert div_a.width == 2
+    assert div_b.width == 10
+    assert div_c.width == 4
+    assert div_d.width == 6
+    assert article.width == 14
+    assert div_a.position_y == div_b.position_y == div_b.padding_box_y() == 0
+    assert div_a.padding_box_y() == 1
+    assert div_c.position_y == div_c.padding_box_y() == div_d.position_y == 4
+    assert div_d.padding_box_y() == 6
+    assert div_a.height == div_d.height == 2
+    assert div_b.height == 4
+    assert div_c.height == 6
+    assert article.height == 10
+
+
+@assert_no_logs
+def test_grid_margin():
+    page, = render_pages('''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        article {
+          display: grid;
+          font-family: weasyprint;
+          font-size: 2px;
+          grid-template-rows: auto 1fr;
+          grid-template-columns: auto 1fr;
+          line-height: 1;
+          width: 14px;
+        }
+      </style>
+      <article>
+        <div style="margin: 1px">a</div> <div>b</div>
+        <div>c</div> <div style="margin: 2px">d</div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    div_a, div_b, div_c, div_d = article.children
+    assert div_a.position_x == div_c.position_x == div_c.border_box_x() == 0
+    assert div_a.border_box_x() == 1
+    assert div_b.position_x == div_b.border_box_x() == div_d.position_x == 4
+    assert div_d.border_box_x() == 6
+    assert div_a.width == 2
+    assert div_b.width == 10
+    assert div_c.width == 4
+    assert div_d.width == 6
+    assert article.width == 14
+    assert div_a.position_y == div_b.position_y == div_b.border_box_y() == 0
+    assert div_a.border_box_y() == 1
+    assert div_c.position_y == div_c.border_box_y() == div_d.position_y == 4
+    assert div_d.border_box_y() == 6
+    assert div_a.height == div_d.height == 2
+    assert div_b.height == 4
+    assert div_c.height == 6
+    assert article.height == 10