figquilt 0.1.0__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/__init__.py +1 -0
- figquilt/cli.py +65 -0
- figquilt/compose_pdf.py +143 -0
- figquilt/compose_svg.py +153 -0
- figquilt/errors.py +11 -0
- figquilt/images.py +27 -0
- figquilt/layout.py +42 -0
- figquilt/parser.py +32 -0
- figquilt/units.py +7 -0
- figquilt-0.1.0.dist-info/METADATA +80 -0
- figquilt-0.1.0.dist-info/RECORD +13 -0
- figquilt-0.1.0.dist-info/WHEEL +4 -0
- figquilt-0.1.0.dist-info/entry_points.txt +3 -0
figquilt/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
figquilt/cli.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .parser import parse_layout
|
|
5
|
+
from .errors import FigQuiltError
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(description="FigQuilt: Compose figures from multiple panels.")
|
|
9
|
+
parser.add_argument("layout", type=Path, help="Path to layout YAML file")
|
|
10
|
+
parser.add_argument("output", type=Path, help="Path to output file (PDF/SVG/PNG)")
|
|
11
|
+
parser.add_argument("--format", choices=["pdf", "svg", "png"], help="Override output format")
|
|
12
|
+
parser.add_argument("--check", action="store_true", help="Validate layout only")
|
|
13
|
+
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
14
|
+
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
layout = parse_layout(args.layout)
|
|
19
|
+
print(f"Layout parsed successfully: {args.layout}")
|
|
20
|
+
print(f"Page size: {layout.page.width}x{layout.page.height} {layout.page.units}")
|
|
21
|
+
print(f"Panels: {len(layout.panels)}")
|
|
22
|
+
|
|
23
|
+
if args.check:
|
|
24
|
+
sys.exit(0)
|
|
25
|
+
|
|
26
|
+
# Determine output format
|
|
27
|
+
suffix = args.output.suffix.lower()
|
|
28
|
+
fmt = args.format or suffix.lstrip('.')
|
|
29
|
+
|
|
30
|
+
if fmt == 'pdf':
|
|
31
|
+
from .compose_pdf import PDFComposer
|
|
32
|
+
composer = PDFComposer(layout)
|
|
33
|
+
composer.compose(args.output)
|
|
34
|
+
print(f"Successfully created: {args.output}")
|
|
35
|
+
|
|
36
|
+
elif fmt == 'svg':
|
|
37
|
+
from .compose_svg import SVGComposer
|
|
38
|
+
composer = SVGComposer(layout)
|
|
39
|
+
composer.compose(args.output)
|
|
40
|
+
print(f"Successfully created: {args.output}")
|
|
41
|
+
|
|
42
|
+
elif fmt == 'png':
|
|
43
|
+
from .compose_pdf import PDFComposer
|
|
44
|
+
composer = PDFComposer(layout)
|
|
45
|
+
doc = composer.build()
|
|
46
|
+
# Rasterize first page
|
|
47
|
+
page = doc[0]
|
|
48
|
+
pix = page.get_pixmap(dpi=layout.page.dpi)
|
|
49
|
+
pix.save(str(args.output))
|
|
50
|
+
doc.close()
|
|
51
|
+
print(f"Successfully created: {args.output}")
|
|
52
|
+
|
|
53
|
+
else:
|
|
54
|
+
print(f"Unsupported format: {fmt}", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
except FigQuiltError as e:
|
|
58
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
main()
|
figquilt/compose_pdf.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fitz
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .layout import Layout, Panel
|
|
4
|
+
from .units import mm_to_pt
|
|
5
|
+
from .errors import FigQuiltError
|
|
6
|
+
|
|
7
|
+
class PDFComposer:
|
|
8
|
+
def __init__(self, layout: Layout):
|
|
9
|
+
self.layout = layout
|
|
10
|
+
self.width_pt = mm_to_pt(layout.page.width)
|
|
11
|
+
self.height_pt = mm_to_pt(layout.page.height)
|
|
12
|
+
|
|
13
|
+
def compose(self, output_path: Path):
|
|
14
|
+
doc = self.build()
|
|
15
|
+
doc.save(str(output_path))
|
|
16
|
+
doc.close()
|
|
17
|
+
|
|
18
|
+
def build(self) -> fitz.Document:
|
|
19
|
+
doc = fitz.open()
|
|
20
|
+
page = doc.new_page(width=self.width_pt, height=self.height_pt)
|
|
21
|
+
|
|
22
|
+
# Draw background if specified
|
|
23
|
+
if self.layout.page.background:
|
|
24
|
+
# simple color parsing: strict names or hex?
|
|
25
|
+
# PyMuPDF draw_rect color expects sequence of floats (0..1) or nothing.
|
|
26
|
+
# We need to robustly parse the color string from layout (e.g. "white", "#f0f0f0").
|
|
27
|
+
# fitz.utils.getColor? No, fitz doesn't have a robust color parser built-in for all CSS names.
|
|
28
|
+
# But the user example uses "#f0f0f0".
|
|
29
|
+
# Minimal hex parser:
|
|
30
|
+
col = self._parse_color(self.layout.page.background)
|
|
31
|
+
if col:
|
|
32
|
+
page.draw_rect(page.rect, color=col, fill=col)
|
|
33
|
+
|
|
34
|
+
# Draw panels
|
|
35
|
+
for i, panel in enumerate(self.layout.panels):
|
|
36
|
+
self._place_panel(doc, page, panel, index=i)
|
|
37
|
+
|
|
38
|
+
return doc
|
|
39
|
+
|
|
40
|
+
def _parse_color(self, color_str: str):
|
|
41
|
+
# Very basic hex support
|
|
42
|
+
if color_str.startswith("#"):
|
|
43
|
+
h = color_str.lstrip('#')
|
|
44
|
+
try:
|
|
45
|
+
rgb = tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4))
|
|
46
|
+
return rgb
|
|
47
|
+
except:
|
|
48
|
+
return None
|
|
49
|
+
# Basic name support mapping could be added here
|
|
50
|
+
# For now, just support hex or fallback to None (skip)
|
|
51
|
+
# Using PIL ImageColor is an option if we import it, but we want minimal dep for this file?
|
|
52
|
+
# We process images, so PIL is available.
|
|
53
|
+
try:
|
|
54
|
+
from PIL import ImageColor
|
|
55
|
+
rgb = ImageColor.getrgb(color_str)
|
|
56
|
+
return tuple(c/255.0 for c in rgb)
|
|
57
|
+
except:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def _place_panel(self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int):
|
|
61
|
+
# Calculate position and size first
|
|
62
|
+
x = mm_to_pt(panel.x)
|
|
63
|
+
y = mm_to_pt(panel.y)
|
|
64
|
+
w = mm_to_pt(panel.width)
|
|
65
|
+
|
|
66
|
+
# Determine height from aspect ratio if needed
|
|
67
|
+
# We need to open the source to get aspect ratio
|
|
68
|
+
try:
|
|
69
|
+
# fitz.open can handle PDF, PNG, JPEG, SVG...
|
|
70
|
+
src_doc = fitz.open(panel.file)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise FigQuiltError(f"Failed to open panel file {panel.file}: {e}")
|
|
73
|
+
|
|
74
|
+
# Get source dimension
|
|
75
|
+
if src_doc.is_pdf:
|
|
76
|
+
src_page = src_doc[0]
|
|
77
|
+
src_rect = src_page.rect
|
|
78
|
+
else:
|
|
79
|
+
# For images/SVG, fitz doc acts like a list of pages too?
|
|
80
|
+
# Yes, usually page[0] is the image/svg content.
|
|
81
|
+
src_page = src_doc[0]
|
|
82
|
+
src_rect = src_page.rect
|
|
83
|
+
|
|
84
|
+
aspect = src_rect.height / src_rect.width
|
|
85
|
+
|
|
86
|
+
if panel.height is not None:
|
|
87
|
+
h = mm_to_pt(panel.height)
|
|
88
|
+
else:
|
|
89
|
+
h = w * aspect
|
|
90
|
+
|
|
91
|
+
rect = fitz.Rect(x, y, x + w, y + h)
|
|
92
|
+
|
|
93
|
+
if src_doc.is_pdf:
|
|
94
|
+
page.show_pdf_page(rect, src_doc, 0)
|
|
95
|
+
elif panel.file.suffix.lower() == ".svg":
|
|
96
|
+
# Convert SVG to PDF in memory to allow vector embedding
|
|
97
|
+
pdf_bytes = src_doc.convert_to_pdf()
|
|
98
|
+
src_pdf = fitz.open("pdf", pdf_bytes)
|
|
99
|
+
page.show_pdf_page(rect, src_pdf, 0)
|
|
100
|
+
else:
|
|
101
|
+
# Insert as image (works for PNG/JPEG)
|
|
102
|
+
page.insert_image(rect, filename=panel.file)
|
|
103
|
+
|
|
104
|
+
# Labels
|
|
105
|
+
self._draw_label(page, panel, rect, index)
|
|
106
|
+
|
|
107
|
+
def _draw_label(self, page: fitz.Page, panel: Panel, rect: fitz.Rect, index: int):
|
|
108
|
+
# Determine effective label settings
|
|
109
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
110
|
+
|
|
111
|
+
if not style.enabled:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
text = panel.label
|
|
115
|
+
if text is None and style.auto_sequence:
|
|
116
|
+
text = chr(65 + index) # A, B, C...
|
|
117
|
+
|
|
118
|
+
if not text:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
if style.uppercase:
|
|
122
|
+
text = text.upper()
|
|
123
|
+
|
|
124
|
+
# Position logic
|
|
125
|
+
# Design doc: offset is relative to top-left.
|
|
126
|
+
# SVG implementation uses 'hanging' baseline, so (0,0) is top-left of text char.
|
|
127
|
+
# PyMuPDF insert_text uses 'baseline', so (0,0) is bottom-left of text char.
|
|
128
|
+
# We need to shift Y down by approximately the font sizing to match SVG visual.
|
|
129
|
+
|
|
130
|
+
pos_x = rect.x0 + mm_to_pt(style.offset_x_mm)
|
|
131
|
+
raw_y = rect.y0 + mm_to_pt(style.offset_y_mm)
|
|
132
|
+
|
|
133
|
+
# Approximate baseline shift: font_size
|
|
134
|
+
# (A more precise way uses font.ascender, but for basic standard fonts, size is decent proxy for visual top->baseline)
|
|
135
|
+
pos_y = raw_y + style.font_size_pt
|
|
136
|
+
|
|
137
|
+
# Font - PyMuPDF supports base 14 fonts by name
|
|
138
|
+
fontname = "helv" # default mapping for Helvetica
|
|
139
|
+
if style.bold:
|
|
140
|
+
fontname = "HeBo" # Helvetica-Bold
|
|
141
|
+
|
|
142
|
+
# Insert text
|
|
143
|
+
page.insert_text((pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname)
|
figquilt/compose_svg.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import base64
|
|
3
|
+
from lxml import etree
|
|
4
|
+
from .layout import Layout, Panel
|
|
5
|
+
from .units import mm_to_pt
|
|
6
|
+
from .errors import FigQuiltError
|
|
7
|
+
import fitz
|
|
8
|
+
|
|
9
|
+
class SVGComposer:
|
|
10
|
+
def __init__(self, layout: Layout):
|
|
11
|
+
self.layout = layout
|
|
12
|
+
self.width_pt = mm_to_pt(layout.page.width)
|
|
13
|
+
self.height_pt = mm_to_pt(layout.page.height)
|
|
14
|
+
|
|
15
|
+
def compose(self, output_path: Path):
|
|
16
|
+
# Create root SVG element
|
|
17
|
+
nsmap = {None: "http://www.w3.org/2000/svg", "xlink": "http://www.w3.org/1999/xlink"}
|
|
18
|
+
root = etree.Element("svg", nsmap=nsmap)
|
|
19
|
+
root.set("width", f"{self.layout.page.width}mm")
|
|
20
|
+
root.set("height", f"{self.layout.page.height}mm")
|
|
21
|
+
root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
|
|
22
|
+
root.set("version", "1.1")
|
|
23
|
+
|
|
24
|
+
if self.layout.page.background:
|
|
25
|
+
# Draw background
|
|
26
|
+
bg = etree.SubElement(root, "rect")
|
|
27
|
+
bg.set("width", "100%")
|
|
28
|
+
bg.set("height", "100%")
|
|
29
|
+
bg.set("fill", self.layout.page.background)
|
|
30
|
+
|
|
31
|
+
# Draw panels
|
|
32
|
+
for i, panel in enumerate(self.layout.panels):
|
|
33
|
+
self._place_panel(root, panel, i)
|
|
34
|
+
|
|
35
|
+
# Write to file
|
|
36
|
+
tree = etree.ElementTree(root)
|
|
37
|
+
with open(output_path, "wb") as f:
|
|
38
|
+
tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
def _place_panel(self, root: etree.Element, panel: Panel, index: int):
|
|
41
|
+
x = mm_to_pt(panel.x)
|
|
42
|
+
y = mm_to_pt(panel.y)
|
|
43
|
+
w = mm_to_pt(panel.width)
|
|
44
|
+
|
|
45
|
+
# Determine content sizing
|
|
46
|
+
# For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
|
|
47
|
+
try:
|
|
48
|
+
src_doc = fitz.open(panel.file)
|
|
49
|
+
src_page = src_doc[0]
|
|
50
|
+
src_rect = src_page.rect
|
|
51
|
+
aspect = src_rect.height / src_rect.width
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise FigQuiltError(f"Failed to inspect panel {panel.file}: {e}")
|
|
54
|
+
|
|
55
|
+
if panel.height is not None:
|
|
56
|
+
h = mm_to_pt(panel.height)
|
|
57
|
+
else:
|
|
58
|
+
h = w * aspect
|
|
59
|
+
|
|
60
|
+
# Group for the panel
|
|
61
|
+
g = etree.SubElement(root, "g")
|
|
62
|
+
g.set("transform", f"translate({x}, {y})")
|
|
63
|
+
|
|
64
|
+
# Insert content
|
|
65
|
+
# Check if SVG
|
|
66
|
+
suffix = panel.file.suffix.lower()
|
|
67
|
+
if suffix == ".svg":
|
|
68
|
+
# Embed SVG by creating an <image> tag with data URI to avoid DOM conflicts
|
|
69
|
+
# This is safer than merging trees for V0.
|
|
70
|
+
# Merging trees requires stripping root, handling viewbox/transform matching.
|
|
71
|
+
# <image> handles scaling automatically.
|
|
72
|
+
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
73
|
+
img = etree.SubElement(g, "image")
|
|
74
|
+
img.set("width", str(w))
|
|
75
|
+
img.set("height", str(h))
|
|
76
|
+
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
77
|
+
else:
|
|
78
|
+
# PDF or Raster Image
|
|
79
|
+
# For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs) or embed as image/pdf?
|
|
80
|
+
# Browsers don't support image/pdf in SVG.
|
|
81
|
+
# So if PDF, convert to PNG.
|
|
82
|
+
# If PNG/JPG, embed directly.
|
|
83
|
+
|
|
84
|
+
mime = "image/png"
|
|
85
|
+
if suffix in [".jpg", ".jpeg"]:
|
|
86
|
+
mime = "image/jpeg"
|
|
87
|
+
data_path = panel.file
|
|
88
|
+
elif suffix == ".png":
|
|
89
|
+
mime = "image/png"
|
|
90
|
+
data_path = panel.file
|
|
91
|
+
elif suffix == ".pdf":
|
|
92
|
+
# Rasterize page to PNG
|
|
93
|
+
pix = src_page.get_pixmap(dpi=300) # Use decent DPI
|
|
94
|
+
data = pix.tobytes("png")
|
|
95
|
+
# We can't use _get_data_uri directly on file, we have bytes
|
|
96
|
+
b64 = base64.b64encode(data).decode("utf-8")
|
|
97
|
+
data_uri = f"data:image/png;base64,{b64}"
|
|
98
|
+
data_path = None # signal that we have URI
|
|
99
|
+
else:
|
|
100
|
+
# Fallback
|
|
101
|
+
mime = "application/octet-stream"
|
|
102
|
+
data_path = panel.file
|
|
103
|
+
|
|
104
|
+
if data_path:
|
|
105
|
+
data_uri = self._get_data_uri(data_path, mime)
|
|
106
|
+
|
|
107
|
+
img = etree.SubElement(g, "image")
|
|
108
|
+
img.set("width", str(w))
|
|
109
|
+
img.set("height", str(h))
|
|
110
|
+
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
111
|
+
|
|
112
|
+
# Label
|
|
113
|
+
self._draw_label(g, panel, w, h, index)
|
|
114
|
+
|
|
115
|
+
def _get_data_uri(self, path: Path, mime: str) -> str:
|
|
116
|
+
with open(path, "rb") as f:
|
|
117
|
+
data = f.read()
|
|
118
|
+
b64 = base64.b64encode(data).decode("utf-8")
|
|
119
|
+
return f"data:{mime};base64,{b64}"
|
|
120
|
+
|
|
121
|
+
def _draw_label(self, parent: etree.Element, panel: Panel, w: float, h: float, index: int):
|
|
122
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
123
|
+
if not style.enabled:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
text_str = panel.label
|
|
127
|
+
if text_str is None and style.auto_sequence:
|
|
128
|
+
text_str = chr(65 + index)
|
|
129
|
+
if not text_str:
|
|
130
|
+
return
|
|
131
|
+
if style.uppercase:
|
|
132
|
+
text_str = text_str.upper()
|
|
133
|
+
|
|
134
|
+
# Offset (relative to panel top-left, which is 0,0 inside the group)
|
|
135
|
+
x = mm_to_pt(style.offset_x_mm)
|
|
136
|
+
y = mm_to_pt(style.offset_y_mm)
|
|
137
|
+
|
|
138
|
+
# Create text element
|
|
139
|
+
txt = etree.SubElement(parent, "text")
|
|
140
|
+
txt.text = text_str
|
|
141
|
+
txt.set("x", str(x))
|
|
142
|
+
txt.set("y", str(y))
|
|
143
|
+
|
|
144
|
+
# Style
|
|
145
|
+
# Font family is tricky in SVG (system fonts).
|
|
146
|
+
txt.set("font-family", style.font_family)
|
|
147
|
+
txt.set("font-size", f"{style.font_size_pt}pt")
|
|
148
|
+
if style.bold:
|
|
149
|
+
txt.set("font-weight", "bold")
|
|
150
|
+
|
|
151
|
+
# Baseline alignment? SVG text y is usually baseline.
|
|
152
|
+
# If we want top-left of text at (x,y), we should adjust or use dominant-baseline.
|
|
153
|
+
txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
|
figquilt/errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class FigQuiltError(Exception):
|
|
2
|
+
"""Base exception for figquilt."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class LayoutError(FigQuiltError):
|
|
6
|
+
"""Raised when there is an issue with the layout configuration."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class AssetMissingError(FigQuiltError):
|
|
10
|
+
"""Raised when an input file cannot be found."""
|
|
11
|
+
pass
|
figquilt/images.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Tuple, Optional
|
|
3
|
+
from PIL import Image
|
|
4
|
+
import fitz
|
|
5
|
+
|
|
6
|
+
def get_image_size(path: Path) -> Tuple[float, float]:
|
|
7
|
+
"""Returns (width, height) in pixels/points."""
|
|
8
|
+
try:
|
|
9
|
+
with Image.open(path) as img:
|
|
10
|
+
return float(img.width), float(img.height)
|
|
11
|
+
except Exception:
|
|
12
|
+
# Try fitz for PDF/SVG
|
|
13
|
+
try:
|
|
14
|
+
doc = fitz.open(path)
|
|
15
|
+
page = doc[0]
|
|
16
|
+
return page.rect.width, page.rect.height
|
|
17
|
+
except Exception:
|
|
18
|
+
raise ValueError(f"Could not determine size of {path}")
|
|
19
|
+
|
|
20
|
+
def is_image(path: Path) -> bool:
|
|
21
|
+
"""Checks if the file is a raster image supported by Pillow."""
|
|
22
|
+
try:
|
|
23
|
+
with Image.open(path) as img:
|
|
24
|
+
img.verify()
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
figquilt/layout.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Optional, List, Tuple, Union
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from pydantic import BaseModel, Field, field_validator
|
|
4
|
+
|
|
5
|
+
class LabelStyle(BaseModel):
|
|
6
|
+
enabled: bool = True
|
|
7
|
+
auto_sequence: bool = True
|
|
8
|
+
font_family: str = "Helvetica"
|
|
9
|
+
font_size_pt: float = 8.0
|
|
10
|
+
offset_x_mm: float = 2.0
|
|
11
|
+
offset_y_mm: float = 2.0
|
|
12
|
+
bold: bool = True
|
|
13
|
+
uppercase: bool = True
|
|
14
|
+
|
|
15
|
+
class Panel(BaseModel):
|
|
16
|
+
id: str
|
|
17
|
+
file: Path
|
|
18
|
+
x: float
|
|
19
|
+
y: float
|
|
20
|
+
width: float
|
|
21
|
+
height: Optional[float] = None # If None, compute from aspect ratio
|
|
22
|
+
label: Optional[str] = None
|
|
23
|
+
label_style: Optional[LabelStyle] = None
|
|
24
|
+
|
|
25
|
+
@field_validator("file")
|
|
26
|
+
@classmethod
|
|
27
|
+
def validate_file(cls, v: Path) -> Path:
|
|
28
|
+
# We don't check existence here to allow validation without side effects,
|
|
29
|
+
# but we could add it if desired. The parser/logic will check it.
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
class Page(BaseModel):
|
|
33
|
+
width: float
|
|
34
|
+
height: float
|
|
35
|
+
units: str = "mm"
|
|
36
|
+
dpi: int = 300
|
|
37
|
+
background: Optional[str] = "white"
|
|
38
|
+
label: LabelStyle = Field(default_factory=LabelStyle)
|
|
39
|
+
|
|
40
|
+
class Layout(BaseModel):
|
|
41
|
+
page: Page
|
|
42
|
+
panels: List[Panel]
|
figquilt/parser.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
from .layout import Layout
|
|
4
|
+
from .errors import LayoutError, AssetMissingError
|
|
5
|
+
|
|
6
|
+
def parse_layout(layout_path: Path) -> Layout:
|
|
7
|
+
"""Parses a YAML layout file and returns a Layout object."""
|
|
8
|
+
if not layout_path.exists():
|
|
9
|
+
raise LayoutError(f"Layout file not found: {layout_path}")
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
with open(layout_path, 'r') as f:
|
|
13
|
+
data = yaml.safe_load(f)
|
|
14
|
+
except yaml.YAMLError as e:
|
|
15
|
+
raise LayoutError(f"Failed to parse YAML: {e}")
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
layout = Layout(**data)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
raise LayoutError(f"Layout validation failed: {e}")
|
|
21
|
+
|
|
22
|
+
# Validate assets exist relative to the layout file
|
|
23
|
+
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}")
|
|
31
|
+
|
|
32
|
+
return layout
|
figquilt/units.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: figquilt
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author: YY Ahn
|
|
6
|
+
Author-email: YY Ahn <yongyeol@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Dist: lxml>=6.0.2
|
|
13
|
+
Requires-Dist: matplotlib>=3.10.7
|
|
14
|
+
Requires-Dist: pillow>=12.0.0
|
|
15
|
+
Requires-Dist: pydantic>=2.12.5
|
|
16
|
+
Requires-Dist: pymupdf>=1.26.6
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
18
|
+
Requires-Dist: seaborn>=0.13.2
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/yy/figquilt
|
|
21
|
+
Project-URL: Repository, https://github.com/yy/figquilt
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# figquilt
|
|
25
|
+
|
|
26
|
+
**Figure quilter**: A CLI tool to compositing multiple figures (PDF, SVG, PNG) into a publication-ready figure layout.
|
|
27
|
+
|
|
28
|
+
`figquilt` takes a simple layout file (YAML) describing panels and their positions, composed of various inputs (plots from R/Python, diagrams, photos), and stitches them into a single output file (PDF, SVG) with precise dimension control and automatic labeling.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Precise Layout**: Define exact physical dimensions (mm) for the page and panels.
|
|
33
|
+
- **Mixed Media**: Combine PDF, SVG, and PNG inputs in one figure.
|
|
34
|
+
- **Automated Labeling**: Automatically add subfigure labels (A, B, C...) with consistent styling.
|
|
35
|
+
- **Reproducible**: Layouts are defined in version-controllable text files (YAML).
|
|
36
|
+
- **Language Agnostic**: It is a CLI tool, so it works with outputs from any tool (R, Python, Julia, Inkscape, etc.).
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
This project uses `uv` for dependency management.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Clone the repository
|
|
44
|
+
git clone https://github.com/yy/figquilt.git
|
|
45
|
+
cd figquilt
|
|
46
|
+
|
|
47
|
+
# Install dependencies and set up the environment
|
|
48
|
+
uv sync
|
|
49
|
+
|
|
50
|
+
# Install the package in editable mode
|
|
51
|
+
uv pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
Define a layout in a YAML file (e.g., `figure1.yaml`):
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
page:
|
|
60
|
+
width: 180 # mm
|
|
61
|
+
height: 120 # mm
|
|
62
|
+
|
|
63
|
+
panels:
|
|
64
|
+
- id: A
|
|
65
|
+
file: "plots/scatter.pdf"
|
|
66
|
+
width: 80
|
|
67
|
+
x: 0
|
|
68
|
+
y: 0
|
|
69
|
+
- id: B
|
|
70
|
+
file: "diagrams/schematic.svg"
|
|
71
|
+
width: 80
|
|
72
|
+
x: 90
|
|
73
|
+
y: 0
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run `figquilt` to generate the figure:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
figquilt figure1.yaml figure1.pdf
|
|
80
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
|
|
2
|
+
figquilt/cli.py,sha256=481535c3c2d77a73692d76c1be8dfd02056a2b20edb7e9877edca0edf73d67ab,2352
|
|
3
|
+
figquilt/compose_pdf.py,sha256=4da4d5f9b656cd1da6c1b58e3e92a18955d60aa498bd1720050fc6a5988f0e4c,5385
|
|
4
|
+
figquilt/compose_svg.py,sha256=2a4f01369be6c4acca62f823967bc3027b9b341b339e55391186ed5eb6f0a5fb,5921
|
|
5
|
+
figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
|
|
6
|
+
figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
|
|
7
|
+
figquilt/layout.py,sha256=44514c72f8883afe02f5dd05e674410440dc52c40966a8028f1d9693d4be3364,1159
|
|
8
|
+
figquilt/parser.py,sha256=d33d21178721072bbf681be940312b5fda0275f451fca3be2affad707f80a7fb,1065
|
|
9
|
+
figquilt/units.py,sha256=0c1b6b3f7380fb8ebf51ae81fb093c1830ae4a240b9151191333a3b0af16cd84,233
|
|
10
|
+
figquilt-0.1.0.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
|
|
11
|
+
figquilt-0.1.0.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
|
|
12
|
+
figquilt-0.1.0.dist-info/METADATA,sha256=91ec1ccc28fee0be464922bae83d7f154447d1ff69c014b64b007ef949ef640b,2518
|
|
13
|
+
figquilt-0.1.0.dist-info/RECORD,,
|