From 8ef8c39a03d8e1074d4a32861309edfec903e799 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 29 Aug 2021 19:34:13 +0200 Subject: [PATCH 1/3] Initial support of PNG emojis Related to #1406. --- weasyprint/document.py | 39 +++++++++++++++++++++++------ weasyprint/draw.py | 56 ++++++++++++++++++++++++++++++++++++++++-- weasyprint/svg/text.py | 10 +++++--- weasyprint/text/ffi.py | 9 +++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/weasyprint/document.py b/weasyprint/document.py index 3ef9042b4a..da4d49a7e2 100644 --- a/weasyprint/document.py +++ b/weasyprint/document.py @@ -16,7 +16,7 @@ import pydyf from fontTools import subset -from fontTools.ttLib import TTFont, TTLibError +from fontTools.ttLib import TTFont, TTLibError, ttFont from . import CSS, Attachment, __version__ from .css import get_all_computed_styles @@ -30,7 +30,7 @@ from .layout import LayoutContext, layout_document from .layout.percentages import percentage from .logger import LOGGER, PROGRESS_LOGGER -from .text.ffi import ffi, pango +from .text.ffi import ffi, harfbuzz, pango from .text.fonts import FontConfiguration from .urls import URLFetchingError @@ -66,7 +66,7 @@ def _w3c_date_to_pdf(string, attr_name): class Font: - def __init__(self, file_content, pango_font, index): + def __init__(self, file_content, pango_font, index, png, svg): pango_metrics = pango.pango_font_get_metrics(pango_font, ffi.NULL) self._font_description = pango.pango_font_describe(pango_font) self.family = ffi.string(pango.pango_font_description_get_family( @@ -98,6 +98,8 @@ def __init__(self, file_content, pango_font, index): self.bbox = [0, 0, 0, 0] self.widths = {} self.cmap = {} + self.png = png + self.svg = svg @property def flags(self): @@ -213,7 +215,12 @@ def set_alpha(self, alpha, stroke=False, fill=None): super().set_state(key) def add_font(self, font_hash, font_content, pango_font, index): - self._document.fonts[font_hash] = Font(font_content, pango_font, index) + hb_font = pango.pango_font_get_hb_font(pango_font) + hb_face = harfbuzz.hb_font_get_face(hb_font) + png = harfbuzz.hb_ot_color_has_png(hb_face) + svg = harfbuzz.hb_ot_color_has_svg(hb_face) + self._document.fonts[font_hash] = Font( + font_content, pango_font, index, png, svg) return self._document.fonts[font_hash] def get_fonts(self): @@ -1286,12 +1293,14 @@ def write_pdf(self, target=None, zoom=1, attachments=None, finisher=None): fonts_by_file_hash[font.file_hash] = [font] font_references_by_file_hash = {} for file_hash, fonts in fonts_by_file_hash.items(): + content = fonts[0].file_content + if 'fonts' in self.optimize_size: # Optimize font cmap = {} for font in fonts: cmap = {**cmap, **font.cmap} - full_font = io.BytesIO(fonts[0].file_content) + full_font = io.BytesIO(content) optimized_font = io.BytesIO() try: ttfont = TTFont(full_font, fontNumber=fonts[0].index) @@ -1306,8 +1315,24 @@ def write_pdf(self, target=None, zoom=1, attachments=None, finisher=None): except TTLibError: LOGGER.warning('Unable to optimize font') content = fonts[0].file_content - else: - content = fonts[0].file_content + + if fonts[0].png: + # Add empty glyphs instead of PNG emojis + full_font = io.BytesIO(content) + try: + ttfont = TTFont(full_font, fontNumber=fonts[0].index) + if 'loca' not in ttfont or 'glyf' not in ttfont: + ttfont['loca'] = ttFont.getTableClass('loca')() + ttfont['glyf'] = ttFont.getTableClass('glyf')() + ttfont['glyf'].glyphOrder = ttfont.getGlyphOrder() + ttfont['glyf'].glyphs = { + name: ttFont.getTableModule('glyf').Glyph() + for name in ttfont['glyf'].glyphOrder} + output_font = io.BytesIO() + ttfont.save(output_font) + content = output_font.getvalue() + except TTLibError: + LOGGER.warning('Unable to save emoji font') # Include font font_type = 'otf' if content[:4] == b'OTTO' else 'ttf' diff --git a/weasyprint/draw.py b/weasyprint/draw.py index 2f73fe846d..5dc053d50c 100644 --- a/weasyprint/draw.py +++ b/weasyprint/draw.py @@ -9,8 +9,11 @@ import contextlib import operator from colorsys import hsv_to_rgb, rgb_to_hsv +from io import BytesIO from math import ceil, floor, pi, sqrt, tan +from PIL import Image + from .formatting_structure import boxes from .images import SVGImage from .layout import replaced @@ -982,9 +985,12 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): textbox.pango_layout.reactivate(textbox.style) stream.begin_text() - draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y) + emojis = draw_first_line( + stream, textbox, text_overflow, block_ellipsis, x, y) stream.end_text() + draw_emojis(stream, emojis) + # Draw text decoration values = textbox.style['text_decoration_line'] color = textbox.style['text_decoration_color'] @@ -1010,6 +1016,20 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): textbox.pango_layout.deactivate() +def draw_emojis(stream, emojis): + if emojis: + font_size, x, y, emojis = emojis + for emoji in emojis: + stream.push_state() + stream.transform(a=font_size, d=-font_size, e=x, f=y) + for image_name, a, d, e, f in emojis: + stream.push_state() + stream.transform(a=a, d=d, e=e, f=f) + stream.draw_x_object(image_name) + stream.pop_state() + stream.pop_state() + + def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): """Draw the given ``textbox`` line to the document ``stream``.""" pango.pango_layout_set_single_paragraph_mode( @@ -1066,6 +1086,8 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): last_font = None string = '' fonts = stream.get_fonts() + x_advance = 0 + emojis = [] for run in runs: # Pango objects glyph_item = ffi.cast('PangoGlyphItem *', run.data) @@ -1083,7 +1105,9 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): if font_hash in fonts: font = fonts[font_hash] else: - hb_blob = harfbuzz.hb_face_reference_blob(hb_face) + hb_blob = ffi.gc( + harfbuzz.hb_face_reference_blob(hb_face), + harfbuzz.hb_blob_destroy) hb_data = harfbuzz.hb_blob_get_data(hb_blob, stream.length) file_content = ffi.unpack(hb_data, int(stream.length[0])) index = harfbuzz.hb_face_get_index(hb_face) @@ -1147,6 +1171,30 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): font.cmap[glyph] = utf8_text[utf8_slice].decode('utf-8') previous_utf8_position = utf8_position + if font.png: + hb_blob = ffi.gc( + harfbuzz.hb_ot_color_glyph_reference_png(hb_font, glyph), + harfbuzz.hb_blob_destroy) + hb_data = harfbuzz.hb_blob_get_data(hb_blob, stream.length) + if hb_data != ffi.NULL: + png_data = ffi.unpack(hb_data, int(stream.length[0])) + pillow_image = Image.open(BytesIO(png_data)) + d = font.widths[glyph] / 1000 + a = pillow_image.width / pillow_image.height * d + pango.pango_font_get_glyph_extents( + pango_font, glyph, stream.ink_rect, + stream.logical_rect) + f = units_to_double( + (-stream.logical_rect.y - stream.logical_rect.height)) + f /= font_size + pillow_image.id = f'f{font.index}-{glyph}' + image_name = stream.add_image( + pillow_image, textbox.style['image_rendering'], + optimize_size=()) + emojis.append([image_name, a, d, x_advance, f]) + + x_advance += (font.widths[glyph] + offset) / 1000 + # Close the last glyphs list, remove if empty if string[-1] == '<': string = string[:-1] @@ -1156,6 +1204,10 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): # Draw text stream.show_text(string) + # Draw emojis + if emojis: + return font_size, x, y, emojis + def draw_wave(stream, x, y, width, offset_x, radius): up = 1 diff --git a/weasyprint/svg/text.py b/weasyprint/svg/text.py index b774963f18..40be5b0b54 100644 --- a/weasyprint/svg/text.py +++ b/weasyprint/svg/text.py @@ -22,7 +22,7 @@ def __init__(self, pango_layout, style): def text(svg, node, font_size): """Draw text node.""" from ..css.properties import INITIAL_VALUES - from ..draw import draw_first_line + from ..draw import draw_emojis, draw_first_line from ..text.line_break import split_first_line # TODO: use real computed values @@ -121,6 +121,7 @@ def text(svg, node, font_size): return svg.stream.begin_text() + emojis = [] # Draw letters for i, ((x, y, dx, dy, r), letter) in enumerate(letters_positions): @@ -160,10 +161,13 @@ def text(svg, node, font_size): layout.reactivate(style) svg.fill_stroke(node, font_size, text=True) - draw_first_line( + emojis.append(draw_first_line( svg.stream, TextBox(layout, style), 'none', 'none', - x_position, y_position) + x_position, y_position)) svg.stream.pop_state() svg.cursor_position = cursor_position svg.stream.end_text() + + for emojis in emojis: + draw_emojis(svg.stream, emojis) diff --git a/weasyprint/text/ffi.py b/weasyprint/text/ffi.py index b964c4f64f..9a9e44dd3e 100644 --- a/weasyprint/text/ffi.py +++ b/weasyprint/text/ffi.py @@ -15,10 +15,19 @@ typedef ... hb_font_t; typedef ... hb_face_t; typedef ... hb_blob_t; + typedef uint32_t hb_codepoint_t; hb_face_t * hb_font_get_face (hb_font_t *font); hb_blob_t * hb_face_reference_blob (hb_face_t *face); unsigned int hb_face_get_index (const hb_face_t *face); const char * hb_blob_get_data (hb_blob_t *blob, unsigned int *length); + bool hb_ot_color_has_png (hb_face_t *face); + hb_blob_t * hb_ot_color_glyph_reference_png ( + hb_font_t *font, hb_codepoint_t glyph); + bool hb_ot_color_has_svg (hb_face_t *face); + hb_blob_t * hb_ot_color_glyph_reference_svg ( + hb_font_t *font, hb_codepoint_t glyph); + void hb_blob_destroy (hb_blob_t *blob); + // Pango From cb97228c04445ace161f81cfb989a8995fa35428 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Mon, 30 Aug 2021 11:10:12 +0200 Subject: [PATCH 2/3] Initial support of SVG emojis Related to #1406. --- weasyprint/document.py | 31 ++++++++++++++------------- weasyprint/draw.py | 46 +++++++++++++++++++++------------------- weasyprint/images.py | 7 +++--- weasyprint/svg/images.py | 7 +++--- weasyprint/svg/text.py | 11 +++++----- weasyprint/text/ffi.py | 3 ++- 6 files changed, 56 insertions(+), 49 deletions(-) diff --git a/weasyprint/document.py b/weasyprint/document.py index da4d49a7e2..59074ca7d7 100644 --- a/weasyprint/document.py +++ b/weasyprint/document.py @@ -66,8 +66,10 @@ def _w3c_date_to_pdf(string, attr_name): class Font: - def __init__(self, file_content, pango_font, index, png, svg): + def __init__(self, file_content, pango_font, index): pango_metrics = pango.pango_font_get_metrics(pango_font, ffi.NULL) + hb_font = pango.pango_font_get_hb_font(pango_font) + hb_face = harfbuzz.hb_font_get_face(hb_font) self._font_description = pango.pango_font_describe(pango_font) self.family = ffi.string(pango.pango_font_description_get_family( self._font_description)) @@ -93,13 +95,14 @@ def __init__(self, file_content, pango_font, index, png, svg): self.descent = -int( pango.pango_font_metrics_get_descent(pango_metrics) / font_size * 1000) + self.upem = harfbuzz.hb_face_get_upem(hb_face) + self.png = harfbuzz.hb_ot_color_has_png(hb_face) + self.svg = harfbuzz.hb_ot_color_has_svg(hb_face) self.stemv = 80 self.stemh = 80 self.bbox = [0, 0, 0, 0] self.widths = {} self.cmap = {} - self.png = png - self.svg = svg @property def flags(self): @@ -215,12 +218,7 @@ def set_alpha(self, alpha, stroke=False, fill=None): super().set_state(key) def add_font(self, font_hash, font_content, pango_font, index): - hb_font = pango.pango_font_get_hb_font(pango_font) - hb_face = harfbuzz.hb_font_get_face(hb_font) - png = harfbuzz.hb_ot_color_has_png(hb_face) - svg = harfbuzz.hb_ot_color_has_svg(hb_face) - self._document.fonts[font_hash] = Font( - font_content, pango_font, index, png, svg) + self._document.fonts[font_hash] = Font(font_content, pango_font, index) return self._document.fonts[font_hash] def get_fonts(self): @@ -1314,10 +1312,9 @@ def write_pdf(self, target=None, zoom=1, attachments=None, finisher=None): content = optimized_font.getvalue() except TTLibError: LOGGER.warning('Unable to optimize font') - content = fonts[0].file_content - if fonts[0].png: - # Add empty glyphs instead of PNG emojis + if fonts[0].png or fonts[0].svg: + # Add empty glyphs instead of PNG or SVG emojis full_font = io.BytesIO(content) try: ttfont = TTFont(full_font, fontNumber=fonts[0].index) @@ -1328,9 +1325,13 @@ def write_pdf(self, target=None, zoom=1, attachments=None, finisher=None): ttfont['glyf'].glyphs = { name: ttFont.getTableModule('glyf').Glyph() for name in ttfont['glyf'].glyphOrder} - output_font = io.BytesIO() - ttfont.save(output_font) - content = output_font.getvalue() + else: + for glyph in ttfont['glyf'].glyphs: + ttfont['glyf'][glyph] = ( + ttFont.getTableModule('glyf').Glyph()) + output_font = io.BytesIO() + ttfont.save(output_font) + content = output_font.getvalue() except TTLibError: LOGGER.warning('Unable to save emoji font') diff --git a/weasyprint/draw.py b/weasyprint/draw.py index 5dc053d50c..2d9ccaba41 100644 --- a/weasyprint/draw.py +++ b/weasyprint/draw.py @@ -15,7 +15,7 @@ from PIL import Image from .formatting_structure import boxes -from .images import SVGImage +from .images import RasterImage, SVGImage from .layout import replaced from .layout.backgrounds import BackgroundLayer from .stacking import StackingContext @@ -989,7 +989,7 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): stream, textbox, text_overflow, block_ellipsis, x, y) stream.end_text() - draw_emojis(stream, emojis) + draw_emojis(stream, textbox.style['font_size'], x, y, emojis) # Draw text decoration values = textbox.style['text_decoration_line'] @@ -1016,17 +1016,12 @@ def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): textbox.pango_layout.deactivate() -def draw_emojis(stream, emojis): - if emojis: - font_size, x, y, emojis = emojis - for emoji in emojis: +def draw_emojis(stream, font_size, x, y, emojis): + for emoji in emojis: + for image, font, a, d, e, f in emojis: stream.push_state() - stream.transform(a=font_size, d=-font_size, e=x, f=y) - for image_name, a, d, e, f in emojis: - stream.push_state() - stream.transform(a=a, d=d, e=e, f=f) - stream.draw_x_object(image_name) - stream.pop_state() + stream.transform(a=a, d=d, e=x + e * font_size, f=y + f) + image.draw(stream, font_size, font_size, None) stream.pop_state() @@ -1171,7 +1166,17 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): font.cmap[glyph] = utf8_text[utf8_slice].decode('utf-8') previous_utf8_position = utf8_position - if font.png: + if font.svg: + hb_blob = ffi.gc( + harfbuzz.hb_ot_color_glyph_reference_svg(hb_face, glyph), + harfbuzz.hb_blob_destroy) + hb_data = harfbuzz.hb_blob_get_data(hb_blob, stream.length) + if hb_data != ffi.NULL: + svg_data = ffi.unpack(hb_data, int(stream.length[0])) + image = SVGImage(svg_data, None, None, stream) + a = d = font.widths[glyph] / 1000 / font.upem * font_size + emojis.append([image, font, a, d, x_advance, 0]) + elif font.png: hb_blob = ffi.gc( harfbuzz.hb_ot_color_glyph_reference_png(hb_font, glyph), harfbuzz.hb_blob_destroy) @@ -1179,6 +1184,9 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): if hb_data != ffi.NULL: png_data = ffi.unpack(hb_data, int(stream.length[0])) pillow_image = Image.open(BytesIO(png_data)) + image_id = f'{font.hash}{glyph}' + image = RasterImage( + pillow_image, image_id, optimize_size=()) d = font.widths[glyph] / 1000 a = pillow_image.width / pillow_image.height * d pango.pango_font_get_glyph_extents( @@ -1186,12 +1194,8 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): stream.logical_rect) f = units_to_double( (-stream.logical_rect.y - stream.logical_rect.height)) - f /= font_size - pillow_image.id = f'f{font.index}-{glyph}' - image_name = stream.add_image( - pillow_image, textbox.style['image_rendering'], - optimize_size=()) - emojis.append([image_name, a, d, x_advance, f]) + f = f / font_size - font_size + emojis.append([image, font, a, d, x_advance, f]) x_advance += (font.widths[glyph] + offset) / 1000 @@ -1204,9 +1208,7 @@ def draw_first_line(stream, textbox, text_overflow, block_ellipsis, x, y): # Draw text stream.show_text(string) - # Draw emojis - if emojis: - return font_size, x, y, emojis + return emojis def draw_wave(stream, x, y, width, offset_x, radius): diff --git a/weasyprint/images.py b/weasyprint/images.py index 09f6c5a39b..03a51a6bce 100644 --- a/weasyprint/images.py +++ b/weasyprint/images.py @@ -34,7 +34,8 @@ def from_exception(cls, exception): class RasterImage: - def __init__(self, pillow_image, optimize_size): + def __init__(self, pillow_image, image_id, optimize_size): + pillow_image.id = image_id self._pillow_image = pillow_image self._optimize_size = optimize_size self._intrinsic_width = pillow_image.width @@ -131,8 +132,8 @@ def get_image_from_uri(cache, url_fetcher, optimize_size, url, raster_exception) else: # Store image id to enable cache in Stream.add_image - pillow_image.id = len(cache) - image = RasterImage(pillow_image, optimize_size) + image_id = len(cache) + image = RasterImage(pillow_image, image_id, optimize_size) except (URLFetchingError, ImageLoadingError) as exception: LOGGER.error('Failed to load image at %r: %s', url, exception) diff --git a/weasyprint/svg/images.py b/weasyprint/svg/images.py index 1775b3d9a0..d0307d4a1e 100644 --- a/weasyprint/svg/images.py +++ b/weasyprint/svg/images.py @@ -22,9 +22,10 @@ def svg(svg, node, font_size): node.get('width'), node.get('height'), font_size) scale_x, scale_y, translate_x, translate_y = preserve_ratio( svg, node, font_size, width, height) - svg.stream.rectangle(0, 0, width, height) - svg.stream.clip() - svg.stream.end() + if svg.tree != node: + svg.stream.rectangle(0, 0, width, height) + svg.stream.clip() + svg.stream.end() svg.stream.transform(a=scale_x, d=scale_y, e=translate_x, f=translate_y) diff --git a/weasyprint/svg/text.py b/weasyprint/svg/text.py index 40be5b0b54..115855f235 100644 --- a/weasyprint/svg/text.py +++ b/weasyprint/svg/text.py @@ -121,7 +121,7 @@ def text(svg, node, font_size): return svg.stream.begin_text() - emojis = [] + emoji_lines = [] # Draw letters for i, ((x, y, dx, dy, r), letter) in enumerate(letters_positions): @@ -161,13 +161,14 @@ def text(svg, node, font_size): layout.reactivate(style) svg.fill_stroke(node, font_size, text=True) - emojis.append(draw_first_line( + emojis = draw_first_line( svg.stream, TextBox(layout, style), 'none', 'none', - x_position, y_position)) + x_position, y_position) + emoji_lines.append((font_size, x, y, emojis)) svg.stream.pop_state() svg.cursor_position = cursor_position svg.stream.end_text() - for emojis in emojis: - draw_emojis(svg.stream, emojis) + for font_size, x, y, emojis in emoji_lines: + draw_emojis(svg.stream, font_size, x, y, emojis) diff --git a/weasyprint/text/ffi.py b/weasyprint/text/ffi.py index 9a9e44dd3e..0734cbeace 100644 --- a/weasyprint/text/ffi.py +++ b/weasyprint/text/ffi.py @@ -19,13 +19,14 @@ hb_face_t * hb_font_get_face (hb_font_t *font); hb_blob_t * hb_face_reference_blob (hb_face_t *face); unsigned int hb_face_get_index (const hb_face_t *face); + unsigned int hb_face_get_upem (const hb_face_t *face); const char * hb_blob_get_data (hb_blob_t *blob, unsigned int *length); bool hb_ot_color_has_png (hb_face_t *face); hb_blob_t * hb_ot_color_glyph_reference_png ( hb_font_t *font, hb_codepoint_t glyph); bool hb_ot_color_has_svg (hb_face_t *face); hb_blob_t * hb_ot_color_glyph_reference_svg ( - hb_font_t *font, hb_codepoint_t glyph); + hb_face_t *face, hb_codepoint_t glyph); void hb_blob_destroy (hb_blob_t *blob); From 32eb7c2abbc0167e8bdb450868815ba881004ddd Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Mon, 30 Aug 2021 15:00:04 +0200 Subject: [PATCH 3/3] Remove emoji tables --- weasyprint/document.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/weasyprint/document.py b/weasyprint/document.py index 59074ca7d7..a257492a45 100644 --- a/weasyprint/document.py +++ b/weasyprint/document.py @@ -1329,6 +1329,9 @@ def write_pdf(self, target=None, zoom=1, attachments=None, finisher=None): for glyph in ttfont['glyf'].glyphs: ttfont['glyf'][glyph] = ( ttFont.getTableModule('glyf').Glyph()) + for table_name in ('CBDT', 'CBLC', 'SVG '): + if table_name in ttfont: + del ttfont[table_name] output_font = io.BytesIO() ttfont.save(output_font) content = output_font.getvalue()