Created
June 21, 2026 18:08
-
-
Save 0x9900/78f098a6e4eac0773a28067bd93758ea to your computer and use it in GitHub Desktop.
Pelican extention for image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #! /usr/bin/env python | |
| # vim:fenc=utf-8 | |
| # | |
| # Copyright © 2024-2026 fred <github-fred@hidzz.com> | |
| # | |
| # Distributed under terms of the BSD 3-Clause license. | |
| """ | |
| Usage example: | |
|  | |
| size=240, 520, 760 | |
| class=aligncenter, imgshadow | |
| link=https://0x9900.com/ | |
| Regular paragraph text here. | |
|  | |
| class=alignright | |
| """ | |
| import re | |
| import uuid | |
| from pathlib import Path | |
| from lxml import etree | |
| from lxml import html as html_parser | |
| from markdown.extensions import Extension | |
| from markdown.postprocessors import Postprocessor | |
| from markdown.preprocessors import Preprocessor | |
| from PIL import Image | |
| from PIL.PngImagePlugin import PngInfo | |
| def process_images(source, target, url, sizes): | |
| if url.is_absolute(): | |
| url = url.relative_to('/') | |
| source_img = source / url | |
| target_path = target / url.parent | |
| target_path.mkdir(parents=True, exist_ok=True) | |
| try: | |
| img = Image.open(source_img) | |
| except Image.UnidentifiedImageError as err: | |
| print(f'Image format not supported: {err}') | |
| raise | |
| is_animated = hasattr(img, 'is_animated') and img.is_animated | |
| h_size, v_size = img.size | |
| metadata = PngInfo() | |
| metadata.add_text("Author", "W6BSD @ 0x9900.com") | |
| target_images = [] | |
| for width in sizes: | |
| target_img = (target_path / f'{url.stem}-{width}').with_suffix(url.suffix) | |
| ratio = width / float(h_size) | |
| height = int(v_size * ratio) | |
| target_images.append((width, '/' + str(target_img.relative_to(target)))) | |
| if target_img.exists(): | |
| continue | |
| if not is_animated: | |
| res_img = img.resize((width, height), resample=Image.Resampling.LANCZOS) | |
| res_img.save(target_img, dpi=(72, 72), pnginfo=metadata) | |
| else: | |
| frames = [] | |
| durations = [] | |
| try: | |
| while True: # Loop through all frames | |
| duration = img.info.get('duration', 100) # 100ms if not specified | |
| durations.append(duration) | |
| frames.append(img.resize((width, height), Image.LANCZOS)) | |
| img.seek(img.tell() + 1) | |
| except EOFError: | |
| pass # We've reached the last frame | |
| # Save the resized animated WebP | |
| frames[0].save(target_img, save_all=True, append_images=frames[1:], | |
| duration=durations, loop=0) | |
| target_images.sort() | |
| return target_images | |
| class ImageProcessor(Preprocessor): | |
| """ | |
| Preprocessor to handle multi-line commands in Markdown. | |
| For example: | |
| [Caption](path/to/image.jpg) | |
| size=240, 520, 760 | |
| class=aligncenter, imgshadow | |
| link=https://example.com/ | |
| """ | |
| pattern_start = re.compile(r'^!\[(.*?)\]\((.*?)\)$') | |
| pattern_attribute = re.compile(r'^\s+([a-zA-Z0-9_-]+)=(.+)$') | |
| def __init__(self, md): | |
| super().__init__(md) | |
| self.placeholders = {} | |
| self.figcaption = None | |
| def create_placeholder(self, html): | |
| """Create a unique placeholder and store the HTML for later replacement""" | |
| placeholder_id = str(uuid.uuid1()) | |
| self.placeholders[placeholder_id] = html | |
| return placeholder_id | |
| def run(self, lines): | |
| """ | |
| Process lines of text, looking for image tag and generating HTML with | |
| placeholders. | |
| """ | |
| new_lines = [] | |
| i = 0 | |
| while i < len(lines): | |
| line = lines[i] | |
| match = self.pattern_start.match(line) | |
| if not match: | |
| new_lines.append(line) | |
| i += 1 | |
| continue | |
| caption, url = match.group(1), match.group(2) | |
| attributes, j = self._parse_attributes(lines, i + 1) | |
| html = self.generate_html(caption, url, attributes) | |
| placeholder = self.create_placeholder(html) | |
| new_lines.append(placeholder) | |
| i = j # Skip processed lines | |
| return new_lines | |
| def _parse_attributes(self, lines, start_index): | |
| """ | |
| Parses valid attribute lines starting at start_index. | |
| Returns a dictionary of attributes and the index after the last attribute line. | |
| """ | |
| attributes = {} | |
| i = start_index | |
| while i < len(lines): | |
| match = self.pattern_attribute.match(lines[i]) | |
| if not match: | |
| break | |
| key, raw_value = match.group(1).lower(), match.group(2).strip() | |
| if key not in ('size', 'class', 'link'): | |
| raise KeyError(f'Attribute "{key}" unknown') | |
| if key == 'link': | |
| attributes[key] = raw_value | |
| else: | |
| values = [v.strip() for v in raw_value.split(',')] | |
| if any(' ' in v for v in values): | |
| raise ValueError(f'Bad character in: {raw_value}') | |
| attributes[key] = values | |
| i += 1 | |
| return attributes, i | |
| def generate_html(self, caption, url, attributes): | |
| """ | |
| Generate HTML for the multi-line command based on attributes. | |
| This is a simple implementation - extend as needed. | |
| """ | |
| self.figcaption = caption | |
| root = figure = etree.Element('figure') | |
| if 'class' in attributes and attributes['class']: | |
| figure.set('class', ' '.join(attributes['class'])) | |
| if 'link' in attributes and attributes['link']: | |
| link = etree.SubElement(figure, 'a') | |
| link.set('href', attributes['link']) | |
| link.set('title', caption) | |
| root = link | |
| picture = etree.SubElement(root, 'picture') | |
| if 'size' in attributes and attributes['size']: | |
| sizes = [int(s) for s in attributes['size']] | |
| if url.startswith('http'): | |
| img_sets = [(s, url) for s in sizes] | |
| else: | |
| img_sets = process_images(self.source, self.target, Path(url), sizes) | |
| for size, img_set in img_sets: | |
| source = etree.SubElement(picture, 'source') | |
| source.set('media', f"(max-width: {size}px)") | |
| source.set('srcset', str(img_set)) | |
| img = etree.SubElement(picture, 'img') | |
| img.set('src', img_sets[-1][1]) | |
| img.set('width', str(img_sets[-1][0])) | |
| else: | |
| img = etree.SubElement(picture, 'img') | |
| img.set('src', url) | |
| img.set('alt', caption) | |
| figcaption = etree.SubElement(figure, 'figcaption') | |
| fragment = html_parser.fragments_fromstring(caption) | |
| for node in fragment: | |
| if isinstance(node, str): | |
| if figcaption.text: | |
| figcaption.text += node | |
| else: | |
| figcaption.text = node | |
| else: | |
| figcaption.append(node) | |
| html = etree.tostring(figure, encoding='unicode', method='html') | |
| html = html.replace('</source>', '') # this is some bullshit. | |
| return html | |
| class ImagePlaceholderPostprocessor(Postprocessor): | |
| """ | |
| Postprocessor to replace placeholders with actual HTML content. | |
| """ | |
| def __init__(self, md, placeholders): | |
| super().__init__(self) | |
| self.placeholders = placeholders | |
| def run(self, text): | |
| # Replace all placeholders with their corresponding HTML | |
| for placeholder, html in self.placeholders.items(): | |
| # Using regex to handle both the placeholder inside paragraph | |
| # tags and standalone | |
| text = re.sub(r'<p>\s*' + placeholder + r'\s*</p>', html, text) | |
| text = text.replace(placeholder, html) | |
| return text | |
| class ImageExtension(Extension): | |
| def __init__(self, **kwargs): | |
| self.config = { | |
| 'source': [Path('content'), 'Source path for images'], | |
| 'target': [Path('output'), 'Destination path for images'], | |
| } | |
| super().__init__(**kwargs) | |
| def extendMarkdown(self, md): | |
| # Register the preprocessor with high priority (1) | |
| processor = ImageProcessor(md) | |
| processor.source = Path(self.getConfig('source')) | |
| processor.target = Path(self.getConfig('target')) | |
| md.preprocessors.register(processor, 'multiline_image', 1) | |
| # Add the postprocessor to handle placeholders after all processing is done | |
| md.postprocessors.register( | |
| ImagePlaceholderPostprocessor(md, processor.placeholders), | |
| 'image_placeholder_postprocessor', | |
| 100 # High priority to run at the end | |
| ) | |
| def makeExtension(**kwargs): # pragma: no cover | |
| return ImageExtension(**kwargs) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment