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 +7 -3
- figquilt/compose_pdf.py +12 -8
- figquilt/compose_svg.py +12 -7
- figquilt/grid.py +100 -0
- figquilt/layout.py +136 -28
- figquilt/parser.py +89 -9
- figquilt/units.py +0 -5
- {figquilt-0.1.4.dist-info → figquilt-0.1.6.dist-info}/METADATA +119 -2
- figquilt-0.1.6.dist-info/RECORD +14 -0
- figquilt-0.1.4.dist-info/RECORD +0 -13
- {figquilt-0.1.4.dist-info → figquilt-0.1.6.dist-info}/WHEEL +0 -0
- {figquilt-0.1.4.dist-info → figquilt-0.1.6.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
#
|
|
37
|
-
|
|
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 +
|
|
155
|
-
raw_y = rect.y0 +
|
|
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
|
|
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
|
-
#
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 +
|
|
178
|
-
y = offset_y +
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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,,
|
figquilt-0.1.4.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|