summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorDennis Fink2026-08-08 19:32:47 +0200
committerDennis Fink2026-08-08 19:32:47 +0200
commit1af817249d115ff7013d3ab1a30ea145e30924c7 (patch)
treeabaccd3582453e702754da0f111bf0a75002f14a /packages
parent0d0b6cf226c5a06c265de540ca0c2997e7ed2b06 (diff)
downloaddennisfink.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.
Diffstat (limited to '')
-rw-r--r--packages/gallery/lektor_gallery.py624
-rw-r--r--packages/gallery/setup.py13
2 files changed, 637 insertions, 0 deletions
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=[],
+)