figquilt 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figquilt/cli.py CHANGED
@@ -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(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)
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
- # Determine output format
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(args.output)
34
- print(f"Successfully created: {args.output}")
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(args.output)
40
- print(f"Successfully created: {args.output}")
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(args.output))
63
+ pix.save(str(output_path))
50
64
  doc.close()
51
- print(f"Successfully created: {args.output}")
52
-
65
+
53
66
  else:
54
67
  print(f"Unsupported format: {fmt}", file=sys.stderr)
55
- sys.exit(1)
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
- sys.exit(1)
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
@@ -1,14 +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
  class PDFComposer:
8
9
  def __init__(self, layout: Layout):
9
10
  self.layout = layout
10
- self.width_pt = mm_to_pt(layout.page.width)
11
- self.height_pt = mm_to_pt(layout.page.height)
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)
12
14
 
13
15
  def compose(self, output_path: Path):
14
16
  doc = self.build()
@@ -29,20 +31,20 @@ class PDFComposer:
29
31
  # Minimal hex parser:
30
32
  col = self._parse_color(self.layout.page.background)
31
33
  if col:
32
- page.draw_rect(page.rect, color=col, fill=col)
34
+ page.draw_rect(page.rect, color=col, fill=col)
33
35
 
34
36
  # Draw panels
35
37
  for i, panel in enumerate(self.layout.panels):
36
38
  self._place_panel(doc, page, panel, index=i)
37
-
39
+
38
40
  return doc
39
-
41
+
40
42
  def _parse_color(self, color_str: str):
41
43
  # Very basic hex support
42
44
  if color_str.startswith("#"):
43
- h = color_str.lstrip('#')
45
+ h = color_str.lstrip("#")
44
46
  try:
45
- rgb = tuple(int(h[i:i+2], 16)/255.0 for i in (0, 2, 4))
47
+ rgb = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
46
48
  return rgb
47
49
  except:
48
50
  return None
@@ -52,17 +54,20 @@ class PDFComposer:
52
54
  # We process images, so PIL is available.
53
55
  try:
54
56
  from PIL import ImageColor
57
+
55
58
  rgb = ImageColor.getrgb(color_str)
56
- return tuple(c/255.0 for c in rgb)
59
+ return tuple(c / 255.0 for c in rgb)
57
60
  except:
58
- return None
61
+ return None
59
62
 
60
- def _place_panel(self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int):
63
+ def _place_panel(
64
+ self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
65
+ ):
61
66
  # 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
-
67
+ x = to_pt(panel.x, self.units)
68
+ y = to_pt(panel.y, self.units)
69
+ w = to_pt(panel.width, self.units)
70
+
66
71
  # Determine height from aspect ratio if needed
67
72
  # We need to open the source to get aspect ratio
68
73
  try:
@@ -71,35 +76,41 @@ class PDFComposer:
71
76
  except Exception as e:
72
77
  raise FigQuiltError(f"Failed to open panel file {panel.file}: {e}")
73
78
 
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)
79
+ try:
80
+ # Get source dimension
81
+ if src_doc.is_pdf:
82
+ src_page = src_doc[0]
83
+ src_rect = src_page.rect
84
+ else:
85
+ # For images/SVG, fitz doc acts like a list of pages too?
86
+ # Yes, usually page[0] is the image/svg content.
87
+ src_page = src_doc[0]
88
+ src_rect = src_page.rect
89
+
90
+ aspect = src_rect.height / src_rect.width
91
+
92
+ if panel.height is not None:
93
+ h = to_pt(panel.height, self.units)
94
+ else:
95
+ h = w * aspect
96
+
97
+ rect = fitz.Rect(x, y, x + w, y + h)
98
+
99
+ if src_doc.is_pdf:
100
+ page.show_pdf_page(rect, src_doc, 0)
101
+ elif panel.file.suffix.lower() == ".svg":
102
+ # Convert SVG to PDF in memory to allow vector embedding
103
+ pdf_bytes = src_doc.convert_to_pdf()
104
+ src_pdf = fitz.open("pdf", pdf_bytes)
105
+ try:
106
+ page.show_pdf_page(rect, src_pdf, 0)
107
+ finally:
108
+ src_pdf.close()
109
+ else:
110
+ # Insert as image (works for PNG/JPEG)
111
+ page.insert_image(rect, filename=panel.file)
112
+ finally:
113
+ src_doc.close()
103
114
 
104
115
  # Labels
105
116
  self._draw_label(page, panel, rect, index)
@@ -107,14 +118,14 @@ class PDFComposer:
107
118
  def _draw_label(self, page: fitz.Page, panel: Panel, rect: fitz.Rect, index: int):
108
119
  # Determine effective label settings
109
120
  style = panel.label_style if panel.label_style else self.layout.page.label
110
-
121
+
111
122
  if not style.enabled:
112
123
  return
113
124
 
114
125
  text = panel.label
115
126
  if text is None and style.auto_sequence:
116
127
  text = chr(65 + index) # A, B, C...
117
-
128
+
118
129
  if not text:
119
130
  return
120
131
 
@@ -126,10 +137,10 @@ class PDFComposer:
126
137
  # SVG implementation uses 'hanging' baseline, so (0,0) is top-left of text char.
127
138
  # PyMuPDF insert_text uses 'baseline', so (0,0) is bottom-left of text char.
128
139
  # We need to shift Y down by approximately the font sizing to match SVG visual.
129
-
140
+
130
141
  pos_x = rect.x0 + mm_to_pt(style.offset_x_mm)
131
142
  raw_y = rect.y0 + mm_to_pt(style.offset_y_mm)
132
-
143
+
133
144
  # Approximate baseline shift: font_size
134
145
  # (A more precise way uses font.ascender, but for basic standard fonts, size is decent proxy for visual top->baseline)
135
146
  pos_y = raw_y + style.font_size_pt
@@ -137,7 +148,9 @@ class PDFComposer:
137
148
  # Font - PyMuPDF supports base 14 fonts by name
138
149
  fontname = "helv" # default mapping for Helvetica
139
150
  if style.bold:
140
- fontname = "HeBo" # Helvetica-Bold
141
-
151
+ fontname = "HeBo" # Helvetica-Bold
152
+
142
153
  # Insert text
143
- page.insert_text((pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname)
154
+ page.insert_text(
155
+ (pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
156
+ )
figquilt/compose_svg.py CHANGED
@@ -2,25 +2,32 @@ 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
 
9
+
9
10
  class SVGComposer:
10
11
  def __init__(self, layout: Layout):
11
12
  self.layout = layout
12
- self.width_pt = mm_to_pt(layout.page.width)
13
- self.height_pt = mm_to_pt(layout.page.height)
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)
14
16
 
15
17
  def compose(self, output_path: Path):
16
18
  # Create root SVG element
17
- nsmap = {None: "http://www.w3.org/2000/svg", "xlink": "http://www.w3.org/1999/xlink"}
19
+ nsmap = {
20
+ None: "http://www.w3.org/2000/svg",
21
+ "xlink": "http://www.w3.org/1999/xlink",
22
+ }
18
23
  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")
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}")
21
28
  root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
22
29
  root.set("version", "1.1")
23
-
30
+
24
31
  if self.layout.page.background:
25
32
  # Draw background
26
33
  bg = etree.SubElement(root, "rect")
@@ -38,79 +45,81 @@ class SVGComposer:
38
45
  tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
39
46
 
40
47
  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
-
48
+ x = to_pt(panel.x, self.units)
49
+ y = to_pt(panel.y, self.units)
50
+ w = to_pt(panel.width, self.units)
51
+
45
52
  # Determine content sizing
46
53
  # For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
47
54
  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
55
+ src_doc = fitz.open(panel.file)
52
56
  except Exception as e:
53
57
  raise FigQuiltError(f"Failed to inspect panel {panel.file}: {e}")
54
58
 
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
59
+ try:
60
+ src_page = src_doc[0]
61
+ src_rect = src_page.rect
62
+ aspect = src_rect.height / src_rect.width
63
+
64
+ if panel.height is not None:
65
+ h = to_pt(panel.height, self.units)
99
66
  else:
100
- # Fallback
101
- mime = "application/octet-stream"
102
- data_path = panel.file
67
+ h = w * aspect
68
+
69
+ # Group for the panel
70
+ g = etree.SubElement(root, "g")
71
+ g.set("transform", f"translate({x}, {y})")
103
72
 
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)
73
+ # Insert content
74
+ # Check if SVG
75
+ suffix = panel.file.suffix.lower()
76
+ if suffix == ".svg":
77
+ # Embed SVG by creating an <image> tag with data URI to avoid DOM conflicts
78
+ # This is safer than merging trees for V0.
79
+ # Merging trees requires stripping root, handling viewbox/transform matching.
80
+ # <image> handles scaling automatically.
81
+ data_uri = self._get_data_uri(panel.file, "image/svg+xml")
82
+ img = etree.SubElement(g, "image")
83
+ img.set("width", str(w))
84
+ img.set("height", str(h))
85
+ img.set("{http://www.w3.org/1999/xlink}href", data_uri)
86
+ else:
87
+ # PDF or Raster Image
88
+ # For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
89
+ # Browsers don't support image/pdf in SVG.
90
+ # If PNG/JPG, embed directly.
111
91
 
112
- # Label
113
- self._draw_label(g, panel, w, h, index)
92
+ mime = "image/png"
93
+ if suffix in [".jpg", ".jpeg"]:
94
+ mime = "image/jpeg"
95
+ data_path = panel.file
96
+ elif suffix == ".png":
97
+ mime = "image/png"
98
+ data_path = panel.file
99
+ elif suffix == ".pdf":
100
+ # Rasterize page to PNG
101
+ pix = src_page.get_pixmap(dpi=300)
102
+ data = pix.tobytes("png")
103
+ b64 = base64.b64encode(data).decode("utf-8")
104
+ data_uri = f"data:image/png;base64,{b64}"
105
+ data_path = None # signal that we have URI
106
+ else:
107
+ # Fallback
108
+ mime = "application/octet-stream"
109
+ data_path = panel.file
110
+
111
+ if data_path:
112
+ data_uri = self._get_data_uri(data_path, mime)
113
+
114
+ img = etree.SubElement(g, "image")
115
+ img.set("width", str(w))
116
+ img.set("height", str(h))
117
+ img.set("{http://www.w3.org/1999/xlink}href", data_uri)
118
+
119
+ # Label
120
+ self._draw_label(g, panel, w, h, index)
121
+ finally:
122
+ src_doc.close()
114
123
 
115
124
  def _get_data_uri(self, path: Path, mime: str) -> str:
116
125
  with open(path, "rb") as f:
@@ -118,7 +127,9 @@ class SVGComposer:
118
127
  b64 = base64.b64encode(data).decode("utf-8")
119
128
  return f"data:{mime};base64,{b64}"
120
129
 
121
- def _draw_label(self, parent: etree.Element, panel: Panel, w: float, h: float, index: int):
130
+ def _draw_label(
131
+ self, parent: etree.Element, panel: Panel, w: float, h: float, index: int
132
+ ):
122
133
  style = panel.label_style if panel.label_style else self.layout.page.label
123
134
  if not style.enabled:
124
135
  return
@@ -134,20 +145,20 @@ class SVGComposer:
134
145
  # Offset (relative to panel top-left, which is 0,0 inside the group)
135
146
  x = mm_to_pt(style.offset_x_mm)
136
147
  y = mm_to_pt(style.offset_y_mm)
137
-
148
+
138
149
  # Create text element
139
150
  txt = etree.SubElement(parent, "text")
140
151
  txt.text = text_str
141
152
  txt.set("x", str(x))
142
153
  txt.set("y", str(y))
143
-
154
+
144
155
  # Style
145
156
  # Font family is tricky in SVG (system fonts).
146
157
  txt.set("font-family", style.font_family)
147
158
  txt.set("font-size", f"{style.font_size_pt}pt")
148
159
  if style.bold:
149
160
  txt.set("font-weight", "bold")
150
-
161
+
151
162
  # Baseline alignment? SVG text y is usually baseline.
152
163
  # 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
164
+ txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
figquilt/units.py CHANGED
@@ -2,6 +2,24 @@ def mm_to_pt(mm: float) -> float:
2
2
  """Converts millimeters to points (1 inch = 25.4 mm = 72 pts)."""
3
3
  return mm * 72 / 25.4
4
4
 
5
+
5
6
  def pt_to_mm(pt: float) -> float:
6
7
  """Converts points to millimeters."""
7
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}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: figquilt
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -23,9 +24,16 @@ Description-Content-Type: text/markdown
23
24
 
24
25
  # figquilt
25
26
 
26
- **Figure quilter**: A CLI tool to compositing multiple figures (PDF, SVG, PNG) into a publication-ready figure layout.
27
+ **Figure quilter**: A declarative CLI tool for compositing multiple figures (PDF, SVG, PNG) into publication-ready layouts.
27
28
 
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
+ `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.
29
37
 
30
38
  ## Features
31
39
 
@@ -81,4 +89,14 @@ Run `figquilt` to generate the figure:
81
89
 
82
90
  ```bash
83
91
  figquilt figure1.yaml figure1.pdf
84
- ```
92
+ ```
93
+
94
+ ### Watch Mode
95
+
96
+ Use `--watch` to automatically rebuild when the layout file or any panel source files change:
97
+
98
+ ```bash
99
+ figquilt --watch figure1.yaml figure1.pdf
100
+ ```
101
+
102
+ This is useful during layout iteration - edit your YAML or regenerate a panel, and the output updates automatically.
@@ -0,0 +1,13 @@
1
+ figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
+ figquilt/cli.py,sha256=d73f2f97ca86b0d932e5a4dac8596e3137017517e2b7017916920bfe8e36a789,6930
3
+ figquilt/compose_pdf.py,sha256=5c0391e2259c830cfaa760a5f06b33b3173ecfd8fb067ca63321dae15ea252dc,5700
4
+ figquilt/compose_svg.py,sha256=161042f0067d87891cc0d95e363b759fb6b3d40e3c55d50673d7c2bfe9011ba2,6246
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=cb14151b8b456a53d823ac5021b339bddefe3aac09e945d81a8306c1370f49fd,684
10
+ figquilt-0.1.3.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
11
+ figquilt-0.1.3.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
12
+ figquilt-0.1.3.dist-info/METADATA,sha256=fda0ebf7ae6118a48a337b983a8e9bd09c31c1df4bb8f5b329af404ae46a37fb,3330
13
+ figquilt-0.1.3.dist-info/RECORD,,
@@ -1,13 +0,0 @@
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.1.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
11
- figquilt-0.1.1.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
12
- figquilt-0.1.1.dist-info/METADATA,sha256=b3dedf15af8566eef3e86d23957e9a461136aff2200efd25ff180ea0de8b6428,2466
13
- figquilt-0.1.1.dist-info/RECORD,,