figquilt 0.1.2__py3-none-any.whl → 0.1.4__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/compose_pdf.py CHANGED
@@ -1,15 +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
8
  class PDFComposer:
9
9
  def __init__(self, layout: Layout):
10
10
  self.layout = layout
11
- self.width_pt = mm_to_pt(layout.page.width)
12
- self.height_pt = mm_to_pt(layout.page.height)
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)
13
14
 
14
15
  def compose(self, output_path: Path):
15
16
  doc = self.build()
@@ -63,9 +64,9 @@ class PDFComposer:
63
64
  self, doc: fitz.Document, page: fitz.Page, panel: Panel, index: int
64
65
  ):
65
66
  # Calculate position and size first
66
- x = mm_to_pt(panel.x)
67
- y = mm_to_pt(panel.y)
68
- w = mm_to_pt(panel.width)
67
+ x = to_pt(panel.x, self.units)
68
+ y = to_pt(panel.y, self.units)
69
+ w = to_pt(panel.width, self.units)
69
70
 
70
71
  # Determine height from aspect ratio if needed
71
72
  # We need to open the source to get aspect ratio
@@ -86,14 +87,27 @@ class PDFComposer:
86
87
  src_page = src_doc[0]
87
88
  src_rect = src_page.rect
88
89
 
89
- aspect = src_rect.height / src_rect.width
90
+ src_aspect = src_rect.height / src_rect.width
90
91
 
92
+ # Calculate cell height
91
93
  if panel.height is not None:
92
- h = mm_to_pt(panel.height)
94
+ h = to_pt(panel.height, self.units)
93
95
  else:
94
- h = w * aspect
95
-
96
- rect = fitz.Rect(x, y, x + w, y + h)
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
+ )
97
111
 
98
112
  if src_doc.is_pdf:
99
113
  page.show_pdf_page(rect, src_doc, 0)
figquilt/compose_svg.py CHANGED
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
  import base64
3
3
  from lxml import etree
4
4
  from .layout import Layout, Panel
5
- from .units import mm_to_pt
5
+ from .units import mm_to_pt, to_pt
6
6
  from .errors import FigQuiltError
7
7
  import fitz
8
8
 
@@ -10,8 +10,9 @@ import fitz
10
10
  class SVGComposer:
11
11
  def __init__(self, layout: Layout):
12
12
  self.layout = layout
13
- self.width_pt = mm_to_pt(layout.page.width)
14
- self.height_pt = mm_to_pt(layout.page.height)
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)
15
16
 
16
17
  def compose(self, output_path: Path):
17
18
  # Create root SVG element
@@ -20,8 +21,10 @@ class SVGComposer:
20
21
  "xlink": "http://www.w3.org/1999/xlink",
21
22
  }
22
23
  root = etree.Element("svg", nsmap=nsmap)
23
- root.set("width", f"{self.layout.page.width}mm")
24
- root.set("height", f"{self.layout.page.height}mm")
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}")
25
28
  root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
26
29
  root.set("version", "1.1")
27
30
 
@@ -42,9 +45,9 @@ class SVGComposer:
42
45
  tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
43
46
 
44
47
  def _place_panel(self, root: etree.Element, panel: Panel, index: int):
45
- x = mm_to_pt(panel.x)
46
- y = mm_to_pt(panel.y)
47
- w = mm_to_pt(panel.width)
48
+ x = to_pt(panel.x, self.units)
49
+ y = to_pt(panel.y, self.units)
50
+ w = to_pt(panel.width, self.units)
48
51
 
49
52
  # Determine content sizing
50
53
  # For simplicity in V0, relying on fitz for aspect ratio of all inputs (robust)
@@ -56,30 +59,50 @@ class SVGComposer:
56
59
  try:
57
60
  src_page = src_doc[0]
58
61
  src_rect = src_page.rect
59
- aspect = src_rect.height / src_rect.width
62
+ src_aspect = src_rect.height / src_rect.width
60
63
 
61
64
  if panel.height is not None:
62
- h = mm_to_pt(panel.height)
65
+ h = to_pt(panel.height, self.units)
63
66
  else:
64
- h = w * aspect
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
+ )
65
75
 
66
76
  # Group for the panel
67
77
  g = etree.SubElement(root, "g")
68
78
  g.set("transform", f"translate({x}, {y})")
69
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
+
70
92
  # Insert content
71
93
  # Check if SVG
72
94
  suffix = panel.file.suffix.lower()
73
95
  if suffix == ".svg":
74
96
  # Embed SVG by creating an <image> tag with data URI to avoid DOM conflicts
75
- # This is safer than merging trees for V0.
76
- # Merging trees requires stripping root, handling viewbox/transform matching.
77
- # <image> handles scaling automatically.
78
97
  data_uri = self._get_data_uri(panel.file, "image/svg+xml")
79
98
  img = etree.SubElement(g, "image")
80
- img.set("width", str(w))
81
- img.set("height", str(h))
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))
82
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})")
83
106
  else:
84
107
  # PDF or Raster Image
85
108
  # For PDF, we rasterize to PNG (easiest for SVG compatibility without huge libs)
@@ -109,12 +132,16 @@ class SVGComposer:
109
132
  data_uri = self._get_data_uri(data_path, mime)
110
133
 
111
134
  img = etree.SubElement(g, "image")
112
- img.set("width", str(w))
113
- img.set("height", str(h))
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))
114
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})")
115
142
 
116
- # Label
117
- self._draw_label(g, panel, w, h, index)
143
+ # Label (positioned relative to content, not cell)
144
+ self._draw_label(g, panel, content_w, content_h, offset_x, offset_y, index)
118
145
  finally:
119
146
  src_doc.close()
120
147
 
@@ -125,7 +152,14 @@ class SVGComposer:
125
152
  return f"data:{mime};base64,{b64}"
126
153
 
127
154
  def _draw_label(
128
- self, parent: etree.Element, panel: Panel, w: float, h: float, index: int
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,
129
163
  ):
130
164
  style = panel.label_style if panel.label_style else self.layout.page.label
131
165
  if not style.enabled:
@@ -139,9 +173,9 @@ class SVGComposer:
139
173
  if style.uppercase:
140
174
  text_str = text_str.upper()
141
175
 
142
- # Offset (relative to panel top-left, which is 0,0 inside the group)
143
- x = mm_to_pt(style.offset_x_mm)
144
- 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)
145
179
 
146
180
  # Create text element
147
181
  txt = etree.SubElement(parent, "text")
figquilt/layout.py CHANGED
@@ -1,7 +1,10 @@
1
- from typing import Optional, List, Tuple, Union
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]
figquilt/units.py CHANGED
@@ -2,6 +2,69 @@ def mm_to_pt(mm: float) -> float:
2
2
  """Converts millimeters to points (1 inch = 25.4 mm = 72 pts)."""
3
3
  return mm * 72 / 25.4
4
4
 
5
+
5
6
  def pt_to_mm(pt: float) -> float:
6
7
  """Converts points to millimeters."""
7
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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: figquilt
3
- Version: 0.1.2
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>
@@ -24,9 +24,16 @@ Description-Content-Type: text/markdown
24
24
 
25
25
  # figquilt
26
26
 
27
- **Figure quilter**: A CLI tool to compositing multiple figures (PDF, SVG, PNG) into a publication-ready figure layout.
27
+ **Figure quilter**: A declarative CLI tool for compositing multiple figures (PDF, SVG, PNG) into publication-ready layouts.
28
28
 
29
- `figquilt` takes a simple layout file (YAML) describing panels and their positions, composed of various inputs (plots from R/Python, diagrams, photos), and stitches them into a single output file (PDF, SVG) with precise dimension control and automatic labeling.
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.
30
37
 
31
38
  ## Features
32
39
 
@@ -0,0 +1,13 @@
1
+ figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
+ figquilt/cli.py,sha256=d73f2f97ca86b0d932e5a4dac8596e3137017517e2b7017916920bfe8e36a789,6930
3
+ figquilt/compose_pdf.py,sha256=b9418a415eb52da82b3126501cdded8048083418ba69cb6e9c281f0ecc0ebe0f,6172
4
+ figquilt/compose_svg.py,sha256=3a3499c9805508c38906568e984d46cd391f1432ab2fea21b3fe2be783816c81,7418
5
+ figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
6
+ figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
7
+ figquilt/layout.py,sha256=12a1169f05630ba97b3b29f2d269708b38da697357e5d511e12fad5e484fbfbe,1226
8
+ figquilt/parser.py,sha256=d33d21178721072bbf681be940312b5fda0275f451fca3be2affad707f80a7fb,1065
9
+ figquilt/units.py,sha256=25098b6d7559cb78214fe1a9889fd38ce88633993d0c92819cc609510654275d,2124
10
+ figquilt-0.1.4.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
11
+ figquilt-0.1.4.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
12
+ figquilt-0.1.4.dist-info/METADATA,sha256=a21301d1e2a2d00ae70e43531d0e2145f79cc0cffa93eac07be19fadc39a4463,3330
13
+ figquilt-0.1.4.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- figquilt/__init__.py,sha256=91447944015cec709e8aa7655f7e9d64e1e4508e7023a57fe3746911c0fc6fed,22
2
- figquilt/cli.py,sha256=d73f2f97ca86b0d932e5a4dac8596e3137017517e2b7017916920bfe8e36a789,6930
3
- figquilt/compose_pdf.py,sha256=f05dbd491aa0adfc06f2c5f7fd59fd8a9d42e7c61b28da4043cc24822b25adf6,5600
4
- figquilt/compose_svg.py,sha256=ae102920d4c1d135220cfaee53d9eba925a9543be4552978251cb87cc67504f2,5990
5
- figquilt/errors.py,sha256=6f4001dcae85d2171f7aa7df4161926771dbe8c21068ccb70a7865298f05cf2b,298
6
- figquilt/images.py,sha256=c613655fb3a0790fca98182c558c584e632a8822225220a5feb6080c7c68eb9e,815
7
- figquilt/layout.py,sha256=44514c72f8883afe02f5dd05e674410440dc52c40966a8028f1d9693d4be3364,1159
8
- figquilt/parser.py,sha256=d33d21178721072bbf681be940312b5fda0275f451fca3be2affad707f80a7fb,1065
9
- figquilt/units.py,sha256=0c1b6b3f7380fb8ebf51ae81fb093c1830ae4a240b9151191333a3b0af16cd84,233
10
- figquilt-0.1.2.dist-info/WHEEL,sha256=76443c98c0efcfdd1191eac5fa1d8223dba1c474dbd47676674a255e7ca48770,79
11
- figquilt-0.1.2.dist-info/entry_points.txt,sha256=8f70ce07f585bed28aca569052c7f0029384ac67c5e738faeb0daeb31695bc85,48
12
- figquilt-0.1.2.dist-info/METADATA,sha256=78a83d8a0570b4829292a1410d3cf667a8f7508721826ee807f30f513392fd0b,2783
13
- figquilt-0.1.2.dist-info/RECORD,,