#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Main composition logic for combining multiple figures.
Supports three composition modes:
1. Grid-based: layout=(nrows, ncols) with sources={(row, col): path}
2. Mm-based: canvas_size_mm=(w, h) with sources={path: {"xy_mm": ..., "size_mm": ...}}
3. Tiled: layout=[["A","B"],["C"]] with sources={"A": path, ...} (row-justified,
aspect-preserving, whitespace-free) -- see ``_tile.py``.
All layouts maintain matplotlib editability - no PIL image pasting.
"""
from typing import Any, Dict, List, Optional, Tuple, Union
from numpy.typing import NDArray
from .._utils._grid import grid_id, parse_grid_id
from .._wrappers import RecordingAxes, RecordingFigure
from ._compose_builders import _compose_grid_based, _compose_mm_based
# Compatibility re-exports: these were reachable via ._compose before the
# builders were split out, and _import_axes imports _replay_axes_record from
# here. Kept so the split stays behaviour-neutral for importers.
from ._replay_record import ( # noqa: F401
_replay_axes_record,
_replay_axes_record_mm,
)
from ._source_parser import is_image_file as _is_image_file # noqa: F401
from ._tile import _is_tiled_layout, build_tiled_sources
# Default DPI for mm-based composition
DEFAULT_DPI = 300
def _is_mm_based_sources(sources: Dict) -> bool:
"""Check if sources dict uses mm-based positioning."""
if not sources:
return False
first_key = next(iter(sources.keys()))
if isinstance(first_key, tuple):
return False
first_value = sources[first_key]
return isinstance(first_value, dict) and "xy_mm" in first_value
def _mm_to_inch(mm: float) -> float:
"""Convert millimeters to inches."""
return mm / 25.4
[docs]
def compose(
sources: Dict[Any, Any],
layout: Optional[Union[Tuple[int, int], str, List[List[str]]]] = None,
canvas_size_mm: Optional[Tuple[float, float]] = None,
width_mm: Optional[float] = None,
gap_mm: float = 2.0,
dpi: int = DEFAULT_DPI,
panel_labels: bool = False,
label_style: str = "uppercase",
caption: Optional[str] = None,
panel_captions: Optional[List[str]] = None,
max_whitespace_frac: Optional[float] = None,
**kwargs,
) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray, List[RecordingAxes]]]:
"""Compose a new figure from multiple sources (recipes or raw images).
Supports three modes automatically detected from sources/layout format:
1. Grid-based: sources={(row, col): path}
Uses layout=(nrows, ncols) for subplot grid.
2. Mm-based: sources={path: {"xy_mm": (x, y), "size_mm": (w, h)}}
Uses canvas_size_mm for precise positioning.
3. Tiled (row-justified, whitespace-free): layout=[["A","B","C"],["D"]]
(or the multiline string "A B C\\nD") with sources={"A": path, ...}.
Each panel keeps its true aspect ratio; within a row all panels share
one common height and sit edge-to-edge (only ``gap_mm`` between) so
there is no whitespace, and every row spans the same width so the
right edge is never ragged. The first layout row is rendered on top.
Parameters
----------
sources : dict
One of:
- Grid-based: {(row, col): source_path} mapping positions to sources
- Mm-based: {source_path: {"xy_mm": (x, y), "size_mm": (w, h)}}
- Tiled: {label: source} keyed by the string labels used in ``layout``
layout : tuple, str, or list of list of str, optional
- (nrows, ncols) for grid-based composition (auto-detected if omitted).
- list of rows of labels (``[["A","B"],["C"]]``) or a multiline string
(``"A B\\nC"``) for tiled composition.
canvas_size_mm : tuple, optional
(width_mm, height_mm) for mm-based composition. Required for mm-based mode.
width_mm : float, optional
Overall content width (mm) for TILED composition. When omitted the
default width is the widest row at its true content size, i.e.
``max over rows of (sum of true panel widths + (k-1)*gap_mm)``.
Ignored by grid/mm modes.
gap_mm : float
Gap between panels in mm (gutter; tiled mode uses it as the edge-to-edge
spacing, and ``gap_mm=0`` makes panels share edges exactly).
dpi : int
DPI for the output figure.
panel_labels : bool
If True, add panel labels (A, B, C...) to each panel.
label_style : str
'uppercase', 'lowercase', or 'numeric'.
caption : str, optional
Figure-level caption text. Rendered on the figure and persisted
in the recipe so it survives save→reproduce.
panel_captions : list of str, optional
Per-panel caption texts. When provided, panel labels (A, B, C...)
are placed with the corresponding caption text on each panel.
max_whitespace_frac : float, optional
Refuse the composite when more than this fraction of the canvas is
blank — e.g. ``0.4`` raises above 40% whitespace, naming the measured
figure and the largest empty regions. Omitted (the default) measures
without enforcing: a composite over half blank warns, nothing raises.
Enforcement is opt-in because a raising default would break every
existing sparse composite at once.
**kwargs
Additional arguments passed to figure creation.
Returns
-------
fig : RecordingFigure
Composed figure (editable, recordable).
axes : RecordingAxes, ndarray, or list
Axes of the composed figure.
Examples
--------
Grid-based composition:
>>> fig, axes = fr.compose(
... layout=(1, 2),
... sources={
... (0, 0): "panel_a.yaml",
... (0, 1): "panel_b.yaml",
... }
... )
Composition with figure-level caption:
>>> fig, axes = fr.compose(
... layout=(2, 2),
... sources={
... (0, 0): "a.yaml", (0, 1): "b.yaml",
... (1, 0): "c.yaml", (1, 1): "d.yaml",
... },
... caption="Figure 1. Four-condition comparison (n=3).",
... )
Mm-based free-form composition:
>>> fig, axes = fr.compose(
... canvas_size_mm=(180, 120),
... sources={
... "panel_a.yaml": {"xy_mm": (0, 0), "size_mm": (85, 55)},
... "panel_b.yaml": {"xy_mm": (90, 0), "size_mm": (85, 55)},
... "panel_c.yaml": {"xy_mm": (0, 60), "size_mm": (175, 55)},
... }
... )
Tiled (row-justified, whitespace-free) composition:
>>> fig, axes = fr.compose(
... layout=[["A", "B", "C"], ["D"]],
... sources={"A": "a.yaml", "B": "b.yaml",
... "C": "c.yaml", "D": "d.yaml"},
... width_mm=180, gap_mm=1.0,
... )
"""
if _is_tiled_layout(layout, sources):
sources_mm, computed_canvas = _tiled_to_mm_sources(
layout,
sources,
width_mm=width_mm,
canvas_size_mm=canvas_size_mm,
gap_mm=gap_mm,
)
fig, axes = _compose_mm_based(
sources_mm,
computed_canvas,
dpi,
panel_labels,
label_style,
caption=caption,
panel_captions=panel_captions,
**kwargs,
)
elif _is_mm_based_sources(sources):
fig, axes = _compose_mm_based(
sources,
canvas_size_mm,
dpi,
panel_labels,
label_style,
caption=caption,
panel_captions=panel_captions,
**kwargs,
)
else:
fig, axes = _compose_grid_based(
sources,
layout,
panel_labels,
label_style,
caption=caption,
panel_captions=panel_captions,
**kwargs,
)
# Single exit so every composition mode is checked by one call. Two
# compose-time quality checks live here:
# - width: an over-wide composite is not rejected downstream, it is
# silently shrunk by \resizebox along with its text.
# - whitespace: a mostly-blank composite wastes page and usually means the
# panels were sized for a different layout than the one they landed in.
# Reading the finished figure covers all three composition modes without
# per-mode geometry arithmetic.
from .._quality._compose_whitespace import check_compose_whitespace
from .._quality._compose_width import check_compose_width
check_compose_width(fig, dpi=dpi)
check_compose_whitespace(fig, max_whitespace_frac=max_whitespace_frac)
return fig, axes
def _tiled_to_mm_sources(
layout: Union[str, List[List[str]]],
sources: Dict[str, Any],
width_mm: Optional[float],
canvas_size_mm: Optional[Tuple[float, float]],
gap_mm: float,
) -> Tuple[Dict[str, Dict[str, Any]], Tuple[float, float]]:
"""Adapter over ``_tile.build_tiled_sources`` (the whitespace-free,
aspect-preserving algorithm). ``canvas_size_mm[0]`` supplies the width when
``width_mm`` is omitted; the dispatcher then delegates to ``_compose_mm_based``.
"""
effective_width = width_mm
if effective_width is None and canvas_size_mm is not None:
effective_width = canvas_size_mm[0]
return build_tiled_sources(layout, sources, width_mm=effective_width, gap_mm=gap_mm)
__all__ = ["compose"]