|
| 1 | +import posixpath |
| 2 | +from html import escape |
| 3 | +from html.parser import HTMLParser |
| 4 | +from pathlib import Path |
| 5 | +from urllib.parse import urlparse |
| 6 | + |
| 7 | +from PIL import Image |
| 8 | + |
| 9 | +IMAGE_EXTENSIONS = (".apng", ".avif", ".gif", ".jpg", ".jpeg", ".png", ".webp") |
| 10 | +SKIP_IMAGE_CLASSES = {"twemoji", "md-author", "md-post__cover"} |
| 11 | + |
| 12 | +def is_supported_image_url(url: str) -> bool: |
| 13 | + parsed = urlparse(url.strip()) |
| 14 | + if is_remote_url(url): |
| 15 | + return True |
| 16 | + if parsed.scheme: |
| 17 | + return False |
| 18 | + return parsed.path.lower().endswith(IMAGE_EXTENSIONS) |
| 19 | + |
| 20 | +def is_remote_url(url: str) -> bool: |
| 21 | + parsed = urlparse(url.strip()) |
| 22 | + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) |
| 23 | + |
| 24 | +def resolve_rendered_path(src: str, page_url: str) -> str: |
| 25 | + parsed = urlparse(src.strip()) |
| 26 | + if parsed.path.startswith("/"): |
| 27 | + return parsed.path.lstrip("/") |
| 28 | + |
| 29 | + base = page_url if page_url.endswith("/") else posixpath.dirname(page_url) |
| 30 | + return posixpath.normpath(posixpath.join(base, parsed.path)) |
| 31 | + |
| 32 | +class PhotoSwipeTransformer(HTMLParser): |
| 33 | + def __init__(self, page_url: str, image_info: dict[str, tuple[int, int, str]]): |
| 34 | + super().__init__(convert_charrefs=False) |
| 35 | + self.page_url = page_url |
| 36 | + self.image_info = image_info |
| 37 | + self.parts = [] |
| 38 | + self.anchor_depth = 0 |
| 39 | + |
| 40 | + def handle_starttag(self, tag, attrs): |
| 41 | + if tag == "a": |
| 42 | + self.anchor_depth += 1 |
| 43 | + |
| 44 | + if tag != "img" or self.anchor_depth: |
| 45 | + self.parts.append(self.get_starttag_text()) |
| 46 | + return |
| 47 | + |
| 48 | + attrs_dict = dict(attrs) |
| 49 | + src = attrs_dict.get("src", "") |
| 50 | + classes = set(attrs_dict.get("class", "").split()) |
| 51 | + if not src or classes & SKIP_IMAGE_CLASSES or not is_supported_image_url(src): |
| 52 | + self.parts.append(self.get_starttag_text()) |
| 53 | + return |
| 54 | + |
| 55 | + attrs_dict["data-pswp-src"] = src |
| 56 | + attrs_dict["data-pswp-gallery"] = "content" |
| 57 | + |
| 58 | + if not is_remote_url(src): |
| 59 | + rendered_path = resolve_rendered_path(src, self.page_url) |
| 60 | + if rendered_path not in self.image_info: |
| 61 | + self.parts.append(self.get_starttag_text()) |
| 62 | + return |
| 63 | + |
| 64 | + width, height, _ = self.image_info[rendered_path] |
| 65 | + attrs_dict["data-pswp-width"] = str(width) |
| 66 | + attrs_dict["data-pswp-height"] = str(height) |
| 67 | + |
| 68 | + classes = attrs_dict.get("class", "").split() |
| 69 | + if "pswp-image" not in classes: |
| 70 | + classes.append("pswp-image") |
| 71 | + attrs_dict["class"] = " ".join(classes).strip() |
| 72 | + self.parts.append(render_starttag(tag, attrs_dict, self.get_starttag_text())) |
| 73 | + |
| 74 | + def handle_startendtag(self, tag, attrs): |
| 75 | + self.handle_starttag(tag, attrs) |
| 76 | + |
| 77 | + def handle_endtag(self, tag): |
| 78 | + if tag == "a" and self.anchor_depth: |
| 79 | + self.anchor_depth -= 1 |
| 80 | + self.parts.append(f"</{tag}>") |
| 81 | + |
| 82 | + def handle_data(self, data): |
| 83 | + self.parts.append(data) |
| 84 | + |
| 85 | + def handle_entityref(self, name): |
| 86 | + self.parts.append(f"&{name};") |
| 87 | + |
| 88 | + def handle_charref(self, name): |
| 89 | + self.parts.append(f"&#{name};") |
| 90 | + |
| 91 | + def handle_comment(self, data): |
| 92 | + self.parts.append(f"<!--{data}-->") |
| 93 | + |
| 94 | + def handle_decl(self, decl): |
| 95 | + self.parts.append(f"<!{decl}>") |
| 96 | + |
| 97 | + def output(self) -> str: |
| 98 | + return "".join(self.parts) |
| 99 | + |
| 100 | +def transform_html(html: str, page_url: str, image_info: dict[str, tuple[int, int, str]]) -> str: |
| 101 | + transformer = PhotoSwipeTransformer(page_url, image_info) |
| 102 | + transformer.feed(html) |
| 103 | + transformer.close() |
| 104 | + return transformer.output() |
| 105 | + |
| 106 | +def render_starttag(tag: str, attrs: dict[str, str], original: str) -> str: |
| 107 | + suffix = " /" if original.rstrip().endswith("/>") else "" |
| 108 | + rendered = " ".join( |
| 109 | + f'{name}="{escape(value, quote=True)}"' |
| 110 | + for name, value in attrs.items() |
| 111 | + if value is not None |
| 112 | + ) |
| 113 | + if rendered: |
| 114 | + return f"<{tag} {rendered}{suffix}>" |
| 115 | + return f"<{tag}{suffix}>" |
| 116 | + |
| 117 | +def build_image_info(files, config) -> dict[str, tuple[int, int, str]]: |
| 118 | + docs_dir = Path(config["docs_dir"]) |
| 119 | + info = {} |
| 120 | + for file in files.media_files(): |
| 121 | + if not file.src_uri.lower().endswith(IMAGE_EXTENSIONS): |
| 122 | + continue |
| 123 | + |
| 124 | + path = docs_dir / file.src_path |
| 125 | + if not path.is_file(): |
| 126 | + continue |
| 127 | + |
| 128 | + try: |
| 129 | + with Image.open(path) as image: |
| 130 | + size = (*image.size, file.url) |
| 131 | + info[file.src_uri] = size |
| 132 | + info[file.url.lstrip("/")] = size |
| 133 | + except Exception: |
| 134 | + continue |
| 135 | + |
| 136 | + return info |
| 137 | + |
| 138 | +def is_blog_post_page(page) -> bool: |
| 139 | + return bool(page and page.meta and page.meta.get("template") == "blog-post.html") |
| 140 | + |
| 141 | +def on_page_content(html, page, config, files, **kwargs): |
| 142 | + if not is_blog_post_page(page): |
| 143 | + return html |
| 144 | + |
| 145 | + return transform_html(html, page.url, build_image_info(files, config)) |
0 commit comments