markdown-to-confluence 0.5.1__py3-none-any.whl → 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/METADATA +82 -9
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/RECORD +16 -15
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +56 -9
- md2conf/api.py +28 -2
- md2conf/converter.py +282 -38
- md2conf/domain.py +10 -3
- md2conf/latex.py +4 -4
- md2conf/publisher.py +3 -0
- md2conf/scanner.py +2 -2
- md2conf/svg.py +319 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/zip-safe +0 -0
md2conf/svg.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SVG dimension extraction utilities.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import lxml.etree as ET
|
|
14
|
+
|
|
15
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
16
|
+
|
|
17
|
+
LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_svg(root: ElementType) -> bool:
|
|
23
|
+
"Tests if the element is a plain or scoped SVG element."
|
|
24
|
+
|
|
25
|
+
root_tag = root.tag
|
|
26
|
+
if not isinstance(root_tag, str):
|
|
27
|
+
raise TypeError("expected: tag names as `str`")
|
|
28
|
+
|
|
29
|
+
# Handle namespaced and non-namespaced SVG
|
|
30
|
+
qname = ET.QName(root_tag)
|
|
31
|
+
return qname.localname == "svg" and (not qname.namespace or qname.namespace == SVG_NAMESPACE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int | None]:
|
|
35
|
+
"""
|
|
36
|
+
Extracts width and height from an SVG root element.
|
|
37
|
+
|
|
38
|
+
Attempts to read dimensions from:
|
|
39
|
+
1. Explicit width/height attributes on the root <svg> element
|
|
40
|
+
2. The viewBox attribute if width/height are not specified
|
|
41
|
+
|
|
42
|
+
:param root: The root element of the SVG document.
|
|
43
|
+
:returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
if not _check_svg(root):
|
|
47
|
+
return None, None
|
|
48
|
+
|
|
49
|
+
width_attr = root.get("width")
|
|
50
|
+
height_attr = root.get("height")
|
|
51
|
+
|
|
52
|
+
width = _parse_svg_length(width_attr) if width_attr else None
|
|
53
|
+
height = _parse_svg_length(height_attr) if height_attr else None
|
|
54
|
+
|
|
55
|
+
# If width/height not specified, try to derive from viewBox
|
|
56
|
+
if width is None or height is None:
|
|
57
|
+
viewbox = root.get("viewBox")
|
|
58
|
+
if viewbox:
|
|
59
|
+
vb_width, vb_height = _parse_viewbox(viewbox)
|
|
60
|
+
if width is None:
|
|
61
|
+
width = vb_width
|
|
62
|
+
if height is None:
|
|
63
|
+
height = vb_height
|
|
64
|
+
|
|
65
|
+
return width, height
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_svg_dimensions(path: Path) -> tuple[int | None, int | None]:
|
|
69
|
+
"""
|
|
70
|
+
Extracts width and height from an SVG file.
|
|
71
|
+
|
|
72
|
+
Attempts to read dimensions from:
|
|
73
|
+
1. Explicit width/height attributes on the root <svg> element
|
|
74
|
+
2. The viewBox attribute if width/height are not specified
|
|
75
|
+
|
|
76
|
+
:param path: Path to the SVG file.
|
|
77
|
+
:returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
tree = ET.parse(str(path))
|
|
82
|
+
root = tree.getroot()
|
|
83
|
+
width, height = _extract_dimensions_from_root(root)
|
|
84
|
+
if width is None and height is None:
|
|
85
|
+
LOGGER.warning("SVG file %s does not have an <svg> root element", path)
|
|
86
|
+
return width, height
|
|
87
|
+
|
|
88
|
+
except ET.XMLSyntaxError as ex:
|
|
89
|
+
LOGGER.warning("Failed to parse SVG file %s: %s", path, ex)
|
|
90
|
+
return None, None
|
|
91
|
+
except Exception as ex:
|
|
92
|
+
LOGGER.warning("Unexpected error reading SVG dimensions from %s: %s", path, ex)
|
|
93
|
+
return None, None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_svg_dimensions_from_bytes(data: bytes) -> tuple[int | None, int | None]:
|
|
97
|
+
"""
|
|
98
|
+
Extracts width and height from SVG data in memory.
|
|
99
|
+
|
|
100
|
+
Attempts to read dimensions from:
|
|
101
|
+
1. Explicit width/height attributes on the root <svg> element
|
|
102
|
+
2. The viewBox attribute if width/height are not specified
|
|
103
|
+
|
|
104
|
+
:param data: The SVG content as bytes.
|
|
105
|
+
:returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
root = ET.fromstring(data)
|
|
110
|
+
return _extract_dimensions_from_root(root)
|
|
111
|
+
|
|
112
|
+
except ET.XMLSyntaxError as ex:
|
|
113
|
+
LOGGER.warning("Failed to parse SVG data: %s", ex)
|
|
114
|
+
return None, None
|
|
115
|
+
except Exception as ex:
|
|
116
|
+
LOGGER.warning("Unexpected error reading SVG dimensions from data: %s", ex)
|
|
117
|
+
return None, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _serialize_svg_opening_tag(root: ElementType) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Serializes just the opening tag of an SVG element (without children or closing tag).
|
|
123
|
+
|
|
124
|
+
:param root: The root SVG element.
|
|
125
|
+
:returns: The opening tag string, e.g., '<svg width="100" height="200" ...>'.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
# Build the opening tag from element name and attributes
|
|
129
|
+
root_tag = root.tag
|
|
130
|
+
if not isinstance(root_tag, str):
|
|
131
|
+
raise TypeError("expected: tag names as `str`")
|
|
132
|
+
tag_name = ET.QName(root_tag).localname
|
|
133
|
+
parts = [f"<{tag_name}"]
|
|
134
|
+
|
|
135
|
+
# Add namespace declarations (nsmap)
|
|
136
|
+
for prefix, uri in root.nsmap.items():
|
|
137
|
+
if prefix is None:
|
|
138
|
+
parts.append(f' xmlns="{uri}"')
|
|
139
|
+
else:
|
|
140
|
+
parts.append(f' xmlns:{prefix}="{uri}"')
|
|
141
|
+
|
|
142
|
+
# Add attributes
|
|
143
|
+
for name, value in root.attrib.items():
|
|
144
|
+
qname = ET.QName(name)
|
|
145
|
+
|
|
146
|
+
# Handle namespaced attributes
|
|
147
|
+
if qname.namespace:
|
|
148
|
+
# Find prefix for this namespace
|
|
149
|
+
prefix = None
|
|
150
|
+
for p, u in root.nsmap.items():
|
|
151
|
+
if u == qname.namespace and p is not None:
|
|
152
|
+
prefix = p
|
|
153
|
+
break
|
|
154
|
+
if prefix:
|
|
155
|
+
parts.append(f' {prefix}:{qname.localname}="{value}"')
|
|
156
|
+
else:
|
|
157
|
+
parts.append(f' {qname.localname}="{value}"')
|
|
158
|
+
else:
|
|
159
|
+
parts.append(f' {name}="{value}"')
|
|
160
|
+
|
|
161
|
+
parts.append(">")
|
|
162
|
+
return "".join(parts)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fix_svg_dimensions(data: bytes) -> bytes:
|
|
166
|
+
"""
|
|
167
|
+
Fixes SVG data by setting explicit width/height attributes based on viewBox.
|
|
168
|
+
|
|
169
|
+
Mermaid generates SVGs with width="100%" which Confluence doesn't handle well.
|
|
170
|
+
This function replaces percentage-based dimensions with explicit pixel values
|
|
171
|
+
derived from the viewBox.
|
|
172
|
+
|
|
173
|
+
Note: SVGs containing foreignObject elements are NOT modified, as Confluence
|
|
174
|
+
has rendering issues with foreignObject when explicit dimensions are set.
|
|
175
|
+
|
|
176
|
+
Uses lxml to parse and modify the root element's attributes, then replaces
|
|
177
|
+
just the opening tag in the original document to preserve the rest exactly.
|
|
178
|
+
|
|
179
|
+
:param data: The SVG content as bytes.
|
|
180
|
+
:returns: The modified SVG content with explicit dimensions, or original data if modification fails.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
text = data.decode("utf-8")
|
|
185
|
+
|
|
186
|
+
# Skip SVGs with foreignObject - Confluence has issues rendering
|
|
187
|
+
# foreignObject content when explicit width/height are set on the SVG
|
|
188
|
+
if "<foreignObject" in text:
|
|
189
|
+
LOGGER.debug("Skipping dimension fix for SVG with foreignObject elements")
|
|
190
|
+
return data
|
|
191
|
+
|
|
192
|
+
# Parse the SVG to extract root element attributes
|
|
193
|
+
root = ET.fromstring(data)
|
|
194
|
+
|
|
195
|
+
# Verify it's an SVG element
|
|
196
|
+
if not _check_svg(root):
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
# Check if we need to fix (has width="100%" or similar percentage)
|
|
200
|
+
width_attr = root.get("width")
|
|
201
|
+
if width_attr != "100%":
|
|
202
|
+
# Check if it already has a valid numeric width
|
|
203
|
+
if width_attr is not None and _parse_svg_length(width_attr) is not None:
|
|
204
|
+
return data # Already has numeric width
|
|
205
|
+
|
|
206
|
+
# Get viewBox dimensions
|
|
207
|
+
viewbox = root.get("viewBox")
|
|
208
|
+
if not viewbox:
|
|
209
|
+
return data
|
|
210
|
+
|
|
211
|
+
vb_width, vb_height = _parse_viewbox(viewbox)
|
|
212
|
+
if vb_width is None or vb_height is None:
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
# Extract the original opening tag from the text
|
|
216
|
+
svg_tag_match = re.search(r"<svg\b[^>]*>", text)
|
|
217
|
+
if not svg_tag_match:
|
|
218
|
+
return data
|
|
219
|
+
|
|
220
|
+
original_tag = svg_tag_match.group(0)
|
|
221
|
+
|
|
222
|
+
# Modify the root element's attributes
|
|
223
|
+
root.set("width", str(vb_width))
|
|
224
|
+
|
|
225
|
+
# Set height if missing or if it's a percentage
|
|
226
|
+
height_attr = root.get("height")
|
|
227
|
+
if height_attr is None or height_attr == "100%":
|
|
228
|
+
root.set("height", str(vb_height))
|
|
229
|
+
|
|
230
|
+
# Serialize just the opening tag with modified attributes
|
|
231
|
+
new_tag = _serialize_svg_opening_tag(root)
|
|
232
|
+
|
|
233
|
+
# Replace the original opening tag with the new one
|
|
234
|
+
text = text.replace(original_tag, new_tag, 1)
|
|
235
|
+
|
|
236
|
+
return text.encode("utf-8")
|
|
237
|
+
|
|
238
|
+
except Exception as ex:
|
|
239
|
+
LOGGER.warning("Unexpected error fixing SVG dimensions: %s", ex)
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_svg_length(value: str) -> int | None:
|
|
244
|
+
"""
|
|
245
|
+
Parses an SVG length value and converts it to pixels.
|
|
246
|
+
|
|
247
|
+
Supports: px, pt, em, ex, in, cm, mm, pc, and unit-less values.
|
|
248
|
+
For simplicity, assumes 96 DPI and 16px base font size.
|
|
249
|
+
|
|
250
|
+
:param value: The SVG length string (e.g., "100", "100px", "10em").
|
|
251
|
+
:returns: The length in pixels as an integer, or None if parsing fails.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
if not value:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
value = value.strip()
|
|
258
|
+
|
|
259
|
+
# Match number with optional unit
|
|
260
|
+
match = re.match(r"^([+-]?(?:\d+\.?\d*|\.\d+))(%|px|pt|em|ex|in|cm|mm|pc)?$", value, re.IGNORECASE)
|
|
261
|
+
if not match:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
num_str, unit = match.groups()
|
|
265
|
+
try:
|
|
266
|
+
num = float(num_str)
|
|
267
|
+
except ValueError:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# Convert to pixels (assuming 96 DPI, 16px base font)
|
|
271
|
+
match unit.lower() if unit else None:
|
|
272
|
+
case None | "px":
|
|
273
|
+
pixels = num
|
|
274
|
+
case "pt":
|
|
275
|
+
pixels = num * 96 / 72 # 1pt = 1/72 inch
|
|
276
|
+
case "in":
|
|
277
|
+
pixels = num * 96
|
|
278
|
+
case "cm":
|
|
279
|
+
pixels = num * 96 / 2.54
|
|
280
|
+
case "mm":
|
|
281
|
+
pixels = num * 96 / 25.4
|
|
282
|
+
case "pc":
|
|
283
|
+
pixels = num * 96 / 6 # 1pc = 12pt = 1/6 inch
|
|
284
|
+
case "em":
|
|
285
|
+
pixels = num * 16 # assume 16px base font
|
|
286
|
+
case "ex":
|
|
287
|
+
pixels = num * 8 # assume ex ≈ 0.5em
|
|
288
|
+
case "%":
|
|
289
|
+
# Percentage values can't be resolved without a container; skip
|
|
290
|
+
return None
|
|
291
|
+
case _:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
return int(round(pixels))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _parse_viewbox(viewbox: str) -> tuple[int | None, int | None]:
|
|
298
|
+
"""
|
|
299
|
+
Parses an SVG viewBox attribute and extracts width and height.
|
|
300
|
+
|
|
301
|
+
:param viewbox: The viewBox string (e.g., "0 0 100 200").
|
|
302
|
+
:returns: A tuple of (width, height) in pixels, or (None, None) if parsing fails.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
if not viewbox:
|
|
306
|
+
return None, None
|
|
307
|
+
|
|
308
|
+
# viewBox format: "min-x min-y width height"
|
|
309
|
+
# Values can be separated by whitespace and/or commas
|
|
310
|
+
parts = re.split(r"[\s,]+", viewbox.strip())
|
|
311
|
+
if len(parts) != 4:
|
|
312
|
+
return None, None
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
width = int(round(float(parts[2])))
|
|
316
|
+
height = int(round(float(parts[3])))
|
|
317
|
+
return width, height
|
|
318
|
+
except ValueError:
|
|
319
|
+
return None, None
|
|
File without changes
|
{markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|