figquilt 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: figquilt
3
- Version: 0.1.0
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
@@ -37,18 +38,22 @@ Description-Content-Type: text/markdown
37
38
 
38
39
  ## Installation
39
40
 
40
- This project uses `uv` for dependency management.
41
+ ```bash
42
+ uv tool install figquilt
43
+ ```
44
+
45
+ Or add it as a project dependency:
46
+
47
+ ```bash
48
+ uv add figquilt
49
+ ```
50
+
51
+ ### Development Installation
41
52
 
42
53
  ```bash
43
- # Clone the repository
44
54
  git clone https://github.com/yy/figquilt.git
45
55
  cd figquilt
46
-
47
- # Install dependencies and set up the environment
48
56
  uv sync
49
-
50
- # Install the package in editable mode
51
- uv pip install -e .
52
57
  ```
53
58
 
54
59
  ## Usage
@@ -77,4 +82,14 @@ Run `figquilt` to generate the figure:
77
82
 
78
83
  ```bash
79
84
  figquilt figure1.yaml figure1.pdf
80
- ```
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.
@@ -14,18 +14,22 @@
14
14
 
15
15
  ## Installation
16
16
 
17
- This project uses `uv` for dependency management.
17
+ ```bash
18
+ uv tool install figquilt
19
+ ```
20
+
21
+ Or add it as a project dependency:
22
+
23
+ ```bash
24
+ uv add figquilt
25
+ ```
26
+
27
+ ### Development Installation
18
28
 
19
29
  ```bash
20
- # Clone the repository
21
30
  git clone https://github.com/yy/figquilt.git
22
31
  cd figquilt
23
-
24
- # Install dependencies and set up the environment
25
32
  uv sync
26
-
27
- # Install the package in editable mode
28
- uv pip install -e .
29
33
  ```
30
34
 
31
35
  ## Usage
@@ -54,4 +58,14 @@ Run `figquilt` to generate the figure:
54
58
 
55
59
  ```bash
56
60
  figquilt figure1.yaml figure1.pdf
57
- ```
61
+ ```
62
+
63
+ ### Watch Mode
64
+
65
+ Use `--watch` to automatically rebuild when the layout file or any panel source files change:
66
+
67
+ ```bash
68
+ figquilt --watch figure1.yaml figure1.pdf
69
+ ```
70
+
71
+ This is useful during layout iteration - edit your YAML or regenerate a panel, and the output updates automatically.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "figquilt"
3
- version = "0.1.0"
3
+ version = "0.1.2"
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 = [
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "pymupdf>=1.26.6",
23
23
  "pyyaml>=6.0.3",
24
24
  "seaborn>=0.13.2",
25
+ "watchfiles>=1.0.0",
25
26
  ]
26
27
 
27
28
  [project.scripts]
@@ -0,0 +1,217 @@
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Optional, Set, Tuple
5
+ import threading
6
+
7
+ from .parser import parse_layout
8
+ from .errors import FigQuiltError
9
+ from .layout import Layout
10
+
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
+ """
35
+ try:
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)}")
43
+
44
+ if fmt == "pdf":
45
+ from .compose_pdf import PDFComposer
46
+
47
+ composer = PDFComposer(layout)
48
+ composer.compose(output_path)
49
+
50
+ elif fmt == "svg":
51
+ from .compose_svg import SVGComposer
52
+
53
+ composer = SVGComposer(layout)
54
+ composer.compose(output_path)
55
+
56
+ elif fmt == "png":
57
+ from .compose_pdf import PDFComposer
58
+
59
+ composer = PDFComposer(layout)
60
+ doc = composer.build()
61
+ page = doc[0]
62
+ pix = page.get_pixmap(dpi=layout.page.dpi)
63
+ pix.save(str(output_path))
64
+ doc.close()
65
+
66
+ else:
67
+ print(f"Unsupported format: {fmt}", file=sys.stderr)
68
+ return False
69
+
70
+ return True
71
+
72
+ except FigQuiltError as e:
73
+ print(f"Error: {e}", file=sys.stderr)
74
+ return False
75
+ except Exception as e:
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)
184
+ sys.exit(1)
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
+
216
+ if __name__ == "__main__":
217
+ main()
@@ -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
- page.draw_rect(page.rect, color=col, fill=col)
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
- return None
60
+ return None
59
61
 
60
- def _place_panel(self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int):
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
- # 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)
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" # Helvetica-Bold
141
-
150
+ fontname = "HeBo" # Helvetica-Bold
151
+
142
152
  # Insert text
143
- page.insert_text((pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname)
153
+ page.insert_text(
154
+ (pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
155
+ )
@@ -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 = {None: "http://www.w3.org/2000/svg", "xlink": "http://www.w3.org/1999/xlink"}
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
- 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
+ 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
- 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
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
- # Fallback
101
- mime = "application/octet-stream"
102
- data_path = panel.file
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
- 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)
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
- # Label
113
- self._draw_label(g, panel, w, h, index)
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(self, parent: etree.Element, panel: Panel, w: float, h: float, index: int):
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") # Matches top-down coordinate logic
161
+ txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
@@ -1,65 +0,0 @@
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()
File without changes