figquilt 0.1.1__py3-none-any.whl → 0.1.2__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 +187 -35
- figquilt/compose_pdf.py +57 -45
- figquilt/compose_svg.py +76 -68
- {figquilt-0.1.1.dist-info → figquilt-0.1.2.dist-info}/METADATA +13 -2
- {figquilt-0.1.1.dist-info → figquilt-0.1.2.dist-info}/RECORD +7 -7
- {figquilt-0.1.1.dist-info → figquilt-0.1.2.dist-info}/WHEEL +0 -0
- {figquilt-0.1.1.dist-info → figquilt-0.1.2.dist-info}/entry_points.txt +0 -0
figquilt/cli.py
CHANGED
|
@@ -1,65 +1,217 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import sys
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from typing import Optional, Set, Tuple
|
|
5
|
+
import threading
|
|
6
|
+
|
|
4
7
|
from .parser import parse_layout
|
|
5
8
|
from .errors import FigQuiltError
|
|
9
|
+
from .layout import Layout
|
|
6
10
|
|
|
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
11
|
|
|
12
|
+
def get_watched_paths(layout_path: Path, layout: Layout) -> Tuple[Set[Path], Set[Path]]:
|
|
13
|
+
"""
|
|
14
|
+
Return the set of files and directories to watch.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Tuple of (watched_files, watched_dirs)
|
|
18
|
+
"""
|
|
19
|
+
layout_path = layout_path.resolve()
|
|
20
|
+
files = {layout_path}
|
|
21
|
+
for panel in layout.panels:
|
|
22
|
+
files.add(panel.file.resolve())
|
|
23
|
+
dirs = {f.parent for f in files}
|
|
24
|
+
return files, dirs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compose_figure(
|
|
28
|
+
layout_path: Path, output_path: Path, fmt: str, verbose: bool
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Compose a figure from a layout file.
|
|
32
|
+
|
|
33
|
+
Returns True on success, False on error.
|
|
34
|
+
"""
|
|
17
35
|
try:
|
|
18
|
-
layout = parse_layout(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
layout = parse_layout(layout_path)
|
|
37
|
+
if verbose:
|
|
38
|
+
print(f"Layout parsed: {layout_path}")
|
|
39
|
+
print(
|
|
40
|
+
f"Page size: {layout.page.width}x{layout.page.height} {layout.page.units}"
|
|
41
|
+
)
|
|
42
|
+
print(f"Panels: {len(layout.panels)}")
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
suffix = args.output.suffix.lower()
|
|
28
|
-
fmt = args.format or suffix.lstrip('.')
|
|
29
|
-
|
|
30
|
-
if fmt == 'pdf':
|
|
44
|
+
if fmt == "pdf":
|
|
31
45
|
from .compose_pdf import PDFComposer
|
|
46
|
+
|
|
32
47
|
composer = PDFComposer(layout)
|
|
33
|
-
composer.compose(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
elif fmt == 'svg':
|
|
48
|
+
composer.compose(output_path)
|
|
49
|
+
|
|
50
|
+
elif fmt == "svg":
|
|
37
51
|
from .compose_svg import SVGComposer
|
|
52
|
+
|
|
38
53
|
composer = SVGComposer(layout)
|
|
39
|
-
composer.compose(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
elif fmt == 'png':
|
|
54
|
+
composer.compose(output_path)
|
|
55
|
+
|
|
56
|
+
elif fmt == "png":
|
|
43
57
|
from .compose_pdf import PDFComposer
|
|
58
|
+
|
|
44
59
|
composer = PDFComposer(layout)
|
|
45
60
|
doc = composer.build()
|
|
46
|
-
# Rasterize first page
|
|
47
61
|
page = doc[0]
|
|
48
62
|
pix = page.get_pixmap(dpi=layout.page.dpi)
|
|
49
|
-
pix.save(str(
|
|
63
|
+
pix.save(str(output_path))
|
|
50
64
|
doc.close()
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
|
|
53
66
|
else:
|
|
54
67
|
print(f"Unsupported format: {fmt}", file=sys.stderr)
|
|
55
|
-
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
return True
|
|
56
71
|
|
|
57
72
|
except FigQuiltError as e:
|
|
58
73
|
print(f"Error: {e}", file=sys.stderr)
|
|
59
|
-
|
|
74
|
+
return False
|
|
60
75
|
except Exception as e:
|
|
61
76
|
print(f"Unexpected error: {e}", file=sys.stderr)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_watch_mode(
|
|
81
|
+
layout_path: Path,
|
|
82
|
+
output_path: Path,
|
|
83
|
+
fmt: str,
|
|
84
|
+
verbose: bool,
|
|
85
|
+
stop_event: Optional[threading.Event] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Watch layout and panel files for changes and rebuild on each change.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
layout_path: Path to the layout YAML file
|
|
92
|
+
output_path: Path to the output file
|
|
93
|
+
fmt: Output format (pdf, svg, png)
|
|
94
|
+
verbose: Whether to print verbose output
|
|
95
|
+
stop_event: Optional threading event to stop watching (for testing)
|
|
96
|
+
"""
|
|
97
|
+
from watchfiles import watch
|
|
98
|
+
|
|
99
|
+
print("Watching for changes... (Ctrl+C to stop)")
|
|
100
|
+
|
|
101
|
+
# Initial build
|
|
102
|
+
if compose_figure(layout_path, output_path, fmt, verbose):
|
|
103
|
+
print(f"Created: {output_path}")
|
|
104
|
+
else:
|
|
105
|
+
print("Initial build failed, watching for changes...")
|
|
106
|
+
|
|
107
|
+
# Get initial set of watched files and directories
|
|
108
|
+
try:
|
|
109
|
+
layout = parse_layout(layout_path)
|
|
110
|
+
watched_files, watch_dirs = get_watched_paths(layout_path, layout)
|
|
111
|
+
except FigQuiltError:
|
|
112
|
+
# If layout is invalid, just watch the layout file itself
|
|
113
|
+
layout_path_resolved = layout_path.resolve()
|
|
114
|
+
watched_files = {layout_path_resolved}
|
|
115
|
+
watch_dirs = {layout_path_resolved.parent}
|
|
116
|
+
|
|
117
|
+
while True:
|
|
118
|
+
restart_watcher = False
|
|
119
|
+
|
|
120
|
+
for changes in watch(*watch_dirs, stop_event=stop_event):
|
|
121
|
+
if stop_event and stop_event.is_set():
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Check if any changed file is in our watched set
|
|
125
|
+
changed_paths = {Path(change[1]).resolve() for change in changes}
|
|
126
|
+
relevant_changes = changed_paths & watched_files
|
|
127
|
+
|
|
128
|
+
if relevant_changes:
|
|
129
|
+
if verbose:
|
|
130
|
+
for p in relevant_changes:
|
|
131
|
+
print(f"Changed: {p}")
|
|
132
|
+
|
|
133
|
+
print("Rebuilding...", end=" ", flush=True)
|
|
134
|
+
if compose_figure(layout_path, output_path, fmt, verbose):
|
|
135
|
+
print(f"done: {output_path}")
|
|
136
|
+
else:
|
|
137
|
+
print("failed")
|
|
138
|
+
|
|
139
|
+
# Re-parse layout to update watched files (panels might have changed)
|
|
140
|
+
try:
|
|
141
|
+
layout = parse_layout(layout_path)
|
|
142
|
+
new_files, new_dirs = get_watched_paths(layout_path, layout)
|
|
143
|
+
if new_dirs != watch_dirs:
|
|
144
|
+
# Directories changed, need to restart the watcher
|
|
145
|
+
watched_files = new_files
|
|
146
|
+
watch_dirs = new_dirs
|
|
147
|
+
restart_watcher = True
|
|
148
|
+
if verbose:
|
|
149
|
+
print("Watch directories changed, restarting watcher...")
|
|
150
|
+
break
|
|
151
|
+
watched_files = new_files
|
|
152
|
+
except FigQuiltError:
|
|
153
|
+
pass # Keep watching existing files if layout is invalid
|
|
154
|
+
|
|
155
|
+
if stop_event and stop_event.is_set():
|
|
156
|
+
return
|
|
157
|
+
if not restart_watcher:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main():
|
|
162
|
+
parser = argparse.ArgumentParser(
|
|
163
|
+
description="FigQuilt: Compose figures from multiple panels."
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument("layout", type=Path, help="Path to layout YAML file")
|
|
166
|
+
parser.add_argument("output", type=Path, help="Path to output file (PDF/SVG/PNG)")
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--format", choices=["pdf", "svg", "png"], help="Override output format"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument("--check", action="store_true", help="Validate layout only")
|
|
171
|
+
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--watch", action="store_true", help="Watch for changes and rebuild"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
args = parser.parse_args()
|
|
177
|
+
|
|
178
|
+
# Determine output format
|
|
179
|
+
suffix = args.output.suffix.lower()
|
|
180
|
+
fmt = args.format or suffix.lstrip(".")
|
|
181
|
+
|
|
182
|
+
if fmt not in ("pdf", "svg", "png"):
|
|
183
|
+
print(f"Unsupported format: {fmt}", file=sys.stderr)
|
|
62
184
|
sys.exit(1)
|
|
63
185
|
|
|
186
|
+
# Check-only mode
|
|
187
|
+
if args.check:
|
|
188
|
+
try:
|
|
189
|
+
layout = parse_layout(args.layout)
|
|
190
|
+
print(f"Layout parsed successfully: {args.layout}")
|
|
191
|
+
print(
|
|
192
|
+
f"Page size: {layout.page.width}x{layout.page.height} {layout.page.units}"
|
|
193
|
+
)
|
|
194
|
+
print(f"Panels: {len(layout.panels)}")
|
|
195
|
+
sys.exit(0)
|
|
196
|
+
except FigQuiltError as e:
|
|
197
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
# Watch mode
|
|
201
|
+
if args.watch:
|
|
202
|
+
try:
|
|
203
|
+
run_watch_mode(args.layout, args.output, fmt, args.verbose)
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
print("\nStopped watching.")
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Single build mode
|
|
210
|
+
if compose_figure(args.layout, args.output, fmt, args.verbose):
|
|
211
|
+
print(f"Successfully created: {args.output}")
|
|
212
|
+
else:
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
|
|
215
|
+
|
|
64
216
|
if __name__ == "__main__":
|
|
65
217
|
main()
|
figquilt/compose_pdf.py
CHANGED
|
@@ -4,6 +4,7 @@ from .layout import Layout, Panel
|
|
|
4
4
|
from .units import mm_to_pt
|
|
5
5
|
from .errors import FigQuiltError
|
|
6
6
|
|
|
7
|
+
|
|
7
8
|
class PDFComposer:
|
|
8
9
|
def __init__(self, layout: Layout):
|
|
9
10
|
self.layout = layout
|
|
@@ -29,20 +30,20 @@ class PDFComposer:
|
|
|
29
30
|
# Minimal hex parser:
|
|
30
31
|
col = self._parse_color(self.layout.page.background)
|
|
31
32
|
if col:
|
|
32
|
-
|
|
33
|
+
page.draw_rect(page.rect, color=col, fill=col)
|
|
33
34
|
|
|
34
35
|
# Draw panels
|
|
35
36
|
for i, panel in enumerate(self.layout.panels):
|
|
36
37
|
self._place_panel(doc, page, panel, index=i)
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
return doc
|
|
39
|
-
|
|
40
|
+
|
|
40
41
|
def _parse_color(self, color_str: str):
|
|
41
42
|
# Very basic hex support
|
|
42
43
|
if color_str.startswith("#"):
|
|
43
|
-
h = color_str.lstrip(
|
|
44
|
+
h = color_str.lstrip("#")
|
|
44
45
|
try:
|
|
45
|
-
rgb = tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4))
|
|
46
|
+
rgb = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
|
46
47
|
return rgb
|
|
47
48
|
except:
|
|
48
49
|
return None
|
|
@@ -52,17 +53,20 @@ class PDFComposer:
|
|
|
52
53
|
# We process images, so PIL is available.
|
|
53
54
|
try:
|
|
54
55
|
from PIL import ImageColor
|
|
56
|
+
|
|
55
57
|
rgb = ImageColor.getrgb(color_str)
|
|
56
|
-
return tuple(c/255.0 for c in rgb)
|
|
58
|
+
return tuple(c / 255.0 for c in rgb)
|
|
57
59
|
except:
|
|
58
|
-
|
|
60
|
+
return None
|
|
59
61
|
|
|
60
|
-
def _place_panel(
|
|
62
|
+
def _place_panel(
|
|
63
|
+
self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
|
|
64
|
+
):
|
|
61
65
|
# Calculate position and size first
|
|
62
66
|
x = mm_to_pt(panel.x)
|
|
63
67
|
y = mm_to_pt(panel.y)
|
|
64
68
|
w = mm_to_pt(panel.width)
|
|
65
|
-
|
|
69
|
+
|
|
66
70
|
# Determine height from aspect ratio if needed
|
|
67
71
|
# We need to open the source to get aspect ratio
|
|
68
72
|
try:
|
|
@@ -71,35 +75,41 @@ class PDFComposer:
|
|
|
71
75
|
except Exception as e:
|
|
72
76
|
raise FigQuiltError(f"Failed to open panel file {panel.file}: {e}")
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
78
|
+
try:
|
|
79
|
+
# Get source dimension
|
|
80
|
+
if src_doc.is_pdf:
|
|
81
|
+
src_page = src_doc[0]
|
|
82
|
+
src_rect = src_page.rect
|
|
83
|
+
else:
|
|
84
|
+
# For images/SVG, fitz doc acts like a list of pages too?
|
|
85
|
+
# Yes, usually page[0] is the image/svg content.
|
|
86
|
+
src_page = src_doc[0]
|
|
87
|
+
src_rect = src_page.rect
|
|
88
|
+
|
|
89
|
+
aspect = src_rect.height / src_rect.width
|
|
90
|
+
|
|
91
|
+
if panel.height is not None:
|
|
92
|
+
h = mm_to_pt(panel.height)
|
|
93
|
+
else:
|
|
94
|
+
h = w * aspect
|
|
95
|
+
|
|
96
|
+
rect = fitz.Rect(x, y, x + w, y + h)
|
|
97
|
+
|
|
98
|
+
if src_doc.is_pdf:
|
|
99
|
+
page.show_pdf_page(rect, src_doc, 0)
|
|
100
|
+
elif panel.file.suffix.lower() == ".svg":
|
|
101
|
+
# Convert SVG to PDF in memory to allow vector embedding
|
|
102
|
+
pdf_bytes = src_doc.convert_to_pdf()
|
|
103
|
+
src_pdf = fitz.open("pdf", pdf_bytes)
|
|
104
|
+
try:
|
|
105
|
+
page.show_pdf_page(rect, src_pdf, 0)
|
|
106
|
+
finally:
|
|
107
|
+
src_pdf.close()
|
|
108
|
+
else:
|
|
109
|
+
# Insert as image (works for PNG/JPEG)
|
|
110
|
+
page.insert_image(rect, filename=panel.file)
|
|
111
|
+
finally:
|
|
112
|
+
src_doc.close()
|
|
103
113
|
|
|
104
114
|
# Labels
|
|
105
115
|
self._draw_label(page, panel, rect, index)
|
|
@@ -107,14 +117,14 @@ class PDFComposer:
|
|
|
107
117
|
def _draw_label(self, page: fitz.Page, panel: Panel, rect: fitz.Rect, index: int):
|
|
108
118
|
# Determine effective label settings
|
|
109
119
|
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
110
|
-
|
|
120
|
+
|
|
111
121
|
if not style.enabled:
|
|
112
122
|
return
|
|
113
123
|
|
|
114
124
|
text = panel.label
|
|
115
125
|
if text is None and style.auto_sequence:
|
|
116
126
|
text = chr(65 + index) # A, B, C...
|
|
117
|
-
|
|
127
|
+
|
|
118
128
|
if not text:
|
|
119
129
|
return
|
|
120
130
|
|
|
@@ -126,10 +136,10 @@ class PDFComposer:
|
|
|
126
136
|
# SVG implementation uses 'hanging' baseline, so (0,0) is top-left of text char.
|
|
127
137
|
# PyMuPDF insert_text uses 'baseline', so (0,0) is bottom-left of text char.
|
|
128
138
|
# We need to shift Y down by approximately the font sizing to match SVG visual.
|
|
129
|
-
|
|
139
|
+
|
|
130
140
|
pos_x = rect.x0 + mm_to_pt(style.offset_x_mm)
|
|
131
141
|
raw_y = rect.y0 + mm_to_pt(style.offset_y_mm)
|
|
132
|
-
|
|
142
|
+
|
|
133
143
|
# Approximate baseline shift: font_size
|
|
134
144
|
# (A more precise way uses font.ascender, but for basic standard fonts, size is decent proxy for visual top->baseline)
|
|
135
145
|
pos_y = raw_y + style.font_size_pt
|
|
@@ -137,7 +147,9 @@ class PDFComposer:
|
|
|
137
147
|
# Font - PyMuPDF supports base 14 fonts by name
|
|
138
148
|
fontname = "helv" # default mapping for Helvetica
|
|
139
149
|
if style.bold:
|
|
140
|
-
fontname = "HeBo"
|
|
141
|
-
|
|
150
|
+
fontname = "HeBo" # Helvetica-Bold
|
|
151
|
+
|
|
142
152
|
# Insert text
|
|
143
|
-
page.insert_text(
|
|
153
|
+
page.insert_text(
|
|
154
|
+
(pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
|
|
155
|
+
)
|
figquilt/compose_svg.py
CHANGED
|
@@ -6,6 +6,7 @@ from .units import mm_to_pt
|
|
|
6
6
|
from .errors import FigQuiltError
|
|
7
7
|
import fitz
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
class SVGComposer:
|
|
10
11
|
def __init__(self, layout: Layout):
|
|
11
12
|
self.layout = layout
|
|
@@ -14,13 +15,16 @@ class SVGComposer:
|
|
|
14
15
|
|
|
15
16
|
def compose(self, output_path: Path):
|
|
16
17
|
# Create root SVG element
|
|
17
|
-
nsmap = {
|
|
18
|
+
nsmap = {
|
|
19
|
+
None: "http://www.w3.org/2000/svg",
|
|
20
|
+
"xlink": "http://www.w3.org/1999/xlink",
|
|
21
|
+
}
|
|
18
22
|
root = etree.Element("svg", nsmap=nsmap)
|
|
19
23
|
root.set("width", f"{self.layout.page.width}mm")
|
|
20
24
|
root.set("height", f"{self.layout.page.height}mm")
|
|
21
25
|
root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
|
|
22
26
|
root.set("version", "1.1")
|
|
23
|
-
|
|
27
|
+
|
|
24
28
|
if self.layout.page.background:
|
|
25
29
|
# Draw background
|
|
26
30
|
bg = etree.SubElement(root, "rect")
|
|
@@ -41,76 +45,78 @@ class SVGComposer:
|
|
|
41
45
|
x = mm_to_pt(panel.x)
|
|
42
46
|
y = mm_to_pt(panel.y)
|
|
43
47
|
w = mm_to_pt(panel.width)
|
|
44
|
-
|
|
48
|
+
|
|
45
49
|
# Determine content sizing
|
|
46
50
|
# For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
|
|
47
51
|
try:
|
|
48
|
-
|
|
49
|
-
src_page = src_doc[0]
|
|
50
|
-
src_rect = src_page.rect
|
|
51
|
-
aspect = src_rect.height / src_rect.width
|
|
52
|
+
src_doc = fitz.open(panel.file)
|
|
52
53
|
except Exception as e:
|
|
53
54
|
raise FigQuiltError(f"Failed to inspect panel {panel.file}: {e}")
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
56
|
+
try:
|
|
57
|
+
src_page = src_doc[0]
|
|
58
|
+
src_rect = src_page.rect
|
|
59
|
+
aspect = src_rect.height / src_rect.width
|
|
60
|
+
|
|
61
|
+
if panel.height is not None:
|
|
62
|
+
h = mm_to_pt(panel.height)
|
|
99
63
|
else:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
64
|
+
h = w * aspect
|
|
65
|
+
|
|
66
|
+
# Group for the panel
|
|
67
|
+
g = etree.SubElement(root, "g")
|
|
68
|
+
g.set("transform", f"translate({x}, {y})")
|
|
103
69
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
70
|
+
# Insert content
|
|
71
|
+
# Check if SVG
|
|
72
|
+
suffix = panel.file.suffix.lower()
|
|
73
|
+
if suffix == ".svg":
|
|
74
|
+
# 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
|
+
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
79
|
+
img = etree.SubElement(g, "image")
|
|
80
|
+
img.set("width", str(w))
|
|
81
|
+
img.set("height", str(h))
|
|
82
|
+
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
83
|
+
else:
|
|
84
|
+
# PDF or Raster Image
|
|
85
|
+
# For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
|
|
86
|
+
# Browsers don't support image/pdf in SVG.
|
|
87
|
+
# If PNG/JPG, embed directly.
|
|
111
88
|
|
|
112
|
-
|
|
113
|
-
|
|
89
|
+
mime = "image/png"
|
|
90
|
+
if suffix in [".jpg", ".jpeg"]:
|
|
91
|
+
mime = "image/jpeg"
|
|
92
|
+
data_path = panel.file
|
|
93
|
+
elif suffix == ".png":
|
|
94
|
+
mime = "image/png"
|
|
95
|
+
data_path = panel.file
|
|
96
|
+
elif suffix == ".pdf":
|
|
97
|
+
# Rasterize page to PNG
|
|
98
|
+
pix = src_page.get_pixmap(dpi=300)
|
|
99
|
+
data = pix.tobytes("png")
|
|
100
|
+
b64 = base64.b64encode(data).decode("utf-8")
|
|
101
|
+
data_uri = f"data:image/png;base64,{b64}"
|
|
102
|
+
data_path = None # signal that we have URI
|
|
103
|
+
else:
|
|
104
|
+
# Fallback
|
|
105
|
+
mime = "application/octet-stream"
|
|
106
|
+
data_path = panel.file
|
|
107
|
+
|
|
108
|
+
if data_path:
|
|
109
|
+
data_uri = self._get_data_uri(data_path, mime)
|
|
110
|
+
|
|
111
|
+
img = etree.SubElement(g, "image")
|
|
112
|
+
img.set("width", str(w))
|
|
113
|
+
img.set("height", str(h))
|
|
114
|
+
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
115
|
+
|
|
116
|
+
# Label
|
|
117
|
+
self._draw_label(g, panel, w, h, index)
|
|
118
|
+
finally:
|
|
119
|
+
src_doc.close()
|
|
114
120
|
|
|
115
121
|
def _get_data_uri(self, path: Path, mime: str) -> str:
|
|
116
122
|
with open(path, "rb") as f:
|
|
@@ -118,7 +124,9 @@ class SVGComposer:
|
|
|
118
124
|
b64 = base64.b64encode(data).decode("utf-8")
|
|
119
125
|
return f"data:{mime};base64,{b64}"
|
|
120
126
|
|
|
121
|
-
def _draw_label(
|
|
127
|
+
def _draw_label(
|
|
128
|
+
self, parent: etree.Element, panel: Panel, w: float, h: float, index: int
|
|
129
|
+
):
|
|
122
130
|
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
123
131
|
if not style.enabled:
|
|
124
132
|
return
|
|
@@ -134,20 +142,20 @@ class SVGComposer:
|
|
|
134
142
|
# Offset (relative to panel top-left, which is 0,0 inside the group)
|
|
135
143
|
x = mm_to_pt(style.offset_x_mm)
|
|
136
144
|
y = mm_to_pt(style.offset_y_mm)
|
|
137
|
-
|
|
145
|
+
|
|
138
146
|
# Create text element
|
|
139
147
|
txt = etree.SubElement(parent, "text")
|
|
140
148
|
txt.text = text_str
|
|
141
149
|
txt.set("x", str(x))
|
|
142
150
|
txt.set("y", str(y))
|
|
143
|
-
|
|
151
|
+
|
|
144
152
|
# Style
|
|
145
153
|
# Font family is tricky in SVG (system fonts).
|
|
146
154
|
txt.set("font-family", style.font_family)
|
|
147
155
|
txt.set("font-size", f"{style.font_size_pt}pt")
|
|
148
156
|
if style.bold:
|
|
149
157
|
txt.set("font-weight", "bold")
|
|
150
|
-
|
|
158
|
+
|
|
151
159
|
# Baseline alignment? SVG text y is usually baseline.
|
|
152
160
|
# If we want top-left of text at (x,y), we should adjust or use dominant-baseline.
|
|
153
|
-
txt.set("dominant-baseline", "hanging")
|
|
161
|
+
txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: figquilt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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>
|
|
@@ -16,6 +16,7 @@ Requires-Dist: pydantic>=2.12.5
|
|
|
16
16
|
Requires-Dist: pymupdf>=1.26.6
|
|
17
17
|
Requires-Dist: pyyaml>=6.0.3
|
|
18
18
|
Requires-Dist: seaborn>=0.13.2
|
|
19
|
+
Requires-Dist: watchfiles>=1.0.0
|
|
19
20
|
Requires-Python: >=3.12
|
|
20
21
|
Project-URL: Homepage, https://github.com/yy/figquilt
|
|
21
22
|
Project-URL: Repository, https://github.com/yy/figquilt
|
|
@@ -81,4 +82,14 @@ Run `figquilt` to generate the figure:
|
|
|
81
82
|
|
|
82
83
|
```bash
|
|
83
84
|
figquilt figure1.yaml figure1.pdf
|
|
84
|
-
```
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Watch Mode
|
|
88
|
+
|
|
89
|
+
Use `--watch` to automatically rebuild when the layout file or any panel source files change:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
figquilt --watch figure1.yaml figure1.pdf
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This is useful during layout iteration - edit your YAML or regenerate a panel, and the output updates automatically.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
|
|
2
|
-
figquilt/cli.py,sha256=
|
|
3
|
-
figquilt/compose_pdf.py,sha256=
|
|
4
|
-
figquilt/compose_svg.py,sha256=
|
|
2
|
+
figquilt/cli.py,sha256=d73f2f97ca86b0d932e5a4dac8596e3137017517e2b7017916920bfe8e36a789,6930
|
|
3
|
+
figquilt/compose_pdf.py,sha256=f05dbd491aa0adfc06f2c5f7fd59fd8a9d42e7c61b28da4043cc24822b25adf6,5600
|
|
4
|
+
figquilt/compose_svg.py,sha256=ae102920d4c1d135220cfaee53d9eba925a9543be4552978251cb87cc67504f2,5990
|
|
5
5
|
figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
|
|
6
6
|
figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
|
|
7
7
|
figquilt/layout.py,sha256=44514c72f8883afe02f5dd05e674410440dc52c40966a8028f1d9693d4be3364,1159
|
|
8
8
|
figquilt/parser.py,sha256=d33d21178721072bbf681be940312b5fda0275f451fca3be2affad707f80a7fb,1065
|
|
9
9
|
figquilt/units.py,sha256=0c1b6b3f7380fb8ebf51ae81fb093c1830ae4a240b9151191333a3b0af16cd84,233
|
|
10
|
-
figquilt-0.1.
|
|
11
|
-
figquilt-0.1.
|
|
12
|
-
figquilt-0.1.
|
|
13
|
-
figquilt-0.1.
|
|
10
|
+
figquilt-0.1.2.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
|
|
11
|
+
figquilt-0.1.2.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
|
|
12
|
+
figquilt-0.1.2.dist-info/METADATA,sha256=78a83d8a0570b4829292a1410d3cf667a8f7508721826ee807f30f513392fd0b,2783
|
|
13
|
+
figquilt-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|