Repository: Kozea/WeasyPrint

Issue:
Support `unicode-range`

Hints from issue comments:
@liladude: I would love support for "unicode-range" in the @font-face because our text (and css) is mainly latin script with quotations in polytonic Greek or Arabic and this feature let us decide which font is used ONLY for the gliphs in the range!@Quphoria: I'm working on a project which would really benefit from this, would be cool to see it implemented@liZe: > I'm working on a project which would really benefit from this, would be cool to see it implemented

Yes, that would be great.

I think that it’s possible by setting the range in Fontconfig’s config file. Looks like it’s not documented in Fontconfig, but [this StackOverflow example](https://stackoverflow.com/questions/47501411/how-to-set-font-per-unicode-range-codepoint-in-fontconfig) should do the trick.

The first test that can be done is to try hard-coded values in [the configuration](https://github.com/Kozea/WeasyPrint/blob/e018bf3fbb13a05d2cfb647cb28cc4c0434558b0/weasyprint/text/fonts.py#L213) and see if it works. If it does, we’ll find a way to parse the range, validate it, and store it somewhere so that it’ll be available when we create the configuration.

Interested in getting this cool feature implemented?

Solution patch:
diff --git a/weasyprint/css/validation/descriptors.py b/weasyprint/css/validation/descriptors.py
index 8f2cadca9..06db3be47 100644
--- a/weasyprint/css/validation/descriptors.py
+++ b/weasyprint/css/validation/descriptors.py
@@ -208,6 +208,15 @@ def font_variant(tokens):
     return values
 
 
+@descriptor('font-face')
+@comma_separated_list
+@single_token
+def unicode_range(token):
+    """``unicode_range`` descriptor validation."""
+    if token.type == 'unicode-range':
+        return token
+
+
 @descriptor('counter-style')
 def system(tokens):
     """``system`` descriptor validation."""
diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py
index dee57d99c..56b45251d 100644
--- a/weasyprint/draw/text.py
+++ b/weasyprint/draw/text.py
@@ -134,7 +134,6 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
             first_line, index = textbox.pango_layout.get_first_line()
 
     utf8_text = textbox.pango_layout.text.encode()
-    previous_utf8_position = 0
     stream.set_text_matrix(*matrix.values)
     previous_pango_font = None
     string = ''
@@ -152,8 +151,11 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
         clusters = glyph_string.log_clusters
 
         # Get positions of the glyphs in the UTF-8 string.
-        utf8_positions = [offset + clusters[i] for i in range(1, num_glyphs)]
-        utf8_positions.append(offset + glyph_item.item.length)
+        utf8_positions = [offset + clusters[i] for i in range(num_glyphs)]
+        if glyph_item.item.analysis.level % 2:
+            utf8_positions.insert(0, offset + glyph_item.item.length)  # rtl
+        else:
+            utf8_positions.append(offset + glyph_item.item.length)  # ltr
 
         pango_font = glyph_item.item.analysis.font
         if pango_font != previous_pango_font:
@@ -161,6 +163,10 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
             previous_pango_font = pango_font
             font, font_size = stream.add_font(pango_font)
 
+            # Workaround for https://gitlab.gnome.org/GNOME/pango/-/issues/530.
+            if pango.pango_version() < 14802:
+                font_size = textbox.style['font_size']
+
             # Go through the run glyphs.
             if string:
                 stream.show_text(string)
@@ -175,7 +181,6 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
                     glyph & pango.PANGO_GLYPH_UNKNOWN_FLAG):
                 string += f'>{-width / font_size}<'
                 continue
-            utf8_position = utf8_positions[i]
 
             offset = glyph_info.geometry.x_offset / font_size
             rise = glyph_info.geometry.y_offset / 1000
@@ -213,9 +218,8 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix):
 
             # Create mapping between glyphs and characters.
             if glyph not in font.cmap:
-                utf8_slice = slice(previous_utf8_position, utf8_position)
+                utf8_slice = slice(*sorted(utf8_positions[i:i+2]))
                 font.cmap[glyph] = utf8_text[utf8_slice].decode()
-            previous_utf8_position = utf8_position
 
             # Create list of emojis.
             if font.svg:
diff --git a/weasyprint/text/fonts.py b/weasyprint/text/fonts.py
index efa16435d..61c46ef0c 100644
--- a/weasyprint/text/fonts.py
+++ b/weasyprint/text/fonts.py
@@ -208,14 +208,23 @@ def add_font_face(self, rule_descriptors, url_fetcher):
             match = SubElement(root, 'match', target='font')
             test = SubElement(match, 'test', name='file', compare='eq')
             SubElement(test, 'string').text = str(font_path)
-            edit = SubElement(match, 'edit', name='fontfeatures', mode=mode)
             descriptors = {
                 rules[0][0].replace('-', '_'): rules[0][1] for rules in
                 rule_descriptors.get('font_variant', [])}
             settings = rule_descriptors.get('font_feature_settings', 'normal')
             features = font_features(font_feature_settings=settings, **descriptors)
-            for key, value in features.items():
-                SubElement(edit, 'string').text = f'{key} {value}'
+            if features:
+                edit = SubElement(match, 'edit', name='fontfeatures', mode=mode)
+                for key, value in features.items():
+                    SubElement(edit, 'string').text = f'{key} {value}'
+            if unicode_ranges := rule_descriptors.get('unicode_range'):
+                edit = SubElement(match, 'edit', name='charset', mode=mode)
+                plus = SubElement(edit, 'plus')
+                for unicode_range in unicode_ranges:
+                    charset = SubElement(plus, 'charset')
+                    range_ = SubElement(charset, 'range')
+                    for value in (unicode_range.start, unicode_range.end):
+                        SubElement(range_, 'int').text = f'0x{value:x}'
             header = (
                 b'<?xml version="1.0"?>',
                 b'<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">')


Test patch:
diff --git a/tests/draw/test_text.py b/tests/draw/test_text.py
index c1be0036a..9fab6373a 100644
--- a/tests/draw/test_text.py
+++ b/tests/draw/test_text.py
@@ -1230,3 +1230,29 @@ def test_font_variant_caps_titling(assert_pixels):
       </style>
       <p>Pp</p>
     ''' % SANS_FONTS)
+
+
+def test_unicode_range(assert_pixels):
+    assert_pixels('''
+        __________
+        _RRRRRR___
+        _RRRRRRzz_
+        __________
+    ''', '''
+      <style>
+        @font-face {
+          font-family: uni;
+          src: url(weasyprint.otf);
+          unicode-range: u+41, u+043-045, u+005?;
+        }
+        @page {
+          size: 10px 4px;
+        }
+        body {
+          color: red;
+          font-family: uni;
+          font-size: 2px;
+          line-height: 0;
+          margin: 2px 1px;
+        }
+      </style>ADZB''')
diff --git a/tests/test_api.py b/tests/test_api.py
index e0cba1d02..4fc256582 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -550,6 +550,15 @@ def test_cmap():
         assert int(match) <= 100
 
 
+def test_cmap_rtl():
+    # Regression test for #378.
+    stdout = _run(
+        '--uncompressed-pdf -e utf-8 - -',
+        '<div style="font-family: weasyprint">اب'.encode())
+    assert b'<00cf> <0627>' in stdout
+    assert b'<00d0> <0628>' in stdout
+
+
 @pytest.mark.parametrize('html, fields', (
     ('<input>', ['/Tx', '/V ()']),
     ('<input value="">', ['/Tx', '/V ()']),