figquilt 0.1.7__py3-none-any.whl → 0.1.8__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/base_composer.py +163 -0
- figquilt/compose_pdf.py +64 -135
- figquilt/compose_svg.py +110 -144
- figquilt/units.py +19 -34
- {figquilt-0.1.7.dist-info → figquilt-0.1.8.dist-info}/METADATA +1 -1
- figquilt-0.1.8.dist-info/RECORD +15 -0
- {figquilt-0.1.7.dist-info → figquilt-0.1.8.dist-info}/WHEEL +1 -1
- figquilt-0.1.7.dist-info/RECORD +0 -14
- {figquilt-0.1.7.dist-info → figquilt-0.1.8.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Base class for figure composers with shared functionality."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
import fitz
|
|
8
|
+
|
|
9
|
+
from .errors import FigQuiltError
|
|
10
|
+
from .grid import resolve_layout
|
|
11
|
+
from .layout import Layout, Panel
|
|
12
|
+
from .units import calculate_fit, to_pt
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceInfo(NamedTuple):
|
|
16
|
+
"""Information about a source file."""
|
|
17
|
+
|
|
18
|
+
doc: fitz.Document
|
|
19
|
+
aspect_ratio: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContentRect(NamedTuple):
|
|
23
|
+
"""Computed content rectangle within a cell."""
|
|
24
|
+
|
|
25
|
+
x: float
|
|
26
|
+
y: float
|
|
27
|
+
width: float
|
|
28
|
+
height: float
|
|
29
|
+
offset_x: float
|
|
30
|
+
offset_y: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseComposer(ABC):
|
|
34
|
+
"""Base class for PDF and SVG composers with shared initialization and helpers."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, layout: Layout):
|
|
37
|
+
self.layout = layout
|
|
38
|
+
self.units = layout.page.units
|
|
39
|
+
self.width_pt = to_pt(layout.page.width, self.units)
|
|
40
|
+
self.height_pt = to_pt(layout.page.height, self.units)
|
|
41
|
+
self.margin_pt = to_pt(layout.page.margin, self.units)
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def compose(self, output_path: Path) -> None:
|
|
45
|
+
"""Compose the layout and write to output file."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def get_panels(self) -> list[Panel]:
|
|
49
|
+
"""Resolve and return the list of panels from the layout."""
|
|
50
|
+
return resolve_layout(self.layout)
|
|
51
|
+
|
|
52
|
+
def open_source(self, panel: Panel) -> SourceInfo:
|
|
53
|
+
"""
|
|
54
|
+
Open a source file and return its document and aspect ratio.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
panel: Panel containing the file path
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
SourceInfo with the opened document and aspect ratio
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
FigQuiltError: If the file cannot be opened
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
src_doc = fitz.open(panel.file)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise FigQuiltError(f"Failed to open panel file {panel.file}: {e}")
|
|
69
|
+
|
|
70
|
+
src_page = src_doc[0]
|
|
71
|
+
src_rect = src_page.rect
|
|
72
|
+
aspect_ratio = src_rect.height / src_rect.width
|
|
73
|
+
|
|
74
|
+
return SourceInfo(doc=src_doc, aspect_ratio=aspect_ratio)
|
|
75
|
+
|
|
76
|
+
def calculate_content_rect(self, panel: Panel, src_aspect: float) -> ContentRect:
|
|
77
|
+
"""
|
|
78
|
+
Calculate the content rectangle for a panel.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
panel: Panel with position and size
|
|
82
|
+
src_aspect: Source aspect ratio (height / width)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
ContentRect with position, dimensions, and offsets
|
|
86
|
+
"""
|
|
87
|
+
x = to_pt(panel.x, self.units) + self.margin_pt
|
|
88
|
+
y = to_pt(panel.y, self.units) + self.margin_pt
|
|
89
|
+
w = to_pt(panel.width, self.units)
|
|
90
|
+
|
|
91
|
+
if panel.height is not None:
|
|
92
|
+
h = to_pt(panel.height, self.units)
|
|
93
|
+
else:
|
|
94
|
+
h = w * src_aspect
|
|
95
|
+
|
|
96
|
+
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
97
|
+
src_aspect, w, h, panel.fit, panel.align
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return ContentRect(
|
|
101
|
+
x=x,
|
|
102
|
+
y=y,
|
|
103
|
+
width=content_w,
|
|
104
|
+
height=content_h,
|
|
105
|
+
offset_x=offset_x,
|
|
106
|
+
offset_y=offset_y,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def get_label_text(self, panel: Panel, index: int) -> str | None:
|
|
110
|
+
"""
|
|
111
|
+
Get the label text for a panel.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
panel: Panel with optional label override
|
|
115
|
+
index: Panel index for auto-sequencing
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Label text or None if labels are disabled
|
|
119
|
+
"""
|
|
120
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
121
|
+
|
|
122
|
+
if not style.enabled:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
text = panel.label
|
|
126
|
+
if text is None and style.auto_sequence:
|
|
127
|
+
text = chr(65 + index) # A, B, C...
|
|
128
|
+
|
|
129
|
+
if not text:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
if style.uppercase:
|
|
133
|
+
text = text.upper()
|
|
134
|
+
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
def parse_color(self, color_str: str) -> tuple[float, float, float] | None:
|
|
138
|
+
"""
|
|
139
|
+
Parse a color string to RGB tuple (0-1 range).
|
|
140
|
+
|
|
141
|
+
Supports hex colors (#rrggbb) and CSS color names via PIL.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
color_str: Color string (e.g., "#f0f0f0", "white")
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
RGB tuple with values 0-1, or None if parsing fails
|
|
148
|
+
"""
|
|
149
|
+
if color_str.startswith("#"):
|
|
150
|
+
h = color_str.lstrip("#")
|
|
151
|
+
try:
|
|
152
|
+
rgb = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
|
153
|
+
return rgb # type: ignore
|
|
154
|
+
except (ValueError, IndexError):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
from PIL import ImageColor
|
|
159
|
+
|
|
160
|
+
rgb = ImageColor.getrgb(color_str)
|
|
161
|
+
return tuple(c / 255.0 for c in rgb) # type: ignore
|
|
162
|
+
except (ValueError, ImportError):
|
|
163
|
+
return None
|
figquilt/compose_pdf.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
"""PDF composer using PyMuPDF (fitz)."""
|
|
2
|
+
|
|
2
3
|
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import fitz
|
|
6
|
+
|
|
7
|
+
from .base_composer import BaseComposer
|
|
3
8
|
from .layout import Layout, Panel
|
|
4
9
|
from .units import to_pt
|
|
5
|
-
|
|
10
|
+
|
|
11
|
+
# PyMuPDF font name mappings
|
|
12
|
+
_FONT_REGULAR = "helv" # Helvetica
|
|
13
|
+
_FONT_BOLD = "HeBo" # Helvetica-Bold
|
|
6
14
|
|
|
7
15
|
|
|
8
|
-
class PDFComposer:
|
|
16
|
+
class PDFComposer(BaseComposer):
|
|
9
17
|
def __init__(self, layout: Layout):
|
|
10
|
-
|
|
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)
|
|
14
|
-
self.margin_pt = to_pt(layout.page.margin, self.units)
|
|
18
|
+
super().__init__(layout)
|
|
15
19
|
|
|
16
|
-
def compose(self, output_path: Path):
|
|
20
|
+
def compose(self, output_path: Path) -> None:
|
|
17
21
|
doc = self.build()
|
|
18
22
|
doc.save(str(output_path))
|
|
19
23
|
doc.close()
|
|
@@ -22,152 +26,77 @@ class PDFComposer:
|
|
|
22
26
|
doc = fitz.open()
|
|
23
27
|
page = doc.new_page(width=self.width_pt, height=self.height_pt)
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# PyMuPDF draw_rect color expects sequence of floats (0..1) or nothing.
|
|
29
|
-
# We need to robustly parse the color string from layout (e.g. "white", "#f0f0f0").
|
|
30
|
-
# fitz.utils.getColor? No, fitz doesn't have a robust color parser built-in for all CSS names.
|
|
31
|
-
# But the user example uses "#f0f0f0".
|
|
32
|
-
# Minimal hex parser:
|
|
33
|
-
col = self._parse_color(self.layout.page.background)
|
|
34
|
-
if col:
|
|
35
|
-
page.draw_rect(page.rect, color=col, fill=col)
|
|
36
|
-
|
|
37
|
-
# Get panels (resolves grid layout if needed)
|
|
38
|
-
from .grid import resolve_layout
|
|
39
|
-
|
|
40
|
-
panels = resolve_layout(self.layout)
|
|
29
|
+
self._draw_background(page)
|
|
30
|
+
|
|
31
|
+
panels = self.get_panels()
|
|
41
32
|
for i, panel in enumerate(panels):
|
|
42
|
-
self._place_panel(
|
|
33
|
+
self._place_panel(page, panel, index=i)
|
|
43
34
|
|
|
44
35
|
return doc
|
|
45
36
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
if
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# We process images, so PIL is available.
|
|
59
|
-
try:
|
|
60
|
-
from PIL import ImageColor
|
|
61
|
-
|
|
62
|
-
rgb = ImageColor.getrgb(color_str)
|
|
63
|
-
return tuple(c / 255.0 for c in rgb)
|
|
64
|
-
except:
|
|
65
|
-
return None
|
|
66
|
-
|
|
67
|
-
def _place_panel(
|
|
68
|
-
self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
|
|
69
|
-
):
|
|
70
|
-
# Calculate position and size first, offset by page margin
|
|
71
|
-
x = to_pt(panel.x, self.units) + self.margin_pt
|
|
72
|
-
y = to_pt(panel.y, self.units) + self.margin_pt
|
|
73
|
-
w = to_pt(panel.width, self.units)
|
|
74
|
-
|
|
75
|
-
# Determine height from aspect ratio if needed
|
|
76
|
-
# We need to open the source to get aspect ratio
|
|
77
|
-
try:
|
|
78
|
-
# fitz.open can handle PDF, PNG, JPEG, SVG...
|
|
79
|
-
src_doc = fitz.open(panel.file)
|
|
80
|
-
except Exception as e:
|
|
81
|
-
raise FigQuiltError(f"Failed to open panel file {panel.file}: {e}")
|
|
37
|
+
def _draw_background(self, page: fitz.Page) -> None:
|
|
38
|
+
"""Draw background color if specified."""
|
|
39
|
+
if not self.layout.page.background:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
col = self.parse_color(self.layout.page.background)
|
|
43
|
+
if col:
|
|
44
|
+
page.draw_rect(page.rect, color=col, fill=col)
|
|
45
|
+
|
|
46
|
+
def _place_panel(self, page: fitz.Page, panel: Panel, index: int) -> None:
|
|
47
|
+
"""Place a panel on the page."""
|
|
48
|
+
source_info = self.open_source(panel)
|
|
82
49
|
|
|
83
50
|
try:
|
|
84
|
-
|
|
85
|
-
if src_doc.is_pdf:
|
|
86
|
-
src_page = src_doc[0]
|
|
87
|
-
src_rect = src_page.rect
|
|
88
|
-
else:
|
|
89
|
-
# For images/SVG, fitz doc acts like a list of pages too?
|
|
90
|
-
# Yes, usually page[0] is the image/svg content.
|
|
91
|
-
src_page = src_doc[0]
|
|
92
|
-
src_rect = src_page.rect
|
|
93
|
-
|
|
94
|
-
src_aspect = src_rect.height / src_rect.width
|
|
95
|
-
|
|
96
|
-
# Calculate cell height
|
|
97
|
-
if panel.height is not None:
|
|
98
|
-
h = to_pt(panel.height, self.units)
|
|
99
|
-
else:
|
|
100
|
-
# No height specified: use source aspect ratio
|
|
101
|
-
h = w * src_aspect
|
|
102
|
-
|
|
103
|
-
# Calculate content rect using fit mode and alignment
|
|
104
|
-
from .units import calculate_fit
|
|
105
|
-
|
|
106
|
-
content_w, content_h, offset_x, offset_y = calculate_fit(
|
|
107
|
-
src_aspect, w, h, panel.fit, panel.align
|
|
108
|
-
)
|
|
51
|
+
content_rect = self.calculate_content_rect(panel, source_info.aspect_ratio)
|
|
109
52
|
rect = fitz.Rect(
|
|
110
|
-
x + offset_x,
|
|
111
|
-
y + offset_y,
|
|
112
|
-
x + offset_x +
|
|
113
|
-
y + offset_y +
|
|
53
|
+
content_rect.x + content_rect.offset_x,
|
|
54
|
+
content_rect.y + content_rect.offset_y,
|
|
55
|
+
content_rect.x + content_rect.offset_x + content_rect.width,
|
|
56
|
+
content_rect.y + content_rect.offset_y + content_rect.height,
|
|
114
57
|
)
|
|
115
58
|
|
|
116
|
-
|
|
117
|
-
page.show_pdf_page(rect, src_doc, 0)
|
|
118
|
-
elif panel.file.suffix.lower() == ".svg":
|
|
119
|
-
# Convert SVG to PDF in memory to allow vector embedding
|
|
120
|
-
pdf_bytes = src_doc.convert_to_pdf()
|
|
121
|
-
src_pdf = fitz.open("pdf", pdf_bytes)
|
|
122
|
-
try:
|
|
123
|
-
page.show_pdf_page(rect, src_pdf, 0)
|
|
124
|
-
finally:
|
|
125
|
-
src_pdf.close()
|
|
126
|
-
else:
|
|
127
|
-
# Insert as image (works for PNG/JPEG)
|
|
128
|
-
page.insert_image(rect, filename=panel.file)
|
|
59
|
+
self._embed_content(page, rect, source_info.doc, panel)
|
|
129
60
|
finally:
|
|
130
|
-
|
|
61
|
+
source_info.doc.close()
|
|
131
62
|
|
|
132
|
-
# Labels
|
|
133
63
|
self._draw_label(page, panel, rect, index)
|
|
134
64
|
|
|
135
|
-
def
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
65
|
+
def _embed_content(
|
|
66
|
+
self, page: fitz.Page, rect: fitz.Rect, src_doc: fitz.Document, panel: Panel
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Embed the source content into the page at the given rect."""
|
|
69
|
+
if src_doc.is_pdf:
|
|
70
|
+
page.show_pdf_page(rect, src_doc, 0)
|
|
71
|
+
elif panel.file.suffix.lower() == ".svg":
|
|
72
|
+
# Convert SVG to PDF in memory for vector preservation
|
|
73
|
+
pdf_bytes = src_doc.convert_to_pdf()
|
|
74
|
+
src_pdf = fitz.open("pdf", pdf_bytes)
|
|
75
|
+
try:
|
|
76
|
+
page.show_pdf_page(rect, src_pdf, 0)
|
|
77
|
+
finally:
|
|
78
|
+
src_pdf.close()
|
|
79
|
+
else:
|
|
80
|
+
# Insert as image (works for PNG/JPEG)
|
|
81
|
+
page.insert_image(rect, filename=panel.file)
|
|
82
|
+
|
|
83
|
+
def _draw_label(
|
|
84
|
+
self, page: fitz.Page, panel: Panel, rect: fitz.Rect, index: int
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Draw the label for a panel."""
|
|
87
|
+
text = self.get_label_text(panel, index)
|
|
146
88
|
if not text:
|
|
147
89
|
return
|
|
148
90
|
|
|
149
|
-
if
|
|
150
|
-
text = text.upper()
|
|
151
|
-
|
|
152
|
-
# Position logic
|
|
153
|
-
# Design doc: offset is relative to top-left.
|
|
154
|
-
# SVG implementation uses 'hanging' baseline, so (0,0) is top-left of text char.
|
|
155
|
-
# PyMuPDF insert_text uses 'baseline', so (0,0) is bottom-left of text char.
|
|
156
|
-
# We need to shift Y down by approximately the font sizing to match SVG visual.
|
|
91
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
157
92
|
|
|
93
|
+
# Position: offset relative to top-left of content rect
|
|
94
|
+
# PyMuPDF uses baseline positioning, so add font_size to Y
|
|
158
95
|
pos_x = rect.x0 + to_pt(style.offset_x, self.units)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# Approximate baseline shift: font_size
|
|
162
|
-
# (A more precise way uses font.ascender, but for basic standard fonts, size is decent proxy for visual top->baseline)
|
|
163
|
-
pos_y = raw_y + style.font_size_pt
|
|
96
|
+
pos_y = rect.y0 + to_pt(style.offset_y, self.units) + style.font_size_pt
|
|
164
97
|
|
|
165
|
-
|
|
166
|
-
fontname = "helv" # default mapping for Helvetica
|
|
167
|
-
if style.bold:
|
|
168
|
-
fontname = "HeBo" # Helvetica-Bold
|
|
98
|
+
fontname = _FONT_BOLD if style.bold else _FONT_REGULAR
|
|
169
99
|
|
|
170
|
-
# Insert text
|
|
171
100
|
page.insert_text(
|
|
172
101
|
(pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
|
|
173
102
|
)
|
figquilt/compose_svg.py
CHANGED
|
@@ -1,200 +1,166 @@
|
|
|
1
|
-
|
|
1
|
+
"""SVG composer using lxml."""
|
|
2
|
+
|
|
2
3
|
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
3
6
|
from lxml import etree
|
|
7
|
+
|
|
8
|
+
from .base_composer import BaseComposer
|
|
4
9
|
from .layout import Layout, Panel
|
|
5
10
|
from .units import to_pt
|
|
6
|
-
from .errors import FigQuiltError
|
|
7
|
-
import fitz
|
|
8
11
|
|
|
9
12
|
|
|
10
|
-
class SVGComposer:
|
|
13
|
+
class SVGComposer(BaseComposer):
|
|
11
14
|
def __init__(self, layout: Layout):
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
self.height_pt = to_pt(layout.page.height, self.units)
|
|
16
|
-
self.margin_pt = to_pt(layout.page.margin, self.units)
|
|
17
|
-
|
|
18
|
-
def compose(self, output_path: Path):
|
|
19
|
-
# Create root SVG element
|
|
15
|
+
super().__init__(layout)
|
|
16
|
+
|
|
17
|
+
def compose(self, output_path: Path) -> None:
|
|
20
18
|
nsmap = {
|
|
21
19
|
None: "http://www.w3.org/2000/svg",
|
|
22
20
|
"xlink": "http://www.w3.org/1999/xlink",
|
|
23
21
|
}
|
|
24
22
|
root = etree.Element("svg", nsmap=nsmap)
|
|
25
|
-
|
|
23
|
+
|
|
24
|
+
# Set SVG dimensions
|
|
26
25
|
svg_unit = "in" if self.units == "inches" else self.units
|
|
27
26
|
root.set("width", f"{self.layout.page.width}{svg_unit}")
|
|
28
27
|
root.set("height", f"{self.layout.page.height}{svg_unit}")
|
|
29
28
|
root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
|
|
30
29
|
root.set("version", "1.1")
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
# Draw background
|
|
34
|
-
bg = etree.SubElement(root, "rect")
|
|
35
|
-
bg.set("width", "100%")
|
|
36
|
-
bg.set("height", "100%")
|
|
37
|
-
bg.set("fill", self.layout.page.background)
|
|
31
|
+
self._draw_background(root)
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
from .grid import resolve_layout
|
|
41
|
-
|
|
42
|
-
panels = resolve_layout(self.layout)
|
|
33
|
+
panels = self.get_panels()
|
|
43
34
|
for i, panel in enumerate(panels):
|
|
44
35
|
self._place_panel(root, panel, i)
|
|
45
36
|
|
|
46
|
-
# Write to file
|
|
47
37
|
tree = etree.ElementTree(root)
|
|
48
38
|
with open(output_path, "wb") as f:
|
|
49
39
|
tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
|
|
50
40
|
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
w = to_pt(panel.width, self.units)
|
|
56
|
-
|
|
57
|
-
# Determine content sizing
|
|
58
|
-
# For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
|
|
59
|
-
try:
|
|
60
|
-
src_doc = fitz.open(panel.file)
|
|
61
|
-
except Exception as e:
|
|
62
|
-
raise FigQuiltError(f"Failed to inspect panel {panel.file}: {e}")
|
|
63
|
-
|
|
64
|
-
try:
|
|
65
|
-
src_page = src_doc[0]
|
|
66
|
-
src_rect = src_page.rect
|
|
67
|
-
src_aspect = src_rect.height / src_rect.width
|
|
41
|
+
def _draw_background(self, root: etree.Element) -> None:
|
|
42
|
+
"""Draw background color if specified."""
|
|
43
|
+
if not self.layout.page.background:
|
|
44
|
+
return
|
|
68
45
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
bg = etree.SubElement(root, "rect")
|
|
47
|
+
bg.set("width", "100%")
|
|
48
|
+
bg.set("height", "100%")
|
|
49
|
+
bg.set("fill", self.layout.page.background)
|
|
73
50
|
|
|
74
|
-
|
|
75
|
-
|
|
51
|
+
def _place_panel(self, root: etree.Element, panel: Panel, index: int) -> None:
|
|
52
|
+
"""Place a panel on the SVG."""
|
|
53
|
+
source_info = self.open_source(panel)
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
)
|
|
55
|
+
try:
|
|
56
|
+
content_rect = self.calculate_content_rect(panel, source_info.aspect_ratio)
|
|
80
57
|
|
|
81
|
-
#
|
|
58
|
+
# Create group for the panel
|
|
82
59
|
g = etree.SubElement(root, "g")
|
|
83
|
-
g.set("transform", f"translate({x}, {y})")
|
|
60
|
+
g.set("transform", f"translate({content_rect.x}, {content_rect.y})")
|
|
84
61
|
|
|
85
|
-
#
|
|
62
|
+
# Set up clipping for cover mode
|
|
63
|
+
clip_id = None
|
|
86
64
|
if panel.fit == "cover":
|
|
87
|
-
clip_id =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# Embed SVG by creating an <image> tag with data URI to avoid DOM conflicts
|
|
102
|
-
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
103
|
-
img = etree.SubElement(g, "image")
|
|
104
|
-
img.set("x", str(offset_x))
|
|
105
|
-
img.set("y", str(offset_y))
|
|
106
|
-
img.set("width", str(content_w))
|
|
107
|
-
img.set("height", str(content_h))
|
|
108
|
-
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
109
|
-
if panel.fit == "cover":
|
|
110
|
-
img.set("clip-path", f"url(#{clip_id})")
|
|
111
|
-
else:
|
|
112
|
-
# PDF or Raster Image
|
|
113
|
-
# For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
|
|
114
|
-
# Browsers don't support image/pdf in SVG.
|
|
115
|
-
# If PNG/JPG, embed directly.
|
|
116
|
-
|
|
117
|
-
mime = "image/png"
|
|
118
|
-
if suffix in [".jpg", ".jpeg"]:
|
|
119
|
-
mime = "image/jpeg"
|
|
120
|
-
data_path = panel.file
|
|
121
|
-
elif suffix == ".png":
|
|
122
|
-
mime = "image/png"
|
|
123
|
-
data_path = panel.file
|
|
124
|
-
elif suffix == ".pdf":
|
|
125
|
-
# Rasterize page to PNG
|
|
126
|
-
pix = src_page.get_pixmap(dpi=300)
|
|
127
|
-
data = pix.tobytes("png")
|
|
128
|
-
b64 = base64.b64encode(data).decode("utf-8")
|
|
129
|
-
data_uri = f"data:image/png;base64,{b64}"
|
|
130
|
-
data_path = None # signal that we have URI
|
|
131
|
-
else:
|
|
132
|
-
# Fallback
|
|
133
|
-
mime = "application/octet-stream"
|
|
134
|
-
data_path = panel.file
|
|
135
|
-
|
|
136
|
-
if data_path:
|
|
137
|
-
data_uri = self._get_data_uri(data_path, mime)
|
|
138
|
-
|
|
139
|
-
img = etree.SubElement(g, "image")
|
|
140
|
-
img.set("x", str(offset_x))
|
|
141
|
-
img.set("y", str(offset_y))
|
|
142
|
-
img.set("width", str(content_w))
|
|
143
|
-
img.set("height", str(content_h))
|
|
144
|
-
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
145
|
-
if panel.fit == "cover":
|
|
146
|
-
img.set("clip-path", f"url(#{clip_id})")
|
|
147
|
-
|
|
148
|
-
# Label (positioned relative to content, not cell)
|
|
149
|
-
self._draw_label(g, panel, content_w, content_h, offset_x, offset_y, index)
|
|
65
|
+
clip_id = self._add_clip_path(
|
|
66
|
+
g,
|
|
67
|
+
panel.id,
|
|
68
|
+
to_pt(panel.width, self.units),
|
|
69
|
+
content_rect.height
|
|
70
|
+
if panel.height is None
|
|
71
|
+
else to_pt(panel.height, self.units),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Embed content
|
|
75
|
+
self._embed_content(g, panel, content_rect, source_info.doc[0], clip_id)
|
|
76
|
+
|
|
77
|
+
# Draw label
|
|
78
|
+
self._draw_label(g, panel, content_rect, index)
|
|
150
79
|
finally:
|
|
151
|
-
|
|
80
|
+
source_info.doc.close()
|
|
81
|
+
|
|
82
|
+
def _add_clip_path(
|
|
83
|
+
self, g: etree.Element, panel_id: str, width: float, height: float
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Add a clip path for cover mode and return its ID."""
|
|
86
|
+
clip_id = f"clip-{panel_id}"
|
|
87
|
+
defs = etree.SubElement(g, "defs")
|
|
88
|
+
clip_path = etree.SubElement(defs, "clipPath")
|
|
89
|
+
clip_path.set("id", clip_id)
|
|
90
|
+
clip_rect = etree.SubElement(clip_path, "rect")
|
|
91
|
+
clip_rect.set("x", "0")
|
|
92
|
+
clip_rect.set("y", "0")
|
|
93
|
+
clip_rect.set("width", str(width))
|
|
94
|
+
clip_rect.set("height", str(height))
|
|
95
|
+
return clip_id
|
|
96
|
+
|
|
97
|
+
def _embed_content(
|
|
98
|
+
self,
|
|
99
|
+
g: etree.Element,
|
|
100
|
+
panel: Panel,
|
|
101
|
+
content_rect,
|
|
102
|
+
src_page,
|
|
103
|
+
clip_id: str | None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Embed the source content into the SVG group."""
|
|
106
|
+
suffix = panel.file.suffix.lower()
|
|
107
|
+
|
|
108
|
+
if suffix == ".svg":
|
|
109
|
+
data_uri = self._get_data_uri(panel.file, "image/svg+xml")
|
|
110
|
+
elif suffix in [".jpg", ".jpeg"]:
|
|
111
|
+
data_uri = self._get_data_uri(panel.file, "image/jpeg")
|
|
112
|
+
elif suffix == ".png":
|
|
113
|
+
data_uri = self._get_data_uri(panel.file, "image/png")
|
|
114
|
+
elif suffix == ".pdf":
|
|
115
|
+
# Rasterize PDF page to PNG
|
|
116
|
+
pix = src_page.get_pixmap(dpi=300)
|
|
117
|
+
data = pix.tobytes("png")
|
|
118
|
+
b64 = base64.b64encode(data).decode("utf-8")
|
|
119
|
+
data_uri = f"data:image/png;base64,{b64}"
|
|
120
|
+
else:
|
|
121
|
+
# Fallback for unknown types
|
|
122
|
+
data_uri = self._get_data_uri(panel.file, "application/octet-stream")
|
|
123
|
+
|
|
124
|
+
img = etree.SubElement(g, "image")
|
|
125
|
+
img.set("x", str(content_rect.offset_x))
|
|
126
|
+
img.set("y", str(content_rect.offset_y))
|
|
127
|
+
img.set("width", str(content_rect.width))
|
|
128
|
+
img.set("height", str(content_rect.height))
|
|
129
|
+
img.set("{http://www.w3.org/1999/xlink}href", data_uri)
|
|
130
|
+
|
|
131
|
+
if clip_id:
|
|
132
|
+
img.set("clip-path", f"url(#{clip_id})")
|
|
152
133
|
|
|
153
134
|
def _get_data_uri(self, path: Path, mime: str) -> str:
|
|
135
|
+
"""Read file and encode as data URI."""
|
|
154
136
|
with open(path, "rb") as f:
|
|
155
137
|
data = f.read()
|
|
156
138
|
b64 = base64.b64encode(data).decode("utf-8")
|
|
157
139
|
return f"data:{mime};base64,{b64}"
|
|
158
140
|
|
|
159
141
|
def _draw_label(
|
|
160
|
-
self,
|
|
161
|
-
|
|
162
|
-
panel
|
|
163
|
-
|
|
164
|
-
content_h: float,
|
|
165
|
-
offset_x: float,
|
|
166
|
-
offset_y: float,
|
|
167
|
-
index: int,
|
|
168
|
-
):
|
|
169
|
-
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
170
|
-
if not style.enabled:
|
|
171
|
-
return
|
|
172
|
-
|
|
173
|
-
text_str = panel.label
|
|
174
|
-
if text_str is None and style.auto_sequence:
|
|
175
|
-
text_str = chr(65 + index)
|
|
142
|
+
self, parent: etree.Element, panel: Panel, content_rect, index: int
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Draw the label for a panel."""
|
|
145
|
+
text_str = self.get_label_text(panel, index)
|
|
176
146
|
if not text_str:
|
|
177
147
|
return
|
|
178
|
-
|
|
179
|
-
|
|
148
|
+
|
|
149
|
+
style = panel.label_style if panel.label_style else self.layout.page.label
|
|
180
150
|
|
|
181
151
|
# Offset relative to the content position
|
|
182
|
-
x = offset_x + to_pt(style.offset_x, self.units)
|
|
183
|
-
y = offset_y + to_pt(style.offset_y, self.units)
|
|
152
|
+
x = content_rect.offset_x + to_pt(style.offset_x, self.units)
|
|
153
|
+
y = content_rect.offset_y + to_pt(style.offset_y, self.units)
|
|
184
154
|
|
|
185
|
-
# Create text element
|
|
186
155
|
txt = etree.SubElement(parent, "text")
|
|
187
156
|
txt.text = text_str
|
|
188
157
|
txt.set("x", str(x))
|
|
189
158
|
txt.set("y", str(y))
|
|
190
|
-
|
|
191
|
-
# Style
|
|
192
|
-
# Font family is tricky in SVG (system fonts).
|
|
193
159
|
txt.set("font-family", style.font_family)
|
|
194
160
|
txt.set("font-size", f"{style.font_size_pt}pt")
|
|
161
|
+
|
|
195
162
|
if style.bold:
|
|
196
163
|
txt.set("font-weight", "bold")
|
|
197
164
|
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
txt.set("dominant-baseline", "hanging") # Matches top-down coordinate logic
|
|
165
|
+
# Use hanging baseline so (x, y) is top-left of text
|
|
166
|
+
txt.set("dominant-baseline", "hanging")
|
figquilt/units.py
CHANGED
|
@@ -20,6 +20,21 @@ def to_pt(value: float, units: str) -> float:
|
|
|
20
20
|
raise ValueError(f"Unknown unit: {units}")
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# Alignment offset factors: (horizontal_factor, vertical_factor)
|
|
24
|
+
# Multiply by (space_x, space_y) to get offset
|
|
25
|
+
_ALIGNMENT_OFFSETS: dict[str, tuple[float, float]] = {
|
|
26
|
+
"center": (0.5, 0.5),
|
|
27
|
+
"top": (0.5, 0.0),
|
|
28
|
+
"bottom": (0.5, 1.0),
|
|
29
|
+
"left": (0.0, 0.5),
|
|
30
|
+
"right": (1.0, 0.5),
|
|
31
|
+
"top-left": (0.0, 0.0),
|
|
32
|
+
"top-right": (1.0, 0.0),
|
|
33
|
+
"bottom-left": (0.0, 1.0),
|
|
34
|
+
"bottom-right": (1.0, 1.0),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
23
38
|
def calculate_fit(
|
|
24
39
|
src_aspect: float,
|
|
25
40
|
cell_w: float,
|
|
@@ -64,41 +79,11 @@ def calculate_fit(
|
|
|
64
79
|
content_w = cell_w
|
|
65
80
|
content_h = cell_w * src_aspect
|
|
66
81
|
|
|
67
|
-
# Calculate offsets based on alignment
|
|
82
|
+
# Calculate offsets based on alignment using lookup table
|
|
68
83
|
space_x = cell_w - content_w
|
|
69
84
|
space_y = cell_h - content_h
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
offset_x = space_x / 2
|
|
74
|
-
offset_y = space_y / 2
|
|
75
|
-
elif align == "top":
|
|
76
|
-
offset_x = space_x / 2
|
|
77
|
-
offset_y = 0
|
|
78
|
-
elif align == "bottom":
|
|
79
|
-
offset_x = space_x / 2
|
|
80
|
-
offset_y = space_y
|
|
81
|
-
elif align == "left":
|
|
82
|
-
offset_x = 0
|
|
83
|
-
offset_y = space_y / 2
|
|
84
|
-
elif align == "right":
|
|
85
|
-
offset_x = space_x
|
|
86
|
-
offset_y = space_y / 2
|
|
87
|
-
elif align == "top-left":
|
|
88
|
-
offset_x = 0
|
|
89
|
-
offset_y = 0
|
|
90
|
-
elif align == "top-right":
|
|
91
|
-
offset_x = space_x
|
|
92
|
-
offset_y = 0
|
|
93
|
-
elif align == "bottom-left":
|
|
94
|
-
offset_x = 0
|
|
95
|
-
offset_y = space_y
|
|
96
|
-
elif align == "bottom-right":
|
|
97
|
-
offset_x = space_x
|
|
98
|
-
offset_y = space_y
|
|
99
|
-
else:
|
|
100
|
-
# Unknown alignment, default to center
|
|
101
|
-
offset_x = space_x / 2
|
|
102
|
-
offset_y = space_y / 2
|
|
85
|
+
h_factor, v_factor = _ALIGNMENT_OFFSETS.get(align, (0.5, 0.5))
|
|
86
|
+
offset_x = space_x * h_factor
|
|
87
|
+
offset_y = space_y * v_factor
|
|
103
88
|
|
|
104
89
|
return content_w, content_h, offset_x, offset_y
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: figquilt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
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>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
figquilt/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
figquilt/base_composer.py,sha256=Mk16r7Gpo7M5e3ipHEDWBAbs75pp59f4uiF815d-9YQ,4659
|
|
3
|
+
figquilt/cli.py,sha256=BK-Ump6TzH_cLe1ciSzwY1bUSQPBxPv96ipKEEKWFt4,7062
|
|
4
|
+
figquilt/compose_pdf.py,sha256=u_nrWKo0Z6W8ToXhg5suf-e1tWGQFBPvmUg7sEQ-yPI,3425
|
|
5
|
+
figquilt/compose_svg.py,sha256=qr3-wGeZezZje9SoApLiPdzntDlToD2fvZDK6kW_0TM,5806
|
|
6
|
+
figquilt/errors.py,sha256=b0AB3K6F0hcfeqffQWGSZ3Hb6MIQaMy3CnhlKY8Fzys,298
|
|
7
|
+
figquilt/grid.py,sha256=6Xf7-S4-Kghg9ZjIni8jBx1R5m0sjB59b6s0vXWSVjE,3084
|
|
8
|
+
figquilt/images.py,sha256=xhNlX7OgeQ_KmBgsVYxYTmMqiCIiUiCl_rYIDHxo654,815
|
|
9
|
+
figquilt/layout.py,sha256=492Cwf7sGNhMshvYu2LF3Q_CguRa31-2TLotq4fUNCI,6449
|
|
10
|
+
figquilt/parser.py,sha256=lljHPplk8gYK_QHaQSOqAGPIBi04JEPB4cqseefr9kg,4029
|
|
11
|
+
figquilt/units.py,sha256=hBq3SHvrAgfCinNvGnNDLrRQ0xAGxyGraRmp6UNrWxQ,2786
|
|
12
|
+
figquilt-0.1.8.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
13
|
+
figquilt-0.1.8.dist-info/entry_points.txt,sha256=j3DOB_WFvtKKylaQUsfwApOErGfF5zj66w2usxaVvIU,48
|
|
14
|
+
figquilt-0.1.8.dist-info/METADATA,sha256=TROdXq82uE-gwZzgD25WdlJTw_Uv2YtIdR5Ndzbj-3I,6217
|
|
15
|
+
figquilt-0.1.8.dist-info/RECORD,,
|
figquilt-0.1.7.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
|
|
2
|
-
figquilt/cli.py,sha256=04af949a9e93cc7fdc2ded5c892cf06356d44903c1c4fbfdea2a4a10429616de,7062
|
|
3
|
-
figquilt/compose_pdf.py,sha256=d593a009863abab96dfef54602407e3e73d318ce141277dcc3efaf2324a1c96b,6428
|
|
4
|
-
figquilt/compose_svg.py,sha256=690feedc5788451ed8d30d32a92680956fcb86b7bc9eae7a7ffbf6b5958c3a0c,7683
|
|
5
|
-
figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
|
|
6
|
-
figquilt/grid.py,sha256=e977fbf92e3e2a0860f598c89e2f23071d51e66d2c8c1e7d6fab34bd75925631,3084
|
|
7
|
-
figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
|
|
8
|
-
figquilt/layout.py,sha256=e3dd82c1feec18d84cb21bd8bb62c5dd0fc282e45adf5fb64cba2dab87d43422,6449
|
|
9
|
-
figquilt/parser.py,sha256=9658c73e9964f2060afd01da4123aa0063c8062d382443c1e1caac79e7ebf648,4029
|
|
10
|
-
figquilt/units.py,sha256=2b38d5d7a474211e64588c075943fbcdfc29c98ab7db7dc1c727ea5946351f0e,3093
|
|
11
|
-
figquilt-0.1.7.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
|
|
12
|
-
figquilt-0.1.7.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
|
|
13
|
-
figquilt-0.1.7.dist-info/METADATA,sha256=267f47988e7304d05eb89698bf9989b95971763159df4d0cf9877f1cdbb53905,6217
|
|
14
|
-
figquilt-0.1.7.dist-info/RECORD,,
|
|
File without changes
|