Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize 'social' plugin on cold load #4546

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
73 changes: 53 additions & 20 deletions material/plugins/social/plugin.py
Expand Up @@ -18,6 +18,8 @@
# 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
import posixpath
Expand Down Expand Up @@ -63,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")
Expand Down Expand Up @@ -107,9 +112,11 @@ def on_config(self, config):
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)

self._image_promises = []

# Create social cards
def on_page_markdown(self, markdown, page, config, files):
if not self.config.cards:
Expand Down Expand Up @@ -147,46 +154,61 @@ def on_page_markdown(self, markdown, page, config, files):
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)

# 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)
)

# 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)
Expand All @@ -199,26 +221,32 @@ def _render_card(self, site_name, title, description):
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)
Expand Down Expand Up @@ -299,6 +327,11 @@ def _generate_meta(self, page, config):
{ "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
Expand Down Expand Up @@ -379,7 +412,7 @@ def _load_font_from_google(self, name):

# 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
Expand Down
73 changes: 53 additions & 20 deletions src/plugins/social/plugin.py
Expand Up @@ -18,6 +18,8 @@
# 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
import posixpath
Expand Down Expand Up @@ -63,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")
Expand Down Expand Up @@ -107,9 +112,11 @@ def on_config(self, config):
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)

self._image_promises = []

# Create social cards
def on_page_markdown(self, markdown, page, config, files):
if not self.config.cards:
Expand Down Expand Up @@ -147,46 +154,61 @@ def on_page_markdown(self, markdown, page, config, files):
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)

# 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)
)

# 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)
Expand All @@ -199,26 +221,32 @@ def _render_card(self, site_name, title, description):
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)
Expand Down Expand Up @@ -299,6 +327,11 @@ def _generate_meta(self, page, config):
{ "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
Expand Down Expand Up @@ -379,7 +412,7 @@ def _load_font_from_google(self, name):

# 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
Expand Down