figquilt 0.1.7__tar.gz → 0.1.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: figquilt
3
- Version: 0.1.7
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "figquilt"
3
- version = "0.1.7"
3
+ version = "0.1.8"
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 = [
@@ -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
@@ -0,0 +1,102 @@
1
+ """PDF composer using PyMuPDF (fitz)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import fitz
6
+
7
+ from .base_composer import BaseComposer
8
+ from .layout import Layout, Panel
9
+ from .units import to_pt
10
+
11
+ # PyMuPDF font name mappings
12
+ _FONT_REGULAR = "helv" # Helvetica
13
+ _FONT_BOLD = "HeBo" # Helvetica-Bold
14
+
15
+
16
+ class PDFComposer(BaseComposer):
17
+ def __init__(self, layout: Layout):
18
+ super().__init__(layout)
19
+
20
+ def compose(self, output_path: Path) -> None:
21
+ doc = self.build()
22
+ doc.save(str(output_path))
23
+ doc.close()
24
+
25
+ def build(self) -> fitz.Document:
26
+ doc = fitz.open()
27
+ page = doc.new_page(width=self.width_pt, height=self.height_pt)
28
+
29
+ self._draw_background(page)
30
+
31
+ panels = self.get_panels()
32
+ for i, panel in enumerate(panels):
33
+ self._place_panel(page, panel, index=i)
34
+
35
+ return doc
36
+
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)
49
+
50
+ try:
51
+ content_rect = self.calculate_content_rect(panel, source_info.aspect_ratio)
52
+ rect = fitz.Rect(
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,
57
+ )
58
+
59
+ self._embed_content(page, rect, source_info.doc, panel)
60
+ finally:
61
+ source_info.doc.close()
62
+
63
+ self._draw_label(page, panel, rect, index)
64
+
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)
88
+ if not text:
89
+ return
90
+
91
+ style = panel.label_style if panel.label_style else self.layout.page.label
92
+
93
+ # Position: offset relative to top-left of content rect
94
+ # PyMuPDF uses baseline positioning, so add font_size to Y
95
+ pos_x = rect.x0 + to_pt(style.offset_x, self.units)
96
+ pos_y = rect.y0 + to_pt(style.offset_y, self.units) + style.font_size_pt
97
+
98
+ fontname = _FONT_BOLD if style.bold else _FONT_REGULAR
99
+
100
+ page.insert_text(
101
+ (pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
102
+ )
@@ -0,0 +1,166 @@
1
+ """SVG composer using lxml."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ from lxml import etree
7
+
8
+ from .base_composer import BaseComposer
9
+ from .layout import Layout, Panel
10
+ from .units import to_pt
11
+
12
+
13
+ class SVGComposer(BaseComposer):
14
+ def __init__(self, layout: Layout):
15
+ super().__init__(layout)
16
+
17
+ def compose(self, output_path: Path) -> None:
18
+ nsmap = {
19
+ None: "http://www.w3.org/2000/svg",
20
+ "xlink": "http://www.w3.org/1999/xlink",
21
+ }
22
+ root = etree.Element("svg", nsmap=nsmap)
23
+
24
+ # Set SVG dimensions
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
+ self._draw_background(root)
32
+
33
+ panels = self.get_panels()
34
+ for i, panel in enumerate(panels):
35
+ self._place_panel(root, panel, i)
36
+
37
+ tree = etree.ElementTree(root)
38
+ with open(output_path, "wb") as f:
39
+ tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
40
+
41
+ def _draw_background(self, root: etree.Element) -> None:
42
+ """Draw background color if specified."""
43
+ if not self.layout.page.background:
44
+ return
45
+
46
+ bg = etree.SubElement(root, "rect")
47
+ bg.set("width", "100%")
48
+ bg.set("height", "100%")
49
+ bg.set("fill", self.layout.page.background)
50
+
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)
54
+
55
+ try:
56
+ content_rect = self.calculate_content_rect(panel, source_info.aspect_ratio)
57
+
58
+ # Create group for the panel
59
+ g = etree.SubElement(root, "g")
60
+ g.set("transform", f"translate({content_rect.x}, {content_rect.y})")
61
+
62
+ # Set up clipping for cover mode
63
+ clip_id = None
64
+ if panel.fit == "cover":
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)
79
+ finally:
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})")
133
+
134
+ def _get_data_uri(self, path: Path, mime: str) -> str:
135
+ """Read file and encode as data URI."""
136
+ with open(path, "rb") as f:
137
+ data = f.read()
138
+ b64 = base64.b64encode(data).decode("utf-8")
139
+ return f"data:{mime};base64,{b64}"
140
+
141
+ def _draw_label(
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)
146
+ if not text_str:
147
+ return
148
+
149
+ style = panel.label_style if panel.label_style else self.layout.page.label
150
+
151
+ # Offset relative to the content position
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)
154
+
155
+ txt = etree.SubElement(parent, "text")
156
+ txt.text = text_str
157
+ txt.set("x", str(x))
158
+ txt.set("y", str(y))
159
+ txt.set("font-family", style.font_family)
160
+ txt.set("font-size", f"{style.font_size_pt}pt")
161
+
162
+ if style.bold:
163
+ txt.set("font-weight", "bold")
164
+
165
+ # Use hanging baseline so (x, y) is top-left of text
166
+ txt.set("dominant-baseline", "hanging")
@@ -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
- # Parse alignment
72
- if align == "center":
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,173 +0,0 @@
1
- import fitz
2
- from pathlib import Path
3
- from .layout import Layout, Panel
4
- from .units import to_pt
5
- from .errors import FigQuiltError
6
-
7
-
8
- class PDFComposer:
9
- 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)
15
-
16
- def compose(self, output_path: Path):
17
- doc = self.build()
18
- doc.save(str(output_path))
19
- doc.close()
20
-
21
- def build(self) -> fitz.Document:
22
- doc = fitz.open()
23
- page = doc.new_page(width=self.width_pt, height=self.height_pt)
24
-
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)
41
- for i, panel in enumerate(panels):
42
- self._place_panel(doc, page, panel, index=i)
43
-
44
- return doc
45
-
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}")
82
-
83
- 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 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
- )
109
- rect = fitz.Rect(
110
- x + offset_x,
111
- y + offset_y,
112
- x + offset_x + content_w,
113
- y + offset_y + content_h,
114
- )
115
-
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)
129
- finally:
130
- src_doc.close()
131
-
132
- # Labels
133
- self._draw_label(page, panel, rect, index)
134
-
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
-
146
- if not text:
147
- return
148
-
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.
157
-
158
- 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
164
-
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
169
-
170
- # Insert text
171
- page.insert_text(
172
- (pos_x, pos_y), text, fontsize=style.font_size_pt, fontname=fontname
173
- )
@@ -1,200 +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 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
- self.margin_pt = to_pt(layout.page.margin, self.units)
17
-
18
- def compose(self, output_path: Path):
19
- # Create root SVG element
20
- nsmap = {
21
- None: "http://www.w3.org/2000/svg",
22
- "xlink": "http://www.w3.org/1999/xlink",
23
- }
24
- root = etree.Element("svg", nsmap=nsmap)
25
- # SVG uses "in" for inches, "pt" for points, "mm" for millimeters
26
- svg_unit = "in" if self.units == "inches" else self.units
27
- root.set("width", f"{self.layout.page.width}{svg_unit}")
28
- root.set("height", f"{self.layout.page.height}{svg_unit}")
29
- root.set("viewBox", f"0 0 {self.width_pt} {self.height_pt}")
30
- root.set("version", "1.1")
31
-
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)
38
-
39
- # Get panels (resolves grid layout if needed)
40
- from .grid import resolve_layout
41
-
42
- panels = resolve_layout(self.layout)
43
- for i, panel in enumerate(panels):
44
- self._place_panel(root, panel, i)
45
-
46
- # Write to file
47
- tree = etree.ElementTree(root)
48
- with open(output_path, "wb") as f:
49
- tree.write(f, pretty_print=True, xml_declaration=True, encoding="utf-8")
50
-
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
68
-
69
- if panel.height is not None:
70
- h = to_pt(panel.height, self.units)
71
- else:
72
- h = w * src_aspect
73
-
74
- # Calculate content dimensions using fit mode and alignment
75
- from .units import calculate_fit
76
-
77
- content_w, content_h, offset_x, offset_y = calculate_fit(
78
- src_aspect, w, h, panel.fit, panel.align
79
- )
80
-
81
- # Group for the panel
82
- g = etree.SubElement(root, "g")
83
- g.set("transform", f"translate({x}, {y})")
84
-
85
- # For cover mode, add a clip path to crop the overflow
86
- 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)
150
- finally:
151
- src_doc.close()
152
-
153
- def _get_data_uri(self, path: Path, mime: str) -> str:
154
- with open(path, "rb") as f:
155
- data = f.read()
156
- b64 = base64.b64encode(data).decode("utf-8")
157
- return f"data:{mime};base64,{b64}"
158
-
159
- 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)
176
- if not text_str:
177
- return
178
- if style.uppercase:
179
- text_str = text_str.upper()
180
-
181
- # 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)
184
-
185
- # Create text element
186
- txt = etree.SubElement(parent, "text")
187
- txt.text = text_str
188
- txt.set("x", str(x))
189
- txt.set("y", str(y))
190
-
191
- # Style
192
- # Font family is tricky in SVG (system fonts).
193
- txt.set("font-family", style.font_family)
194
- txt.set("font-size", f"{style.font_size_pt}pt")
195
- if style.bold:
196
- txt.set("font-weight", "bold")
197
-
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
File without changes
File without changes
File without changes