figquilt 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figquilt/cli.py CHANGED
@@ -7,6 +7,7 @@ import threading
7
7
  from .parser import parse_layout
8
8
  from .errors import FigQuiltError
9
9
  from .layout import Layout
10
+ from .grid import resolve_layout
10
11
 
11
12
 
12
13
  def get_watched_paths(layout_path: Path, layout: Layout) -> Tuple[Set[Path], Set[Path]]:
@@ -18,7 +19,8 @@ def get_watched_paths(layout_path: Path, layout: Layout) -> Tuple[Set[Path], Set
18
19
  """
19
20
  layout_path = layout_path.resolve()
20
21
  files = {layout_path}
21
- for panel in layout.panels:
22
+ panels = resolve_layout(layout)
23
+ for panel in panels:
22
24
  files.add(panel.file.resolve())
23
25
  dirs = {f.parent for f in files}
24
26
  return files, dirs
@@ -34,12 +36,13 @@ def compose_figure(
34
36
  """
35
37
  try:
36
38
  layout = parse_layout(layout_path)
39
+ panels = resolve_layout(layout)
37
40
  if verbose:
38
41
  print(f"Layout parsed: {layout_path}")
39
42
  print(
40
43
  f"Page size: {layout.page.width}x{layout.page.height} {layout.page.units}"
41
44
  )
42
- print(f"Panels: {len(layout.panels)}")
45
+ print(f"Panels: {len(panels)}")
43
46
 
44
47
  if fmt == "pdf":
45
48
  from .compose_pdf import PDFComposer
@@ -187,11 +190,12 @@ def main():
187
190
  if args.check:
188
191
  try:
189
192
  layout = parse_layout(args.layout)
193
+ panels = resolve_layout(layout)
190
194
  print(f"Layout parsed successfully: {args.layout}")
191
195
  print(
192
196
  f"Page size: {layout.page.width}x{layout.page.height} {layout.page.units}"
193
197
  )
194
- print(f"Panels: {len(layout.panels)}")
198
+ print(f"Panels: {len(panels)}")
195
199
  sys.exit(0)
196
200
  except FigQuiltError as e:
197
201
  print(f"Error: {e}", file=sys.stderr)
figquilt/compose_pdf.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import fitz
2
2
  from pathlib import Path
3
3
  from .layout import Layout, Panel
4
- from .units import mm_to_pt, to_pt
4
+ from .units import to_pt
5
5
  from .errors import FigQuiltError
6
6
 
7
7
 
@@ -11,6 +11,7 @@ class PDFComposer:
11
11
  self.units = layout.page.units
12
12
  self.width_pt = to_pt(layout.page.width, self.units)
13
13
  self.height_pt = to_pt(layout.page.height, self.units)
14
+ self.margin_pt = to_pt(layout.page.margin, self.units)
14
15
 
15
16
  def compose(self, output_path: Path):
16
17
  doc = self.build()
@@ -33,8 +34,11 @@ class PDFComposer:
33
34
  if col:
34
35
  page.draw_rect(page.rect, color=col, fill=col)
35
36
 
36
- # Draw panels
37
- for i, panel in enumerate(self.layout.panels):
37
+ # Get panels (resolves grid layout if needed)
38
+ from .grid import resolve_layout
39
+
40
+ panels = resolve_layout(self.layout)
41
+ for i, panel in enumerate(panels):
38
42
  self._place_panel(doc, page, panel, index=i)
39
43
 
40
44
  return doc
@@ -63,9 +67,9 @@ class PDFComposer:
63
67
  def _place_panel(
64
68
  self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
65
69
  ):
66
- # Calculate position and size first
67
- x = to_pt(panel.x, self.units)
68
- y = to_pt(panel.y, self.units)
70
+ # Calculate position and size first, offset by page margin
71
+ x = to_pt(panel.x, self.units) + self.margin_pt
72
+ y = to_pt(panel.y, self.units) + self.margin_pt
69
73
  w = to_pt(panel.width, self.units)
70
74
 
71
75
  # Determine height from aspect ratio if needed
@@ -151,8 +155,8 @@ class PDFComposer:
151
155
  # PyMuPDF insert_text uses 'baseline', so (0,0) is bottom-left of text char.
152
156
  # We need to shift Y down by approximately the font sizing to match SVG visual.
153
157
 
154
- pos_x = rect.x0 + mm_to_pt(style.offset_x_mm)
155
- raw_y = rect.y0 + mm_to_pt(style.offset_y_mm)
158
+ pos_x = rect.x0 + to_pt(style.offset_x, self.units)
159
+ raw_y = rect.y0 + to_pt(style.offset_y, self.units)
156
160
 
157
161
  # Approximate baseline shift: font_size
158
162
  # (A more precise way uses font.ascender, but for basic standard fonts, size is decent proxy for visual top->baseline)
figquilt/compose_svg.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
  import base64
3
3
  from lxml import etree
4
4
  from .layout import Layout, Panel
5
- from .units import mm_to_pt, to_pt
5
+ from .units import to_pt
6
6
  from .errors import FigQuiltError
7
7
  import fitz
8
8
 
@@ -13,6 +13,7 @@ class SVGComposer:
13
13
  self.units = layout.page.units
14
14
  self.width_pt = to_pt(layout.page.width, self.units)
15
15
  self.height_pt = to_pt(layout.page.height, self.units)
16
+ self.margin_pt = to_pt(layout.page.margin, self.units)
16
17
 
17
18
  def compose(self, output_path: Path):
18
19
  # Create root SVG element
@@ -35,8 +36,11 @@ class SVGComposer:
35
36
  bg.set("height", "100%")
36
37
  bg.set("fill", self.layout.page.background)
37
38
 
38
- # Draw panels
39
- for i, panel in enumerate(self.layout.panels):
39
+ # Get panels (resolves grid layout if needed)
40
+ from .grid import resolve_layout
41
+
42
+ panels = resolve_layout(self.layout)
43
+ for i, panel in enumerate(panels):
40
44
  self._place_panel(root, panel, i)
41
45
 
42
46
  # Write to file
@@ -45,8 +49,9 @@ class SVGComposer:
45
49
  tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
46
50
 
47
51
  def _place_panel(self, root: etree.Element, panel: Panel, index: int):
48
- x = to_pt(panel.x, self.units)
49
- y = to_pt(panel.y, self.units)
52
+ # Offset by page margin
53
+ x = to_pt(panel.x, self.units) + self.margin_pt
54
+ y = to_pt(panel.y, self.units) + self.margin_pt
50
55
  w = to_pt(panel.width, self.units)
51
56
 
52
57
  # Determine content sizing
@@ -174,8 +179,8 @@ class SVGComposer:
174
179
  text_str = text_str.upper()
175
180
 
176
181
  # Offset relative to the content position
177
- x = offset_x + mm_to_pt(style.offset_x_mm)
178
- y = offset_y + mm_to_pt(style.offset_y_mm)
182
+ x = offset_x + to_pt(style.offset_x, self.units)
183
+ y = offset_y + to_pt(style.offset_y, self.units)
179
184
 
180
185
  # Create text element
181
186
  txt = etree.SubElement(parent, "text")
figquilt/grid.py ADDED
@@ -0,0 +1,100 @@
1
+ """Grid layout resolution: converts layout tree to flat list of positioned panels."""
2
+
3
+ from typing import List
4
+ from .layout import Layout, LayoutNode, Panel
5
+
6
+
7
+ def resolve_layout(layout: Layout) -> List[Panel]:
8
+ """
9
+ Resolve a grid-based Layout to a flat list of Panels with computed positions.
10
+
11
+ For legacy layouts with explicit panels, returns those directly.
12
+ For grid layouts, recursively computes panel positions based on
13
+ container structure, ratios, gaps, and margins.
14
+ """
15
+ if layout.panels is not None:
16
+ return layout.panels
17
+
18
+ if layout.layout is None:
19
+ return []
20
+
21
+ # Calculate content area after page margin
22
+ # Note: x, y are in content coordinate space (0,0 = top-left of content area)
23
+ # The composer will add the page margin offset when rendering
24
+ margin = layout.page.margin
25
+ content_w = layout.page.width - 2 * margin
26
+ content_h = layout.page.height - 2 * margin
27
+
28
+ panels: List[Panel] = []
29
+ _resolve_node(layout.layout, 0, 0, content_w, content_h, panels)
30
+ return panels
31
+
32
+
33
+ def _resolve_node(
34
+ node: LayoutNode,
35
+ x: float,
36
+ y: float,
37
+ width: float,
38
+ height: float,
39
+ panels: List[Panel],
40
+ ) -> None:
41
+ """
42
+ Recursively resolve a layout node into panels.
43
+
44
+ Args:
45
+ node: The layout node to resolve
46
+ x, y: Top-left position of this node's cell
47
+ width, height: Size of this node's cell
48
+ panels: List to append resolved panels to
49
+ """
50
+ if not node.is_container():
51
+ # Leaf node: create a panel
52
+ panels.append(
53
+ Panel(
54
+ id=node.id,
55
+ file=node.file,
56
+ x=x,
57
+ y=y,
58
+ width=width,
59
+ height=height,
60
+ fit=node.fit,
61
+ label=node.label,
62
+ label_style=node.label_style,
63
+ )
64
+ )
65
+ return
66
+
67
+ # Container node: apply margin and distribute children
68
+ margin = node.margin
69
+ inner_x = x + margin
70
+ inner_y = y + margin
71
+ inner_w = width - 2 * margin
72
+ inner_h = height - 2 * margin
73
+
74
+ children = node.children
75
+ n = len(children)
76
+
77
+ # Calculate ratios (default to equal distribution)
78
+ ratios = node.ratios if node.ratios else [1.0] * n
79
+ total_ratio = sum(ratios)
80
+
81
+ # Calculate available space after gaps
82
+ gap = node.gap
83
+ total_gap = gap * (n - 1) if n > 1 else 0
84
+
85
+ if node.type == "row":
86
+ # Horizontal layout
87
+ available = inner_w - total_gap
88
+ cursor = inner_x
89
+ for i, child in enumerate(children):
90
+ child_w = (ratios[i] / total_ratio) * available
91
+ _resolve_node(child, cursor, inner_y, child_w, inner_h, panels)
92
+ cursor += child_w + gap
93
+ else:
94
+ # Vertical layout (col)
95
+ available = inner_h - total_gap
96
+ cursor = inner_y
97
+ for i, child in enumerate(children):
98
+ child_h = (ratios[i] / total_ratio) * available
99
+ _resolve_node(child, inner_x, cursor, inner_w, child_h, panels)
100
+ cursor += child_h + gap
figquilt/layout.py CHANGED
@@ -1,49 +1,157 @@
1
+ from __future__ import annotations
1
2
  from typing import Literal, Optional, List
2
3
  from pathlib import Path
3
- from pydantic import BaseModel, Field, field_validator
4
+ from pydantic import BaseModel, Field, field_validator, model_validator
4
5
 
5
6
  FitMode = Literal["contain", "cover"]
6
7
 
7
8
 
8
9
  class LabelStyle(BaseModel):
9
- enabled: bool = True
10
- auto_sequence: bool = True
11
- font_family: str = "Helvetica"
12
- font_size_pt: float = 8.0
13
- offset_x_mm: float = 2.0
14
- offset_y_mm: float = 2.0
15
- bold: bool = True
16
- uppercase: bool = True
10
+ """Styling options for panel labels."""
11
+
12
+ enabled: bool = Field(True, description="Whether to show labels")
13
+ auto_sequence: bool = Field(True, description="Auto-generate labels A, B, C...")
14
+ font_family: str = Field("Helvetica", description="Font family for labels")
15
+ font_size_pt: float = Field(8.0, description="Font size in points")
16
+ offset_x: float = Field(
17
+ 2.0, description="Horizontal offset from panel edge (in page units)"
18
+ )
19
+ offset_y: float = Field(
20
+ 2.0, description="Vertical offset from panel edge (in page units)"
21
+ )
22
+ bold: bool = Field(True, description="Use bold font")
23
+ uppercase: bool = Field(True, description="Use uppercase letters")
17
24
 
18
25
 
19
26
  class Panel(BaseModel):
20
- id: str
21
- file: Path
22
- x: float
23
- y: float
24
- width: float
25
- height: Optional[float] = None # If None, compute from aspect ratio
26
- fit: FitMode = "contain"
27
- label: Optional[str] = None
28
- label_style: Optional[LabelStyle] = None
27
+ """A figure panel to place on the page (with explicit coordinates)."""
28
+
29
+ id: str = Field(..., description="Unique identifier for this panel")
30
+ file: Path = Field(..., description="Path to source file (PDF, SVG, or PNG)")
31
+ x: float = Field(..., description="X position from left edge (in page units)")
32
+ y: float = Field(..., description="Y position from top edge (in page units)")
33
+ width: float = Field(..., description="Panel width (in page units)")
34
+ height: Optional[float] = Field(
35
+ None, description="Panel height; if omitted, computed from aspect ratio"
36
+ )
37
+ fit: FitMode = Field(
38
+ "contain",
39
+ description="How to fit the figure: contain (preserve aspect) or cover (fill and clip)",
40
+ )
41
+ label: Optional[str] = Field(
42
+ None, description="Override the auto-generated label text"
43
+ )
44
+ label_style: Optional[LabelStyle] = Field(
45
+ None, description="Override default label styling for this panel"
46
+ )
29
47
 
30
48
  @field_validator("file")
31
49
  @classmethod
32
50
  def validate_file(cls, v: Path) -> Path:
33
- # We don't check existence here to allow validation without side effects,
34
- # but we could add it if desired. The parser/logic will check it.
35
51
  return v
36
52
 
37
53
 
54
+ # Grid layout system
55
+
56
+
57
+ class LayoutNode(BaseModel):
58
+ """A node in the layout tree - either a container or a leaf panel."""
59
+
60
+ # Container fields (used when type is "row" or "col")
61
+ type: Optional[Literal["row", "col"]] = Field(
62
+ None, description="Container type: row (horizontal) or col (vertical)"
63
+ )
64
+ children: Optional[List[LayoutNode]] = Field(
65
+ None, description="Child nodes (containers or leaves)"
66
+ )
67
+ ratios: Optional[List[float]] = Field(
68
+ None, description="Relative sizing of children (e.g., [3, 2] = 60%/40%)"
69
+ )
70
+ gap: float = Field(0.0, description="Gap between children (in page units)")
71
+ margin: float = Field(0.0, description="Inner margin of this container")
72
+
73
+ # Leaf fields (used when type is None)
74
+ id: Optional[str] = Field(None, description="Unique identifier for this panel")
75
+ file: Optional[Path] = Field(
76
+ None, description="Path to source file (PDF, SVG, or PNG)"
77
+ )
78
+ fit: FitMode = Field("contain", description="How to fit the figure in its cell")
79
+ label: Optional[str] = Field(
80
+ None, description="Override the auto-generated label text"
81
+ )
82
+ label_style: Optional[LabelStyle] = Field(
83
+ None, description="Override default label styling for this panel"
84
+ )
85
+
86
+ @field_validator("file")
87
+ @classmethod
88
+ def validate_file(cls, v: Optional[Path]) -> Optional[Path]:
89
+ return v
90
+
91
+ @model_validator(mode="after")
92
+ def validate_node(self) -> LayoutNode:
93
+ is_container = self.type is not None
94
+ is_leaf = self.id is not None or self.file is not None
95
+
96
+ if is_container and is_leaf:
97
+ raise ValueError("Node cannot be both container and leaf")
98
+
99
+ if is_container:
100
+ if not self.children:
101
+ raise ValueError("Container must have children")
102
+ if self.ratios is not None and len(self.ratios) != len(self.children):
103
+ raise ValueError(
104
+ f"ratios length ({len(self.ratios)}) must match children length ({len(self.children)})"
105
+ )
106
+ elif is_leaf:
107
+ if not self.id:
108
+ raise ValueError("Leaf node must have id")
109
+ if not self.file:
110
+ raise ValueError("Leaf node must have file")
111
+ else:
112
+ raise ValueError("Node must be either container (type) or leaf (id, file)")
113
+
114
+ return self
115
+
116
+ def is_container(self) -> bool:
117
+ return self.type is not None
118
+
119
+
38
120
  class Page(BaseModel):
39
- width: float
40
- height: float
41
- units: str = "mm"
42
- dpi: int = 300
43
- background: Optional[str] = "white"
44
- label: LabelStyle = Field(default_factory=LabelStyle)
121
+ """Page dimensions and default settings."""
122
+
123
+ width: float = Field(..., description="Page width (in specified units)")
124
+ height: float = Field(..., description="Page height (in specified units)")
125
+ units: Literal["mm", "inches", "pt"] = Field(
126
+ "mm", description="Units for dimensions"
127
+ )
128
+ dpi: int = Field(300, description="Resolution for rasterized output")
129
+ background: Optional[str] = Field(
130
+ "white", description="Background color (name or hex)"
131
+ )
132
+ margin: float = Field(
133
+ 0.0, description="Page margin; panel coordinates are offset by this"
134
+ )
135
+ label: LabelStyle = Field(
136
+ default_factory=LabelStyle, description="Default label style"
137
+ )
45
138
 
46
139
 
47
140
  class Layout(BaseModel):
48
- page: Page
49
- panels: List[Panel]
141
+ """Root layout object for figquilt."""
142
+
143
+ page: Page = Field(..., description="Page dimensions and settings")
144
+ panels: Optional[List[Panel]] = Field(
145
+ None, description="List of panels with explicit coordinates (legacy mode)"
146
+ )
147
+ layout: Optional[LayoutNode] = Field(
148
+ None, description="Root layout node for grid-based layout"
149
+ )
150
+
151
+ @model_validator(mode="after")
152
+ def validate_layout_or_panels(self) -> Layout:
153
+ if self.panels is None and self.layout is None:
154
+ raise ValueError("Must specify either 'panels' or 'layout'")
155
+ if self.panels is not None and self.layout is not None:
156
+ raise ValueError("Cannot specify both 'panels' and 'layout'")
157
+ return self
figquilt/parser.py CHANGED
@@ -1,32 +1,112 @@
1
1
  from pathlib import Path
2
+ from typing import Any
2
3
  import yaml
4
+ from pydantic import ValidationError
3
5
  from .layout import Layout
4
6
  from .errors import LayoutError, AssetMissingError
5
7
 
8
+
9
+ def _parse_yaml_with_lines(content: str) -> tuple[Any, dict[tuple, int]]:
10
+ """Parse YAML and return data along with a mapping of paths to line numbers."""
11
+ line_map: dict[tuple, int] = {}
12
+
13
+ def build_line_map(node: yaml.Node, path: tuple = ()) -> Any:
14
+ if isinstance(node, yaml.MappingNode):
15
+ result = {}
16
+ for key_node, value_node in node.value:
17
+ key = key_node.value
18
+ line_map[(*path, key)] = value_node.start_mark.line + 1
19
+ result[key] = build_line_map(value_node, (*path, key))
20
+ return result
21
+ elif isinstance(node, yaml.SequenceNode):
22
+ result = []
23
+ for i, item_node in enumerate(node.value):
24
+ line_map[(*path, i)] = item_node.start_mark.line + 1
25
+ result.append(build_line_map(item_node, (*path, i)))
26
+ return result
27
+ else:
28
+ return yaml.safe_load(yaml.serialize(node))
29
+
30
+ loader = yaml.SafeLoader(content)
31
+ try:
32
+ root = loader.get_single_node()
33
+ if root is None:
34
+ return None, {}
35
+ data = build_line_map(root)
36
+ return data, line_map
37
+ finally:
38
+ loader.dispose()
39
+
40
+
41
+ def _get_line_for_path(line_map: dict[tuple, int], path_str: str) -> int | None:
42
+ """Convert a Pydantic error path like 'panels.0.y' to a line number."""
43
+ parts: list[str | int] = []
44
+ for part in path_str.split("."):
45
+ if part.isdigit():
46
+ parts.append(int(part))
47
+ else:
48
+ parts.append(part)
49
+
50
+ return line_map.get(tuple(parts))
51
+
52
+
6
53
  def parse_layout(layout_path: Path) -> Layout:
7
54
  """Parses a YAML layout file and returns a Layout object."""
8
55
  if not layout_path.exists():
9
56
  raise LayoutError(f"Layout file not found: {layout_path}")
10
57
 
11
58
  try:
12
- with open(layout_path, 'r') as f:
13
- data = yaml.safe_load(f)
59
+ content = layout_path.read_text()
60
+ data, line_map = _parse_yaml_with_lines(content)
14
61
  except yaml.YAMLError as e:
15
62
  raise LayoutError(f"Failed to parse YAML: {e}")
16
63
 
17
64
  try:
18
65
  layout = Layout(**data)
66
+ except ValidationError as e:
67
+ # Try to add line numbers to validation errors
68
+ errors_with_lines = []
69
+ for error in e.errors():
70
+ loc = ".".join(str(p) for p in error["loc"])
71
+ line = _get_line_for_path(line_map, loc)
72
+ msg = error["msg"]
73
+ if line:
74
+ errors_with_lines.append(f" {loc} (line {line}): {msg}")
75
+ else:
76
+ errors_with_lines.append(f" {loc}: {msg}")
77
+ raise LayoutError("Layout validation failed:\n" + "\n".join(errors_with_lines))
19
78
  except Exception as e:
20
79
  raise LayoutError(f"Layout validation failed: {e}")
21
80
 
22
81
  # Validate assets exist relative to the layout file
23
82
  base_dir = layout_path.parent
24
- for panel in layout.panels:
25
- # Resolve path relative to layout file if not absolute
26
- if not panel.file.is_absolute():
27
- panel.file = base_dir / panel.file
28
-
29
- if not panel.file.exists():
30
- raise AssetMissingError(f"Asset for panel '{panel.id}' not found: {panel.file}")
83
+
84
+ if layout.panels:
85
+ # Legacy mode: iterate over explicit panels
86
+ for panel in layout.panels:
87
+ if not panel.file.is_absolute():
88
+ panel.file = base_dir / panel.file
89
+ if not panel.file.exists():
90
+ raise AssetMissingError(
91
+ f"Asset for panel '{panel.id}' not found: {panel.file}"
92
+ )
93
+ elif layout.layout:
94
+ # Grid mode: recursively validate assets in the layout tree
95
+ _validate_layout_assets(layout.layout, base_dir)
31
96
 
32
97
  return layout
98
+
99
+
100
+ def _validate_layout_assets(node, base_dir: Path) -> None:
101
+ """Recursively validate and resolve asset paths in a layout tree."""
102
+ if node.is_container():
103
+ for child in node.children:
104
+ _validate_layout_assets(child, base_dir)
105
+ else:
106
+ # Leaf node: validate file exists
107
+ if not node.file.is_absolute():
108
+ node.file = base_dir / node.file
109
+ if not node.file.exists():
110
+ raise AssetMissingError(
111
+ f"Asset for panel '{node.id}' not found: {node.file}"
112
+ )
figquilt/units.py CHANGED
@@ -3,11 +3,6 @@ def mm_to_pt(mm: float) -> float:
3
3
  return mm * 72 / 25.4
4
4
 
5
5
 
6
- def pt_to_mm(pt: float) -> float:
7
- """Converts points to millimeters."""
8
- return pt * 25.4 / 72
9
-
10
-
11
6
  def inches_to_pt(inches: float) -> float:
12
7
  """Converts inches to points (1 inch = 72 pts)."""
13
8
  return inches * 72
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: figquilt
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: figquilt is a small, language-agnostic CLI tool that composes multiple figures (PDF/SVG/PNG) into a single publication-ready figure, based on a simple layout file (YAML/JSON). The key function is creating a PDF by composing multiple PDFs and adding subfigure labels and minimal annotations.
5
5
  Author: YY Ahn
6
6
  Author-email: YY Ahn <yongyeol@gmail.com>
@@ -99,4 +99,121 @@ Use `--watch` to automatically rebuild when the layout file or any panel source
99
99
  figquilt --watch figure1.yaml figure1.pdf
100
100
  ```
101
101
 
102
- This is useful during layout iteration - edit your YAML or regenerate a panel, and the output updates automatically.
102
+ This is useful during layout iteration - edit your YAML or regenerate a panel, and the output updates automatically.
103
+
104
+ ### Fit Modes
105
+
106
+ When specifying both `width` and `height` for a panel, use `fit` to control how the source image scales:
107
+
108
+ - **`contain`** (default): Scale to fit within the cell, preserving aspect ratio. May leave empty space (letterbox/pillarbox).
109
+ - **`cover`**: Scale to cover the entire cell, preserving aspect ratio. May crop overflow.
110
+
111
+ ```yaml
112
+ panels:
113
+ - id: A
114
+ file: "photo.png"
115
+ x: 0
116
+ y: 0
117
+ width: 80
118
+ height: 60
119
+ fit: cover # Fill the cell, cropping if needed
120
+ ```
121
+
122
+ If `height` is omitted, the panel automatically sizes to preserve the source aspect ratio.
123
+
124
+ ### Page Margins
125
+
126
+ Add consistent margins around your content with the `margin` property on the page:
127
+
128
+ ```yaml
129
+ page:
130
+ width: 180
131
+ height: 120
132
+ margin: 10 # 10mm margin on all sides
133
+
134
+ panels:
135
+ - id: A
136
+ file: "plots/scatter.pdf"
137
+ width: 70
138
+ x: 0 # Positioned relative to margin, not page edge
139
+ y: 0
140
+ ```
141
+
142
+ Panel coordinates are relative to the margin edge. A panel at `x: 0, y: 0` with a 10mm margin will appear at position (10mm, 10mm) on the page.
143
+
144
+ ### Grid Layout
145
+
146
+ Instead of manually specifying x/y coordinates for each panel, use the grid layout system to define structure with rows and columns:
147
+
148
+ ```yaml
149
+ page:
150
+ width: 180
151
+ height: 100
152
+ units: mm
153
+
154
+ layout:
155
+ type: row
156
+ ratios: [3, 2] # Left panel is 60%, right is 40%
157
+ gap: 5
158
+ children:
159
+ - id: A
160
+ file: "plot1.pdf"
161
+ - id: B
162
+ file: "plot2.pdf"
163
+ ```
164
+
165
+ #### Container Types
166
+
167
+ - **`row`**: Arranges children horizontally (left to right)
168
+ - **`col`**: Arranges children vertically (top to bottom)
169
+
170
+ #### Container Properties
171
+
172
+ | Property | Default | Description |
173
+ |----------|---------|-------------|
174
+ | `ratios` | Equal | Relative sizing of children (e.g., `[3, 2]` = 60%/40%) |
175
+ | `gap` | 0 | Space between children (in page units) |
176
+ | `margin` | 0 | Inner padding of the container |
177
+
178
+ #### Nested Layouts
179
+
180
+ Containers can be nested for complex layouts:
181
+
182
+ ```yaml
183
+ layout:
184
+ type: col
185
+ ratios: [1, 2] # Top row 1/3 height, bottom row 2/3
186
+ children:
187
+ - id: A
188
+ file: "header.pdf"
189
+ - type: row
190
+ ratios: [1, 1]
191
+ gap: 5
192
+ children:
193
+ - id: B
194
+ file: "left.pdf"
195
+ - id: C
196
+ file: "right.pdf"
197
+ ```
198
+
199
+ This creates:
200
+ - Panel A spanning the full width in the top third
201
+ - Panels B and C side-by-side in the bottom two-thirds
202
+
203
+ ### Editor Autocomplete (JSON Schema)
204
+
205
+ For autocomplete and validation in your editor, reference the JSON schema in your layout file:
206
+
207
+ ```yaml
208
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/yy/figquilt/main/schema/layout.schema.json
209
+ page:
210
+ width: 180
211
+ height: 120
212
+
213
+ panels:
214
+ - id: A
215
+ file: "plots/scatter.pdf"
216
+ # ... your editor will now provide autocomplete for all fields
217
+ ```
218
+
219
+ This works with [YAML Language Server](https://github.com/redhat-developer/yaml-language-server) in VS Code (via the YAML extension) and other editors.
@@ -0,0 +1,14 @@
1
+ figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
+ figquilt/cli.py,sha256=04af949a9e93cc7fdc2ded5c892cf06356d44903c1c4fbfdea2a4a10429616de,7062
3
+ figquilt/compose_pdf.py,sha256=20ac74b06df83f7db019a3686e8a5318c8744599c16100fd818177c60c2597e8,6401
4
+ figquilt/compose_svg.py,sha256=8e8b65725ef37e6a66cff625ac34be0c3e87d263f9e03ce6b1fb1456f99d0754,7656
5
+ figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
6
+ figquilt/grid.py,sha256=b56aa9fabe023e9c9447d4d13c573a7ad64a60a1de38435fceb4429ecfaed00f,3050
7
+ figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
8
+ figquilt/layout.py,sha256=c7c65eeacf1179ee25dcbac05d729ea0a1102ed6abffa7ee401f63099958aa61,6028
9
+ figquilt/parser.py,sha256=9658c73e9964f2060afd01da4123aa0063c8062d382443c1e1caac79e7ebf648,4029
10
+ figquilt/units.py,sha256=9f87b73643e39b54f1bd1011a8118d570ebab03c1bb4526169bbf6457e5a2b43,2020
11
+ figquilt-0.1.6.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
12
+ figquilt-0.1.6.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
13
+ figquilt-0.1.6.dist-info/METADATA,sha256=6f99e38c83ed059062ef8c3e79ab5ff233273930dd4d376ca01bb2e1668047d7,6217
14
+ figquilt-0.1.6.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
- figquilt/cli.py,sha256=d73f2f97ca86b0d932e5a4dac8596e3137017517e2b7017916920bfe8e36a789,6930
3
- figquilt/compose_pdf.py,sha256=b9418a415eb52da82b3126501cdded8048083418ba69cb6e9c281f0ecc0ebe0f,6172
4
- figquilt/compose_svg.py,sha256=3a3499c9805508c38906568e984d46cd391f1432ab2fea21b3fe2be783816c81,7418
5
- figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
6
- figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
7
- figquilt/layout.py,sha256=12a1169f05630ba97b3b29f2d269708b38da697357e5d511e12fad5e484fbfbe,1226
8
- figquilt/parser.py,sha256=d33d21178721072bbf681be940312b5fda0275f451fca3be2affad707f80a7fb,1065
9
- figquilt/units.py,sha256=25098b6d7559cb78214fe1a9889fd38ce88633993d0c92819cc609510654275d,2124
10
- figquilt-0.1.4.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
11
- figquilt-0.1.4.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
12
- figquilt-0.1.4.dist-info/METADATA,sha256=a21301d1e2a2d00ae70e43531d0e2145f79cc0cffa93eac07be19fadc39a4463,3330
13
- figquilt-0.1.4.dist-info/RECORD,,