Repository: Kozea/WeasyPrint
Pull request #2286 (2024-10-24 15:40:51)

Add support of CSS Color Level 4


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8af5b05d..c5e0e576 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ dependencies = [
   'pydyf >=0.11.0,<0.12',
   'cffi >=0.6,<2',
   'tinyhtml5 >=2.0.0b1,<3',
-  'tinycss2 >=1.3.0,<2',
+  'tinycss2 >=1.4.0,<2',
   'cssselect2 >=0.1,<0.8',
   'Pyphen >=0.9.1,<0.16',
   'Pillow >=9.1.0,<11',
diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py
index acf36c1b..6186cb2d 100644
--- a/weasyprint/css/computed_values.py
+++ b/weasyprint/css/computed_values.py
@@ -3,7 +3,7 @@
 from math import pi
 from urllib.parse import unquote
 
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from ..logger import LOGGER
 from ..text.ffi import FROM_UNITS, ffi, pango
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 365b5cd1..947d52ac 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -3,7 +3,7 @@
 import collections
 from math import inf
 
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 Dimension = collections.namedtuple('Dimension', ['value', 'unit'])
 
@@ -49,20 +49,20 @@ INITIAL_VALUES = {
                              'top', Dimension(0, '%')),),
     'background_repeat': (('repeat', 'repeat'),),
     'background_size': (('auto', 'auto'),),
-    'border_bottom_color': 'currentColor',
+    'border_bottom_color': 'currentcolor',
     'border_bottom_left_radius': (ZERO_PIXELS, ZERO_PIXELS),
     'border_bottom_right_radius': (ZERO_PIXELS, ZERO_PIXELS),
     'border_bottom_style': 'none',
     'border_bottom_width': 3,
     'border_collapse': 'separate',
-    'border_left_color': 'currentColor',
+    'border_left_color': 'currentcolor',
     'border_left_style': 'none',
     'border_left_width': 3,
-    'border_right_color': 'currentColor',
+    'border_right_color': 'currentcolor',
     'border_right_style': 'none',
     'border_right_width': 3,
     'border_spacing': (0, 0),
-    'border_top_color': 'currentColor',
+    'border_top_color': 'currentcolor',
     'border_top_left_radius': (ZERO_PIXELS, ZERO_PIXELS),
     'border_top_right_radius': (ZERO_PIXELS, ZERO_PIXELS),
     'border_top_style': 'none',
@@ -96,7 +96,7 @@ INITIAL_VALUES = {
     # Multi-column Layout (WD): https://www.w3.org/TR/css-multicol-1/
     'column_width': 'auto',
     'column_count': 'auto',
-    'column_rule_color': 'currentColor',
+    'column_rule_color': 'currentcolor',
     'column_rule_style': 'none',
     'column_rule_width': 'medium',
     'column_fill': 'balance',
@@ -179,7 +179,7 @@ INITIAL_VALUES = {
 
     # User Interface 3/4 (REC/WD): https://www.w3.org/TR/css-ui-4/
     'appearance': 'none',
-    'outline_color': 'currentColor',  # invert is not supported
+    'outline_color': 'currentcolor',  # invert is not supported
     'outline_style': 'none',
     'outline_width': 3,  # computed value for 'medium'
 
@@ -224,7 +224,7 @@ INITIAL_VALUES = {
 
     # Text Decoration Module 3 (CR): https://www.w3.org/TR/css-text-decor-3/
     'text_decoration_line': 'none',
-    'text_decoration_color': 'currentColor',
+    'text_decoration_color': 'currentcolor',
     'text_decoration_style': 'solid',
 
     # Overflow Module 3/4 (WD): https://www.w3.org/TR/css-overflow-4/
diff --git a/weasyprint/css/utils.py b/weasyprint/css/utils.py
index 433c54ff..1de75707 100644
--- a/weasyprint/css/utils.py
+++ b/weasyprint/css/utils.py
@@ -5,7 +5,7 @@ import math
 from abc import ABC, abstractmethod
 from urllib.parse import unquote, urljoin
 
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from .. import LOGGER
 from ..urls import iri_to_uri, url_is_absolute
diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py
index e6358571..cffab845 100644
--- a/weasyprint/css/validation/expanders.py
+++ b/weasyprint/css/validation/expanders.py
@@ -3,7 +3,7 @@
 import functools
 
 from tinycss2.ast import DimensionToken, IdentToken, NumberToken
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from ..properties import INITIAL_VALUES
 from .descriptors import expand_font_variant
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index 27e64568..e7c98c6e 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -7,7 +7,7 @@ See https://www.w3.org/TR/CSS21/propidx.html and various CSS3 modules.
 from math import inf
 
 from tinycss2 import parse_component_value_list
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from .. import computed_values
 from ..properties import KNOWN_PROPERTIES, ZERO_PIXELS, Dimension
@@ -135,7 +135,7 @@ def other_colors(token):
 @single_token
 def outline_color(token):
     if get_keyword(token) == 'invert':
-        return 'currentColor'
+        return 'currentcolor'
     else:
         return parse_color(token)
 
@@ -158,7 +158,7 @@ def empty_cells(keyword):
 def color(token):
     """``*-color`` and ``color`` properties validation."""
     result = parse_color(token)
-    if result == 'currentColor':
+    if result == 'currentcolor':
         return 'inherit'
     else:
         return result
diff --git a/weasyprint/draw/__init__.py b/weasyprint/draw/__init__.py
index a26d74bc..63c09c36 100644
--- a/weasyprint/draw/__init__.py
+++ b/weasyprint/draw/__init__.py
@@ -171,8 +171,7 @@ def draw_background(stream, bg, clip_box=True, bleed=None, marks=()):
         # Draw background color.
         if bg.color.alpha > 0:
             with stacked(stream):
-                stream.set_color_rgb(*bg.color[:3])
-                stream.set_alpha(bg.color.alpha)
+                stream.set_color(bg.color)
                 painting_area = bg.layers[-1].painting_area
                 stream.rectangle(*painting_area)
                 stream.clip()
diff --git a/weasyprint/draw/border.py b/weasyprint/draw/border.py
index 79723ded..27ffb017 100644
--- a/weasyprint/draw/border.py
+++ b/weasyprint/draw/border.py
@@ -500,19 +500,16 @@ def clip_border_segment(stream, style, width, side, border_box,
 
 def draw_rounded_border(stream, box, style, color):
     if style in ('ridge', 'groove'):
-        stream.set_color_rgb(*color[0][:3])
-        stream.set_alpha(color[0][3])
+        stream.set_color(color[0])
         rounded_box(stream, box.rounded_padding_box())
         rounded_box(stream, box.rounded_box_ratio(1 / 2))
         stream.fill(even_odd=True)
-        stream.set_color_rgb(*color[1][:3])
-        stream.set_alpha(color[1][3])
+        stream.set_color(color[1])
         rounded_box(stream, box.rounded_box_ratio(1 / 2))
         rounded_box(stream, box.rounded_border_box())
         stream.fill(even_odd=True)
         return
-    stream.set_color_rgb(*color[:3])
-    stream.set_alpha(color[3])
+    stream.set_color(color)
     rounded_box(stream, box.rounded_padding_box())
     if style == 'double':
         rounded_box(stream, box.rounded_box_ratio(1 / 3))
@@ -525,8 +522,7 @@ def draw_rect_border(stream, box, widths, style, color):
     bbx, bby, bbw, bbh = box
     bt, br, bb, bl = widths
     if style in ('ridge', 'groove'):
-        stream.set_color_rgb(*color[0][:3])
-        stream.set_alpha(color[0][3])
+        stream.set_color(color[0])
         stream.rectangle(*box)
         stream.rectangle(
             bbx + bl / 2, bby + bt / 2,
@@ -536,12 +532,10 @@ def draw_rect_border(stream, box, widths, style, color):
             bbx + bl / 2, bby + bt / 2,
             bbw - (bl + br) / 2, bbh - (bt + bb) / 2)
         stream.rectangle(bbx + bl, bby + bt, bbw - bl - br, bbh - bt - bb)
-        stream.set_color_rgb(*color[1][:3])
-        stream.set_alpha(color[1][3])
+        stream.set_color(color[1])
         stream.fill(even_odd=True)
         return
-    stream.set_color_rgb(*color[:3])
-    stream.set_alpha(color[3])
+    stream.set_color(color)
     stream.rectangle(*box)
     if style == 'double':
         stream.rectangle(
@@ -559,8 +553,7 @@ def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0):
 
     with stacked(stream):
         if style not in ('ridge', 'groove'):
-            stream.set_color_rgb(*color[:3], stroke=True)
-            stream.set_alpha(color[3], stroke=True)
+            stream.set_color(color, stroke=True)
 
         if style == 'dashed':
             stream.set_dash([5 * thickness], offset)
@@ -581,8 +574,7 @@ def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0):
                 stream.line_to(x2, y2 + thickness / 3)
         elif style in ('ridge', 'groove'):
             stream.set_line_width(thickness / 2)
-            stream.set_color_rgb(*color[0][:3], stroke=True)
-            stream.set_alpha(color[0][3], stroke=True)
+            stream.set_color(color[0], stroke=True)
             if x1 == x2:
                 stream.move_to(x1 + thickness / 4, y1)
                 stream.line_to(x2 + thickness / 4, y2)
@@ -590,8 +582,7 @@ def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0):
                 stream.move_to(x1, y1 + thickness / 4)
                 stream.line_to(x2, y2 + thickness / 4)
             stream.stroke()
-            stream.set_color_rgb(*color[1][:3], stroke=True)
-            stream.set_alpha(color[1][3], stroke=True)
+            stream.set_color(color[1], stroke=True)
             if x1 == x2:
                 stream.move_to(x1 - thickness / 4, y1)
                 stream.line_to(x2 - thickness / 4, y2)
diff --git a/weasyprint/draw/color.py b/weasyprint/draw/color.py
index 5f15c30f..56d33dd1 100644
--- a/weasyprint/draw/color.py
+++ b/weasyprint/draw/color.py
@@ -2,28 +2,34 @@
 
 from colorsys import hsv_to_rgb, rgb_to_hsv
 
+from tinycss2.color4 import parse_color
+
 
 def get_color(style, key):
     """Return color, taking care of possible currentColor value."""
     value = style[key]
-    return value if value != 'currentColor' else style['color']
+    return value if value != 'currentcolor' else style['color']
 
 
 def darken(color):
     """Return a darker color."""
-    hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue)
+    # TODO: handle color spaces.
+    hue, saturation, value = rgb_to_hsv(*color.to('srgb')[:3])
     value /= 1.5
     saturation /= 1.25
-    return (*hsv_to_rgb(hue, saturation, value), color.alpha)
+    return parse_color(
+        'rgb(%f%% %f%% %f%%/%f)' % (*hsv_to_rgb(hue, saturation, value), color.alpha))
 
 
 def lighten(color):
     """Return a lighter color."""
-    hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue)
+    # TODO: handle color spaces.
+    hue, saturation, value = rgb_to_hsv(*color.to('srgb')[:3])
     value = 1 - (1 - value) / 1.5
     if saturation:
         saturation = 1 - (1 - saturation) / 1.25
-    return (*hsv_to_rgb(hue, saturation, value), color.alpha)
+    return parse_color(
+        'rgb(%f%% %f%% %f%%/%f)' % (*hsv_to_rgb(hue, saturation, value), color.alpha))
 
 
 def styled_color(style, color, side):
diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py
index 289688e7..84c1a1ef 100644
--- a/weasyprint/draw/text.py
+++ b/weasyprint/draw/text.py
@@ -45,8 +45,7 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis):
 
     # Draw text.
     x, y = textbox.position_x, textbox.position_y + textbox.baseline
-    stream.set_color_rgb(*textbox.style['color'][:3])
-    stream.set_alpha(textbox.style['color'][3])
+    stream.set_color(textbox.style['color'])
     textbox.pango_layout.reactivate(textbox.style)
     stream.begin_text()
     emojis = draw_first_line(
diff --git a/weasyprint/images.py b/weasyprint/images.py
index add806b8..8e0a3cbb 100644
--- a/weasyprint/images.py
+++ b/weasyprint/images.py
@@ -14,6 +14,7 @@ from xml.etree import ElementTree
 
 import pydyf
 from PIL import Image, ImageFile, ImageOps
+from tinycss2.color4 import parse_color
 
 from . import DEFAULT_OPTIONS
 from .layout.percent import percentage
@@ -441,6 +442,7 @@ def gradient_average_color(colors, positions):
     """
     https://drafts.csswg.org/css-images-3/#gradient-average-color
     """
+    # TODO: handle color spaces.
     nb_stops = len(positions)
     assert nb_stops > 1
     assert nb_stops == len(colors)
@@ -461,9 +463,13 @@ def gradient_average_color(colors, positions):
             result_g += premul_g[j] * weight
             result_b += premul_b[j] * weight
             result_a += alpha[j] * weight
-    # Un-premultiply:
-    return (result_r / result_a, result_g / result_a,
-            result_b / result_a, result_a) if result_a != 0 else (0, 0, 0, 0)
+    # Un-premultiply.
+    if result_a == 0:
+        return parse_color('transparent')
+    else:
+        return parse_color(
+            f'rgb({result_r / result_a * 255} {result_g / result_a * 255} '
+            f'{result_b / result_a * 255}/{ result_a })')
 
 
 class Gradient:
@@ -486,10 +492,7 @@ class Gradient:
 
         if type_ == 'solid':
             stream.rectangle(0, 0, concrete_width, concrete_height)
-            red, green, blue, alpha = colors[0]
-            stream.set_color_rgb(red, green, blue)
-            if alpha != 1:
-                stream.set_alpha(alpha, stroke=False)
+            stream.set_color(colors[0])
             stream.fill()
             return
 
diff --git a/weasyprint/layout/background.py b/weasyprint/layout/background.py
index c23b2662..b9aacf7f 100644
--- a/weasyprint/layout/background.py
+++ b/weasyprint/layout/background.py
@@ -3,7 +3,7 @@
 from collections import namedtuple
 from itertools import cycle
 
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from ..formatting_structure import boxes
 from . import replaced
diff --git a/weasyprint/layout/table.py b/weasyprint/layout/table.py
index f730756c..2a8992c8 100644
--- a/weasyprint/layout/table.py
+++ b/weasyprint/layout/table.py
@@ -2,7 +2,7 @@
 
 from math import inf
 
-import tinycss2.color3
+import tinycss2.color4
 
 from ..formatting_structure import boxes
 from ..logger import LOGGER
@@ -928,7 +928,7 @@ def distribute_excess_width(context, grid, excess_width, column_widths, constrai
         column_widths[i] += excess_width / len(columns)
 
 
-TRANSPARENT = tinycss2.color3.parse_color('transparent')
+TRANSPARENT = tinycss2.color4.parse_color('transparent')
 
 
 def collapse_table_borders(table, grid_width, grid_height):
diff --git a/weasyprint/pdf/__init__.py b/weasyprint/pdf/__init__.py
index 66f38ae4..27dce58c 100644
--- a/weasyprint/pdf/__init__.py
+++ b/weasyprint/pdf/__init__.py
@@ -3,6 +3,7 @@
 from importlib.resources import files
 
 import pydyf
+from tinycss2.color4 import D50, D65
 
 from .. import VERSION, Attachment
 from ..html import W3C_DATE_RE
@@ -128,16 +129,24 @@ def generate_pdf(document, target, zoom, **options):
             srgb = properties['srgb']
 
     pdf = pydyf.PDF()
-    states = pydyf.Dictionary()
-    x_objects = pydyf.Dictionary()
-    patterns = pydyf.Dictionary()
-    shadings = pydyf.Dictionary()
     images = {}
+    color_space = pydyf.Dictionary({
+        'lab-d50': pydyf.Array(('/Lab', pydyf.Dictionary({
+            'WhitePoint': pydyf.Array(D50),
+            'Range': pydyf.Array((-125, 125, -125, 125)),
+        }))),
+        'lab-d65': pydyf.Array(('/Lab', pydyf.Dictionary({
+            'WhitePoint': pydyf.Array(D65),
+            'Range': pydyf.Array((-125, 125, -125, 125)),
+        }))),
+    })
+    pdf.add_object(color_space)
     resources = pydyf.Dictionary({
-        'ExtGState': states,
-        'XObject': x_objects,
-        'Pattern': patterns,
-        'Shading': shadings,
+        'ExtGState': pydyf.Dictionary(),
+        'XObject': pydyf.Dictionary(),
+        'Pattern': pydyf.Dictionary(),
+        'Shading': pydyf.Dictionary(),
+        'ColorSpace': color_space.reference,
     })
     pdf.add_object(resources)
     pdf_names = []
@@ -166,8 +175,7 @@ def generate_pdf(document, target, zoom, **options):
             left / scale, top / scale,
             (right - left) / scale, (bottom - top) / scale)
         stream = Stream(
-            document.fonts, page_rectangle, states, x_objects, patterns,
-            shadings, images, mark, compress=compress)
+            document.fonts, page_rectangle, resources, images, mark, compress=compress)
         stream.transform(d=-1, f=(page.height * scale))
         pdf.add_object(stream)
         page_streams.append(stream)
@@ -191,7 +199,7 @@ def generate_pdf(document, target, zoom, **options):
             compress)
         add_forms(
             page.forms, matrix, pdf, pdf_page, resources, stream,
-            document.font_config.font_map, compress)
+            document.font_config.font_map)
         page.paint(stream, scale)
 
         # Bleed
diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py
index c2b60bd0..ebf489cd 100644
--- a/weasyprint/pdf/anchors.py
+++ b/weasyprint/pdf/anchors.py
@@ -92,8 +92,7 @@ def add_outlines(pdf, bookmarks, parent=None):
     return outlines, count
 
 
-def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
-              compress):
+def add_forms(forms, matrix, pdf, page, resources, stream, font_map):
     """Include form inputs in PDF."""
     if not forms or not any(forms.values()):
         return
@@ -128,8 +127,8 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
         input_name = element.attrib.get('name', default_name)
         # TODO: where does this 0.75 scale come from?
         font_size = style['font_size'] * 0.75
-        field_stream = pydyf.Stream(compress=compress)
-        field_stream.set_color_rgb(*style['color'][:3])
+        field_stream = stream.clone()
+        field_stream.set_color(style['color'])
         field = pydyf.Dictionary({
             'Type': '/Annot',
             'Subtype': '/Widget',
@@ -138,7 +137,6 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
             'F': 1 << (3 - 1),  # Print flag
             'T': pydyf.String(input_name),
         })
-
         if input_type in ('radio', 'checkbox'):
             if input_type == 'radio':
                 if input_name not in radio_groups[form]:
@@ -161,15 +159,15 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
             # Create stream when input is checked.
             width = rectangle[2] - rectangle[0]
             height = rectangle[1] - rectangle[3]
-            checked_stream = pydyf.Stream(extra={
+            checked_stream = stream.clone(extra={
                 'Resources': resources.reference,
                 'Type': '/XObject',
                 'Subtype': '/Form',
                 'BBox': pydyf.Array((0, 0, width, height)),
-            }, compress=compress)
+            })
             checked_stream.push_state()
             checked_stream.begin_text()
-            checked_stream.set_color_rgb(*style['color'][:3])
+            checked_stream.set_color(style['color'])
             checked_stream.set_font_size('ZaDb', font_size)
             # Center (assuming that Dingbat’s characters have a 0.75em size).
             x = (width - font_size * 0.75) / 2
diff --git a/weasyprint/pdf/stream.py b/weasyprint/pdf/stream.py
index f1d73254..ec3274a9 100644
--- a/weasyprint/pdf/stream.py
+++ b/weasyprint/pdf/stream.py
@@ -2,6 +2,7 @@
 
 import pydyf
 
+from ..logger import LOGGER
 from ..matrix import Matrix
 from ..text.ffi import ffi
 from ..text.fonts import get_pango_font_key
@@ -10,16 +11,12 @@ from .fonts import Font
 
 class Stream(pydyf.Stream):
     """PDF stream object with extra features."""
-    def __init__(self, fonts, page_rectangle, states, x_objects, patterns,
-                 shadings, images, mark, *args, **kwargs):
+    def __init__(self, fonts, page_rectangle, resources, images, mark, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.page_rectangle = page_rectangle
         self.marked = []
         self._fonts = fonts
-        self._states = states
-        self._x_objects = x_objects
-        self._patterns = patterns
-        self._shadings = shadings
+        self._resources = resources
         self._images = images
         self._mark = mark
         self._current_color = self._current_color_stroke = None
@@ -33,6 +30,21 @@ class Stream(pydyf.Stream):
         self.ink_rect = ffi.new('PangoRectangle *')
         self.logical_rect = ffi.new('PangoRectangle *')
 
+    def clone(self, **kwargs):
+        if 'fonts' not in kwargs:
+            kwargs['fonts'] = self._fonts
+        if 'page_rectangle' not in kwargs:
+            kwargs['page_rectangle'] = self.page_rectangle
+        if 'resources' not in kwargs:
+            kwargs['resources'] = self._resources
+        if 'images' not in kwargs:
+            kwargs['images'] = self._images
+        if 'mark' not in kwargs:
+            kwargs['mark'] = self._mark
+        if 'compress' not in kwargs:
+            kwargs['compress'] = self.compress
+        return Stream(**kwargs)
+
     @property
     def ctm(self):
         return self._ctm_stack[-1]
@@ -67,19 +79,33 @@ class Stream(pydyf.Stream):
         self._old_font, self._current_font = self._current_font, None
         super().end_text()
 
-    def set_color_rgb(self, r, g, b, stroke=False):
+    def set_color(self, color, stroke=False):
+        *channels, alpha = color
         if stroke:
-            if (r, g, b) == self._current_color_stroke:
+            if (color.space, *channels) == self._current_color_stroke:
                 return
             else:
-                self._current_color_stroke = (r, g, b)
+                self._current_color_stroke = (color.space, *channels)
         else:
-            if (r, g, b) == self._current_color:
+            if (color.space, *channels) == self._current_color:
                 return
             else:
-                self._current_color = (r, g, b)
+                self._current_color = (color.space, *channels)
 
-        super().set_color_rgb(r, g, b, stroke)
+        if color.space in ('srgb', 'hsl', 'hwb'):
+            self.set_color_rgb(*color.to('srgb').coordinates, stroke)
+        elif color.space in ('xyz-d65', 'oklab', 'oklch'):
+            self.set_color_space('lab-d65', stroke)
+            lightness, a, b = color.to('lab').coordinates
+            self.set_color_special(None, stroke, lightness, a, b)
+        elif color.space in ('xyz-d50', 'lab', 'lch'):
+            self.set_color_space('lab-d50', stroke)
+            lightness, a, b = color.to('lab').coordinates
+            self.set_color_special(None, stroke, lightness, a, b)
+        else:
+            LOGGER.warn('Unsupported color space %s, use sRGB instead', color.space)
+            self.set_color_rgb(*channels, stroke)
+        self.set_alpha(alpha, stroke)
 
     def set_font_size(self, font, size):
         if (font, size) == self._current_font:
@@ -88,8 +114,8 @@ class Stream(pydyf.Stream):
         super().set_font_size(font, size)
 
     def set_state(self, state):
-        key = f's{len(self._states)}'
-        self._states[key] = state
+        key = f's{len(self._resources["ExtGState"])}'
+        self._resources['ExtGState'][key] = state
         super().set_state(key)
 
     def set_alpha(self, alpha, stroke=False, fill=None):
@@ -100,16 +126,16 @@ class Stream(pydyf.Stream):
             key = f'A{alpha}'
             if key != self._current_alpha_stroke:
                 self._current_alpha_stroke = key
-                if key not in self._states:
-                    self._states[key] = pydyf.Dictionary({'CA': alpha})
+                if key not in self._resources['ExtGState']:
+                    self._resources['ExtGState'][key] = pydyf.Dictionary({'CA': alpha})
                 super().set_state(key)
 
         if fill:
             key = f'a{alpha}'
             if key != self._current_alpha:
                 self._current_alpha = key
-                if key not in self._states:
-                    self._states[key] = pydyf.Dictionary({'ca': alpha})
+                if key not in self._resources['ExtGState']:
+                    self._resources['ExtGState'][key] = pydyf.Dictionary({'ca': alpha})
                 super().set_state(key)
 
     def set_alpha_state(self, x, y, width, height, mode='luminosity'):
@@ -140,15 +166,12 @@ class Stream(pydyf.Stream):
         return self._fonts[key]
 
     def add_group(self, x, y, width, height):
-        states = pydyf.Dictionary()
-        x_objects = pydyf.Dictionary()
-        patterns = pydyf.Dictionary()
-        shadings = pydyf.Dictionary()
         resources = pydyf.Dictionary({
-            'ExtGState': states,
-            'XObject': x_objects,
-            'Pattern': patterns,
-            'Shading': shadings,
+            'ExtGState': pydyf.Dictionary(),
+            'XObject': pydyf.Dictionary(),
+            'Pattern': pydyf.Dictionary(),
+            'Shading': pydyf.Dictionary(),
+            'ColorSpace': self._resources['ColorSpace'],
             'Font': None,  # Will be set by _use_references
         })
         extra = pydyf.Dictionary({
@@ -163,17 +186,14 @@ class Stream(pydyf.Stream):
                 'CS': '/DeviceRGB',
             }),
         })
-        group = Stream(
-            self._fonts, self.page_rectangle, states, x_objects, patterns,
-            shadings, self._images, self._mark, extra=extra,
-            compress=self.compress)
-        group.id = f'x{len(self._x_objects)}'
-        self._x_objects[group.id] = group
+        group = self.clone(resources=resources, extra=extra)
+        group.id = f'x{len(self._resources["XObject"])}'
+        self._resources['XObject'][group.id] = group
         return group
 
     def add_image(self, image, interpolate, ratio):
         image_name = f'i{image.id}{int(interpolate)}'
-        self._x_objects[image_name] = None  # Set by write_pdf
+        self._resources['XObject'][image_name] = None  # Set by write_pdf
         if image_name in self._images:
             # Reuse image already stored in document
             self._images[image_name]['dpi_ratios'].add(ratio)
@@ -187,17 +207,13 @@ class Stream(pydyf.Stream):
         }
         return image_name
 
-    def add_pattern(self, x, y, width, height, repeat_width, repeat_height,
-                    matrix):
-        states = pydyf.Dictionary()
-        x_objects = pydyf.Dictionary()
-        patterns = pydyf.Dictionary()
-        shadings = pydyf.Dictionary()
+    def add_pattern(self, x, y, width, height, repeat_width, repeat_height, matrix):
         resources = pydyf.Dictionary({
-            'ExtGState': states,
-            'XObject': x_objects,
-            'Pattern': patterns,
-            'Shading': shadings,
+            'ExtGState': pydyf.Dictionary(),
+            'XObject': pydyf.Dictionary(),
+            'Pattern': pydyf.Dictionary(),
+            'Shading': pydyf.Dictionary(),
+            'ColorSpace': self._resources['ColorSpace'],
             'Font': None,  # Will be set by _use_references
         })
         extra = pydyf.Dictionary({
@@ -211,12 +227,9 @@ class Stream(pydyf.Stream):
             'Matrix': pydyf.Array(matrix.values),
             'Resources': resources,
         })
-        pattern = Stream(
-            self._fonts, self.page_rectangle, states, x_objects, patterns,
-            shadings, self._images, self._mark, extra=extra,
-            compress=self.compress)
-        pattern.id = f'p{len(self._patterns)}'
-        self._patterns[pattern.id] = pattern
+        pattern = self.clone(resources=resources, extra=extra)
+        pattern.id = f'p{len(self._resources["Pattern"])}'
+        self._resources['Pattern'][pattern.id] = pattern
         return pattern
 
     def add_shading(self, shading_type, color_space, domain, coords, extend,
@@ -230,8 +243,8 @@ class Stream(pydyf.Stream):
         })
         if extend:
             shading['Extend'] = pydyf.Array((b'true', b'true'))
-        shading.id = f's{len(self._shadings)}'
-        self._shadings[shading.id] = shading
+        shading.id = f's{len(self._resources["Shading"])}'
+        self._resources['Shading'][shading.id] = shading
         return shading
 
     def begin_marked_content(self, box, mcid=False, tag=None):
diff --git a/weasyprint/svg/__init__.py b/weasyprint/svg/__init__.py
index 46002e81..9fb96af8 100644
--- a/weasyprint/svg/__init__.py
+++ b/weasyprint/svg/__init__.py
@@ -672,9 +672,9 @@ class SVG:
         fill_drawn = draw_gradient_or_pattern(
             self, node, fill_source, font_size, fill_opacity, stroke=False)
         if fill_color and not fill_drawn:
-            red, green, blue, alpha = color(fill_color)
-            self.stream.set_color_rgb(red, green, blue)
-            self.stream.set_alpha(alpha * fill_opacity)
+            stream_color = color(fill_color)
+            stream_color.alpha *= fill_opacity
+            self.stream.set_color(stream_color)
         fill = fill_color or fill_drawn
 
         # Get stroke data
@@ -683,9 +683,9 @@ class SVG:
         stroke_drawn = draw_gradient_or_pattern(
             self, node, stroke_source, font_size, stroke_opacity, stroke=True)
         if stroke_color and not stroke_drawn:
-            red, green, blue, alpha = color(stroke_color)
-            self.stream.set_color_rgb(red, green, blue, stroke=True)
-            self.stream.set_alpha(alpha * stroke_opacity, stroke=True)
+            stream_color = color(stroke_color)
+            stream_color.alpha *= stroke_opacity
+            self.stream.set_color(stream_color, stroke=True)
         stroke = stroke_color or stroke_drawn
         stroke_width = self.length(node.get('stroke-width', '1px'), font_size)
         if stroke_width:
diff --git a/weasyprint/svg/defs.py b/weasyprint/svg/defs.py
index cc7197f9..26d07115 100644
--- a/weasyprint/svg/defs.py
+++ b/weasyprint/svg/defs.py
@@ -86,18 +86,13 @@ def draw_gradient(svg, node, gradient, font_size, opacity, stroke):
             size(child.get('offset'), font_size, 1)))
         stop_opacity = alpha_value(child.get('stop-opacity', 1)) * opacity
         stop_color = color(child.get('stop-color', 'black'))
-        if stop_opacity < 1:
-            stop_color = tuple(
-                stop_color[:3] + (stop_color[3] * stop_opacity,))
+        stop_color.alpha *= stop_opacity
         colors.append(stop_color)
 
     if not colors:
         return False
     elif len(colors) == 1:
-        red, green, blue, alpha = colors[0]
-        svg.stream.set_color_rgb(red, green, blue)
-        if alpha != 1:
-            svg.stream.set_alpha(alpha, stroke=stroke)
+        svg.stream.set_color(colors[0])
         return True
 
     bounding_box = svg.calculate_bounding_box(node, font_size, stroke)
diff --git a/weasyprint/svg/utils.py b/weasyprint/svg/utils.py
index a6d15ff3..b6db9b53 100644
--- a/weasyprint/svg/utils.py
+++ b/weasyprint/svg/utils.py
@@ -5,7 +5,7 @@ from contextlib import suppress
 from math import cos, radians, sin, tan
 from urllib.parse import urlparse
 
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from ..matrix import Matrix
 
@@ -146,7 +146,7 @@ def parse_url(url):
 
 def color(string):
     """Safely parse a color string and return a RGBA tuple."""
-    return parse_color(string or '') or (0, 0, 0, 1)
+    return parse_color(string or '') or parse_color('black')
 
 
 def transform(transform_string, font_size, normalized_diagonal):


Test patch:
diff --git a/tests/css/test_common.py b/tests/css/test_common.py
index c7c39205..0877dd56 100644
--- a/tests/css/test_common.py
+++ b/tests/css/test_common.py
@@ -73,7 +73,7 @@ def test_annotate_document():
     assert p['margin_right'] == (0, 'px')
     assert p['margin_bottom'] == (24, 'px')
     assert p['margin_left'] == (0, 'px')
-    assert p['background_color'] == 'currentColor'
+    assert p['background_color'] == 'currentcolor'
 
     # 2em * 1.25ex = 2 * 20 * 1.25 * 0.8 = 40
     # 2.5ex * 1.25ex = 2.5 * 0.8 * 20 * 1.25 * 0.8 = 40
@@ -108,7 +108,7 @@ def test_annotate_document():
     assert a['border_bottom_width'] == 42
 
     assert a['color'] == (1, 0, 0, 1)
-    assert a['border_top_color'] == 'currentColor'
+    assert a['border_top_color'] == 'currentcolor'
 
     assert div['font_size'] == 40  # 2 * 20px
     assert span1['width'] == (160, 'px')  # 10 * 16px (root default is 16px)
diff --git a/tests/css/test_expanders.py b/tests/css/test_expanders.py
index 1c2b830a..a2ca5e4d 100644
--- a/tests/css/test_expanders.py
+++ b/tests/css/test_expanders.py
@@ -2,7 +2,7 @@
 
 import pytest
 import tinycss2
-from tinycss2.color3 import parse_color
+from tinycss2.color4 import parse_color
 
 from weasyprint.css import preprocess_declarations
 from weasyprint.css.properties import INITIAL_VALUES, ZERO_PIXELS
diff --git a/tests/layout/test_image.py b/tests/layout/test_image.py
index c4d73085..d34a6cf6 100644
--- a/tests/layout/test_image.py
+++ b/tests/layout/test_image.py
@@ -352,7 +352,7 @@ def test_linear_gradient():
         assert result[2] == (None if init is None else pytest.approx(init))
         assert result[3] == pytest.approx(positions)
         for color1, color2 in zip(result[4], colors):
-            assert color1 == pytest.approx(color2)
+            assert tuple(color1) == pytest.approx(color2)
 
     layout('linear-gradient(blue)', 'solid', None, [], [blue])
     layout('repeating-linear-gradient(blue)', 'solid', None, [], [blue])
@@ -417,7 +417,7 @@ def test_radial_gradient():
         assert result[2] == (None if init is None else pytest.approx(init))
         assert result[3] == pytest.approx(positions)
         for color1, color2 in zip(result[4], colors):
-            assert color1 == pytest.approx(color2)
+            assert tuple(color1) == pytest.approx(color2)
 
     layout('radial-gradient(blue)', 'solid', None, [], [blue])
     layout('repeating-radial-gradient(blue)', 'solid', None, [], [blue])