From 5f8269b78fb5b3279e324ba29c8384c826a1d7be Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Mon, 17 Oct 2022 14:48:38 +0200 Subject: [PATCH 1/4] Optimize social plugin: cache font objects --- material/plugins/social/plugin.py | 13 +++++++++---- src/plugins/social/plugin.py | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index 0c574589c..8e31399a8 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -18,6 +18,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. +import functools import logging import os import posixpath @@ -160,6 +161,10 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # ------------------------------------------------------------------------- + @functools.lru_cache(maxsize=None) + def _get_font(self, kind, size): + return ImageFont.truetype(self.font[kind], size) + # Render social card def _render_card(self, site_name, title, description): logo = self.logo @@ -172,21 +177,21 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): ) # Render site name - font = ImageFont.truetype(self.font["Bold"], 36) + font = self._get_font("Bold", 36) image.alpha_composite( self._render_text((826, 48), font, site_name, 1, 20), (64 + 4, 64) ) # Render page title - font = ImageFont.truetype(self.font["Bold"], 92) + font = self._get_font("Bold", 92) image.alpha_composite( self._render_text((826, 328), font, title, 3, 30), (64, 160) ) # Render page description - font = ImageFont.truetype(self.font["Regular"], 28) + font = self._get_font("Regular", 28) image.alpha_composite( self._render_text((826, 80), font, description, 2, 14), (64 + 4, 512) @@ -379,7 +384,7 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # Write archive to temporary file tmp = TemporaryFile() - for chunk in res.iter_content(chunk_size = 128): + for chunk in res.iter_content(chunk_size = 32768): tmp.write(chunk) # Unzip fonts from temporary file diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index 0c574589c..8e31399a8 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -18,6 +18,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. +import functools import logging import os import posixpath @@ -160,6 +161,10 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # ------------------------------------------------------------------------- + @functools.lru_cache(maxsize=None) + def _get_font(self, kind, size): + return ImageFont.truetype(self.font[kind], size) + # Render social card def _render_card(self, site_name, title, description): logo = self.logo @@ -172,21 +177,21 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): ) # Render site name - font = ImageFont.truetype(self.font["Bold"], 36) + font = self._get_font("Bold", 36) image.alpha_composite( self._render_text((826, 48), font, site_name, 1, 20), (64 + 4, 64) ) # Render page title - font = ImageFont.truetype(self.font["Bold"], 92) + font = self._get_font("Bold", 92) image.alpha_composite( self._render_text((826, 328), font, title, 3, 30), (64, 160) ) # Render page description - font = ImageFont.truetype(self.font["Regular"], 28) + font = self._get_font("Regular", 28) image.alpha_composite( self._render_text((826, 80), font, description, 2, 14), (64 + 4, 512) @@ -379,7 +384,7 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # Write archive to temporary file tmp = TemporaryFile() - for chunk in res.iter_content(chunk_size = 128): + for chunk in res.iter_content(chunk_size = 32768): tmp.write(chunk) # Unzip fonts from temporary file From c681be2f7f5aa4eeb9d3082391a5863f494473d6 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Mon, 17 Oct 2022 15:51:11 +0200 Subject: [PATCH 2/4] Optimize social plugin: reuse resized logo --- material/plugins/social/plugin.py | 15 +++++++++++---- src/plugins/social/plugin.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index 8e31399a8..188e7ab9a 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -18,6 +18,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. +import concurrent.futures import functools import logging import os @@ -64,6 +65,9 @@ class SocialPluginConfig(Config): # Social plugin class SocialPlugin(BasePlugin[SocialPluginConfig]): + def __init__(self): + self._executor = concurrent.futures.ThreadPoolExecutor(4) + # Retrieve configuration def on_config(self, config): self.color = colors.get("indigo") @@ -108,7 +112,7 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): self.color = { **self.color, **self.config.cards_color } # Retrieve logo and font - self.logo = self._load_logo(config) + self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config) self.font = self._load_font(config) # Create social cards @@ -167,12 +171,10 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # Render social card def _render_card(self, site_name, title, description): - logo = self.logo - # Render background and logo image = self._render_card_background((1200, 630), self.color["fill"]) image.alpha_composite( - logo.resize((144, int(144 * logo.height / logo.width))), + self._resized_logo_promise.result(), (1200 - 228, 64 - 4) ) @@ -304,6 +306,11 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): { "name": "twitter:image", "content": url } ] + def _load_resized_logo(self, config, width = 144): + logo = self._load_logo(config) + height = int(width * logo.height / logo.width) + return logo.resize((width, height)) + # Retrieve logo image or icon def _load_logo(self, config): theme = config.theme diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index 8e31399a8..188e7ab9a 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -18,6 +18,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. +import concurrent.futures import functools import logging import os @@ -64,6 +65,9 @@ class SocialPluginConfig(Config): # Social plugin class SocialPlugin(BasePlugin[SocialPluginConfig]): + def __init__(self): + self._executor = concurrent.futures.ThreadPoolExecutor(4) + # Retrieve configuration def on_config(self, config): self.color = colors.get("indigo") @@ -108,7 +112,7 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): self.color = { **self.color, **self.config.cards_color } # Retrieve logo and font - self.logo = self._load_logo(config) + self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config) self.font = self._load_font(config) # Create social cards @@ -167,12 +171,10 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): # Render social card def _render_card(self, site_name, title, description): - logo = self.logo - # Render background and logo image = self._render_card_background((1200, 630), self.color["fill"]) image.alpha_composite( - logo.resize((144, int(144 * logo.height / logo.width))), + self._resized_logo_promise.result(), (1200 - 228, 64 - 4) ) @@ -304,6 +306,11 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): { "name": "twitter:image", "content": url } ] + def _load_resized_logo(self, config, width = 144): + logo = self._load_logo(config) + height = int(width * logo.height / logo.width) + return logo.resize((width, height)) + # Retrieve logo image or icon def _load_logo(self, config): theme = config.theme From 38aca1e206fba07dabd022f6397a6744f2a47729 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Mon, 17 Oct 2022 13:39:12 +0200 Subject: [PATCH 3/4] Optimize social plugin: cache word width computation --- material/plugins/social/plugin.py | 18 ++++++++++++------ src/plugins/social/plugin.py | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index 188e7ab9a..6c65156f3 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -206,26 +206,32 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): def _render_card_background(self, size, fill): return Image.new(mode = "RGBA", size = size, color = fill) + @functools.lru_cache(maxsize=None) + def _tmp_context(self): + image = Image.new(mode = "RGBA", size = (50, 50)) + return ImageDraw.Draw(image) + + @functools.lru_cache(maxsize=None) + def _text_bounding_box(self, text, font): + return self._tmp_context().textbbox((0, 0), text, font = font) + # Render social card text def _render_text(self, size, font, text, lmax, spacing = 0): + width = size[0] lines, words = [], [] # Remove remnant HTML tags text = re.sub(r"(<[^>]+>)", "", text) - # Create temporary image - image = Image.new(mode = "RGBA", size = size) - # Retrieve y-offset of textbox to correct for spacing yoffset = 0 # Create drawing context and split text into lines - context = ImageDraw.Draw(image) for word in text.split(" "): combine = " ".join(words + [word]) - textbox = context.textbbox((0, 0), combine, font = font) + textbox = self._text_bounding_box(combine, font = font) yoffset = textbox[1] - if not words or textbox[2] <= image.width: + if not words or textbox[2] <= width: words.append(word) else: lines.append(words) diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index 188e7ab9a..6c65156f3 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -206,26 +206,32 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): def _render_card_background(self, size, fill): return Image.new(mode = "RGBA", size = size, color = fill) + @functools.lru_cache(maxsize=None) + def _tmp_context(self): + image = Image.new(mode = "RGBA", size = (50, 50)) + return ImageDraw.Draw(image) + + @functools.lru_cache(maxsize=None) + def _text_bounding_box(self, text, font): + return self._tmp_context().textbbox((0, 0), text, font = font) + # Render social card text def _render_text(self, size, font, text, lmax, spacing = 0): + width = size[0] lines, words = [], [] # Remove remnant HTML tags text = re.sub(r"(<[^>]+>)", "", text) - # Create temporary image - image = Image.new(mode = "RGBA", size = size) - # Retrieve y-offset of textbox to correct for spacing yoffset = 0 # Create drawing context and split text into lines - context = ImageDraw.Draw(image) for word in text.split(" "): combine = " ".join(words + [word]) - textbox = context.textbbox((0, 0), combine, font = font) + textbox = self._text_bounding_box(combine, font = font) yoffset = textbox[1] - if not words or textbox[2] <= image.width: + if not words or textbox[2] <= width: words.append(word) else: lines.append(words) From 09580da963c7ee81fd7843d9f8fd6af99d63d6a8 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Mon, 17 Oct 2022 15:13:12 +0200 Subject: [PATCH 4/4] Optimize social plugin: parallelize image saving --- material/plugins/social/plugin.py | 27 +++++++++++++++++++++------ src/plugins/social/plugin.py | 27 +++++++++++++++++++++------ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/material/plugins/social/plugin.py b/material/plugins/social/plugin.py index 6c65156f3..f5727fc78 100644 --- a/material/plugins/social/plugin.py +++ b/material/plugins/social/plugin.py @@ -115,6 +115,8 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config) self.font = self._load_font(config) + self._image_promises = [] + # Create social cards def on_page_markdown(self, markdown, page, config, files): if not self.config.cards: @@ -152,19 +154,32 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): description ]).encode("utf-8")) file = os.path.join(self.cache, f"{hash.hexdigest()}.png") - if not os.path.isfile(file): - image = self._render_card(site_name, title, description) - image.save(file) - - # Copy file from cache - copyfile(file, path) + self._image_promises.append(self._executor.submit( + self._cache_image, + cache_path = file, dest_path = path, + render_function = lambda: self._render_card(site_name, title, description) + )) # Inject meta tags into page meta = page.meta.get("meta", []) page.meta["meta"] = meta + self._generate_meta(page, config) + def on_post_build(self, config): + # Check for exceptions + for promise in self._image_promises: + promise.result() + # ------------------------------------------------------------------------- + # Render image to cache (if not present), then copy from cache to site + def _cache_image(self, cache_path, dest_path, render_function): + if not os.path.isfile(cache_path): + image = render_function() + image.save(cache_path) + + # Copy file from cache + copyfile(cache_path, dest_path) + @functools.lru_cache(maxsize=None) def _get_font(self, kind, size): return ImageFont.truetype(self.font[kind], size) diff --git a/src/plugins/social/plugin.py b/src/plugins/social/plugin.py index 6c65156f3..f5727fc78 100644 --- a/src/plugins/social/plugin.py +++ b/src/plugins/social/plugin.py @@ -115,6 +115,8 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config) self.font = self._load_font(config) + self._image_promises = [] + # Create social cards def on_page_markdown(self, markdown, page, config, files): if not self.config.cards: @@ -152,19 +154,32 @@ class SocialPlugin(BasePlugin[SocialPluginConfig]): description ]).encode("utf-8")) file = os.path.join(self.cache, f"{hash.hexdigest()}.png") - if not os.path.isfile(file): - image = self._render_card(site_name, title, description) - image.save(file) - - # Copy file from cache - copyfile(file, path) + self._image_promises.append(self._executor.submit( + self._cache_image, + cache_path = file, dest_path = path, + render_function = lambda: self._render_card(site_name, title, description) + )) # Inject meta tags into page meta = page.meta.get("meta", []) page.meta["meta"] = meta + self._generate_meta(page, config) + def on_post_build(self, config): + # Check for exceptions + for promise in self._image_promises: + promise.result() + # ------------------------------------------------------------------------- + # Render image to cache (if not present), then copy from cache to site + def _cache_image(self, cache_path, dest_path, render_function): + if not os.path.isfile(cache_path): + image = render_function() + image.save(cache_path) + + # Copy file from cache + copyfile(cache_path, dest_path) + @functools.lru_cache(maxsize=None) def _get_font(self, kind, size): return ImageFont.truetype(self.font[kind], size)