figquilt 0.1.6__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.
- {figquilt-0.1.6 → figquilt-0.1.8}/PKG-INFO +1 -1
- {figquilt-0.1.6 → figquilt-0.1.8}/pyproject.toml +1 -1
- figquilt-0.1.8/src/figquilt/base_composer.py +163 -0
- figquilt-0.1.8/src/figquilt/compose_pdf.py +102 -0
- figquilt-0.1.8/src/figquilt/compose_svg.py +166 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/grid.py +1 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/layout.py +18 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/units.py +29 -5
- figquilt-0.1.6/src/figquilt/compose_pdf.py +0 -173
- figquilt-0.1.6/src/figquilt/compose_svg.py +0 -200
- {figquilt-0.1.6 → figquilt-0.1.8}/README.md +0 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/__init__.py +0 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/cli.py +0 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/errors.py +0 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/images.py +0 -0
- {figquilt-0.1.6 → figquilt-0.1.8}/src/figquilt/parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: figquilt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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.
|
|
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")
|
|
@@ -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
|
)
|
|
@@ -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,
|
|
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
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
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,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
|
|
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
|
-
)
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|