diff options
| author | Dennis Fink | 2026-08-08 19:32:47 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-08-08 19:32:47 +0200 |
| commit | 1af817249d115ff7013d3ab1a30ea145e30924c7 (patch) | |
| tree | abaccd3582453e702754da0f111bf0a75002f14a | |
| parent | 0d0b6cf226c5a06c265de540ca0c2997e7ed2b06 (diff) | |
| download | dennisfink.me-1af817249d115ff7013d3ab1a30ea145e30924c7.tar.gz dennisfink.me-1af817249d115ff7013d3ab1a30ea145e30924c7.zip | |
Add aspect-ratio-aware image galleries
This adds a project-local gallery plugin that builds deterministic rectangular
image layouts from attachment aspect ratios, with both in-process and disk-backed
layout caching.
It also adds gallery flow blocks, templates, and CSS so posts can include
responsive galleries with lazy-loaded images, configurable gaps, target ratios,
and the existing breakout/centering utilities.
| -rw-r--r-- | assets/static/css/style.css | 57 | ||||
| -rw-r--r-- | flowblocks/gallery-image.ini | 12 | ||||
| -rw-r--r-- | flowblocks/gallery.ini | 25 | ||||
| -rw-r--r-- | models/blog-post.ini | 4 | ||||
| -rw-r--r-- | packages/gallery/lektor_gallery.py | 624 | ||||
| -rw-r--r-- | packages/gallery/setup.py | 13 | ||||
| -rw-r--r-- | templates/blocks/gallery-image.html | 9 | ||||
| -rw-r--r-- | templates/blocks/gallery.html | 32 |
8 files changed, 773 insertions, 3 deletions
diff --git a/assets/static/css/style.css b/assets/static/css/style.css index a8fe9b7..fb12054 100644 --- a/assets/static/css/style.css +++ b/assets/static/css/style.css @@ -847,7 +847,7 @@ img.round { border-radius: 100%; } -:is(img, figure) { +:is(img, figure, .gallery) { &.center { display: block; margin-inline: auto; @@ -970,3 +970,58 @@ article::after, .missing-publication-date-warning > dt { color: var(--foreground); } + +.gallery { + margin-block: 1rem; + + [data-direction] { + display: flex; + gap: var(--gap); + width: 100%; + } + + [data-direction="row"] { + align-items: flex-start; + flex-direction: row; + + & > div { + flex: none; + min-width: 0; + width: var(--width); + } + } + + [data-direction="column"] { + flex-direction: column; + + & > div { + width: 100%; + } + } + + img { + display: block; + width: 100%; + height: auto; + border-radius: var(--default-border-radius); + } + + @media (max-width: 500px) { + [data-direction] { + flex-direction: column; + width: 100%; + } + + [data-direction] > div { + flex: none; + min-width: 0; + width: 100%; + } + + img { + height: auto; + max-width: 100%; + width: 100%; + } + } +} diff --git a/flowblocks/gallery-image.ini b/flowblocks/gallery-image.ini new file mode 100644 index 0000000..289bc48 --- /dev/null +++ b/flowblocks/gallery-image.ini @@ -0,0 +1,12 @@ +[block] +name = Gallery Image +button_label = Image + +[fields.image] +label = Image +type = select +source = record.attachments.images + +[fields.alt] +label = Alternative text +type = string diff --git a/flowblocks/gallery.ini b/flowblocks/gallery.ini new file mode 100644 index 0000000..9235ab3 --- /dev/null +++ b/flowblocks/gallery.ini @@ -0,0 +1,25 @@ +[block] +name = Gallery +button_label = Gallery + +[fields.images] +label = Images +type = flow +flow_blocks = gallery-image + +[fields.breakout] +label = Breakout +type = select +choices = none, xxs, xs, sm, md, lg, xl, xxl, full +choice_labels = None, XXS, XS, SM, MD, LG, XL, XXL, Full +default = none + +[fields.target_ratio] +label = Preferred gallery ratio +type = float +default = 1.5 + +[fields.gap] +label = Gap +type = float +default = 0.5 diff --git a/models/blog-post.ini b/models/blog-post.ini index 39b5cb3..006376b 100644 --- a/models/blog-post.ini +++ b/models/blog-post.ini @@ -44,5 +44,5 @@ width = 1/2 [fields.body] label = Body type = flow -flow_blocks = markdown, trail -description = Build the post from Markdown sections and optional trail maps. +flow_blocks = markdown, trail, gallery +description = Build the post from Markdown sections, trail maps or galleries. diff --git a/packages/gallery/lektor_gallery.py b/packages/gallery/lektor_gallery.py new file mode 100644 index 0000000..b2f5e9f --- /dev/null +++ b/packages/gallery/lektor_gallery.py @@ -0,0 +1,624 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from collections.abc import Sequence +from dataclasses import dataclass +from functools import cached_property, lru_cache, partial +from itertools import combinations +from math import gcd, log +from pathlib import Path +from typing import Any, Literal + +from lektor.pluginsystem import Plugin + +LAYOUT_ALGORITHM_VERSION = 1 + +MAX_CANDIDATES_PER_SUBSET = 32 +RATIO_PRECISION = 5 + +T_JUNCTION_WEIGHT = 0.10 +SIBLING_IMBALANCE_WEIGHT = 0.015 +ORIENTATION_SWITCH_WEIGHT = 0.01 +DEPTH_WEIGHT = 0.005 + + +type Direction = Literal["row", "column"] +type AspectRatio = tuple[int, int] +type AspectRatios = tuple[AspectRatio, ...] +type Signature = tuple[Any, ...] + + +@dataclass(frozen=True) +class ImageNode: + index: int + width_factor: float + block: Any = None + gap_factor: float = 0.0 + + @property + def ratio(self) -> float: + return self.width_factor + + @property + def count(self) -> int: + return 1 + + @property + def depth(self) -> int: + return 0 + + @property + def signature(self) -> Signature: + return ("image", self.index) + + def materialize(self, blocks: Sequence[Any]) -> ImageNode: + return ImageNode( + index=self.index, + width_factor=self.width_factor, + block=blocks[self.index], + ) + + def to_data(self) -> dict[str, Any]: + return { + "type": "image", + "index": self.index, + "width_factor": self.width_factor, + } + + +@dataclass(frozen=True) +class GroupNode: + direction: Direction + first: Node + second: Node + width_factor: float + gap_factor: float + + @property + def ratio(self) -> float: + return self.width_factor + + @cached_property + def count(self) -> int: + return self.first.count + self.second.count + + @cached_property + def depth(self) -> int: + return 1 + max( + self.first.depth, + self.second.depth, + ) + + @cached_property + def signature(self) -> Signature: + return ( + self.direction, + self.first.signature, + self.second.signature, + ) + + @property + def first_width_share(self) -> float: + return self.first.width_factor / self.width_factor + + @property + def second_width_share(self) -> float: + return self.second.width_factor / self.width_factor + + @property + def first_gap_coefficient(self) -> float: + return self.first.gap_factor - self.first_width_share * self.gap_factor + + @property + def second_gap_coefficient(self) -> float: + return self.second.gap_factor - self.second_width_share * self.gap_factor + + def materialize(self, blocks: Sequence[Any]) -> GroupNode: + return GroupNode( + direction=self.direction, + first=self.first.materialize(blocks), + second=self.second.materialize(blocks), + width_factor=self.width_factor, + gap_factor=self.gap_factor, + ) + + def to_data(self) -> dict[str, Any]: + return { + "type": "group", + "direction": self.direction, + "width_factor": self.width_factor, + "gap_factor": self.gap_factor, + "first": self.first.to_data(), + "second": self.second.to_data(), + } + + +type Node = ImageNode | GroupNode + + +def gallery_layout( + blocks, + record, + *, + target_ratio: float = 1.5, + cache_dir: Path | None = None, +) -> Node | None: + """Create a deterministic rectangular gallery layout.""" + target_ratio = target_ratio if target_ratio > 0 else 1.5 + + valid_blocks = [] + aspect_ratios = [] + + for block in blocks: + attachment = record.attachments.get(block["image"]) + + if attachment is None or not attachment.width or not attachment.height: + continue + + divisor = gcd( + attachment.width, + attachment.height, + ) + + aspect_ratios.append( + ( + attachment.width // divisor, + attachment.height // divisor, + ) + ) + valid_blocks.append(block) + + if not aspect_ratios: + return None + + ratios = tuple(aspect_ratios) + + if len(ratios) == 1: + width, height = ratios[0] + + return ImageNode( + index=0, + width_factor=width / height, + block=valid_blocks[0], + ) + + normalized_target = round( + target_ratio, + RATIO_PRECISION, + ) + + if cache_dir is None: + layout = select_cached_layout( + LAYOUT_ALGORITHM_VERSION, + ratios, + normalized_target, + ) + else: + layout = LayoutCache(cache_dir).get( + version=LAYOUT_ALGORITHM_VERSION, + aspect_ratios=ratios, + target_ratio=normalized_target, + ) + + return layout.materialize(valid_blocks) + + +@lru_cache(maxsize=128) +def build_cached_candidates( + version: int, + aspect_ratios: AspectRatios, +) -> tuple[Node, ...]: + """Return candidate layouts for the given sequence of aspect ratios.""" + images = [ + ImageNode( + index=index, + width_factor=width / height, + ) + for index, (width, height) in enumerate(aspect_ratios) + ] + + return tuple(build_candidates(images)) + + +@lru_cache(maxsize=256) +def select_cached_layout( + version: int, + aspect_ratios: AspectRatios, + target_ratio: float, +) -> Node: + """Select the best layout and cache it for the current process.""" + candidates = build_cached_candidates( + version, + aspect_ratios, + ) + + return min( + candidates, + key=lambda candidate: ( + final_score( + candidate, + target_ratio=target_ratio, + ), + candidate.signature, + ), + ) + + +@dataclass(frozen=True) +class LayoutCache: + directory: Path + + def get( + self, + *, + version: int, + aspect_ratios: AspectRatios, + target_ratio: float, + ) -> Node: + """Return a cached layout, calculating it when necessary.""" + path = self.path_for( + version=version, + aspect_ratios=aspect_ratios, + target_ratio=target_ratio, + ) + + if layout := self.read(path): + return layout + + layout = select_cached_layout( + version, + aspect_ratios, + target_ratio, + ) + + self.write(path, layout) + + return layout + + def path_for( + self, + *, + version: int, + aspect_ratios: AspectRatios, + target_ratio: float, + ) -> Path: + payload = { + "version": version, + "aspect_ratios": aspect_ratios, + "target_ratio": target_ratio, + } + + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + digest = hashlib.sha256(encoded).hexdigest() + + return self.directory / f"{digest}.json" + + @staticmethod + def read(path: Path) -> Node | None: + try: + with path.open( + "r", + encoding="utf-8", + ) as file: + return node_from_data(json.load(file)) + except ( + OSError, + ValueError, + KeyError, + TypeError, + ): + return None + + @staticmethod + def write( + path: Path, + node: Node, + ) -> None: + temporary_path: Path | None = None + + try: + path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + delete=False, + ) as file: + json.dump( + node.to_data(), + file, + separators=(",", ":"), + ) + temporary_path = Path(file.name) + + os.replace( + temporary_path, + path, + ) + + except OSError: + if temporary_path is not None: + temporary_path.unlink( + missing_ok=True, + ) + + +def node_from_data(data: dict[str, Any]) -> Node: + """Create a layout node from its JSON representation.""" + match data["type"]: + case "image": + return ImageNode( + index=int(data["index"]), + width_factor=float(data["width_factor"]), + ) + + case "group": + direction = data["direction"] + + if direction not in ("row", "column"): + raise ValueError(f"Invalid gallery direction: {direction!r}") + + return GroupNode( + direction=direction, + first=node_from_data(data["first"]), + second=node_from_data(data["second"]), + width_factor=float(data["width_factor"]), + gap_factor=float(data["gap_factor"]), + ) + + case node_type: + raise ValueError(f"Unknown gallery node type: {node_type!r}") + + +def build_candidates( + images: Sequence[ImageNode], +) -> list[Node]: + """Build slicing-layout candidates using dynamic programming.""" + count = len(images) + all_indices = range(count) + + candidates: dict[int, list[Node]] = { + 1 << index: [image] for index, image in enumerate(images) + } + + for subset_size in range(2, count + 1): + for indices in combinations( + all_indices, + subset_size, + ): + mask = mask_for(indices) + first_bit = mask & -mask + + generated: dict[float, Node] = {} + + partition = (mask - 1) & mask + + while partition: + if partition & first_bit: + other = mask ^ partition + + if other: + combine_partitions( + generated, + candidates[partition], + candidates[other], + ) + + partition = (partition - 1) & mask + + candidates[mask] = prune_candidates( + generated.values(), + limit=MAX_CANDIDATES_PER_SUBSET, + ) + + return candidates[(1 << count) - 1] + + +def mask_for(indices) -> int: + """Return the bit mask representing a sequence of image indices.""" + return sum(1 << index for index in indices) + + +def combine_partitions( + generated: dict[float, Node], + first_candidates: Sequence[Node], + second_candidates: Sequence[Node], +) -> None: + """Combine every candidate from two partitions.""" + for first in first_candidates: + for second in second_candidates: + retain_candidate( + generated, + combine_row(first, second), + ) + retain_candidate( + generated, + combine_column(first, second), + ) + + +def combine_row( + first: Node, + second: Node, +) -> GroupNode: + """Place two rectangles beside each other.""" + return GroupNode( + direction="row", + first=first, + second=second, + width_factor=(first.width_factor + second.width_factor), + gap_factor=(first.gap_factor + second.gap_factor + 1), + ) + + +def combine_column( + first: Node, + second: Node, +) -> GroupNode: + """Stack two rectangles vertically.""" + + first_width = first.width_factor + second_width = second.width_factor + combined_width = first_width + second_width + + width_factor = first_width * second_width / combined_width + + gap_factor = ( + second_width * first.gap_factor + + first_width * second.gap_factor + - first_width * second_width + ) / combined_width + + return GroupNode( + direction="column", + first=first, + second=second, + width_factor=width_factor, + gap_factor=gap_factor, + ) + + +def retain_candidate( + candidates: dict[float, Node], + candidate: Node, +) -> None: + """Retain the best candidate for an approximate outer ratio.""" + key = round( + candidate.ratio, + RATIO_PRECISION, + ) + + existing = candidates.get(key) + + if existing is None or candidate_preference(candidate) < candidate_preference( + existing + ): + candidates[key] = candidate + + +def candidate_preference( + node: Node, +) -> tuple[float, Signature]: + """Return the ordering used for equivalent-ratio candidates.""" + return ( + structure_score(node), + node.signature, + ) + + +def prune_candidates( + candidates, + *, + limit: int, +) -> list[Node]: + """Keep a deterministic range of candidate aspect ratios.""" + ordered = sorted( + candidates, + key=lambda candidate: ( + log(candidate.ratio), + *candidate_preference(candidate), + ), + ) + + if len(ordered) <= limit: + return ordered + + last_index = len(ordered) - 1 + step = last_index / (limit - 1) + + return [ordered[round(index * step)] for index in range(limit)] + + +@lru_cache(maxsize=None) +def t_junction_penalty(node: Node) -> float: + """Score seams that terminate against neighbouring groups.""" + if isinstance(node, ImageNode): + return 0.0 + + opposite = "column" if node.direction == "row" else "row" + + penalty = t_junction_penalty(node.first) + t_junction_penalty(node.second) + + for child in (node.first, node.second): + if isinstance(child, GroupNode) and child.direction == opposite: + penalty += child.count - 1 + + return penalty + + +@lru_cache(maxsize=None) +def sibling_imbalance_penalty(node: Node) -> float: + """Score differences in sibling subtree sizes.""" + if isinstance(node, ImageNode): + return 0.0 + + return ( + abs(node.first.count - node.second.count) + + sibling_imbalance_penalty(node.first) + + sibling_imbalance_penalty(node.second) + ) + + +@lru_cache(maxsize=None) +def orientation_switch_penalty(node: Node) -> float: + """Score changes in split direction within the tree.""" + if isinstance(node, ImageNode): + return 0.0 + + penalty = orientation_switch_penalty(node.first) + orientation_switch_penalty( + node.second + ) + + for child in (node.first, node.second): + if isinstance(child, GroupNode) and child.direction != node.direction: + penalty += 1 + + return penalty + + +@lru_cache(maxsize=None) +def structure_score(node: Node) -> float: + """Return the structural penalty for a layout.""" + return ( + node.depth * DEPTH_WEIGHT + + t_junction_penalty(node) * T_JUNCTION_WEIGHT + + sibling_imbalance_penalty(node) * SIBLING_IMBALANCE_WEIGHT + + orientation_switch_penalty(node) * ORIENTATION_SWITCH_WEIGHT + ) + + +def final_score( + node: Node, + *, + target_ratio: float, +) -> float: + """Score a complete layout against the requested gallery ratio.""" + return abs(log(node.ratio / target_ratio)) + structure_score(node) + + +class GalleryPlugin(Plugin): + name = "Gallery" + description = "Static aspect-ratio-aware image gallery layouts." + + def on_setup_env( + self, + **extra: Any, + ) -> None: + cache_dir = Path(self.env.root_path) / ".cache" / "lektor-gallery" + + self.env.jinja_env.filters["gallery_layout"] = partial( + gallery_layout, + cache_dir=cache_dir, + ) diff --git a/packages/gallery/setup.py b/packages/gallery/setup.py new file mode 100644 index 0000000..00a3a4a --- /dev/null +++ b/packages/gallery/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup + +setup( + name="lektor-gallery", + version="0.1.2", + py_modules=["lektor_gallery"], + entry_points={ + "lektor.plugins": [ + "gallery = lektor_gallery:GalleryPlugin", + ] + }, + install_requires=[], +) diff --git a/templates/blocks/gallery-image.html b/templates/blocks/gallery-image.html new file mode 100644 index 0000000..44a8e93 --- /dev/null +++ b/templates/blocks/gallery-image.html @@ -0,0 +1,9 @@ +{% set image = record.attachments.get(this["image"]) %} +{% if image %} + <img src="{{ image | url }}" + width="{{ image.width }}" + height="{{ image.height }}" + alt="{{ this["alt"] }}" + loading="lazy" + decoding="async"> +{% endif %} diff --git a/templates/blocks/gallery.html b/templates/blocks/gallery.html new file mode 100644 index 0000000..414eaa0 --- /dev/null +++ b/templates/blocks/gallery.html @@ -0,0 +1,32 @@ +{% macro render_node(node, gap) %} + {% if node.__class__.__name__ == "ImageNode" %} + {% with this=node.block %} + {% include "blocks/gallery-image.html" %} + {% endwith %} + {% elif node.direction == "row" %} + <div data-direction="row"> + <div style="--width: calc({{ node.first_width_share * 100 }}% + {{ node.first_gap_coefficient * gap }}rem)"> + {{ render_node(node.first, gap) }} + </div> + <div style="--width: calc({{ node.second_width_share * 100 }}% + {{ node.second_gap_coefficient * gap }}rem)"> + {{ render_node(node.second, gap) }} + </div> + </div> + {% else %} + <div data-direction="column"> + <div>{{ render_node(node.first, gap) }}</div> + <div>{{ render_node(node.second, gap) }}</div> + </div> + {% endif %} +{% endmacro %} + +{% set layout = this["images"].blocks + | gallery_layout( + record, + target_ratio=this["target_ratio"] + ) %} + +{% if layout %} + <div class="gallery{% if this["breakout"] != "none" %} breakout-{{ this["breakout"] }}{% endif %}" + style="--gap: {{ this["gap"] }}rem">{{ render_node(layout, this["gap"]) }}</div> +{% endif %} |
