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.
@@ -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,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include glycorender/fonts *.ttf LICENSE
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/glycorender.svg)](https://pypi.org/project/glycorender/)
27
+ [![Python Version](https://img.shields.io/pypi/pyversions/glycorender.svg)](https://pypi.org/project/glycorender/)
28
+ [![License](https://img.shields.io/github/license/BojarLab/glycorender.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/glycorender.svg)](https://pypi.org/project/glycorender/)
6
+ [![Python Version](https://img.shields.io/pypi/pyversions/glycorender.svg)](https://pypi.org/project/glycorender/)
7
+ [![License](https://img.shields.io/github/license/BojarLab/glycorender.svg)](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.
@@ -0,0 +1,3 @@
1
+ from .render import *
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/glycorender.svg)](https://pypi.org/project/glycorender/)
27
+ [![Python Version](https://img.shields.io/pypi/pyversions/glycorender.svg)](https://pypi.org/project/glycorender/)
28
+ [![License](https://img.shields.io/github/license/BojarLab/glycorender.svg)](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,2 @@
1
+ reportlab>=3.6.0
2
+ pymupdf>=1.19.0
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+