summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Fink2026-08-08 19:32:47 +0200
committerDennis Fink2026-08-08 19:32:47 +0200
commit1af817249d115ff7013d3ab1a30ea145e30924c7 (patch)
treeabaccd3582453e702754da0f111bf0a75002f14a
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--assets/static/css/style.css57
-rw-r--r--flowblocks/gallery-image.ini12
-rw-r--r--flowblocks/gallery.ini25
-rw-r--r--models/blog-post.ini4
-rw-r--r--packages/gallery/lektor_gallery.py624
-rw-r--r--packages/gallery/setup.py13
-rw-r--r--templates/blocks/gallery-image.html9
-rw-r--r--templates/blocks/gallery.html32
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 %}