glycorender 0.1.0__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.
- glycorender-0.1.0/LICENSE +21 -0
- glycorender-0.1.0/MANIFEST.in +3 -0
- glycorender-0.1.0/PKG-INFO +112 -0
- glycorender-0.1.0/README.md +91 -0
- glycorender-0.1.0/glycorender/__init__.py +3 -0
- glycorender-0.1.0/glycorender/fonts/Comfortaa-Bold.ttf +0 -0
- glycorender-0.1.0/glycorender/fonts/Comfortaa-Regular.ttf +0 -0
- glycorender-0.1.0/glycorender/render.py +784 -0
- glycorender-0.1.0/glycorender.egg-info/PKG-INFO +112 -0
- glycorender-0.1.0/glycorender.egg-info/SOURCES.txt +13 -0
- glycorender-0.1.0/glycorender.egg-info/dependency_links.txt +1 -0
- glycorender-0.1.0/glycorender.egg-info/requires.txt +2 -0
- glycorender-0.1.0/glycorender.egg-info/top_level.txt +1 -0
- glycorender-0.1.0/pyproject.toml +36 -0
- glycorender-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BojarLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glycorender
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform
|
|
5
|
+
Author-email: Daniel Bojar <daniel.bojar@gu.se>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/BojarLab/glycorender
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/BojarLab/glycorender/issues
|
|
9
|
+
Keywords: svg,pdf,renderer,glycans,chemistry,glycodraw
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: reportlab>=3.6.0
|
|
19
|
+
Requires-Dist: pymupdf>=1.19.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# glycorender
|
|
23
|
+
|
|
24
|
+
A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform, specialized in accurately rendering glycan structures with support for chemical notations and path-based text positioning.
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/glycorender/)
|
|
27
|
+
[](https://pypi.org/project/glycorender/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Accurate SVG to PDF and PNG conversion for glycan structures
|
|
33
|
+
- Support for text on paths with proper positioning and orientation
|
|
34
|
+
- Special handling for monosaccharide properties like "Df" with proper styling
|
|
35
|
+
- Gradient fills for circles and shapes
|
|
36
|
+
- Precise connection path rendering between glycan elements
|
|
37
|
+
- Support for various SVG path commands
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install glycorender
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from glycorender import convert_svg_to_pdf, convert_svg_to_png
|
|
49
|
+
|
|
50
|
+
# Convert SVG file to PDF
|
|
51
|
+
with open('glycan_structure.svg', 'r') as f:
|
|
52
|
+
svg_data = f.read()
|
|
53
|
+
|
|
54
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
55
|
+
|
|
56
|
+
# Convert SVG file to PNG
|
|
57
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
58
|
+
|
|
59
|
+
# Or directly from SVG data
|
|
60
|
+
svg_data = """..."""
|
|
61
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
62
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Use Cases
|
|
66
|
+
|
|
67
|
+
GlycoRender is specifically designed for:
|
|
68
|
+
|
|
69
|
+
- Converting glycan structure diagrams from GlycoDraw to publication-quality PDFs or PNGs
|
|
70
|
+
- Preserving the exact layout and styling of complex carbohydrate representations
|
|
71
|
+
- Ensuring chemical notations are properly formatted in the output
|
|
72
|
+
- Maintaining the correct connections between monosaccharide units
|
|
73
|
+
|
|
74
|
+
## API Reference
|
|
75
|
+
|
|
76
|
+
### `convert_svg_to_pdf(svg_data, pdf_file_path)`
|
|
77
|
+
|
|
78
|
+
Converts SVG data to a PDF file.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
82
|
+
- `pdf_file_path` (str): Path where the output PDF should be saved
|
|
83
|
+
|
|
84
|
+
### `convert_svg_to_png(svg_data, png_file_path)`
|
|
85
|
+
|
|
86
|
+
Converts SVG data to a PNG file.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
90
|
+
- `png_file_path` (str): Path where the output PNG should be saved
|
|
91
|
+
|
|
92
|
+
## Supported SVG Elements
|
|
93
|
+
|
|
94
|
+
- `<path>`: Full support for path commands (M, L, H, V, Z, etc.)
|
|
95
|
+
- `<circle>`: Support for basic circles and gradient-filled circles
|
|
96
|
+
- `<rect>`: Support for rectangles with stroke and fill
|
|
97
|
+
- `<text>` and `<textPath>`: Support for text placement along paths with proper orientation
|
|
98
|
+
- `<defs>`: Support for path and gradient definitions
|
|
99
|
+
- `<radialGradient>`: Support for radial gradients with stops
|
|
100
|
+
|
|
101
|
+
## Dependencies
|
|
102
|
+
|
|
103
|
+
- ReportLab: For PDF generation
|
|
104
|
+
- PyMuPDF: For PNG conversion
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# glycorender
|
|
2
|
+
|
|
3
|
+
A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform, specialized in accurately rendering glycan structures with support for chemical notations and path-based text positioning.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/glycorender/)
|
|
6
|
+
[](https://pypi.org/project/glycorender/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Accurate SVG to PDF and PNG conversion for glycan structures
|
|
12
|
+
- Support for text on paths with proper positioning and orientation
|
|
13
|
+
- Special handling for monosaccharide properties like "Df" with proper styling
|
|
14
|
+
- Gradient fills for circles and shapes
|
|
15
|
+
- Precise connection path rendering between glycan elements
|
|
16
|
+
- Support for various SVG path commands
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install glycorender
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from glycorender import convert_svg_to_pdf, convert_svg_to_png
|
|
28
|
+
|
|
29
|
+
# Convert SVG file to PDF
|
|
30
|
+
with open('glycan_structure.svg', 'r') as f:
|
|
31
|
+
svg_data = f.read()
|
|
32
|
+
|
|
33
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
34
|
+
|
|
35
|
+
# Convert SVG file to PNG
|
|
36
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
37
|
+
|
|
38
|
+
# Or directly from SVG data
|
|
39
|
+
svg_data = """..."""
|
|
40
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
41
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Use Cases
|
|
45
|
+
|
|
46
|
+
GlycoRender is specifically designed for:
|
|
47
|
+
|
|
48
|
+
- Converting glycan structure diagrams from GlycoDraw to publication-quality PDFs or PNGs
|
|
49
|
+
- Preserving the exact layout and styling of complex carbohydrate representations
|
|
50
|
+
- Ensuring chemical notations are properly formatted in the output
|
|
51
|
+
- Maintaining the correct connections between monosaccharide units
|
|
52
|
+
|
|
53
|
+
## API Reference
|
|
54
|
+
|
|
55
|
+
### `convert_svg_to_pdf(svg_data, pdf_file_path)`
|
|
56
|
+
|
|
57
|
+
Converts SVG data to a PDF file.
|
|
58
|
+
|
|
59
|
+
Parameters:
|
|
60
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
61
|
+
- `pdf_file_path` (str): Path where the output PDF should be saved
|
|
62
|
+
|
|
63
|
+
### `convert_svg_to_png(svg_data, png_file_path)`
|
|
64
|
+
|
|
65
|
+
Converts SVG data to a PNG file.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
69
|
+
- `png_file_path` (str): Path where the output PNG should be saved
|
|
70
|
+
|
|
71
|
+
## Supported SVG Elements
|
|
72
|
+
|
|
73
|
+
- `<path>`: Full support for path commands (M, L, H, V, Z, etc.)
|
|
74
|
+
- `<circle>`: Support for basic circles and gradient-filled circles
|
|
75
|
+
- `<rect>`: Support for rectangles with stroke and fill
|
|
76
|
+
- `<text>` and `<textPath>`: Support for text placement along paths with proper orientation
|
|
77
|
+
- `<defs>`: Support for path and gradient definitions
|
|
78
|
+
- `<radialGradient>`: Support for radial gradients with stops
|
|
79
|
+
|
|
80
|
+
## Dependencies
|
|
81
|
+
|
|
82
|
+
- ReportLab: For PDF generation
|
|
83
|
+
- PyMuPDF: For PNG conversion
|
|
84
|
+
|
|
85
|
+
## Contributing
|
|
86
|
+
|
|
87
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ET
|
|
2
|
+
import tempfile
|
|
3
|
+
import re
|
|
4
|
+
import os
|
|
5
|
+
import math
|
|
6
|
+
from reportlab.pdfgen import canvas
|
|
7
|
+
from reportlab.pdfbase import pdfmetrics
|
|
8
|
+
from reportlab.lib.units import mm
|
|
9
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
10
|
+
import fitz
|
|
11
|
+
import pkg_resources
|
|
12
|
+
|
|
13
|
+
reportlab_colors = {
|
|
14
|
+
'darkblue': (0, 0, 0.545),
|
|
15
|
+
'green': (0, 0.5, 0),
|
|
16
|
+
'darkgoldenrod': (0.722, 0.525, 0.043),
|
|
17
|
+
'skyblue': (0.529, 0.808, 0.922),
|
|
18
|
+
'orchid': (0.855, 0.439, 0.839),
|
|
19
|
+
'purple': (0.5, 0, 0.5),
|
|
20
|
+
'saddlebrown': (0.545, 0.271, 0.075),
|
|
21
|
+
'orangered': (1, 0.271, 0),
|
|
22
|
+
'firebrick': (0.698, 0.133, 0.133),
|
|
23
|
+
'white': (1, 1, 1),
|
|
24
|
+
'charcoal': (0.110, 0.098, 0.090)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_color(color_str):
|
|
29
|
+
"""Parse SVG color to RGB tuple."""
|
|
30
|
+
if not color_str or color_str == "none":
|
|
31
|
+
return None
|
|
32
|
+
if color_str.startswith('#'):
|
|
33
|
+
if len(color_str) == 7: # #RRGGBB
|
|
34
|
+
if color_str == "#000000":
|
|
35
|
+
return reportlab_colors['charcoal']
|
|
36
|
+
r = int(color_str[1:3], 16) / 255.0
|
|
37
|
+
g = int(color_str[3:5], 16) / 255.0
|
|
38
|
+
b = int(color_str[5:7], 16) / 255.0
|
|
39
|
+
return (r, g, b)
|
|
40
|
+
if color_str.startswith('url(#'):
|
|
41
|
+
# Return the gradient ID
|
|
42
|
+
return color_str[5:-1] # Remove 'url(#' and ')'
|
|
43
|
+
if color_str in reportlab_colors:
|
|
44
|
+
return reportlab_colors[color_str]
|
|
45
|
+
return reportlab_colors['charcoal']
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_path(d_str):
|
|
49
|
+
"""Parse SVG path data."""
|
|
50
|
+
if not d_str:
|
|
51
|
+
return []
|
|
52
|
+
tokens = []
|
|
53
|
+
i = 0
|
|
54
|
+
d_len = len(d_str)
|
|
55
|
+
while i < d_len:
|
|
56
|
+
if d_str[i].isalpha():
|
|
57
|
+
tokens.append(d_str[i])
|
|
58
|
+
i += 1
|
|
59
|
+
elif d_str[i].isdigit() or d_str[i] in '.-':
|
|
60
|
+
num_start = i
|
|
61
|
+
i += 1
|
|
62
|
+
while i < d_len and (d_str[i].isdigit() or d_str[i] == '.'):
|
|
63
|
+
i += 1
|
|
64
|
+
tokens.append(float(d_str[num_start:i]))
|
|
65
|
+
else:
|
|
66
|
+
i += 1
|
|
67
|
+
# Process tokens into commands
|
|
68
|
+
commands = []
|
|
69
|
+
cmd = None
|
|
70
|
+
params = []
|
|
71
|
+
for token in tokens:
|
|
72
|
+
if isinstance(token, str):
|
|
73
|
+
if cmd:
|
|
74
|
+
commands.append((cmd, params))
|
|
75
|
+
params = []
|
|
76
|
+
cmd = token
|
|
77
|
+
else:
|
|
78
|
+
params.append(token)
|
|
79
|
+
if cmd and params:
|
|
80
|
+
commands.append((cmd, params))
|
|
81
|
+
return commands
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def path_length(points):
|
|
85
|
+
"""Calculate path length."""
|
|
86
|
+
length = 0
|
|
87
|
+
for i in range(len(points) - 1):
|
|
88
|
+
x1, y1 = points[i]
|
|
89
|
+
x2, y2 = points[i + 1]
|
|
90
|
+
length += math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
|
91
|
+
return length
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def point_at_length(points, target_length):
|
|
95
|
+
"""Find point and angle at given length along path."""
|
|
96
|
+
if not points:
|
|
97
|
+
return (0, 0, 0)
|
|
98
|
+
if len(points) == 1:
|
|
99
|
+
return (points[0][0], points[0][1], 0)
|
|
100
|
+
current_length = 0
|
|
101
|
+
for i in range(len(points) - 1):
|
|
102
|
+
x1, y1 = points[i]
|
|
103
|
+
x2, y2 = points[i + 1]
|
|
104
|
+
segment_length = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
|
|
105
|
+
if current_length + segment_length >= target_length:
|
|
106
|
+
# Point is on this segment
|
|
107
|
+
t = (target_length - current_length) / segment_length
|
|
108
|
+
x = x1 + t * (x2 - x1)
|
|
109
|
+
y = y1 + t * (y2 - y1)
|
|
110
|
+
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
|
|
111
|
+
return (x, y, angle)
|
|
112
|
+
current_length += segment_length
|
|
113
|
+
# If we get here, return the last point
|
|
114
|
+
x1, y1 = points[-2]
|
|
115
|
+
x2, y2 = points[-1]
|
|
116
|
+
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
|
|
117
|
+
return (x2, y2, angle)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def draw_path(c, commands, stroke_color=None, fill_color=None, stroke_width=1):
|
|
121
|
+
"""Draw path on canvas."""
|
|
122
|
+
if not commands:
|
|
123
|
+
return
|
|
124
|
+
c.saveState()
|
|
125
|
+
if stroke_color and isinstance(stroke_color, tuple):
|
|
126
|
+
c.setStrokeColorRGB(*stroke_color)
|
|
127
|
+
c.setLineWidth(stroke_width)
|
|
128
|
+
if fill_color and isinstance(fill_color, tuple):
|
|
129
|
+
if len(fill_color) == 3:
|
|
130
|
+
c.setFillColorRGB(*fill_color)
|
|
131
|
+
elif len(fill_color) == 4:
|
|
132
|
+
c.setFillColorRGB(*fill_color[:3], alpha=fill_color[3])
|
|
133
|
+
is_diamond = False
|
|
134
|
+
points = []
|
|
135
|
+
if len(commands) == 5: # M + 3 L + Z potentially
|
|
136
|
+
cmd_types = [cmd for cmd, _ in commands]
|
|
137
|
+
if cmd_types[0] in ['M', 'm'] and cmd_types[-1] in ['Z', 'z'] and all(c in ['L', 'l'] for c in cmd_types[1:-1]):
|
|
138
|
+
# Extract points for possible diamond
|
|
139
|
+
curr_x, curr_y = 0, 0
|
|
140
|
+
for cmd, params in commands:
|
|
141
|
+
if cmd == 'M':
|
|
142
|
+
curr_x, curr_y = params[0], params[1]
|
|
143
|
+
points.append((curr_x, curr_y))
|
|
144
|
+
elif cmd == 'm':
|
|
145
|
+
curr_x += params[0]
|
|
146
|
+
curr_y += params[1]
|
|
147
|
+
points.append((curr_x, curr_y))
|
|
148
|
+
elif cmd == 'L':
|
|
149
|
+
curr_x, curr_y = params[0], params[1]
|
|
150
|
+
points.append((curr_x, curr_y))
|
|
151
|
+
elif cmd == 'l':
|
|
152
|
+
curr_x += params[0]
|
|
153
|
+
curr_y += params[1]
|
|
154
|
+
points.append((curr_x, curr_y))
|
|
155
|
+
if len(points) == 4:
|
|
156
|
+
is_diamond = True
|
|
157
|
+
# Check if this is a bracket part
|
|
158
|
+
is_bracket = False
|
|
159
|
+
if len(commands) == 2:
|
|
160
|
+
cmd1, params1 = commands[0]
|
|
161
|
+
cmd2, params2 = commands[1]
|
|
162
|
+
if cmd1 == 'M' and cmd2 in ['L', 'V', 'H']:
|
|
163
|
+
# Vertical line in bracket
|
|
164
|
+
if cmd2 == 'V' or (cmd2 == 'L' and len(params2) >= 2 and abs(params1[0] - params2[0]) < 0.1):
|
|
165
|
+
is_bracket = True
|
|
166
|
+
# Horizontal line in bracket
|
|
167
|
+
elif cmd2 == 'H' or (cmd2 == 'L' and len(params2) >= 2 and abs(params1[1] - params2[1]) < 0.1 and abs(params1[0] - params2[0]) < 15):
|
|
168
|
+
is_bracket = True
|
|
169
|
+
if is_bracket:
|
|
170
|
+
# For bracket components
|
|
171
|
+
c.setLineJoin(0) # Mitered joins
|
|
172
|
+
c.setLineCap(0) # Butt caps
|
|
173
|
+
else:
|
|
174
|
+
# For diamonds, connections, and other shapes
|
|
175
|
+
c.setLineJoin(1) # Round joins
|
|
176
|
+
# For connection paths with stroke_width=4.0, use round caps
|
|
177
|
+
if stroke_width == 4.0 and not fill_color:
|
|
178
|
+
c.setLineCap(1) # Round caps
|
|
179
|
+
if is_diamond:
|
|
180
|
+
path = c.beginPath()
|
|
181
|
+
for i, (x, y) in enumerate(points):
|
|
182
|
+
if i == 0:
|
|
183
|
+
path.moveTo(x, y)
|
|
184
|
+
else:
|
|
185
|
+
path.lineTo(x, y)
|
|
186
|
+
path.close()
|
|
187
|
+
else:
|
|
188
|
+
path = c.beginPath()
|
|
189
|
+
curr_x, curr_y = 0, 0
|
|
190
|
+
start_x, start_y = None, None
|
|
191
|
+
first_x, first_y = None, None
|
|
192
|
+
for cmd, params in commands:
|
|
193
|
+
if cmd == 'M':
|
|
194
|
+
for i in range(0, len(params), 2):
|
|
195
|
+
curr_x, curr_y = params[i], params[i+1]
|
|
196
|
+
if first_x is None:
|
|
197
|
+
first_x, first_y = curr_x, curr_y
|
|
198
|
+
if start_x is None:
|
|
199
|
+
start_x, start_y = curr_x, curr_y
|
|
200
|
+
path.moveTo(curr_x, curr_y)
|
|
201
|
+
elif cmd == 'm':
|
|
202
|
+
for i in range(0, len(params), 2):
|
|
203
|
+
curr_x += params[i]
|
|
204
|
+
curr_y += params[i+1]
|
|
205
|
+
if first_x is None:
|
|
206
|
+
first_x, first_y = curr_x, curr_y
|
|
207
|
+
if start_x is None:
|
|
208
|
+
start_x, start_y = curr_x, curr_y
|
|
209
|
+
path.moveTo(curr_x, curr_y)
|
|
210
|
+
elif cmd == 'L':
|
|
211
|
+
for i in range(0, len(params), 2):
|
|
212
|
+
curr_x, curr_y = params[i], params[i+1]
|
|
213
|
+
path.lineTo(curr_x, curr_y)
|
|
214
|
+
elif cmd == 'l':
|
|
215
|
+
for i in range(0, len(params), 2):
|
|
216
|
+
curr_x += params[i]
|
|
217
|
+
curr_y += params[i+1]
|
|
218
|
+
path.lineTo(curr_x, curr_y)
|
|
219
|
+
elif cmd == 'H':
|
|
220
|
+
for param in params:
|
|
221
|
+
curr_x = param
|
|
222
|
+
path.lineTo(curr_x, curr_y)
|
|
223
|
+
elif cmd == 'h':
|
|
224
|
+
for param in params:
|
|
225
|
+
curr_x += param
|
|
226
|
+
path.lineTo(curr_x, curr_y)
|
|
227
|
+
elif cmd == 'V':
|
|
228
|
+
for param in params:
|
|
229
|
+
curr_y = param
|
|
230
|
+
path.lineTo(curr_x, curr_y)
|
|
231
|
+
elif cmd == 'v':
|
|
232
|
+
for param in params:
|
|
233
|
+
curr_y += param
|
|
234
|
+
path.lineTo(curr_x, curr_y)
|
|
235
|
+
elif cmd == 'Z' or cmd == 'z':
|
|
236
|
+
if first_x is not None and first_y is not None:
|
|
237
|
+
path.lineTo(first_x, first_y)
|
|
238
|
+
path.close()
|
|
239
|
+
if first_x is not None and (cmd != 'Z' and cmd != 'z') and (curr_x != first_x or curr_y != first_y):
|
|
240
|
+
path.lineTo(first_x, first_y)
|
|
241
|
+
path.close()
|
|
242
|
+
# Draw path with fill if fill color is provided, regardless of stroke width
|
|
243
|
+
if fill_color and isinstance(fill_color, tuple):
|
|
244
|
+
c.drawPath(path, fill=1, stroke=(stroke_color is not None and isinstance(stroke_color, tuple)))
|
|
245
|
+
elif stroke_color and isinstance(stroke_color, tuple):
|
|
246
|
+
c.drawPath(path, fill=0, stroke=1)
|
|
247
|
+
c.restoreState()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def draw_rect(c, x, y, width, height, stroke_color=None, fill_color=None, stroke_width=1):
|
|
251
|
+
"""Draw rectangle on canvas."""
|
|
252
|
+
c.saveState()
|
|
253
|
+
if stroke_color and isinstance(stroke_color, tuple):
|
|
254
|
+
c.setStrokeColorRGB(*stroke_color)
|
|
255
|
+
c.setLineWidth(stroke_width)
|
|
256
|
+
if fill_color and isinstance(fill_color, tuple):
|
|
257
|
+
if len(fill_color) == 3:
|
|
258
|
+
c.setFillColorRGB(*fill_color)
|
|
259
|
+
elif len(fill_color) == 4:
|
|
260
|
+
c.setFillColorRGB(*fill_color[:3], alpha=fill_color[3])
|
|
261
|
+
if fill_color and isinstance(fill_color, tuple) and stroke_color and isinstance(stroke_color, tuple):
|
|
262
|
+
c.rect(x, y, width, height, fill=1, stroke=1)
|
|
263
|
+
elif fill_color and isinstance(fill_color, tuple):
|
|
264
|
+
c.rect(x, y, width, height, fill=1, stroke=0)
|
|
265
|
+
elif stroke_color and isinstance(stroke_color, tuple):
|
|
266
|
+
c.rect(x, y, width, height, fill=0, stroke=1)
|
|
267
|
+
c.restoreState()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def draw_circle(c, cx, cy, r, stroke_color=None, fill_color=None, stroke_width=1):
|
|
271
|
+
"""Draw circle on canvas."""
|
|
272
|
+
c.saveState()
|
|
273
|
+
if stroke_color and isinstance(stroke_color, tuple):
|
|
274
|
+
c.setStrokeColorRGB(*stroke_color)
|
|
275
|
+
c.setLineWidth(stroke_width)
|
|
276
|
+
if fill_color and isinstance(fill_color, tuple):
|
|
277
|
+
if len(fill_color) == 3:
|
|
278
|
+
c.setFillColorRGB(*fill_color)
|
|
279
|
+
elif len(fill_color) == 4:
|
|
280
|
+
c.setFillColorRGB(*fill_color[:3], alpha=fill_color[3])
|
|
281
|
+
if fill_color and isinstance(fill_color, tuple) and stroke_color and isinstance(stroke_color, tuple):
|
|
282
|
+
c.circle(cx, cy, r, fill=1, stroke=1)
|
|
283
|
+
elif fill_color and isinstance(fill_color, tuple):
|
|
284
|
+
c.circle(cx, cy, r, fill=1, stroke=0)
|
|
285
|
+
elif stroke_color and isinstance(stroke_color, tuple):
|
|
286
|
+
c.circle(cx, cy, r, fill=0, stroke=1)
|
|
287
|
+
c.restoreState()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def draw_text_on_path(c, text, path_points, offset_percent, font_name, font_size, fill_color=None, text_anchor='start', offset_y=0, is_bold=False):
|
|
291
|
+
"""Draw text along path."""
|
|
292
|
+
if not path_points or not text:
|
|
293
|
+
return
|
|
294
|
+
text = text.replace(' ', '')
|
|
295
|
+
total_length = path_length(path_points)
|
|
296
|
+
target_length = total_length * (offset_percent / 100.0)
|
|
297
|
+
x, y, angle = point_at_length(path_points, target_length)
|
|
298
|
+
c.saveState()
|
|
299
|
+
c.translate(x, y)
|
|
300
|
+
c.rotate(angle)
|
|
301
|
+
if offset_y:
|
|
302
|
+
c.translate(0, offset_y)
|
|
303
|
+
if text_anchor == 'middle':
|
|
304
|
+
text_width = pdfmetrics.stringWidth(text, font_name, font_size)
|
|
305
|
+
c.translate(-text_width/2, 0)
|
|
306
|
+
elif text_anchor == 'end':
|
|
307
|
+
text_width = pdfmetrics.stringWidth(text, font_name, font_size)
|
|
308
|
+
c.translate(-text_width, 0)
|
|
309
|
+
if fill_color:
|
|
310
|
+
c.setFillColorRGB(*fill_color)
|
|
311
|
+
# Special handling for 'Df'
|
|
312
|
+
if text == 'Df' and abs(offset_y - 0.5 * font_size) < 0.01:
|
|
313
|
+
# Draw 'D' normally
|
|
314
|
+
actual_font = font_name + '-Bold' if is_bold else font_name
|
|
315
|
+
c.setFont(actual_font, font_size)
|
|
316
|
+
c.scale(1, -1)
|
|
317
|
+
d_width = pdfmetrics.stringWidth('D', actual_font, font_size)
|
|
318
|
+
c.drawString(0, 0, 'D')
|
|
319
|
+
# Draw 'f' with italic simulation
|
|
320
|
+
c.saveState()
|
|
321
|
+
c.translate(d_width, 0)
|
|
322
|
+
c.transform(1, 0, 0.3, 1, -0.3 * font_size/2, 0) # Positive skew for forward lean
|
|
323
|
+
c.drawString(0, 0, 'f')
|
|
324
|
+
c.restoreState()
|
|
325
|
+
# Special handling for just 'f'
|
|
326
|
+
elif (text == 'f') and abs(offset_y - 0.5 * font_size) < 0.01:
|
|
327
|
+
# Simulate italic with proper forward slant
|
|
328
|
+
actual_font = font_name + '-Bold' if is_bold else font_name
|
|
329
|
+
c.setFont(actual_font, font_size)
|
|
330
|
+
c.scale(1, -1)
|
|
331
|
+
c.transform(1, 0, 0.3, 1, -0.3 * font_size/2, 0) # Positive skew for forward lean
|
|
332
|
+
c.drawString(0, 0, text)
|
|
333
|
+
else:
|
|
334
|
+
# Normal text rendering
|
|
335
|
+
actual_font = font_name + '-Bold' if is_bold else font_name
|
|
336
|
+
c.setFont(actual_font, font_size)
|
|
337
|
+
c.scale(1, -1)
|
|
338
|
+
c.drawString(0, 0, text, charSpace=1.0)
|
|
339
|
+
c.restoreState()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def calculate_points(commands):
|
|
343
|
+
"""Calculate path points for layout."""
|
|
344
|
+
points = []
|
|
345
|
+
curr_x, curr_y = 0, 0
|
|
346
|
+
for cmd, params in commands:
|
|
347
|
+
if cmd == 'M':
|
|
348
|
+
for i in range(0, len(params), 2):
|
|
349
|
+
curr_x, curr_y = params[i], params[i+1]
|
|
350
|
+
points.append((curr_x, curr_y))
|
|
351
|
+
elif cmd == 'm':
|
|
352
|
+
for i in range(0, len(params), 2):
|
|
353
|
+
curr_x += params[i]
|
|
354
|
+
curr_y += params[i+1]
|
|
355
|
+
points.append((curr_x, curr_y))
|
|
356
|
+
elif cmd == 'L':
|
|
357
|
+
for i in range(0, len(params), 2):
|
|
358
|
+
curr_x, curr_y = params[i], params[i+1]
|
|
359
|
+
points.append((curr_x, curr_y))
|
|
360
|
+
elif cmd == 'l':
|
|
361
|
+
for i in range(0, len(params), 2):
|
|
362
|
+
curr_x += params[i]
|
|
363
|
+
curr_y += params[i+1]
|
|
364
|
+
points.append((curr_x, curr_y))
|
|
365
|
+
elif cmd == 'H':
|
|
366
|
+
for param in params:
|
|
367
|
+
curr_x = param
|
|
368
|
+
points.append((curr_x, curr_y))
|
|
369
|
+
elif cmd == 'h':
|
|
370
|
+
for param in params:
|
|
371
|
+
curr_x += param
|
|
372
|
+
points.append((curr_x, curr_y))
|
|
373
|
+
elif cmd == 'V':
|
|
374
|
+
for param in params:
|
|
375
|
+
curr_y = param
|
|
376
|
+
points.append((curr_x, curr_y))
|
|
377
|
+
elif cmd == 'v':
|
|
378
|
+
for param in params:
|
|
379
|
+
curr_y += param
|
|
380
|
+
points.append((curr_x, curr_y))
|
|
381
|
+
elif cmd == 'Z' or cmd == 'z':
|
|
382
|
+
# Add closing point if needed
|
|
383
|
+
if points and (points[0][0] != curr_x or points[0][1] != curr_y):
|
|
384
|
+
points.append(points[0]) # Close the path properly
|
|
385
|
+
return points
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def process_use_element(c, use_elem, all_paths, ns):
|
|
389
|
+
"""Process a use element which refers to a path."""
|
|
390
|
+
href = use_elem.get('{http://www.w3.org/1999/xlink}href')
|
|
391
|
+
if not href:
|
|
392
|
+
href = use_elem.get('href')
|
|
393
|
+
if not href or not href.startswith('#'):
|
|
394
|
+
return
|
|
395
|
+
path_id = href[1:]
|
|
396
|
+
if path_id not in all_paths:
|
|
397
|
+
return
|
|
398
|
+
path_info = all_paths[path_id]
|
|
399
|
+
# Draw the path with stroke and no fill to ensure we see the line
|
|
400
|
+
draw_path(c, path_info['commands'], path_info['stroke'], None, path_info['stroke_width'])
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def draw_radial_gradient_shape(c, cx, cy, r, stops, shape_func):
|
|
404
|
+
"""Draw a radial gradient applied specifically to a shape."""
|
|
405
|
+
stops = sorted(stops, key=lambda x: x[0])
|
|
406
|
+
num_steps = 20
|
|
407
|
+
for i in range(len(stops)-1, 0, -1):
|
|
408
|
+
start_offset, start_color = stops[i-1]
|
|
409
|
+
end_offset, end_color = stops[i]
|
|
410
|
+
for step in range(num_steps):
|
|
411
|
+
fraction = step / num_steps
|
|
412
|
+
offset = start_offset + (end_offset - start_offset) * fraction
|
|
413
|
+
r_val = start_color[0] + (end_color[0] - start_color[0]) * fraction
|
|
414
|
+
g_val = start_color[1] + (end_color[1] - start_color[1]) * fraction
|
|
415
|
+
b_val = start_color[2] + (end_color[2] - start_color[2]) * fraction
|
|
416
|
+
# Scale opacity to maintain relative differences but reduce overall intensity
|
|
417
|
+
raw_opacity = start_color[3] + (end_color[3] - start_color[3]) * fraction
|
|
418
|
+
opacity = raw_opacity * 0.3 # Reduce opacity to achieve the subtler look
|
|
419
|
+
radius = r * offset
|
|
420
|
+
c.saveState()
|
|
421
|
+
c.setFillColorRGB(r_val, g_val, b_val, alpha=opacity)
|
|
422
|
+
shape_func(c, cx, cy, radius)
|
|
423
|
+
c.restoreState()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def parse_svg_dimensions(root):
|
|
427
|
+
"""Parse SVG dimensions and viewbox."""
|
|
428
|
+
width = float(root.get('width', '100'))
|
|
429
|
+
height = float(root.get('height', '100'))
|
|
430
|
+
viewbox = root.get('viewBox', f'0 0 {width} {height}')
|
|
431
|
+
vbox_parts = [float(x) for x in viewbox.split() if x.strip()]
|
|
432
|
+
if len(vbox_parts) == 4:
|
|
433
|
+
vb_x, vb_y, vb_width, vb_height = vbox_parts
|
|
434
|
+
else:
|
|
435
|
+
vb_x, vb_y, vb_width, vb_height = 0, 0, width, height
|
|
436
|
+
scale_x = width / vb_width
|
|
437
|
+
scale_y = height / vb_height
|
|
438
|
+
return width, height, vb_x, vb_y, scale_x, scale_y
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def extract_defs(root, ns):
|
|
442
|
+
"""Extract definitions like paths and gradients from SVG."""
|
|
443
|
+
all_paths = {}
|
|
444
|
+
all_gradients = {}
|
|
445
|
+
for defs in root.findall('.//svg:defs', ns):
|
|
446
|
+
# Extract paths
|
|
447
|
+
for path in defs.findall('.//svg:path', ns):
|
|
448
|
+
path_id = path.get('id', '')
|
|
449
|
+
if not path_id:
|
|
450
|
+
continue
|
|
451
|
+
path_data = path.get('d', '')
|
|
452
|
+
stroke = parse_color(path.get('stroke', 'none'))
|
|
453
|
+
fill = parse_color(path.get('fill', 'none'))
|
|
454
|
+
stroke_width = float(path.get('stroke-width', '1'))
|
|
455
|
+
path_commands = parse_path(path_data)
|
|
456
|
+
path_points = calculate_points(path_commands)
|
|
457
|
+
all_paths[path_id] = {
|
|
458
|
+
'points': path_points,
|
|
459
|
+
'commands': path_commands,
|
|
460
|
+
'stroke': stroke,
|
|
461
|
+
'fill': fill,
|
|
462
|
+
'stroke_width': stroke_width
|
|
463
|
+
}
|
|
464
|
+
# Extract radial gradients
|
|
465
|
+
for radial_gradient in defs.findall('.//svg:radialGradient', ns):
|
|
466
|
+
gradient_id = radial_gradient.get('id', '')
|
|
467
|
+
if not gradient_id:
|
|
468
|
+
continue
|
|
469
|
+
cx = float(radial_gradient.get('cx', '0'))
|
|
470
|
+
cy = float(radial_gradient.get('cy', '0'))
|
|
471
|
+
r = float(radial_gradient.get('r', '0'))
|
|
472
|
+
stops = []
|
|
473
|
+
for stop in radial_gradient.findall('.//svg:stop', ns):
|
|
474
|
+
offset = float(stop.get('offset', '0'))
|
|
475
|
+
stop_color = stop.get('stop-color', 'white')
|
|
476
|
+
opacity = float(stop.get('stop-opacity', '1'))
|
|
477
|
+
color_tuple = None
|
|
478
|
+
if stop_color.startswith('#'):
|
|
479
|
+
if len(stop_color) == 7: # #RRGGBB
|
|
480
|
+
r_val = int(stop_color[1:3], 16) / 255.0
|
|
481
|
+
g_val = int(stop_color[3:5], 16) / 255.0
|
|
482
|
+
b_val = int(stop_color[5:7], 16) / 255.0
|
|
483
|
+
color_tuple = (r_val, g_val, b_val, opacity)
|
|
484
|
+
elif stop_color in reportlab_colors:
|
|
485
|
+
base_color = reportlab_colors[stop_color]
|
|
486
|
+
color_tuple = base_color + (opacity,)
|
|
487
|
+
if color_tuple:
|
|
488
|
+
stops.append((offset, color_tuple))
|
|
489
|
+
all_gradients[gradient_id] = {'cx': cx, 'cy': cy, 'r': r,'stops': stops}
|
|
490
|
+
# Also process direct paths with stroke-width="4.0" not in defs
|
|
491
|
+
for path in root.findall('.//svg:path', ns):
|
|
492
|
+
if path.get('stroke-width') == '4.0':
|
|
493
|
+
parent = path.find('..')
|
|
494
|
+
if parent is not None and parent.tag.endswith('defs'):
|
|
495
|
+
continue # Skip if in defs (already processed)
|
|
496
|
+
path_id = f"connection_{len(all_paths)}" # Generate unique ID
|
|
497
|
+
path_data = path.get('d', '')
|
|
498
|
+
stroke = parse_color(path.get('stroke', 'none'))
|
|
499
|
+
fill = parse_color(path.get('fill', 'none'))
|
|
500
|
+
stroke_width = float(path.get('stroke-width', '1'))
|
|
501
|
+
path_commands = parse_path(path_data)
|
|
502
|
+
path_points = calculate_points(path_commands)
|
|
503
|
+
all_paths[path_id] = {
|
|
504
|
+
'points': path_points,
|
|
505
|
+
'commands': path_commands,
|
|
506
|
+
'stroke': stroke,
|
|
507
|
+
'fill': fill,
|
|
508
|
+
'stroke_width': stroke_width
|
|
509
|
+
}
|
|
510
|
+
return all_paths, all_gradients
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def find_connection_paths(root, all_paths, ns):
|
|
514
|
+
"""Find connection paths which are used as lines between elements."""
|
|
515
|
+
connection_path_ids = set()
|
|
516
|
+
for use_elem in root.findall('.//svg:use', ns):
|
|
517
|
+
href = use_elem.get('{http://www.w3.org/1999/xlink}href')
|
|
518
|
+
if not href:
|
|
519
|
+
href = use_elem.get('href')
|
|
520
|
+
if not href or not href.startswith('#'):
|
|
521
|
+
continue
|
|
522
|
+
path_id = href[1:]
|
|
523
|
+
if path_id in all_paths:
|
|
524
|
+
# Check if this is likely a connection path by looking at its properties
|
|
525
|
+
path_info = all_paths[path_id]
|
|
526
|
+
# Connection paths typically have a stroke but no fill
|
|
527
|
+
if path_info['stroke'] and not path_info['fill']:
|
|
528
|
+
connection_path_ids.add(path_id)
|
|
529
|
+
for path_id, path_info in all_paths.items():
|
|
530
|
+
# Identify connection paths (horizontal lines)
|
|
531
|
+
if path_info['stroke_width'] == 4.0:
|
|
532
|
+
connection_path_ids.add(path_id)
|
|
533
|
+
return connection_path_ids
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def draw_circles_with_gradients(c, root, all_gradients, ns):
|
|
537
|
+
"""Draw circles with gradient fills."""
|
|
538
|
+
for circle in root.findall('.//svg:circle', ns):
|
|
539
|
+
cx = float(circle.get('cx', '0'))
|
|
540
|
+
cy = float(circle.get('cy', '0'))
|
|
541
|
+
r = float(circle.get('r', '0'))
|
|
542
|
+
fill = parse_color(circle.get('fill', 'none'))
|
|
543
|
+
if isinstance(fill, str) and fill in all_gradients:
|
|
544
|
+
grad = all_gradients[fill]
|
|
545
|
+
draw_radial_gradient_shape(c, cx, cy, r, grad['stops'],
|
|
546
|
+
lambda canvas, center_x, center_y, radius: canvas.circle(center_x, center_y, radius, fill=1, stroke=0))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def draw_connection_paths(c, connection_path_ids, all_paths):
|
|
550
|
+
"""Draw connection paths between elements."""
|
|
551
|
+
c.setLineCap(1) # Set round cap for all connection lines
|
|
552
|
+
for path_id in connection_path_ids:
|
|
553
|
+
path_info = all_paths[path_id]
|
|
554
|
+
# Slightly extend connection paths to ensure overlap
|
|
555
|
+
commands = path_info['commands']
|
|
556
|
+
draw_path(c, commands, path_info['stroke'], None, path_info['stroke_width'])
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def draw_circle_shapes(c, root, all_gradients, ns):
|
|
560
|
+
"""Draw circle shapes."""
|
|
561
|
+
for circle in root.findall('.//svg:circle', ns):
|
|
562
|
+
cx = float(circle.get('cx', '0'))
|
|
563
|
+
cy = float(circle.get('cy', '0'))
|
|
564
|
+
r = float(circle.get('r', '0'))
|
|
565
|
+
stroke = parse_color(circle.get('stroke', 'none'))
|
|
566
|
+
fill = parse_color(circle.get('fill', 'none'))
|
|
567
|
+
stroke_width = float(circle.get('stroke-width', '1'))
|
|
568
|
+
if isinstance(fill, str) and fill in all_gradients:
|
|
569
|
+
if stroke:
|
|
570
|
+
c.saveState()
|
|
571
|
+
c.setStrokeColorRGB(*stroke)
|
|
572
|
+
c.setLineWidth(stroke_width)
|
|
573
|
+
c.circle(cx, cy, r, fill=0, stroke=1)
|
|
574
|
+
c.restoreState()
|
|
575
|
+
else:
|
|
576
|
+
draw_circle(c, cx, cy, r, stroke, fill, stroke_width)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def draw_paths(c, root, connection_path_ids, all_gradients, ns):
|
|
580
|
+
"""Draw regular paths that aren't connection lines."""
|
|
581
|
+
for path in root.findall('.//svg:path', ns):
|
|
582
|
+
parent = path.find('..')
|
|
583
|
+
if parent is not None and parent.tag.endswith('defs'):
|
|
584
|
+
continue # Skip paths in defs
|
|
585
|
+
path_id = path.get('id', '')
|
|
586
|
+
if path_id in connection_path_ids:
|
|
587
|
+
continue # Skip connection paths, they've already been drawn
|
|
588
|
+
# Skip paths with stroke-width="4.0" as these are connection lines
|
|
589
|
+
if path.get('stroke-width') == '4.0':
|
|
590
|
+
continue
|
|
591
|
+
path_data = path.get('d', '')
|
|
592
|
+
stroke = parse_color(path.get('stroke', 'none'))
|
|
593
|
+
fill = parse_color(path.get('fill', 'none'))
|
|
594
|
+
stroke_width = float(path.get('stroke-width', '1'))
|
|
595
|
+
path_commands = parse_path(path_data)
|
|
596
|
+
if isinstance(fill, str) and fill in all_gradients:
|
|
597
|
+
grad = all_gradients[fill]
|
|
598
|
+
first_stop = grad['stops'][0][1] # Use color from first stop
|
|
599
|
+
draw_path(c, path_commands, stroke, first_stop[:3], stroke_width)
|
|
600
|
+
else:
|
|
601
|
+
draw_path(c, path_commands, stroke, fill, stroke_width)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def draw_rectangles(c, root, all_gradients, ns):
|
|
605
|
+
"""Draw rectangle elements."""
|
|
606
|
+
for rect in root.findall('.//svg:rect', ns):
|
|
607
|
+
x = float(rect.get('x', '0'))
|
|
608
|
+
y = float(rect.get('y', '0'))
|
|
609
|
+
width = float(rect.get('width', '0'))
|
|
610
|
+
height = float(rect.get('height', '0'))
|
|
611
|
+
stroke = parse_color(rect.get('stroke', 'none'))
|
|
612
|
+
fill = parse_color(rect.get('fill', 'none'))
|
|
613
|
+
stroke_width = float(rect.get('stroke-width', '1'))
|
|
614
|
+
if isinstance(fill, str) and fill in all_gradients:
|
|
615
|
+
grad = all_gradients[fill]
|
|
616
|
+
first_stop = grad['stops'][0][1] # Use color from first stop
|
|
617
|
+
draw_rect(c, x, y, width, height, stroke, first_stop[:3], stroke_width)
|
|
618
|
+
else:
|
|
619
|
+
draw_rect(c, x, y, width, height, stroke, fill, stroke_width)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def draw_direct_text(c, text, x, y, font_to_use, font_size, fill_color=None, text_anchor='start'):
|
|
623
|
+
"""Draw text at specified coordinates."""
|
|
624
|
+
c.saveState()
|
|
625
|
+
# Handle text anchor positioning
|
|
626
|
+
if text_anchor == 'middle':
|
|
627
|
+
text_width = pdfmetrics.stringWidth(text, font_to_use, font_size)
|
|
628
|
+
x -= text_width/2
|
|
629
|
+
elif text_anchor == 'end':
|
|
630
|
+
text_width = pdfmetrics.stringWidth(text, font_to_use, font_size)
|
|
631
|
+
x -= text_width
|
|
632
|
+
if fill_color:
|
|
633
|
+
c.setFillColorRGB(*fill_color)
|
|
634
|
+
# Since we've already flipped the canvas (scale 1, -1), we need to flip back
|
|
635
|
+
# for the text to be right side up
|
|
636
|
+
c.translate(x, y)
|
|
637
|
+
c.scale(1, -1)
|
|
638
|
+
# Now set the font and draw at the origin (0,0) since we've translated
|
|
639
|
+
c.setFont(font_to_use, font_size)
|
|
640
|
+
c.drawString(0, 0, text)
|
|
641
|
+
c.restoreState()
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def process_text_elements(c, root, all_paths, ns, font_to_use):
|
|
645
|
+
"""Process and draw text elements including text on paths."""
|
|
646
|
+
for text in root.findall('.//svg:text', ns):
|
|
647
|
+
font_size = float(text.get('font-size', '12'))
|
|
648
|
+
fill = parse_color(text.get('fill', '#000000'))
|
|
649
|
+
text_anchor = text.get('text-anchor', 'start')
|
|
650
|
+
# Check if this is a direct text element with x and y attributes
|
|
651
|
+
x = text.get('x')
|
|
652
|
+
y = text.get('y')
|
|
653
|
+
if x is not None and y is not None and text.text:
|
|
654
|
+
# This is a direct text element (not on a path)
|
|
655
|
+
x = float(x)
|
|
656
|
+
y = float(y)
|
|
657
|
+
text_content = text.text.strip()
|
|
658
|
+
if text_content:
|
|
659
|
+
draw_direct_text(c, text_content, x, y, font_to_use, font_size, fill, text_anchor)
|
|
660
|
+
continue
|
|
661
|
+
for textpath in text.findall('.//svg:textPath', ns):
|
|
662
|
+
href = textpath.get('{http://www.w3.org/1999/xlink}href')
|
|
663
|
+
if not href:
|
|
664
|
+
href = textpath.get('href')
|
|
665
|
+
if not href or not href.startswith('#'):
|
|
666
|
+
continue
|
|
667
|
+
path_id = href[1:]
|
|
668
|
+
if path_id not in all_paths:
|
|
669
|
+
continue
|
|
670
|
+
path_points = all_paths[path_id]['points']
|
|
671
|
+
text_content = ""
|
|
672
|
+
offset_y = 0
|
|
673
|
+
is_bold = False
|
|
674
|
+
for tspan in textpath.findall('.//svg:tspan', ns):
|
|
675
|
+
if tspan.text:
|
|
676
|
+
text_content += tspan.text
|
|
677
|
+
dy = tspan.get('dy', '')
|
|
678
|
+
if dy and 'em' in dy:
|
|
679
|
+
try:
|
|
680
|
+
em_value = float(dy.replace('em', ''))
|
|
681
|
+
offset_y = em_value * font_size
|
|
682
|
+
if abs(em_value + 3.15) < 0.01:
|
|
683
|
+
is_bold = True
|
|
684
|
+
except ValueError:
|
|
685
|
+
pass
|
|
686
|
+
if not text_content and textpath.text:
|
|
687
|
+
text_content = textpath.text
|
|
688
|
+
start_offset = textpath.get('startOffset', '50%')
|
|
689
|
+
offset_percent = 50
|
|
690
|
+
if start_offset.endswith('%'):
|
|
691
|
+
offset_percent = float(start_offset[:-1])
|
|
692
|
+
elif start_offset.isdigit():
|
|
693
|
+
offset_percent = float(start_offset) / path_length(path_points) * 100
|
|
694
|
+
draw_text_on_path(c, text_content, path_points, offset_percent,
|
|
695
|
+
font_to_use, font_size, fill, text_anchor, offset_y=offset_y, is_bold=is_bold)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def register_bundled_fonts():
|
|
699
|
+
"""Register bundled Comfortaa font."""
|
|
700
|
+
# Get paths to bundled font files
|
|
701
|
+
font_name = 'Comfortaa'
|
|
702
|
+
font_regular = pkg_resources.resource_filename('glycorender', 'fonts/Comfortaa-Regular.ttf')
|
|
703
|
+
font_bold = pkg_resources.resource_filename('glycorender', 'fonts/Comfortaa-Bold.ttf')
|
|
704
|
+
# Register fonts with ReportLab
|
|
705
|
+
pdfmetrics.registerFont(TTFont(font_name, font_regular))
|
|
706
|
+
pdfmetrics.registerFont(TTFont(f'{font_name}-Bold', font_bold))
|
|
707
|
+
# Create font family mapping
|
|
708
|
+
pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=f'{font_name}-Bold')
|
|
709
|
+
return font_name
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def convert_svg_to_pdf(svg_data, pdf_file_path, return_canvas=False):
|
|
713
|
+
"""Convert SVG to PDF with text path support."""
|
|
714
|
+
if isinstance(svg_data, bytes):
|
|
715
|
+
svg_data = svg_data.decode('utf-8')
|
|
716
|
+
root = ET.fromstring(svg_data)
|
|
717
|
+
ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
|
|
718
|
+
# Parse dimensions and set up canvas
|
|
719
|
+
width, height, vb_x, vb_y, scale_x, scale_y = parse_svg_dimensions(root)
|
|
720
|
+
c = canvas.Canvas(pdf_file_path, pagesize=(width, height))
|
|
721
|
+
# Register bundled font
|
|
722
|
+
font_to_use = register_bundled_fonts()
|
|
723
|
+
# Extract definitions and find connection paths
|
|
724
|
+
all_paths, all_gradients = extract_defs(root, ns)
|
|
725
|
+
connection_path_ids = find_connection_paths(root, all_paths, ns)
|
|
726
|
+
# Set up canvas transformation
|
|
727
|
+
c.translate(0, height)
|
|
728
|
+
c.scale(1, -1)
|
|
729
|
+
c.translate(-vb_x * scale_x, -vb_y * scale_y)
|
|
730
|
+
c.scale(scale_x, scale_y)
|
|
731
|
+
g_transform = root.find('.//{http://www.w3.org/2000/svg}g').get('transform', '')
|
|
732
|
+
if g_transform.startswith('rotate(90'):
|
|
733
|
+
pivot_match = re.search(r'rotate\(90\s+([-\d.]+)\s+([-\d.]+)', g_transform)
|
|
734
|
+
if pivot_match:
|
|
735
|
+
pivot_x, pivot_y = float(pivot_match.group(1)), float(pivot_match.group(2))
|
|
736
|
+
c.translate(pivot_x, pivot_y)
|
|
737
|
+
c.rotate(90)
|
|
738
|
+
c.translate(-pivot_x, -pivot_y)
|
|
739
|
+
# Draw elements in the correct order
|
|
740
|
+
draw_circles_with_gradients(c, root, all_gradients, ns)
|
|
741
|
+
draw_connection_paths(c, connection_path_ids, all_paths)
|
|
742
|
+
draw_circle_shapes(c, root, all_gradients, ns)
|
|
743
|
+
draw_rectangles(c, root, all_gradients, ns)
|
|
744
|
+
draw_paths(c, root, connection_path_ids, all_gradients, ns)
|
|
745
|
+
process_text_elements(c, root, all_paths, ns, font_to_use)
|
|
746
|
+
if return_canvas:
|
|
747
|
+
return c
|
|
748
|
+
c.save()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def convert_svg_to_png(svg_data, png_file_path=None, output_width=None, output_height=None, scale=None, return_bytes=False):
|
|
752
|
+
"""Convert SVG to PNG with pymupdf, with support for scaling and dimensions."""
|
|
753
|
+
temp_pdf = None
|
|
754
|
+
if png_file_path is None:
|
|
755
|
+
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
|
756
|
+
temp_pdf = tmp.name
|
|
757
|
+
pdf_path = temp_pdf
|
|
758
|
+
else:
|
|
759
|
+
pdf_path = f"{png_file_path.split('.')[0]}.pdf"
|
|
760
|
+
canvas_object = convert_svg_to_pdf(svg_data, pdf_path, return_canvas=True)
|
|
761
|
+
canvas_object.save()
|
|
762
|
+
doc = fitz.open(canvas_object._filename)
|
|
763
|
+
page = doc[0]
|
|
764
|
+
zoom_matrix = fitz.Matrix(1, 1)
|
|
765
|
+
if scale is not None:
|
|
766
|
+
zoom_matrix = fitz.Matrix(scale, scale)
|
|
767
|
+
elif output_width is not None or output_height is not None:
|
|
768
|
+
page_rect = page.rect
|
|
769
|
+
page_width, page_height = page_rect.width, page_rect.height
|
|
770
|
+
scale_x = output_width / page_width if output_width else 1
|
|
771
|
+
scale_y = output_height / page_height if output_height else 1
|
|
772
|
+
zoom_matrix = fitz.Matrix(scale_x, scale_y)
|
|
773
|
+
pix = page.get_pixmap(matrix=zoom_matrix)
|
|
774
|
+
if return_bytes:
|
|
775
|
+
png_data = pix.tobytes("png")
|
|
776
|
+
doc.close()
|
|
777
|
+
if temp_pdf:
|
|
778
|
+
os.remove(temp_pdf)
|
|
779
|
+
return png_data
|
|
780
|
+
else:
|
|
781
|
+
pix.save(png_file_path)
|
|
782
|
+
doc.close()
|
|
783
|
+
os.remove(pdf_path)
|
|
784
|
+
return None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glycorender
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform
|
|
5
|
+
Author-email: Daniel Bojar <daniel.bojar@gu.se>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/BojarLab/glycorender
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/BojarLab/glycorender/issues
|
|
9
|
+
Keywords: svg,pdf,renderer,glycans,chemistry,glycodraw
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
14
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: reportlab>=3.6.0
|
|
19
|
+
Requires-Dist: pymupdf>=1.19.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# glycorender
|
|
23
|
+
|
|
24
|
+
A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform, specialized in accurately rendering glycan structures with support for chemical notations and path-based text positioning.
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/glycorender/)
|
|
27
|
+
[](https://pypi.org/project/glycorender/)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Accurate SVG to PDF and PNG conversion for glycan structures
|
|
33
|
+
- Support for text on paths with proper positioning and orientation
|
|
34
|
+
- Special handling for monosaccharide properties like "Df" with proper styling
|
|
35
|
+
- Gradient fills for circles and shapes
|
|
36
|
+
- Precise connection path rendering between glycan elements
|
|
37
|
+
- Support for various SVG path commands
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install glycorender
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from glycorender import convert_svg_to_pdf, convert_svg_to_png
|
|
49
|
+
|
|
50
|
+
# Convert SVG file to PDF
|
|
51
|
+
with open('glycan_structure.svg', 'r') as f:
|
|
52
|
+
svg_data = f.read()
|
|
53
|
+
|
|
54
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
55
|
+
|
|
56
|
+
# Convert SVG file to PNG
|
|
57
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
58
|
+
|
|
59
|
+
# Or directly from SVG data
|
|
60
|
+
svg_data = """..."""
|
|
61
|
+
convert_svg_to_pdf(svg_data, 'output.pdf')
|
|
62
|
+
convert_svg_to_png(svg_data, 'output.png')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Use Cases
|
|
66
|
+
|
|
67
|
+
GlycoRender is specifically designed for:
|
|
68
|
+
|
|
69
|
+
- Converting glycan structure diagrams from GlycoDraw to publication-quality PDFs or PNGs
|
|
70
|
+
- Preserving the exact layout and styling of complex carbohydrate representations
|
|
71
|
+
- Ensuring chemical notations are properly formatted in the output
|
|
72
|
+
- Maintaining the correct connections between monosaccharide units
|
|
73
|
+
|
|
74
|
+
## API Reference
|
|
75
|
+
|
|
76
|
+
### `convert_svg_to_pdf(svg_data, pdf_file_path)`
|
|
77
|
+
|
|
78
|
+
Converts SVG data to a PDF file.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
82
|
+
- `pdf_file_path` (str): Path where the output PDF should be saved
|
|
83
|
+
|
|
84
|
+
### `convert_svg_to_png(svg_data, png_file_path)`
|
|
85
|
+
|
|
86
|
+
Converts SVG data to a PNG file.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
- `svg_data` (str or bytes): SVG content either as a string or bytes
|
|
90
|
+
- `png_file_path` (str): Path where the output PNG should be saved
|
|
91
|
+
|
|
92
|
+
## Supported SVG Elements
|
|
93
|
+
|
|
94
|
+
- `<path>`: Full support for path commands (M, L, H, V, Z, etc.)
|
|
95
|
+
- `<circle>`: Support for basic circles and gradient-filled circles
|
|
96
|
+
- `<rect>`: Support for rectangles with stroke and fill
|
|
97
|
+
- `<text>` and `<textPath>`: Support for text placement along paths with proper orientation
|
|
98
|
+
- `<defs>`: Support for path and gradient definitions
|
|
99
|
+
- `<radialGradient>`: Support for radial gradients with stops
|
|
100
|
+
|
|
101
|
+
## Dependencies
|
|
102
|
+
|
|
103
|
+
- ReportLab: For PDF generation
|
|
104
|
+
- PyMuPDF: For PNG conversion
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
glycorender/__init__.py
|
|
6
|
+
glycorender/render.py
|
|
7
|
+
glycorender.egg-info/PKG-INFO
|
|
8
|
+
glycorender.egg-info/SOURCES.txt
|
|
9
|
+
glycorender.egg-info/dependency_links.txt
|
|
10
|
+
glycorender.egg-info/requires.txt
|
|
11
|
+
glycorender.egg-info/top_level.txt
|
|
12
|
+
glycorender/fonts/Comfortaa-Bold.ttf
|
|
13
|
+
glycorender/fonts/Comfortaa-Regular.ttf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
glycorender
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "glycorender"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A bespoke SVG to PDF/PNG renderer for the GlycoDraw platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Daniel Bojar", email = "daniel.bojar@gu.se"}
|
|
12
|
+
]
|
|
13
|
+
license = {text = "MIT"}
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
19
|
+
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
|
20
|
+
]
|
|
21
|
+
keywords = ["svg", "pdf", "renderer", "glycans", "chemistry", "glycodraw"]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"reportlab>=3.6.0",
|
|
24
|
+
"pymupdf>=1.19.0"
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.9"
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
"Homepage" = "https://github.com/BojarLab/glycorender"
|
|
30
|
+
"Bug Tracker" = "https://github.com/BojarLab/glycorender/issues"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
packages = ["glycorender"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
glycorender = ["fonts/*.ttf", "fonts/LICENSE"]
|