figquilt 0.1.6__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.
@@ -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
- import fitz
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
- from .errors import FigQuiltError
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
- self.layout = layout
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
- # Draw background if specified
26
- if self.layout.page.background:
27
- # simple color parsing: strict names or hex?
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(doc, page, panel, index=i)
33
+ self._place_panel(page, panel, index=i)
43
34
 
44
35
  return doc
45
36
 
46
- def _parse_color(self, color_str: str):
47
- # Very basic hex support
48
- if color_str.startswith("#"):
49
- h = color_str.lstrip("#")
50
- try:
51
- rgb = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
52
- return rgb
53
- except:
54
- return None
55
- # Basic name support mapping could be added here
56
- # For now, just support hex or fallback to None (skip)
57
- # Using PIL ImageColor is an option if we import it, but we want minimal dep for this file?
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
- # Get source dimension
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
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
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 + content_w,
113
- y + offset_y + content_h,
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
- if src_doc.is_pdf:
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
- src_doc.close()
61
+ source_info.doc.close()
131
62
 
132
- # Labels
133
63
  self._draw_label(page, panel, rect, index)
134
64
 
135
- def _draw_label(self, page: fitz.Page, panel: Panel, rect: fitz.Rect, index: int):
136
- # Determine effective label settings
137
- style = panel.label_style if panel.label_style else self.layout.page.label
138
-
139
- if not style.enabled:
140
- return
141
-
142
- text = panel.label
143
- if text is None and style.auto_sequence:
144
- text = chr(65 + index) # A, B, C...
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 style.uppercase:
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
- raw_y = rect.y0 + to_pt(style.offset_y, self.units)
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
- # Font - PyMuPDF supports base 14 fonts by name
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
- from pathlib import Path
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
- 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
- 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
- # SVG uses "in" for inches, "pt" for points, "mm" for millimeters
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
- if self.layout.page.background:
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
- # Get panels (resolves grid layout if needed)
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 _place_panel(self, root: etree.Element, panel: Panel, index: int):
52
- # Offset by page margin
53
- x = to_pt(panel.x, self.units) + self.margin_pt
54
- y = to_pt(panel.y, self.units) + self.margin_pt
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
- if panel.height is not None:
70
- h = to_pt(panel.height, self.units)
71
- else:
72
- h = w * src_aspect
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
- # Calculate content dimensions using fit mode
75
- from .units import calculate_fit
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
- content_w, content_h, offset_x, offset_y = calculate_fit(
78
- src_aspect, w, h, panel.fit
79
- )
55
+ try:
56
+ content_rect = self.calculate_content_rect(panel, source_info.aspect_ratio)
80
57
 
81
- # Group for the panel
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
- # For cover mode, add a clip path to crop the overflow
62
+ # Set up clipping for cover mode
63
+ clip_id = None
86
64
  if panel.fit == "cover":
87
- clip_id = f"clip-{panel.id}"
88
- defs = etree.SubElement(g, "defs")
89
- clip_path = etree.SubElement(defs, "clipPath")
90
- clip_path.set("id", clip_id)
91
- clip_rect = etree.SubElement(clip_path, "rect")
92
- clip_rect.set("x", "0")
93
- clip_rect.set("y", "0")
94
- clip_rect.set("width", str(w))
95
- clip_rect.set("height", str(h))
96
-
97
- # Insert content
98
- # Check if SVG
99
- suffix = panel.file.suffix.lower()
100
- if suffix == ".svg":
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
- src_doc.close()
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
- parent: etree.Element,
162
- panel: Panel,
163
- content_w: float,
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
- if style.uppercase:
179
- text_str = text_str.upper()
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
- # Baseline alignment? SVG text y is usually baseline.
199
- # If we want top-left of text at (x,y), we should adjust or use dominant-baseline.
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/grid.py CHANGED
@@ -58,6 +58,7 @@ def _resolve_node(
58
58
  width=width,
59
59
  height=height,
60
60
  fit=node.fit,
61
+ align=node.align,
61
62
  label=node.label,
62
63
  label_style=node.label_style,
63
64
  )
figquilt/layout.py CHANGED
@@ -4,6 +4,17 @@ from pathlib import Path
4
4
  from pydantic import BaseModel, Field, field_validator, model_validator
5
5
 
6
6
  FitMode = Literal["contain", "cover"]
7
+ Alignment = Literal[
8
+ "center",
9
+ "top",
10
+ "bottom",
11
+ "left",
12
+ "right",
13
+ "top-left",
14
+ "top-right",
15
+ "bottom-left",
16
+ "bottom-right",
17
+ ]
7
18
 
8
19
 
9
20
  class LabelStyle(BaseModel):
@@ -38,6 +49,10 @@ class Panel(BaseModel):
38
49
  "contain",
39
50
  description="How to fit the figure: contain (preserve aspect) or cover (fill and clip)",
40
51
  )
52
+ align: Alignment = Field(
53
+ "center",
54
+ description="How to align content within cell when using contain fit mode",
55
+ )
41
56
  label: Optional[str] = Field(
42
57
  None, description="Override the auto-generated label text"
43
58
  )
@@ -76,6 +91,9 @@ class LayoutNode(BaseModel):
76
91
  None, description="Path to source file (PDF, SVG, or PNG)"
77
92
  )
78
93
  fit: FitMode = Field("contain", description="How to fit the figure in its cell")
94
+ align: Alignment = Field(
95
+ "center", description="How to align content within cell when using contain fit"
96
+ )
79
97
  label: Optional[str] = Field(
80
98
  None, description="Override the auto-generated label text"
81
99
  )
figquilt/units.py CHANGED
@@ -20,17 +20,38 @@ 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
- src_aspect: float, cell_w: float, cell_h: float, fit_mode: str
39
+ src_aspect: float,
40
+ cell_w: float,
41
+ cell_h: float,
42
+ fit_mode: str,
43
+ align: str = "center",
25
44
  ) -> tuple[float, float, float, float]:
26
45
  """
27
- Calculate content dimensions and offset based on fit mode.
46
+ Calculate content dimensions and offset based on fit mode and alignment.
28
47
 
29
48
  Args:
30
49
  src_aspect: Source aspect ratio (height / width)
31
50
  cell_w: Cell width in points
32
51
  cell_h: Cell height in points
33
52
  fit_mode: "contain" or "cover"
53
+ align: Alignment within cell (center, top, bottom, left, right,
54
+ top-left, top-right, bottom-left, bottom-right)
34
55
 
35
56
  Returns:
36
57
  Tuple of (content_w, content_h, offset_x, offset_y)
@@ -58,8 +79,11 @@ def calculate_fit(
58
79
  content_w = cell_w
59
80
  content_h = cell_w * src_aspect
60
81
 
61
- # Center in cell
62
- offset_x = (cell_w - content_w) / 2
63
- offset_y = (cell_h - content_h) / 2
82
+ # Calculate offsets based on alignment using lookup table
83
+ space_x = cell_w - content_w
84
+ space_y = cell_h - content_h
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
64
88
 
65
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.6
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.12
2
+ Generator: uv 0.8.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,14 +0,0 @@
1
- figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
- figquilt/cli.py,sha256=04af949a9e93cc7fdc2ded5c892cf06356d44903c1c4fbfdea2a4a10429616de,7062
3
- figquilt/compose_pdf.py,sha256=20ac74b06df83f7db019a3686e8a5318c8744599c16100fd818177c60c2597e8,6401
4
- figquilt/compose_svg.py,sha256=8e8b65725ef37e6a66cff625ac34be0c3e87d263f9e03ce6b1fb1456f99d0754,7656
5
- figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
6
- figquilt/grid.py,sha256=b56aa9fabe023e9c9447d4d13c573a7ad64a60a1de38435fceb4429ecfaed00f,3050
7
- figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
8
- figquilt/layout.py,sha256=c7c65eeacf1179ee25dcbac05d729ea0a1102ed6abffa7ee401f63099958aa61,6028
9
- figquilt/parser.py,sha256=9658c73e9964f2060afd01da4123aa0063c8062d382443c1e1caac79e7ebf648,4029
10
- figquilt/units.py,sha256=9f87b73643e39b54f1bd1011a8118d570ebab03c1bb4526169bbf6457e5a2b43,2020
11
- figquilt-0.1.6.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
12
- figquilt-0.1.6.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
13
- figquilt-0.1.6.dist-info/METADATA,sha256=6f99e38c83ed059062ef8c3e79ab5ff233273930dd4d376ca01bb2e1668047d7,6217
14
- figquilt-0.1.6.dist-info/RECORD,,