From fc147ba8a148bd66984dcc2f6b06c545c3db639f Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sat, 11 Jan 2025 22:53:41 +0100 Subject: [PATCH 1/7] Handle small-caps synthesis Fix #123. --- weasyprint/draw/text.py | 13 +++++++------ weasyprint/text/constants.py | 9 +++++++++ weasyprint/text/ffi.py | 12 ++++++++++++ weasyprint/text/fonts.py | 6 ++++-- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py index 84c1a1ef4..a8b5671f2 100644 --- a/weasyprint/draw/text.py +++ b/weasyprint/draw/text.py @@ -78,8 +78,7 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): if not textbox.text.strip(): return [] - font_size = textbox.style['font_size'] - if font_size < 1e-6: # default float precision used by pydyf + if textbox.style['font_size'] < 1e-6: # default float precision used by pydyf return [] pango.pango_layout_set_single_paragraph_mode(textbox.pango_layout.layout, True) @@ -126,7 +125,7 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): utf8_text = textbox.pango_layout.text.encode() previous_utf8_position = 0 stream.set_text_matrix(*matrix.values) - last_font = None + last_font = last_font_size = None string = '' x_advance = 0 emojis = [] @@ -141,8 +140,10 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): offset = glyph_item.item.offset clusters = glyph_string.log_clusters - # Add font file content. + # Add font file content and get font size. pango_font = glyph_item.item.analysis.font + description = pango.pango_font_describe(pango_font) + font_size = pango.pango_font_description_get_size(description) * FROM_UNITS font = stream.add_font(pango_font) # Get positions of the glyphs in the UTF-8 string. @@ -150,12 +151,12 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): utf8_positions.append(offset + glyph_item.item.length) # Go through the run glyphs. - if font != last_font: + if (font, font_size) != (last_font, last_font_size): if string: stream.show_text(string) string = '' stream.set_font_size(font.hash, 1 if font.bitmap else font_size) - last_font = font + last_font, last_font_size = font, font_size string += '<' for i in range(num_glyphs): glyph_info = glyphs[i] diff --git a/weasyprint/text/constants.py b/weasyprint/text/constants.py index 8462abc5f..5095793b8 100644 --- a/weasyprint/text/constants.py +++ b/weasyprint/text/constants.py @@ -38,6 +38,15 @@ 'WRAP_CHAR': pango.PANGO_WRAP_CHAR, 'WRAP_WORD_CHAR': pango.PANGO_WRAP_WORD_CHAR } +PANGO_VARIANT = { + 'normal': pango.PANGO_VARIANT_NORMAL, + 'small-caps': pango.PANGO_VARIANT_SMALL_CAPS, + 'all-small-caps': pango.PANGO_VARIANT_ALL_SMALL_CAPS, + 'petite-caps': pango.PANGO_VARIANT_PETITE_CAPS, + 'all-petite-caps': pango.PANGO_VARIANT_ALL_PETITE_CAPS, + 'unicase': pango.PANGO_VARIANT_UNICASE, + 'titling-caps': pango.PANGO_VARIANT_TITLE_CAPS, +} # Language system tags # From https://docs.microsoft.com/typography/opentype/spec/languagetags diff --git a/weasyprint/text/ffi.py b/weasyprint/text/ffi.py index b6577541c..a3c41f46c 100644 --- a/weasyprint/text/ffi.py +++ b/weasyprint/text/ffi.py @@ -145,6 +145,16 @@ PANGO_WRAP_WORD_CHAR } PangoWrapMode; + typedef enum { + PANGO_VARIANT_NORMAL, + PANGO_VARIANT_SMALL_CAPS, + PANGO_VARIANT_ALL_SMALL_CAPS, + PANGO_VARIANT_PETITE_CAPS, + PANGO_VARIANT_ALL_PETITE_CAPS, + PANGO_VARIANT_UNICASE, + PANGO_VARIANT_TITLE_CAPS, + } PangoVariant; + typedef enum { PANGO_TAB_LEFT } PangoTabAlign; @@ -291,6 +301,8 @@ PangoFontDescription *desc, double size); void pango_font_description_set_variations ( PangoFontDescription* desc, const char* variations); + void pango_font_description_set_variant ( + PangoFontDescription* desc, PangoVariant variant); PangoStyle pango_font_description_get_style (const PangoFontDescription *desc); const char* pango_font_description_get_variations ( diff --git a/weasyprint/text/fonts.py b/weasyprint/text/fonts.py index 1fb305600..f7717f7c0 100644 --- a/weasyprint/text/fonts.py +++ b/weasyprint/text/fonts.py @@ -14,8 +14,8 @@ from ..urls import FILESYSTEM_ENCODING, fetch from .constants import ( # isort:skip - CAPS_KEYS, EAST_ASIAN_KEYS, FONTCONFIG_STRETCH, FONTCONFIG_STYLE, - FONTCONFIG_WEIGHT, LIGATURE_KEYS, NUMERIC_KEYS, PANGO_STRETCH, PANGO_STYLE) + CAPS_KEYS, EAST_ASIAN_KEYS, FONTCONFIG_STRETCH, FONTCONFIG_STYLE, FONTCONFIG_WEIGHT, + LIGATURE_KEYS, NUMERIC_KEYS, PANGO_STRETCH, PANGO_STYLE, PANGO_VARIANT) from .ffi import ( # isort:skip TO_UNITS, ffi, fontconfig, gobject, harfbuzz, pango, pangoft2, unicode_to_char_p) @@ -319,6 +319,8 @@ def get_font_description(style): pango.pango_font_description_set_weight(font_description, font_weight) font_size = int(style['font_size'] * TO_UNITS) pango.pango_font_description_set_absolute_size(font_description, font_size) + font_variant = PANGO_VARIANT[style['font_variant_caps']] + pango.pango_font_description_set_variant(font_description, font_variant) if style['font_variation_settings'] != 'normal': string = ','.join( f'{key}={value}' for key, value in From ff38addb663d8c4692e10cafa472791e64a30ced Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 14:33:44 +0100 Subject: [PATCH 2/7] Add tests for font-variant-caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests are fragile, as they rely on pixel-perfect rendering. Let’s see if they work at least on all platforms. --- tests/draw/test_text.py | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/tests/draw/test_text.py b/tests/draw/test_text.py index 27e70ca0a..ae94ebefa 100644 --- a/tests/draw/test_text.py +++ b/tests/draw/test_text.py @@ -900,3 +900,140 @@ def test_huge_justification(assert_pixels): } A B''') + + +def test_font_variant_caps_small(assert_pixels): + assert_pixels(''' + ________ + _BB_BB__ + _BB_B_B_ + _B__BB__ + _B__B___ + ________ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) + + +def test_font_variant_caps_all_small(assert_pixels): + assert_pixels(''' + ________ + BB_BB___ + B_BB_B__ + BB_BB___ + B__B____ + ________ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) + + +def test_font_variant_caps_petite(assert_pixels): + assert_pixels(''' + ________ + _BB_BB__ + _BB_B_B_ + _B__BB__ + _B__B___ + ________ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) + + +def test_font_variant_caps_all_petite(assert_pixels): + assert_pixels(''' + ________ + BB_BB___ + B_BB_B__ + BB_BB___ + B__B____ + ________ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) + + +def test_font_variant_caps_unicase(assert_pixels): + assert_pixels(''' + ________ + BB______ + B_B_BB__ + BB__B_B_ + B___BB__ + ____B___ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) + + +def test_font_variant_caps_titling(assert_pixels): + assert_pixels(''' + _BB_____ + _BB_____ + _BB__BB_ + _B___B_B + _____BB_ + _____B__ + ''', ''' + +

Pp

+ ''' % SANS_FONTS) From 8ef8d1a331c933cd8894ff0cbcabaa159ed080e2 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 15:23:34 +0100 Subject: [PATCH 3/7] Allow all font-variant-caps values in font expander --- weasyprint/css/validation/expanders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/weasyprint/css/validation/expanders.py b/weasyprint/css/validation/expanders.py index cffab8450..52880fb65 100644 --- a/weasyprint/css/validation/expanders.py +++ b/weasyprint/css/validation/expanders.py @@ -16,8 +16,8 @@ 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, gap, grid_line, grid_template, line_height, + flex_grow_shrink, flex_wrap, font_family, font_size, font_stretch, font_style, + font_variant_caps, font_weight, gap, grid_line, grid_template, line_height, list_style_image, list_style_position, list_style_type, mask_border_mode, other_colors, overflow_wrap, validate_non_shorthand) @@ -675,7 +675,7 @@ def expand_font(tokens, name): if font_style([token]) is not None: suffix = '-style' - elif get_keyword(token) in ('normal', 'small-caps'): + elif font_variant_caps([token]) is not None: suffix = '-variant-caps' elif font_weight([token]) is not None: suffix = '-weight' From 60b2f42bfd7b4b65ff5fa68d4bd0bd3f382a3162 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 15:23:53 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Fix=20links=20to=20W3C=E2=80=99s=20font-var?= =?UTF-8?q?iant-alternates=20specification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- weasyprint/css/validation/properties.py | 2 +- weasyprint/text/fonts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py index 1f29d0723..925cbf896 100644 --- a/weasyprint/css/validation/properties.py +++ b/weasyprint/css/validation/properties.py @@ -888,7 +888,7 @@ def font_feature_settings_list(tokens): @single_keyword def font_variant_alternates(keyword): # TODO: support other values - # See https://www.w3.org/TR/css-fonts-3/#font-variant-caps-prop + # See https://drafts.csswg.org/css-fonts/#font-variant-alternates-prop return keyword in ('normal', 'historical-forms') diff --git a/weasyprint/text/fonts.py b/weasyprint/text/fonts.py index f7717f7c0..edf914906 100644 --- a/weasyprint/text/fonts.py +++ b/weasyprint/text/fonts.py @@ -287,7 +287,7 @@ def font_features(font_kerning='normal', font_variant_ligatures='normal', if font_variant_alternates != 'normal': # TODO: support other values - # See https://www.w3.org/TR/css-fonts-3/#font-variant-caps-prop + # See https://drafts.csswg.org/css-fonts/#font-variant-alternates-prop if font_variant_alternates == 'historical-forms': features['hist'] = 1 From 14f288049dc20946893b958e71c44427dbd31edf Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 15:24:16 +0100 Subject: [PATCH 5/7] Remove documentation about unsupported small-caps synthesis --- docs/api_reference.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index f66ae5110..2380f77b2 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -369,10 +369,6 @@ WeasyPrint does **not** support the ``@font-feature-values`` rule and the values of ``font-variant-alternates`` other than ``normal`` and ``historical-forms``. -The ``font-variant-caps`` property is supported but needs the small-caps variant of -the font to be installed. WeasyPrint does **not** simulate missing small-caps -fonts. - From `CSS Fonts Module Level 4`_ we only support the ``font-variation-settings`` property enabling specific font variations. From 37ad2346c2a83fa264be779a74fbf15f3f0af8fc Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 16:22:32 +0100 Subject: [PATCH 6/7] =?UTF-8?q?Don=E2=80=99t=20set=20fallback=20font=20var?= =?UTF-8?q?iant=20values=20for=20Pango=20<=201.50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Other values are ignored anyways by older Pango versions. --- weasyprint/text/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/weasyprint/text/constants.py b/weasyprint/text/constants.py index 5095793b8..bf40b3bd4 100644 --- a/weasyprint/text/constants.py +++ b/weasyprint/text/constants.py @@ -38,6 +38,7 @@ 'WRAP_CHAR': pango.PANGO_WRAP_CHAR, 'WRAP_WORD_CHAR': pango.PANGO_WRAP_WORD_CHAR } +# Some variants have been added in Pango 1.50 and are ignored when used. PANGO_VARIANT = { 'normal': pango.PANGO_VARIANT_NORMAL, 'small-caps': pango.PANGO_VARIANT_SMALL_CAPS, From 6f21a24856a2136d36b627710e4b62f22fe84ecd Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 12 Jan 2025 23:25:47 +0100 Subject: [PATCH 7/7] Avoid getting font descriptions multiple times --- weasyprint/draw/text.py | 18 ++++++++---------- weasyprint/pdf/anchors.py | 4 ++-- weasyprint/pdf/fonts.py | 26 ++++++++++++-------------- weasyprint/pdf/stream.py | 6 +++--- weasyprint/text/fonts.py | 6 ++++-- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py index a8b5671f2..1a14783f3 100644 --- a/weasyprint/draw/text.py +++ b/weasyprint/draw/text.py @@ -125,7 +125,7 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): utf8_text = textbox.pango_layout.text.encode() previous_utf8_position = 0 stream.set_text_matrix(*matrix.values) - last_font = last_font_size = None + previous_pango_font = None string = '' x_advance = 0 emojis = [] @@ -140,23 +140,21 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): offset = glyph_item.item.offset clusters = glyph_string.log_clusters - # Add font file content and get font size. - pango_font = glyph_item.item.analysis.font - description = pango.pango_font_describe(pango_font) - font_size = pango.pango_font_description_get_size(description) * FROM_UNITS - font = stream.add_font(pango_font) - # 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) - # Go through the run glyphs. - if (font, font_size) != (last_font, last_font_size): + pango_font = glyph_item.item.analysis.font + if pango_font != previous_pango_font: + # Add font file content and get font size. + previous_pango_font = pango_font + font, font_size = stream.add_font(pango_font) + + # Go through the run glyphs. if string: stream.show_text(string) string = '' stream.set_font_size(font.hash, 1 if font.bitmap else font_size) - last_font, last_font_size = font, font_size string += '<' for i in range(num_glyphs): glyph_info = glyphs[i] diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py index ebf489cdc..338894a30 100644 --- a/weasyprint/pdf/anchors.py +++ b/weasyprint/pdf/anchors.py @@ -203,7 +203,7 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map): font_description = get_font_description(style) font = pango.pango_font_map_load_font( font_map, context, font_description) - font = stream.add_font(font) + font, _ = stream.add_font(font) font.used_in_forms = True field_stream.set_font_size(font.hash, font_size) @@ -252,7 +252,7 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map): font_description = get_font_description(style) font = pango.pango_font_map_load_font( font_map, context, font_description) - font = stream.add_font(font) + font, _ = stream.add_font(font) font.used_in_forms = True field_stream.set_font_size(font.hash, font_size) diff --git a/weasyprint/pdf/fonts.py b/weasyprint/pdf/fonts.py index 720492711..4e2ecfb00 100644 --- a/weasyprint/pdf/fonts.py +++ b/weasyprint/pdf/fonts.py @@ -17,39 +17,38 @@ class Font: - def __init__(self, pango_font): + def __init__(self, pango_font, description, font_size): self.hb_font = pango.pango_font_get_hb_font(pango_font) self.hb_face = get_pango_font_hb_face(pango_font) self.file_content = get_hb_object_data(self.hb_face) self.index = harfbuzz.hb_face_get_index(self.hb_face) - pango_metrics = pango.pango_font_get_metrics(pango_font, ffi.NULL) - self.description = description = ffi.gc( - pango.pango_font_describe(pango_font), pango.pango_font_description_free) - self.font_size = pango.pango_font_description_get_size(description) + self.font_size = font_size self.style = pango.pango_font_description_get_style(description) self.family = ffi.string(pango.pango_font_description_get_family(description)) self.variations = {} - variations = pango.pango_font_description_get_variations(self.description) + variations = pango.pango_font_description_get_variations(description) if variations != ffi.NULL: self.variations = { part.split('=')[0]: float(part.split('=')[1]) for part in ffi.string(variations).decode().split(',')} if weight := self.variations.get('weight'): - pango.pango_font_description_set_weight( - self.description, int(round(weight))) + self.weight = int(round(weight)) + pango.pango_font_description_set_weight(description, weight) + else: + self.weight = pango.pango_font_description_get_weight(description) if self.variations.get('ital'): pango.pango_font_description_set_style( - self.description, pango.PANGO_STYLE_ITALIC) + description, pango.PANGO_STYLE_ITALIC) elif self.variations.get('slnt'): pango.pango_font_description_set_style( - self.description, pango.PANGO_STYLE_OBLIQUE) + description, pango.PANGO_STYLE_OBLIQUE) if (width := self.variations.get('wdth')) is not None: stretch = min( PANGO_STRETCH_PERCENT.items(), key=lambda item: abs(item[0] - width))[1] - pango.pango_font_description_set_stretch(self.description, stretch) + pango.pango_font_description_set_stretch(description, stretch) description_string = ffi.string( pango.pango_font_description_to_string(description)) @@ -70,6 +69,7 @@ def __init__(self, pango_font): # Set ascent and descent. if self.font_size: + pango_metrics = pango.pango_font_get_metrics(pango_font, ffi.NULL) self.ascent = int( pango.pango_font_metrics_get_ascent(pango_metrics) / self.font_size * 1000) @@ -126,9 +126,7 @@ def clean(self, cmap, hinting): full_font = io.BytesIO(self.file_content) ttfont = TTFont(full_font, fontNumber=self.index) if 'wght' not in self.variations: - weight = pango.pango_font_description_get_weight( - self.description) - self.variations['wght'] = weight + self.variations['wght'] = self.weight if 'opsz' not in self.variations: self.variations['opsz'] = self.font_size * FROM_UNITS if 'slnt' not in self.variations: diff --git a/weasyprint/pdf/stream.py b/weasyprint/pdf/stream.py index 8a0aa09a8..92f32c306 100644 --- a/weasyprint/pdf/stream.py +++ b/weasyprint/pdf/stream.py @@ -161,10 +161,10 @@ def set_blend_mode(self, mode): })) def add_font(self, pango_font): - key = get_pango_font_key(pango_font) + key, description, font_size = get_pango_font_key(pango_font) if key not in self._fonts: - self._fonts[key] = Font(pango_font) - return self._fonts[key] + self._fonts[key] = Font(pango_font, description, font_size) + return self._fonts[key], font_size def add_group(self, x, y, width, height): resources = pydyf.Dictionary({ diff --git a/weasyprint/text/fonts.py b/weasyprint/text/fonts.py index edf914906..c4158cd54 100644 --- a/weasyprint/text/fonts.py +++ b/weasyprint/text/fonts.py @@ -17,7 +17,8 @@ CAPS_KEYS, EAST_ASIAN_KEYS, FONTCONFIG_STRETCH, FONTCONFIG_STYLE, FONTCONFIG_WEIGHT, LIGATURE_KEYS, NUMERIC_KEYS, PANGO_STRETCH, PANGO_STYLE, PANGO_VARIANT) from .ffi import ( # isort:skip - TO_UNITS, ffi, fontconfig, gobject, harfbuzz, pango, pangoft2, unicode_to_char_p) + FROM_UNITS, TO_UNITS, ffi, fontconfig, gobject, harfbuzz, pango, pangoft2, + unicode_to_char_p) def _check_font_configuration(font_config): # pragma: no cover @@ -364,6 +365,7 @@ def get_pango_font_key(pango_font): # FontConfiguration object. See https://github.com/Kozea/WeasyPrint/issues/2144 description = ffi.gc( pango.pango_font_describe(pango_font), pango.pango_font_description_free) + font_size = pango.pango_font_description_get_size(description) * FROM_UNITS mask = pango.PANGO_FONT_MASK_SIZE + pango.PANGO_FONT_MASK_GRAVITY pango.pango_font_description_unset_fields(description, mask) - return pango.pango_font_description_hash(description) + return pango.pango_font_description_hash(description), description, font_size