figquilt 0.1.3__tar.gz → 0.1.4__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.3 → figquilt-0.1.4}/PKG-INFO +1 -1
- {figquilt-0.1.3 → figquilt-0.1.4}/pyproject.toml +1 -1
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/compose_pdf.py +17 -4
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/compose_svg.py +46 -15
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/layout.py +8 -1
- figquilt-0.1.4/src/figquilt/units.py +70 -0
- figquilt-0.1.3/src/figquilt/units.py +0 -25
- {figquilt-0.1.3 → figquilt-0.1.4}/README.md +0 -0
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/__init__.py +0 -0
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/cli.py +0 -0
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/errors.py +0 -0
- {figquilt-0.1.3 → figquilt-0.1.4}/src/figquilt/images.py +0 -0
- {figquilt-0.1.3 → figquilt-0.1.4}/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.4
|
|
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>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "figquilt"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
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 = [
|
|
@@ -87,14 +87,27 @@ class PDFComposer:
|
|
|
87
87
|
src_page = src_doc[0]
|
|
88
88
|
src_rect = src_page.rect
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
src_aspect = src_rect.height / src_rect.width
|
|
91
91
|
|
|
92
|
+
# Calculate cell height
|
|
92
93
|
if panel.height is not None:
|
|
93
94
|
h = to_pt(panel.height, self.units)
|
|
94
95
|
else:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
# No height specified: use source aspect ratio
|
|
97
|
+
h = w * src_aspect
|
|
98
|
+
|
|
99
|
+
# Calculate content rect using fit mode
|
|
100
|
+
from .units import calculate_fit
|
|
101
|
+
|
|
102
|
+
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
103
|
+
src_aspect, w, h, panel.fit
|
|
104
|
+
)
|
|
105
|
+
rect = fitz.Rect(
|
|
106
|
+
x + offset_x,
|
|
107
|
+
y + offset_y,
|
|
108
|
+
x + offset_x + content_w,
|
|
109
|
+
y + offset_y + content_h,
|
|
110
|
+
)
|
|
98
111
|
|
|
99
112
|
if src_doc.is_pdf:
|
|
100
113
|
page.show_pdf_page(rect, src_doc, 0)
|
|
@@ -59,30 +59,50 @@ class SVGComposer:
|
|
|
59
59
|
try:
|
|
60
60
|
src_page = src_doc[0]
|
|
61
61
|
src_rect = src_page.rect
|
|
62
|
-
|
|
62
|
+
src_aspect = src_rect.height / src_rect.width
|
|
63
63
|
|
|
64
64
|
if panel.height is not None:
|
|
65
65
|
h = to_pt(panel.height, self.units)
|
|
66
66
|
else:
|
|
67
|
-
h = w *
|
|
67
|
+
h = w * src_aspect
|
|
68
|
+
|
|
69
|
+
# Calculate content dimensions using fit mode
|
|
70
|
+
from .units import calculate_fit
|
|
71
|
+
|
|
72
|
+
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
73
|
+
src_aspect, w, h, panel.fit
|
|
74
|
+
)
|
|
68
75
|
|
|
69
76
|
# Group for the panel
|
|
70
77
|
g = etree.SubElement(root, "g")
|
|
71
78
|
g.set("transform", f"translate({x}, {y})")
|
|
72
79
|
|
|
80
|
+
# For cover mode, add a clip path to crop the overflow
|
|
81
|
+
if panel.fit == "cover":
|
|
82
|
+
clip_id = f"clip-{panel.id}"
|
|
83
|
+
defs = etree.SubElement(g, "defs")
|
|
84
|
+
clip_path = etree.SubElement(defs, "clipPath")
|
|
85
|
+
clip_path.set("id", clip_id)
|
|
86
|
+
clip_rect = etree.SubElement(clip_path, "rect")
|
|
87
|
+
clip_rect.set("x", "0")
|
|
88
|
+
clip_rect.set("y", "0")
|
|
89
|
+
clip_rect.set("width", str(w))
|
|
90
|
+
clip_rect.set("height", str(h))
|
|
91
|
+
|
|
73
92
|
# Insert content
|
|
74
93
|
# Check if SVG
|
|
75
94
|
suffix = panel.file.suffix.lower()
|
|
76
95
|
if suffix == ".svg":
|
|
77
96
|
# 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
97
|
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
82
98
|
img = etree.SubElement(g, "image")
|
|
83
|
-
img.set("
|
|
84
|
-
img.set("
|
|
99
|
+
img.set("x", str(offset_x))
|
|
100
|
+
img.set("y", str(offset_y))
|
|
101
|
+
img.set("width", str(content_w))
|
|
102
|
+
img.set("height", str(content_h))
|
|
85
103
|
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
104
|
+
if panel.fit == "cover":
|
|
105
|
+
img.set("clip-path", f"url(#{clip_id})")
|
|
86
106
|
else:
|
|
87
107
|
# PDF or Raster Image
|
|
88
108
|
# For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
|
|
@@ -112,12 +132,16 @@ class SVGComposer:
|
|
|
112
132
|
data_uri = self._get_data_uri(data_path, mime)
|
|
113
133
|
|
|
114
134
|
img = etree.SubElement(g, "image")
|
|
115
|
-
img.set("
|
|
116
|
-
img.set("
|
|
135
|
+
img.set("x", str(offset_x))
|
|
136
|
+
img.set("y", str(offset_y))
|
|
137
|
+
img.set("width", str(content_w))
|
|
138
|
+
img.set("height", str(content_h))
|
|
117
139
|
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
140
|
+
if panel.fit == "cover":
|
|
141
|
+
img.set("clip-path", f"url(#{clip_id})")
|
|
118
142
|
|
|
119
|
-
# Label
|
|
120
|
-
self._draw_label(g, panel,
|
|
143
|
+
# Label (positioned relative to content, not cell)
|
|
144
|
+
self._draw_label(g, panel, content_w, content_h, offset_x, offset_y, index)
|
|
121
145
|
finally:
|
|
122
146
|
src_doc.close()
|
|
123
147
|
|
|
@@ -128,7 +152,14 @@ class SVGComposer:
|
|
|
128
152
|
return f"data:{mime};base64,{b64}"
|
|
129
153
|
|
|
130
154
|
def _draw_label(
|
|
131
|
-
self,
|
|
155
|
+
self,
|
|
156
|
+
parent: etree.Element,
|
|
157
|
+
panel: Panel,
|
|
158
|
+
content_w: float,
|
|
159
|
+
content_h: float,
|
|
160
|
+
offset_x: float,
|
|
161
|
+
offset_y: float,
|
|
162
|
+
index: int,
|
|
132
163
|
):
|
|
133
164
|
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
134
165
|
if not style.enabled:
|
|
@@ -142,9 +173,9 @@ class SVGComposer:
|
|
|
142
173
|
if style.uppercase:
|
|
143
174
|
text_str = text_str.upper()
|
|
144
175
|
|
|
145
|
-
# Offset
|
|
146
|
-
x = mm_to_pt(style.offset_x_mm)
|
|
147
|
-
y = mm_to_pt(style.offset_y_mm)
|
|
176
|
+
# Offset relative to the content position
|
|
177
|
+
x = offset_x + mm_to_pt(style.offset_x_mm)
|
|
178
|
+
y = offset_y + mm_to_pt(style.offset_y_mm)
|
|
148
179
|
|
|
149
180
|
# Create text element
|
|
150
181
|
txt = etree.SubElement(parent, "text")
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
from typing import Optional, List
|
|
1
|
+
from typing import Literal, Optional, List
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from pydantic import BaseModel, Field, field_validator
|
|
4
4
|
|
|
5
|
+
FitMode = Literal["contain", "cover"]
|
|
6
|
+
|
|
7
|
+
|
|
5
8
|
class LabelStyle(BaseModel):
|
|
6
9
|
enabled: bool = True
|
|
7
10
|
auto_sequence: bool = True
|
|
@@ -12,6 +15,7 @@ class LabelStyle(BaseModel):
|
|
|
12
15
|
bold: bool = True
|
|
13
16
|
uppercase: bool = True
|
|
14
17
|
|
|
18
|
+
|
|
15
19
|
class Panel(BaseModel):
|
|
16
20
|
id: str
|
|
17
21
|
file: Path
|
|
@@ -19,6 +23,7 @@ class Panel(BaseModel):
|
|
|
19
23
|
y: float
|
|
20
24
|
width: float
|
|
21
25
|
height: Optional[float] = None # If None, compute from aspect ratio
|
|
26
|
+
fit: FitMode = "contain"
|
|
22
27
|
label: Optional[str] = None
|
|
23
28
|
label_style: Optional[LabelStyle] = None
|
|
24
29
|
|
|
@@ -29,6 +34,7 @@ class Panel(BaseModel):
|
|
|
29
34
|
# but we could add it if desired. The parser/logic will check it.
|
|
30
35
|
return v
|
|
31
36
|
|
|
37
|
+
|
|
32
38
|
class Page(BaseModel):
|
|
33
39
|
width: float
|
|
34
40
|
height: float
|
|
@@ -37,6 +43,7 @@ class Page(BaseModel):
|
|
|
37
43
|
background: Optional[str] = "white"
|
|
38
44
|
label: LabelStyle = Field(default_factory=LabelStyle)
|
|
39
45
|
|
|
46
|
+
|
|
40
47
|
class Layout(BaseModel):
|
|
41
48
|
page: Page
|
|
42
49
|
panels: List[Panel]
|
|
@@ -0,0 +1,70 @@
|
|
|
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}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def calculate_fit(
|
|
29
|
+
src_aspect: float, cell_w: float, cell_h: float, fit_mode: str
|
|
30
|
+
) -> tuple[float, float, float, float]:
|
|
31
|
+
"""
|
|
32
|
+
Calculate content dimensions and offset based on fit mode.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
src_aspect: Source aspect ratio (height / width)
|
|
36
|
+
cell_w: Cell width in points
|
|
37
|
+
cell_h: Cell height in points
|
|
38
|
+
fit_mode: "contain" or "cover"
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (content_w, content_h, offset_x, offset_y)
|
|
42
|
+
"""
|
|
43
|
+
cell_aspect = cell_h / cell_w
|
|
44
|
+
|
|
45
|
+
if fit_mode == "cover":
|
|
46
|
+
# Scale to cover entire cell (may crop)
|
|
47
|
+
if src_aspect > cell_aspect:
|
|
48
|
+
# Source is taller: scale by width, crop top/bottom
|
|
49
|
+
content_w = cell_w
|
|
50
|
+
content_h = cell_w * src_aspect
|
|
51
|
+
else:
|
|
52
|
+
# Source is wider: scale by height, crop left/right
|
|
53
|
+
content_h = cell_h
|
|
54
|
+
content_w = cell_h / src_aspect
|
|
55
|
+
else: # contain (default)
|
|
56
|
+
# Scale to fit within cell, preserving aspect ratio
|
|
57
|
+
if src_aspect > cell_aspect:
|
|
58
|
+
# Source is taller: fit by height
|
|
59
|
+
content_h = cell_h
|
|
60
|
+
content_w = cell_h / src_aspect
|
|
61
|
+
else:
|
|
62
|
+
# Source is wider: fit by width
|
|
63
|
+
content_w = cell_w
|
|
64
|
+
content_h = cell_w * src_aspect
|
|
65
|
+
|
|
66
|
+
# Center in cell
|
|
67
|
+
offset_x = (cell_w - content_w) / 2
|
|
68
|
+
offset_y = (cell_h - content_h) / 2
|
|
69
|
+
|
|
70
|
+
return content_w, content_h, offset_x, offset_y
|
|
@@ -1,25 +0,0 @@
|
|
|
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}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|