figquilt 0.1.2__tar.gz → 0.1.4__tar.gz
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-0.1.2 → figquilt-0.1.4}/PKG-INFO +10 -3
- {figquilt-0.1.2 → figquilt-0.1.4}/README.md +9 -2
- {figquilt-0.1.2 → figquilt-0.1.4}/pyproject.toml +1 -1
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/compose_pdf.py +25 -11
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/compose_svg.py +58 -24
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/layout.py +8 -1
- figquilt-0.1.4/src/figquilt/units.py +70 -0
- figquilt-0.1.2/src/figquilt/units.py +0 -7
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/__init__.py +0 -0
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/cli.py +0 -0
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/errors.py +0 -0
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/images.py +0 -0
- {figquilt-0.1.2 → figquilt-0.1.4}/src/figquilt/parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: figquilt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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>
|
|
@@ -24,9 +24,16 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
# figquilt
|
|
26
26
|
|
|
27
|
-
**Figure quilter**: A CLI tool
|
|
27
|
+
**Figure quilter**: A declarative CLI tool for compositing multiple figures (PDF, SVG, PNG) into publication-ready layouts.
|
|
28
28
|
|
|
29
|
-
`figquilt` takes a simple layout file (YAML) describing panels and their
|
|
29
|
+
`figquilt` takes a simple layout file (YAML) describing panels and their structure, composed of various inputs (plots from R/Python, diagrams, photos), and stitches them into a single output file (PDF, SVG) with automatic labeling and precise dimension control.
|
|
30
|
+
|
|
31
|
+
## Philosophy
|
|
32
|
+
|
|
33
|
+
- **Declarative over imperative**: Describe *what* your figure should look like, not *how* to construct it. Layouts are data, not scripts.
|
|
34
|
+
- **Structural composition first**: Prefer high-level layout (rows, columns, ratios) over manual coordinate placement. Let the tool handle positioning.
|
|
35
|
+
- **Fine control when needed**: Override with explicit coordinates and dimensions when precision matters.
|
|
36
|
+
- **Automation-friendly**: Designed to fit into reproducible workflows (Snakemake, Make, CI pipelines). No GUI, no manual steps.
|
|
30
37
|
|
|
31
38
|
## Features
|
|
32
39
|
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# figquilt
|
|
2
2
|
|
|
3
|
-
**Figure quilter**: A CLI tool
|
|
3
|
+
**Figure quilter**: A declarative CLI tool for compositing multiple figures (PDF, SVG, PNG) into publication-ready layouts.
|
|
4
4
|
|
|
5
|
-
`figquilt` takes a simple layout file (YAML) describing panels and their
|
|
5
|
+
`figquilt` takes a simple layout file (YAML) describing panels and their structure, composed of various inputs (plots from R/Python, diagrams, photos), and stitches them into a single output file (PDF, SVG) with automatic labeling and precise dimension control.
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Declarative over imperative**: Describe *what* your figure should look like, not *how* to construct it. Layouts are data, not scripts.
|
|
10
|
+
- **Structural composition first**: Prefer high-level layout (rows, columns, ratios) over manual coordinate placement. Let the tool handle positioning.
|
|
11
|
+
- **Fine control when needed**: Override with explicit coordinates and dimensions when precision matters.
|
|
12
|
+
- **Automation-friendly**: Designed to fit into reproducible workflows (Snakemake, Make, CI pipelines). No GUI, no manual steps.
|
|
6
13
|
|
|
7
14
|
## Features
|
|
8
15
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "figquilt"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import fitz
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from .layout import Layout, Panel
|
|
4
|
-
from .units import mm_to_pt
|
|
4
|
+
from .units import mm_to_pt, to_pt
|
|
5
5
|
from .errors import FigQuiltError
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class PDFComposer:
|
|
9
9
|
def __init__(self, layout: Layout):
|
|
10
10
|
self.layout = layout
|
|
11
|
-
self.
|
|
12
|
-
self.
|
|
11
|
+
self.units = layout.page.units
|
|
12
|
+
self.width_pt = to_pt(layout.page.width, self.units)
|
|
13
|
+
self.height_pt = to_pt(layout.page.height, self.units)
|
|
13
14
|
|
|
14
15
|
def compose(self, output_path: Path):
|
|
15
16
|
doc = self.build()
|
|
@@ -63,9 +64,9 @@ class PDFComposer:
|
|
|
63
64
|
self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
|
|
64
65
|
):
|
|
65
66
|
# Calculate position and size first
|
|
66
|
-
x =
|
|
67
|
-
y =
|
|
68
|
-
w =
|
|
67
|
+
x = to_pt(panel.x, self.units)
|
|
68
|
+
y = to_pt(panel.y, self.units)
|
|
69
|
+
w = to_pt(panel.width, self.units)
|
|
69
70
|
|
|
70
71
|
# Determine height from aspect ratio if needed
|
|
71
72
|
# We need to open the source to get aspect ratio
|
|
@@ -86,14 +87,27 @@ class PDFComposer:
|
|
|
86
87
|
src_page = src_doc[0]
|
|
87
88
|
src_rect = src_page.rect
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
src_aspect = src_rect.height / src_rect.width
|
|
90
91
|
|
|
92
|
+
# Calculate cell height
|
|
91
93
|
if panel.height is not None:
|
|
92
|
-
h =
|
|
94
|
+
h = to_pt(panel.height, self.units)
|
|
93
95
|
else:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
# No height specified: use source aspect ratio
|
|
97
|
+
h = w * src_aspect
|
|
98
|
+
|
|
99
|
+
# Calculate content rect using fit mode
|
|
100
|
+
from .units import calculate_fit
|
|
101
|
+
|
|
102
|
+
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
103
|
+
src_aspect, w, h, panel.fit
|
|
104
|
+
)
|
|
105
|
+
rect = fitz.Rect(
|
|
106
|
+
x + offset_x,
|
|
107
|
+
y + offset_y,
|
|
108
|
+
x + offset_x + content_w,
|
|
109
|
+
y + offset_y + content_h,
|
|
110
|
+
)
|
|
97
111
|
|
|
98
112
|
if src_doc.is_pdf:
|
|
99
113
|
page.show_pdf_page(rect, src_doc, 0)
|
|
@@ -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
|
|
5
|
+
from .units import mm_to_pt, to_pt
|
|
6
6
|
from .errors import FigQuiltError
|
|
7
7
|
import fitz
|
|
8
8
|
|
|
@@ -10,8 +10,9 @@ import fitz
|
|
|
10
10
|
class SVGComposer:
|
|
11
11
|
def __init__(self, layout: Layout):
|
|
12
12
|
self.layout = layout
|
|
13
|
-
self.
|
|
14
|
-
self.
|
|
13
|
+
self.units = layout.page.units
|
|
14
|
+
self.width_pt = to_pt(layout.page.width, self.units)
|
|
15
|
+
self.height_pt = to_pt(layout.page.height, self.units)
|
|
15
16
|
|
|
16
17
|
def compose(self, output_path: Path):
|
|
17
18
|
# Create root SVG element
|
|
@@ -20,8 +21,10 @@ class SVGComposer:
|
|
|
20
21
|
"xlink": "http://www.w3.org/1999/xlink",
|
|
21
22
|
}
|
|
22
23
|
root = etree.Element("svg", nsmap=nsmap)
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
# SVG uses "in" for inches, "pt" for points, "mm" for millimeters
|
|
25
|
+
svg_unit = "in" if self.units == "inches" else self.units
|
|
26
|
+
root.set("width", f"{self.layout.page.width}{svg_unit}")
|
|
27
|
+
root.set("height", f"{self.layout.page.height}{svg_unit}")
|
|
25
28
|
root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
|
|
26
29
|
root.set("version", "1.1")
|
|
27
30
|
|
|
@@ -42,9 +45,9 @@ class SVGComposer:
|
|
|
42
45
|
tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
|
|
43
46
|
|
|
44
47
|
def _place_panel(self, root: etree.Element, panel: Panel, index: int):
|
|
45
|
-
x =
|
|
46
|
-
y =
|
|
47
|
-
w =
|
|
48
|
+
x = to_pt(panel.x, self.units)
|
|
49
|
+
y = to_pt(panel.y, self.units)
|
|
50
|
+
w = to_pt(panel.width, self.units)
|
|
48
51
|
|
|
49
52
|
# Determine content sizing
|
|
50
53
|
# For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
|
|
@@ -56,30 +59,50 @@ class SVGComposer:
|
|
|
56
59
|
try:
|
|
57
60
|
src_page = src_doc[0]
|
|
58
61
|
src_rect = src_page.rect
|
|
59
|
-
|
|
62
|
+
src_aspect = src_rect.height / src_rect.width
|
|
60
63
|
|
|
61
64
|
if panel.height is not None:
|
|
62
|
-
h =
|
|
65
|
+
h = to_pt(panel.height, self.units)
|
|
63
66
|
else:
|
|
64
|
-
h = w *
|
|
67
|
+
h = w * src_aspect
|
|
68
|
+
|
|
69
|
+
# Calculate content dimensions using fit mode
|
|
70
|
+
from .units import calculate_fit
|
|
71
|
+
|
|
72
|
+
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
73
|
+
src_aspect, w, h, panel.fit
|
|
74
|
+
)
|
|
65
75
|
|
|
66
76
|
# Group for the panel
|
|
67
77
|
g = etree.SubElement(root, "g")
|
|
68
78
|
g.set("transform", f"translate({x}, {y})")
|
|
69
79
|
|
|
80
|
+
# For cover mode, add a clip path to crop the overflow
|
|
81
|
+
if panel.fit == "cover":
|
|
82
|
+
clip_id = f"clip-{panel.id}"
|
|
83
|
+
defs = etree.SubElement(g, "defs")
|
|
84
|
+
clip_path = etree.SubElement(defs, "clipPath")
|
|
85
|
+
clip_path.set("id", clip_id)
|
|
86
|
+
clip_rect = etree.SubElement(clip_path, "rect")
|
|
87
|
+
clip_rect.set("x", "0")
|
|
88
|
+
clip_rect.set("y", "0")
|
|
89
|
+
clip_rect.set("width", str(w))
|
|
90
|
+
clip_rect.set("height", str(h))
|
|
91
|
+
|
|
70
92
|
# Insert content
|
|
71
93
|
# Check if SVG
|
|
72
94
|
suffix = panel.file.suffix.lower()
|
|
73
95
|
if suffix == ".svg":
|
|
74
96
|
# Embed SVG by creating an <image> tag with data URI to avoid DOM conflicts
|
|
75
|
-
# This is safer than merging trees for V0.
|
|
76
|
-
# Merging trees requires stripping root, handling viewbox/transform matching.
|
|
77
|
-
# <image> handles scaling automatically.
|
|
78
97
|
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
79
98
|
img = etree.SubElement(g, "image")
|
|
80
|
-
img.set("
|
|
81
|
-
img.set("
|
|
99
|
+
img.set("x", str(offset_x))
|
|
100
|
+
img.set("y", str(offset_y))
|
|
101
|
+
img.set("width", str(content_w))
|
|
102
|
+
img.set("height", str(content_h))
|
|
82
103
|
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
104
|
+
if panel.fit == "cover":
|
|
105
|
+
img.set("clip-path", f"url(#{clip_id})")
|
|
83
106
|
else:
|
|
84
107
|
# PDF or Raster Image
|
|
85
108
|
# For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
|
|
@@ -109,12 +132,16 @@ class SVGComposer:
|
|
|
109
132
|
data_uri = self._get_data_uri(data_path, mime)
|
|
110
133
|
|
|
111
134
|
img = etree.SubElement(g, "image")
|
|
112
|
-
img.set("
|
|
113
|
-
img.set("
|
|
135
|
+
img.set("x", str(offset_x))
|
|
136
|
+
img.set("y", str(offset_y))
|
|
137
|
+
img.set("width", str(content_w))
|
|
138
|
+
img.set("height", str(content_h))
|
|
114
139
|
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
140
|
+
if panel.fit == "cover":
|
|
141
|
+
img.set("clip-path", f"url(#{clip_id})")
|
|
115
142
|
|
|
116
|
-
# Label
|
|
117
|
-
self._draw_label(g, panel,
|
|
143
|
+
# Label (positioned relative to content, not cell)
|
|
144
|
+
self._draw_label(g, panel, content_w, content_h, offset_x, offset_y, index)
|
|
118
145
|
finally:
|
|
119
146
|
src_doc.close()
|
|
120
147
|
|
|
@@ -125,7 +152,14 @@ class SVGComposer:
|
|
|
125
152
|
return f"data:{mime};base64,{b64}"
|
|
126
153
|
|
|
127
154
|
def _draw_label(
|
|
128
|
-
self,
|
|
155
|
+
self,
|
|
156
|
+
parent: etree.Element,
|
|
157
|
+
panel: Panel,
|
|
158
|
+
content_w: float,
|
|
159
|
+
content_h: float,
|
|
160
|
+
offset_x: float,
|
|
161
|
+
offset_y: float,
|
|
162
|
+
index: int,
|
|
129
163
|
):
|
|
130
164
|
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
131
165
|
if not style.enabled:
|
|
@@ -139,9 +173,9 @@ class SVGComposer:
|
|
|
139
173
|
if style.uppercase:
|
|
140
174
|
text_str = text_str.upper()
|
|
141
175
|
|
|
142
|
-
# Offset
|
|
143
|
-
x = mm_to_pt(style.offset_x_mm)
|
|
144
|
-
y = mm_to_pt(style.offset_y_mm)
|
|
176
|
+
# 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)
|
|
145
179
|
|
|
146
180
|
# Create text element
|
|
147
181
|
txt = etree.SubElement(parent, "text")
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
from typing import Optional, List
|
|
1
|
+
from typing import Literal, Optional, List
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from pydantic import BaseModel, Field, field_validator
|
|
4
4
|
|
|
5
|
+
FitMode = Literal["contain", "cover"]
|
|
6
|
+
|
|
7
|
+
|
|
5
8
|
class LabelStyle(BaseModel):
|
|
6
9
|
enabled: bool = True
|
|
7
10
|
auto_sequence: bool = True
|
|
@@ -12,6 +15,7 @@ class LabelStyle(BaseModel):
|
|
|
12
15
|
bold: bool = True
|
|
13
16
|
uppercase: bool = True
|
|
14
17
|
|
|
18
|
+
|
|
15
19
|
class Panel(BaseModel):
|
|
16
20
|
id: str
|
|
17
21
|
file: Path
|
|
@@ -19,6 +23,7 @@ class Panel(BaseModel):
|
|
|
19
23
|
y: float
|
|
20
24
|
width: float
|
|
21
25
|
height: Optional[float] = None # If None, compute from aspect ratio
|
|
26
|
+
fit: FitMode = "contain"
|
|
22
27
|
label: Optional[str] = None
|
|
23
28
|
label_style: Optional[LabelStyle] = None
|
|
24
29
|
|
|
@@ -29,6 +34,7 @@ class Panel(BaseModel):
|
|
|
29
34
|
# but we could add it if desired. The parser/logic will check it.
|
|
30
35
|
return v
|
|
31
36
|
|
|
37
|
+
|
|
32
38
|
class Page(BaseModel):
|
|
33
39
|
width: float
|
|
34
40
|
height: float
|
|
@@ -37,6 +43,7 @@ class Page(BaseModel):
|
|
|
37
43
|
background: Optional[str] = "white"
|
|
38
44
|
label: LabelStyle = Field(default_factory=LabelStyle)
|
|
39
45
|
|
|
46
|
+
|
|
40
47
|
class Layout(BaseModel):
|
|
41
48
|
page: Page
|
|
42
49
|
panels: List[Panel]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
def mm_to_pt(mm: float) -> float:
|
|
2
|
+
"""Converts millimeters to points (1 inch = 25.4 mm = 72 pts)."""
|
|
3
|
+
return mm * 72 / 25.4
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pt_to_mm(pt: float) -> float:
|
|
7
|
+
"""Converts points to millimeters."""
|
|
8
|
+
return pt * 25.4 / 72
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inches_to_pt(inches: float) -> float:
|
|
12
|
+
"""Converts inches to points (1 inch = 72 pts)."""
|
|
13
|
+
return inches * 72
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_pt(value: float, units: str) -> float:
|
|
17
|
+
"""Convert a value from the given units to points."""
|
|
18
|
+
if units == "mm":
|
|
19
|
+
return mm_to_pt(value)
|
|
20
|
+
elif units == "inches":
|
|
21
|
+
return inches_to_pt(value)
|
|
22
|
+
elif units == "pt":
|
|
23
|
+
return value
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"Unknown unit: {units}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def calculate_fit(
|
|
29
|
+
src_aspect: float, cell_w: float, cell_h: float, fit_mode: str
|
|
30
|
+
) -> tuple[float, float, float, float]:
|
|
31
|
+
"""
|
|
32
|
+
Calculate content dimensions and offset based on fit mode.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
src_aspect: Source aspect ratio (height / width)
|
|
36
|
+
cell_w: Cell width in points
|
|
37
|
+
cell_h: Cell height in points
|
|
38
|
+
fit_mode: "contain" or "cover"
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (content_w, content_h, offset_x, offset_y)
|
|
42
|
+
"""
|
|
43
|
+
cell_aspect = cell_h / cell_w
|
|
44
|
+
|
|
45
|
+
if fit_mode == "cover":
|
|
46
|
+
# Scale to cover entire cell (may crop)
|
|
47
|
+
if src_aspect > cell_aspect:
|
|
48
|
+
# Source is taller: scale by width, crop top/bottom
|
|
49
|
+
content_w = cell_w
|
|
50
|
+
content_h = cell_w * src_aspect
|
|
51
|
+
else:
|
|
52
|
+
# Source is wider: scale by height, crop left/right
|
|
53
|
+
content_h = cell_h
|
|
54
|
+
content_w = cell_h / src_aspect
|
|
55
|
+
else: # contain (default)
|
|
56
|
+
# Scale to fit within cell, preserving aspect ratio
|
|
57
|
+
if src_aspect > cell_aspect:
|
|
58
|
+
# Source is taller: fit by height
|
|
59
|
+
content_h = cell_h
|
|
60
|
+
content_w = cell_h / src_aspect
|
|
61
|
+
else:
|
|
62
|
+
# Source is wider: fit by width
|
|
63
|
+
content_w = cell_w
|
|
64
|
+
content_h = cell_w * src_aspect
|
|
65
|
+
|
|
66
|
+
# Center in cell
|
|
67
|
+
offset_x = (cell_w - content_w) / 2
|
|
68
|
+
offset_y = (cell_h - content_h) / 2
|
|
69
|
+
|
|
70
|
+
return content_w, content_h, offset_x, offset_y
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|