From 1af817249d115ff7013d3ab1a30ea145e30924c7 Mon Sep 17 00:00:00 2001 From: Dennis Fink Date: Sat, 8 Aug 2026 19:32:47 +0200 Subject: 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. --- packages/gallery/lektor_gallery.py | 624 +++++++++++++++++++++++++++++++++++++ packages/gallery/setup.py | 13 + 2 files changed, 637 insertions(+) create mode 100644 packages/gallery/lektor_gallery.py create mode 100644 packages/gallery/setup.py (limited to 'packages/gallery') 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=[], +) -- cgit v1.3.1