figquilt 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {figquilt-0.1.1 → figquilt-0.1.3}/PKG-INFO +22 -4
- {figquilt-0.1.1 → figquilt-0.1.3}/README.md +20 -3
- {figquilt-0.1.1 → figquilt-0.1.3}/pyproject.toml +2 -1
- figquilt-0.1.3/src/figquilt/cli.py +217 -0
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/compose_pdf.py +64 -51
- figquilt-0.1.3/src/figquilt/compose_svg.py +164 -0
- figquilt-0.1.3/src/figquilt/units.py +25 -0
- figquilt-0.1.1/src/figquilt/cli.py +0 -65
- figquilt-0.1.1/src/figquilt/compose_svg.py +0 -153
- figquilt-0.1.1/src/figquilt/units.py +0 -7
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/__init__.py +0 -0
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/errors.py +0 -0
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/images.py +0 -0
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/layout.py +0 -0
- {figquilt-0.1.1 → figquilt-0.1.3}/src/figquilt/parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: figquilt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
|
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
|
|
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.
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# figquilt
|
|
2
2
|
|
|
3
|
-
**Figure quilter**: A CLI tool
|
|
3
|
+
**Figure quilter**: A declarative CLI tool for compositing multiple figures (PDF, SVG, PNG) into publication-ready layouts.
|
|
4
4
|
|
|
5
|
-
`figquilt` takes a simple layout file (YAML) describing panels and their
|
|
5
|
+
`figquilt` takes a simple layout file (YAML) describing panels and their structure, composed of various inputs (plots from R/Python, diagrams, photos), and stitches them into a single output file (PDF, SVG) with automatic labeling and precise dimension control.
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Declarative over imperative**: Describe *what* your figure should look like, not *how* to construct it. Layouts are data, not scripts.
|
|
10
|
+
- **Structural composition first**: Prefer high-level layout (rows, columns, ratios) over manual coordinate placement. Let the tool handle positioning.
|
|
11
|
+
- **Fine control when needed**: Override with explicit coordinates and dimensions when precision matters.
|
|
12
|
+
- **Automation-friendly**: Designed to fit into reproducible workflows (Snakemake, Make, CI pipelines). No GUI, no manual steps.
|
|
6
13
|
|
|
7
14
|
## Features
|
|
8
15
|
|
|
@@ -58,4 +65,14 @@ Run `figquilt` to generate the figure:
|
|
|
58
65
|
|
|
59
66
|
```bash
|
|
60
67
|
figquilt figure1.yaml figure1.pdf
|
|
61
|
-
```
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Watch Mode
|
|
71
|
+
|
|
72
|
+
Use `--watch` to automatically rebuild when the layout file or any panel source files change:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
figquilt --watch figure1.yaml figure1.pdf
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
3
|
+
version = "0.1.3"
|
|
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()
|
|
@@ -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.
|
|
11
|
-
self.
|
|
11
|
+
self.units = layout.page.units
|
|
12
|
+
self.width_pt = to_pt(layout.page.width, self.units)
|
|
13
|
+
self.height_pt = to_pt(layout.page.height, self.units)
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
return None
|
|
59
62
|
|
|
60
|
-
def _place_panel(
|
|
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 =
|
|
63
|
-
y =
|
|
64
|
-
w =
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
141
|
-
|
|
151
|
+
fontname = "HeBo" # Helvetica-Bold
|
|
152
|
+
|
|
142
153
|
# Insert text
|
|
143
|
-
page.insert_text(
|
|
154
|
+
page.insert_text(
|
|
155
|
+
(pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
|
|
156
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
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, to_pt
|
|
6
|
+
from .errors import FigQuiltError
|
|
7
|
+
import fitz
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SVGComposer:
|
|
11
|
+
def __init__(self, layout: Layout):
|
|
12
|
+
self.layout = layout
|
|
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)
|
|
16
|
+
|
|
17
|
+
def compose(self, output_path: Path):
|
|
18
|
+
# Create root SVG element
|
|
19
|
+
nsmap = {
|
|
20
|
+
None: "http://www.w3.org/2000/svg",
|
|
21
|
+
"xlink": "http://www.w3.org/1999/xlink",
|
|
22
|
+
}
|
|
23
|
+
root = etree.Element("svg", nsmap=nsmap)
|
|
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}")
|
|
28
|
+
root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
|
|
29
|
+
root.set("version", "1.1")
|
|
30
|
+
|
|
31
|
+
if self.layout.page.background:
|
|
32
|
+
# Draw background
|
|
33
|
+
bg = etree.SubElement(root, "rect")
|
|
34
|
+
bg.set("width", "100%")
|
|
35
|
+
bg.set("height", "100%")
|
|
36
|
+
bg.set("fill", self.layout.page.background)
|
|
37
|
+
|
|
38
|
+
# Draw panels
|
|
39
|
+
for i, panel in enumerate(self.layout.panels):
|
|
40
|
+
self._place_panel(root, panel, i)
|
|
41
|
+
|
|
42
|
+
# Write to file
|
|
43
|
+
tree = etree.ElementTree(root)
|
|
44
|
+
with open(output_path, "wb") as f:
|
|
45
|
+
tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
def _place_panel(self, root: etree.Element, panel: Panel, index: int):
|
|
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
|
+
|
|
52
|
+
# Determine content sizing
|
|
53
|
+
# For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
|
|
54
|
+
try:
|
|
55
|
+
src_doc = fitz.open(panel.file)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise FigQuiltError(f"Failed to inspect panel {panel.file}: {e}")
|
|
58
|
+
|
|
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)
|
|
66
|
+
else:
|
|
67
|
+
h = w * aspect
|
|
68
|
+
|
|
69
|
+
# Group for the panel
|
|
70
|
+
g = etree.SubElement(root, "g")
|
|
71
|
+
g.set("transform", f"translate({x}, {y})")
|
|
72
|
+
|
|
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.
|
|
91
|
+
|
|
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()
|
|
123
|
+
|
|
124
|
+
def _get_data_uri(self, path: Path, mime: str) -> str:
|
|
125
|
+
with open(path, "rb") as f:
|
|
126
|
+
data = f.read()
|
|
127
|
+
b64 = base64.b64encode(data).decode("utf-8")
|
|
128
|
+
return f"data:{mime};base64,{b64}"
|
|
129
|
+
|
|
130
|
+
def _draw_label(
|
|
131
|
+
self, parent: etree.Element, panel: Panel, w: float, h: float, index: int
|
|
132
|
+
):
|
|
133
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
134
|
+
if not style.enabled:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
text_str = panel.label
|
|
138
|
+
if text_str is None and style.auto_sequence:
|
|
139
|
+
text_str = chr(65 + index)
|
|
140
|
+
if not text_str:
|
|
141
|
+
return
|
|
142
|
+
if style.uppercase:
|
|
143
|
+
text_str = text_str.upper()
|
|
144
|
+
|
|
145
|
+
# Offset (relative to panel top-left, which is 0,0 inside the group)
|
|
146
|
+
x = mm_to_pt(style.offset_x_mm)
|
|
147
|
+
y = mm_to_pt(style.offset_y_mm)
|
|
148
|
+
|
|
149
|
+
# Create text element
|
|
150
|
+
txt = etree.SubElement(parent, "text")
|
|
151
|
+
txt.text = text_str
|
|
152
|
+
txt.set("x", str(x))
|
|
153
|
+
txt.set("y", str(y))
|
|
154
|
+
|
|
155
|
+
# Style
|
|
156
|
+
# Font family is tricky in SVG (system fonts).
|
|
157
|
+
txt.set("font-family", style.font_family)
|
|
158
|
+
txt.set("font-size", f"{style.font_size_pt}pt")
|
|
159
|
+
if style.bold:
|
|
160
|
+
txt.set("font-weight", "bold")
|
|
161
|
+
|
|
162
|
+
# Baseline alignment? SVG text y is usually baseline.
|
|
163
|
+
# If we want top-left of text at (x,y), we should adjust or use dominant-baseline.
|
|
164
|
+
txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
def mm_to_pt(mm: float) -> float:
|
|
2
|
+
"""Converts millimeters to points (1 inch = 25.4 mm = 72 pts)."""
|
|
3
|
+
return mm * 72 / 25.4
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pt_to_mm(pt: float) -> float:
|
|
7
|
+
"""Converts points to millimeters."""
|
|
8
|
+
return pt * 25.4 / 72
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def inches_to_pt(inches: float) -> float:
|
|
12
|
+
"""Converts inches to points (1 inch = 72 pts)."""
|
|
13
|
+
return inches * 72
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_pt(value: float, units: str) -> float:
|
|
17
|
+
"""Convert a value from the given units to points."""
|
|
18
|
+
if units == "mm":
|
|
19
|
+
return mm_to_pt(value)
|
|
20
|
+
elif units == "inches":
|
|
21
|
+
return inches_to_pt(value)
|
|
22
|
+
elif units == "pt":
|
|
23
|
+
return value
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"Unknown unit: {units}")
|
|
@@ -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()
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|