markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.4__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.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/METADATA +258 -157
- markdown_to_confluence-0.5.4.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +83 -44
- md2conf/api.py +30 -10
- md2conf/attachment.py +72 -0
- md2conf/coalesce.py +43 -0
- md2conf/collection.py +1 -1
- md2conf/{extra.py → compatibility.py} +1 -1
- md2conf/converter.py +240 -657
- md2conf/csf.py +13 -11
- md2conf/drawio/__init__.py +0 -0
- md2conf/drawio/extension.py +116 -0
- md2conf/{drawio.py → drawio/render.py} +1 -1
- md2conf/emoticon.py +3 -3
- md2conf/environment.py +2 -2
- md2conf/extension.py +82 -0
- md2conf/external.py +66 -0
- md2conf/formatting.py +135 -0
- md2conf/frontmatter.py +70 -0
- md2conf/image.py +128 -0
- md2conf/latex.py +4 -183
- md2conf/local.py +8 -8
- md2conf/markdown.py +1 -1
- md2conf/matcher.py +1 -1
- md2conf/mermaid/__init__.py +0 -0
- md2conf/mermaid/config.py +20 -0
- md2conf/mermaid/extension.py +109 -0
- md2conf/{mermaid.py → mermaid/render.py} +10 -38
- md2conf/mermaid/scanner.py +55 -0
- md2conf/metadata.py +1 -1
- md2conf/{domain.py → options.py} +75 -16
- md2conf/plantuml/__init__.py +0 -0
- md2conf/plantuml/config.py +20 -0
- md2conf/plantuml/extension.py +158 -0
- md2conf/plantuml/render.py +138 -0
- md2conf/plantuml/scanner.py +56 -0
- md2conf/png.py +206 -0
- md2conf/processor.py +55 -13
- md2conf/publisher.py +127 -39
- md2conf/scanner.py +38 -129
- md2conf/serializer.py +2 -2
- md2conf/svg.py +144 -103
- md2conf/text.py +1 -1
- md2conf/toc.py +73 -1
- md2conf/uri.py +1 -1
- md2conf/xml.py +1 -1
- markdown_to_confluence-0.5.2.dist-info/RECORD +0 -36
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.4.dist-info}/zip-safe +0 -0
- /md2conf/{puppeteer-config.json → mermaid/puppeteer-config.json} +0 -0
md2conf/scanner.py
CHANGED
|
@@ -1,55 +1,34 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import re
|
|
10
|
-
import typing
|
|
11
9
|
from dataclasses import dataclass
|
|
12
10
|
from pathlib import Path
|
|
13
|
-
from typing import
|
|
11
|
+
from typing import TypeVar
|
|
14
12
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
from .
|
|
13
|
+
from .coalesce import coalesce
|
|
14
|
+
from .frontmatter import extract_frontmatter_json, extract_value
|
|
15
|
+
from .options import LayoutOptions
|
|
18
16
|
from .serializer import JsonType, json_to_object
|
|
19
17
|
|
|
20
18
|
T = TypeVar("T")
|
|
21
19
|
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
values.append(match.group(1))
|
|
28
|
-
return ""
|
|
29
|
-
|
|
30
|
-
text = re.sub(pattern, _repl_func, text, count=1, flags=re.ASCII)
|
|
31
|
-
value = values[0] if values else None
|
|
32
|
-
return value, text
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def extract_frontmatter_block(text: str) -> tuple[str | None, str]:
|
|
36
|
-
"Extracts the front-matter from a Markdown document as a blob of unparsed text."
|
|
37
|
-
|
|
38
|
-
return extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def extract_frontmatter_properties(text: str) -> tuple[dict[str, JsonType] | None, str]:
|
|
42
|
-
"Extracts the front-matter from a Markdown document as a dictionary."
|
|
43
|
-
|
|
44
|
-
block, text = extract_frontmatter_block(text)
|
|
21
|
+
@dataclass
|
|
22
|
+
class AliasProperties:
|
|
23
|
+
"""
|
|
24
|
+
An object that holds properties extracted from the front-matter of a Markdown document.
|
|
45
25
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if isinstance(data, dict):
|
|
50
|
-
properties = typing.cast(dict[str, JsonType], data)
|
|
26
|
+
:param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
|
|
27
|
+
:param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
|
|
28
|
+
"""
|
|
51
29
|
|
|
52
|
-
|
|
30
|
+
confluence_page_id: str | None = None
|
|
31
|
+
confluence_space_key: str | None = None
|
|
53
32
|
|
|
54
33
|
|
|
55
34
|
@dataclass
|
|
@@ -59,26 +38,22 @@ class DocumentProperties:
|
|
|
59
38
|
|
|
60
39
|
:param page_id: Confluence page ID.
|
|
61
40
|
:param space_key: Confluence space key.
|
|
62
|
-
:param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
|
|
63
|
-
:param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
|
|
64
41
|
:param generated_by: Text identifying the tool that generated the document.
|
|
65
42
|
:param title: The title extracted from front-matter.
|
|
66
43
|
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
67
44
|
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
68
45
|
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
69
|
-
:param
|
|
46
|
+
:param layout: Layout options for content on a Confluence page.
|
|
70
47
|
"""
|
|
71
48
|
|
|
72
49
|
page_id: str | None = None
|
|
73
50
|
space_key: str | None = None
|
|
74
|
-
confluence_page_id: str | None = None
|
|
75
|
-
confluence_space_key: str | None = None
|
|
76
51
|
generated_by: str | None = None
|
|
77
52
|
title: str | None = None
|
|
78
53
|
tags: list[str] | None = None
|
|
79
54
|
synchronized: bool | None = None
|
|
80
55
|
properties: dict[str, JsonType] | None = None
|
|
81
|
-
|
|
56
|
+
layout: LayoutOptions | None = None
|
|
82
57
|
|
|
83
58
|
|
|
84
59
|
@dataclass
|
|
@@ -86,25 +61,11 @@ class ScannedDocument:
|
|
|
86
61
|
"""
|
|
87
62
|
An object that holds properties extracted from a Markdown document, including remaining source text.
|
|
88
63
|
|
|
89
|
-
:param
|
|
90
|
-
:param space_key: Confluence space key.
|
|
91
|
-
:param generated_by: Text identifying the tool that generated the document.
|
|
92
|
-
:param title: The title extracted from front-matter.
|
|
93
|
-
:param tags: A list of tags (content labels) extracted from front-matter.
|
|
94
|
-
:param synchronized: True if the document content is parsed and synchronized with Confluence.
|
|
95
|
-
:param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
|
|
96
|
-
:param alignment: Alignment for block-level images and formulas.
|
|
64
|
+
:param properties: Properties extracted from the front-matter of a Markdown document.
|
|
97
65
|
:param text: Text that remains after front-matter and inline properties have been extracted.
|
|
98
66
|
"""
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
space_key: str | None
|
|
102
|
-
generated_by: str | None
|
|
103
|
-
title: str | None
|
|
104
|
-
tags: list[str] | None
|
|
105
|
-
synchronized: bool | None
|
|
106
|
-
properties: dict[str, JsonType] | None
|
|
107
|
-
alignment: Literal["center", "left", "right"] | None
|
|
68
|
+
properties: DocumentProperties
|
|
108
69
|
text: str
|
|
109
70
|
|
|
110
71
|
|
|
@@ -114,10 +75,16 @@ class Scanner:
|
|
|
114
75
|
Extracts essential properties from a Markdown document.
|
|
115
76
|
"""
|
|
116
77
|
|
|
117
|
-
# parse file
|
|
118
78
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
119
79
|
text = f.read()
|
|
120
80
|
|
|
81
|
+
return self.parse(text)
|
|
82
|
+
|
|
83
|
+
def parse(self, text: str) -> ScannedDocument:
|
|
84
|
+
"""
|
|
85
|
+
Extracts essential properties from a Markdown document.
|
|
86
|
+
"""
|
|
87
|
+
|
|
121
88
|
# extract Confluence page ID
|
|
122
89
|
page_id, text = extract_value(r"<!--\s+confluence[-_]page[-_]id:\s*(\d+)\s+-->", text)
|
|
123
90
|
|
|
@@ -127,77 +94,19 @@ class Scanner:
|
|
|
127
94
|
# extract 'generated-by' tag text
|
|
128
95
|
generated_by, text = extract_value(r"<!--\s+generated[-_]by:\s*(.*)\s+-->", text)
|
|
129
96
|
|
|
130
|
-
|
|
131
|
-
tags: list[str] | None = None
|
|
132
|
-
synchronized: bool | None = None
|
|
133
|
-
properties: dict[str, JsonType] | None = None
|
|
134
|
-
alignment: Literal["center", "left", "right"] | None = None
|
|
97
|
+
body_props = DocumentProperties(page_id=page_id, space_key=space_key, generated_by=generated_by)
|
|
135
98
|
|
|
136
99
|
# extract front-matter
|
|
137
|
-
data, text =
|
|
100
|
+
data, text = extract_frontmatter_json(text)
|
|
138
101
|
if data is not None:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return ScannedDocument(
|
|
150
|
-
page_id=page_id,
|
|
151
|
-
space_key=space_key,
|
|
152
|
-
generated_by=generated_by,
|
|
153
|
-
title=title,
|
|
154
|
-
tags=tags,
|
|
155
|
-
synchronized=synchronized,
|
|
156
|
-
properties=properties,
|
|
157
|
-
alignment=alignment,
|
|
158
|
-
text=text,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
@dataclass
|
|
163
|
-
class MermaidProperties:
|
|
164
|
-
"""
|
|
165
|
-
An object that holds the front-matter properties structure for Mermaid diagrams.
|
|
166
|
-
|
|
167
|
-
:param title: The title of the diagram.
|
|
168
|
-
:param config: Configuration options for rendering.
|
|
169
|
-
"""
|
|
170
|
-
|
|
171
|
-
title: str | None = None
|
|
172
|
-
config: MermaidConfigProperties | None = None
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class MermaidScanner:
|
|
176
|
-
"""
|
|
177
|
-
Extracts properties from the JSON/YAML front-matter of a Mermaid diagram.
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
def read(self, content: str) -> MermaidProperties:
|
|
181
|
-
"""
|
|
182
|
-
Extracts rendering preferences from a Mermaid front-matter content.
|
|
183
|
-
|
|
184
|
-
```
|
|
185
|
-
---
|
|
186
|
-
title: Tiny flow diagram
|
|
187
|
-
config:
|
|
188
|
-
scale: 1
|
|
189
|
-
---
|
|
190
|
-
flowchart LR
|
|
191
|
-
A[Component A] --> B[Component B]
|
|
192
|
-
B --> C[Component C]
|
|
193
|
-
```
|
|
194
|
-
"""
|
|
195
|
-
|
|
196
|
-
properties, _ = extract_frontmatter_properties(content)
|
|
197
|
-
if properties is not None:
|
|
198
|
-
front_matter = json_to_object(MermaidProperties, properties)
|
|
199
|
-
config = front_matter.config or MermaidConfigProperties()
|
|
200
|
-
|
|
201
|
-
return MermaidProperties(title=front_matter.title, config=config)
|
|
202
|
-
|
|
203
|
-
return MermaidProperties()
|
|
102
|
+
frontmatter_props = json_to_object(DocumentProperties, data)
|
|
103
|
+
alias_props = json_to_object(AliasProperties, data)
|
|
104
|
+
if alias_props.confluence_page_id is not None:
|
|
105
|
+
frontmatter_props.page_id = alias_props.confluence_page_id
|
|
106
|
+
if alias_props.confluence_space_key is not None:
|
|
107
|
+
frontmatter_props.space_key = alias_props.confluence_space_key
|
|
108
|
+
props = coalesce(body_props, frontmatter_props)
|
|
109
|
+
else:
|
|
110
|
+
props = body_props
|
|
111
|
+
|
|
112
|
+
return ScannedDocument(properties=props, text=text)
|
md2conf/serializer.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from typing import TypeVar
|
|
12
12
|
|
|
13
|
-
from cattrs.preconf.orjson import make_converter
|
|
13
|
+
from cattrs.preconf.orjson import make_converter # spellchecker:disable-line
|
|
14
14
|
|
|
15
15
|
JsonType = None | bool | int | float | str | dict[str, "JsonType"] | list["JsonType"]
|
|
16
16
|
JsonComposite = dict[str, "JsonType"] | list["JsonType"]
|
md2conf/svg.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
@@ -9,6 +9,7 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
9
9
|
import logging
|
|
10
10
|
import re
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
from typing import overload
|
|
12
13
|
|
|
13
14
|
import lxml.etree as ET
|
|
14
15
|
|
|
@@ -19,6 +20,10 @@ LOGGER = logging.getLogger(__name__)
|
|
|
19
20
|
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
class SVGParseError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
22
27
|
def _check_svg(root: ElementType) -> bool:
|
|
23
28
|
"Tests if the element is a plain or scoped SVG element."
|
|
24
29
|
|
|
@@ -31,7 +36,7 @@ def _check_svg(root: ElementType) -> bool:
|
|
|
31
36
|
return qname.localname == "svg" and (not qname.namespace or qname.namespace == SVG_NAMESPACE)
|
|
32
37
|
|
|
33
38
|
|
|
34
|
-
def _extract_dimensions_from_root(root: ElementType) -> tuple[int
|
|
39
|
+
def _extract_dimensions_from_root(root: ElementType) -> tuple[int, int] | None:
|
|
35
40
|
"""
|
|
36
41
|
Extracts width and height from an SVG root element.
|
|
37
42
|
|
|
@@ -40,11 +45,11 @@ def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int |
|
|
|
40
45
|
2. The viewBox attribute if width/height are not specified
|
|
41
46
|
|
|
42
47
|
:param root: The root element of the SVG document.
|
|
43
|
-
:returns: A tuple of (width, height) in pixels, or
|
|
48
|
+
:returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
|
|
44
49
|
"""
|
|
45
50
|
|
|
46
51
|
if not _check_svg(root):
|
|
47
|
-
|
|
52
|
+
raise SVGParseError("SVG file does not have an <svg> root element")
|
|
48
53
|
|
|
49
54
|
width_attr = root.get("width")
|
|
50
55
|
height_attr = root.get("height")
|
|
@@ -52,69 +57,86 @@ def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int |
|
|
|
52
57
|
width = _parse_svg_length(width_attr) if width_attr else None
|
|
53
58
|
height = _parse_svg_length(height_attr) if height_attr else None
|
|
54
59
|
|
|
55
|
-
#
|
|
60
|
+
# if width/height not specified, try to derive from view-box
|
|
56
61
|
if width is None or height is None:
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
viewbox_attr = root.get("viewBox")
|
|
63
|
+
if viewbox_attr:
|
|
64
|
+
viewbox = _parse_viewbox(viewbox_attr)
|
|
65
|
+
if viewbox is not None:
|
|
66
|
+
vb_width, vb_height = viewbox
|
|
67
|
+
if width is not None:
|
|
68
|
+
height = width * vb_height // vb_width
|
|
69
|
+
elif height is not None:
|
|
70
|
+
width = height * vb_width // vb_height
|
|
71
|
+
else:
|
|
72
|
+
width = vb_width
|
|
73
|
+
height = vb_height
|
|
74
|
+
|
|
75
|
+
if width is None or height is None:
|
|
76
|
+
return None
|
|
64
77
|
|
|
65
78
|
return width, height
|
|
66
79
|
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
@overload
|
|
82
|
+
def get_svg_dimensions(svg: Path) -> tuple[int, int] | None:
|
|
69
83
|
"""
|
|
70
84
|
Extracts width and height from an SVG file.
|
|
71
85
|
|
|
72
86
|
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
87
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
88
|
+
1. Explicit `width` and `height` attributes on the root `<svg>` element
|
|
89
|
+
2. The `viewBox` attribute if `width` or `height` is not specified
|
|
87
90
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
LOGGER.warning("Unexpected error reading SVG dimensions from %s: %s", path, ex)
|
|
93
|
-
return None, None
|
|
91
|
+
:param svg: Path to the SVG file.
|
|
92
|
+
:returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
|
|
93
|
+
"""
|
|
94
|
+
...
|
|
94
95
|
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
@overload
|
|
98
|
+
def get_svg_dimensions(svg: bytes | str) -> tuple[int, int] | None:
|
|
97
99
|
"""
|
|
98
100
|
Extracts width and height from SVG data in memory.
|
|
99
101
|
|
|
100
102
|
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
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"""
|
|
104
|
+
1. Explicit `width` and `height` attributes on the root `<svg>` element
|
|
105
|
+
2. The `viewBox` attribute if `width` or `height` is not specified
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
:param svg: The SVG content as bytes.
|
|
108
|
+
:returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_svg_dimensions(svg: Path | bytes | str) -> tuple[int, int] | None:
|
|
114
|
+
if isinstance(svg, Path):
|
|
115
|
+
path = svg
|
|
116
|
+
try:
|
|
117
|
+
tree = ET.parse(path)
|
|
118
|
+
root = tree.getroot()
|
|
119
|
+
return _extract_dimensions_from_root(root)
|
|
120
|
+
except OSError as ex:
|
|
121
|
+
LOGGER.warning("Failed to open SVG file: %s", path, exc_info=ex)
|
|
122
|
+
return None
|
|
123
|
+
except ET.XMLSyntaxError as ex:
|
|
124
|
+
LOGGER.warning("Failed to parse SVG file: %s", path, exc_info=ex)
|
|
125
|
+
return None
|
|
126
|
+
except SVGParseError as ex:
|
|
127
|
+
LOGGER.warning("Failed to extract dimensions from SVG file: %s", path, exc_info=ex)
|
|
128
|
+
return None
|
|
129
|
+
else:
|
|
130
|
+
data = svg
|
|
131
|
+
try:
|
|
132
|
+
root = ET.fromstring(data)
|
|
133
|
+
return _extract_dimensions_from_root(root)
|
|
134
|
+
except ET.XMLSyntaxError as ex:
|
|
135
|
+
LOGGER.warning("Failed to parse SVG data", exc_info=ex)
|
|
136
|
+
return None
|
|
137
|
+
except SVGParseError as ex:
|
|
138
|
+
LOGGER.warning("Failed to extract dimensions from SVG data", exc_info=ex)
|
|
139
|
+
return None
|
|
118
140
|
|
|
119
141
|
|
|
120
142
|
def _serialize_svg_opening_tag(root: ElementType) -> str:
|
|
@@ -128,7 +150,7 @@ def _serialize_svg_opening_tag(root: ElementType) -> str:
|
|
|
128
150
|
# Build the opening tag from element name and attributes
|
|
129
151
|
root_tag = root.tag
|
|
130
152
|
if not isinstance(root_tag, str):
|
|
131
|
-
raise
|
|
153
|
+
raise SVGParseError("expected: tag names as `str`")
|
|
132
154
|
tag_name = ET.QName(root_tag).localname
|
|
133
155
|
parts = [f"<{tag_name}"]
|
|
134
156
|
|
|
@@ -180,64 +202,56 @@ def fix_svg_dimensions(data: bytes) -> bytes:
|
|
|
180
202
|
:returns: The modified SVG content with explicit dimensions, or original data if modification fails.
|
|
181
203
|
"""
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
# Skip SVGs with foreignObject - Confluence has issues rendering
|
|
206
|
+
# foreignObject content when explicit width/height are set on the SVG
|
|
207
|
+
if b"<foreignObject" in data:
|
|
208
|
+
LOGGER.debug("Skipping dimension fix for SVG with foreignObject elements")
|
|
209
|
+
return data
|
|
205
210
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if not viewbox:
|
|
209
|
-
return data
|
|
211
|
+
# Parse the SVG to extract root element attributes
|
|
212
|
+
root = ET.fromstring(data)
|
|
210
213
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
# Verify it's an SVG element
|
|
215
|
+
if not _check_svg(root):
|
|
216
|
+
return data
|
|
214
217
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
# Check if we need to fix (has width="100%" or similar percentage)
|
|
219
|
+
width_attr = root.get("width")
|
|
220
|
+
if width_attr != "100%":
|
|
221
|
+
# Check if it already has a valid numeric width
|
|
222
|
+
if width_attr is not None and _parse_svg_length(width_attr) is not None:
|
|
223
|
+
return data # Already has numeric width
|
|
224
|
+
|
|
225
|
+
# Get viewBox dimensions
|
|
226
|
+
viewbox_attr = root.get("viewBox")
|
|
227
|
+
if not viewbox_attr:
|
|
228
|
+
return data
|
|
219
229
|
|
|
220
|
-
|
|
230
|
+
viewbox = _parse_viewbox(viewbox_attr)
|
|
231
|
+
if viewbox is None:
|
|
232
|
+
return data
|
|
233
|
+
vb_width, vb_height = viewbox
|
|
221
234
|
|
|
222
|
-
|
|
223
|
-
|
|
235
|
+
# Extract the original opening tag from the text
|
|
236
|
+
svg_tag_match = re.search(rb"<svg\b[^>]*>", data)
|
|
237
|
+
if not svg_tag_match:
|
|
238
|
+
return data
|
|
224
239
|
|
|
225
|
-
|
|
226
|
-
height_attr = root.get("height")
|
|
227
|
-
if height_attr is None or height_attr == "100%":
|
|
228
|
-
root.set("height", str(vb_height))
|
|
240
|
+
original_tag = svg_tag_match.group(0)
|
|
229
241
|
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
# Modify the root element's attributes
|
|
243
|
+
root.set("width", str(vb_width))
|
|
232
244
|
|
|
233
|
-
|
|
234
|
-
|
|
245
|
+
# Set height if missing or if it's a percentage
|
|
246
|
+
height_attr = root.get("height")
|
|
247
|
+
if height_attr is None or height_attr == "100%":
|
|
248
|
+
root.set("height", str(vb_height))
|
|
235
249
|
|
|
236
|
-
|
|
250
|
+
# Serialize just the opening tag with modified attributes
|
|
251
|
+
new_tag = _serialize_svg_opening_tag(root).encode("utf-8")
|
|
237
252
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return data
|
|
253
|
+
# Replace the original opening tag with the new one
|
|
254
|
+
return data.replace(original_tag, new_tag, 1)
|
|
241
255
|
|
|
242
256
|
|
|
243
257
|
def _parse_svg_length(value: str) -> int | None:
|
|
@@ -294,26 +308,53 @@ def _parse_svg_length(value: str) -> int | None:
|
|
|
294
308
|
return int(round(pixels))
|
|
295
309
|
|
|
296
310
|
|
|
297
|
-
def _parse_viewbox(viewbox: str) -> tuple[int
|
|
311
|
+
def _parse_viewbox(viewbox: str) -> tuple[int, int] | None:
|
|
298
312
|
"""
|
|
299
313
|
Parses an SVG viewBox attribute and extracts width and height.
|
|
300
314
|
|
|
301
315
|
:param viewbox: The viewBox string (e.g., "0 0 100 200").
|
|
302
|
-
:returns: A tuple of (width, height) in pixels, or
|
|
316
|
+
:returns: A tuple of (width, height) in pixels, or `None` if parsing fails.
|
|
303
317
|
"""
|
|
304
318
|
|
|
305
319
|
if not viewbox:
|
|
306
|
-
return None
|
|
320
|
+
return None
|
|
307
321
|
|
|
308
322
|
# viewBox format: "min-x min-y width height"
|
|
309
323
|
# Values can be separated by whitespace and/or commas
|
|
310
324
|
parts = re.split(r"[\s,]+", viewbox.strip())
|
|
311
325
|
if len(parts) != 4:
|
|
312
|
-
return None
|
|
326
|
+
return None
|
|
313
327
|
|
|
314
328
|
try:
|
|
315
329
|
width = int(round(float(parts[2])))
|
|
316
330
|
height = int(round(float(parts[3])))
|
|
317
331
|
return width, height
|
|
318
332
|
except ValueError:
|
|
319
|
-
return None
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def fix_svg_get_dimensions(data: bytes) -> tuple[bytes, tuple[int, int] | None]:
|
|
337
|
+
"""
|
|
338
|
+
Post-processes SVG diagram data by fixing dimensions and extracting metadata.
|
|
339
|
+
|
|
340
|
+
This handles the common pattern for SVG diagrams:
|
|
341
|
+
|
|
342
|
+
1. fixes SVG dimensions (converts percentage-based to explicit pixels), and
|
|
343
|
+
2. extracts width/height from the SVG.
|
|
344
|
+
|
|
345
|
+
:param data: Raw SVG data as bytes.
|
|
346
|
+
:returns: Tuple of update raw data, image width, image height.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
# fix SVG to have explicit width/height instead of percentages
|
|
351
|
+
data = fix_svg_dimensions(data)
|
|
352
|
+
|
|
353
|
+
# extract dimensions from the fixed SVG
|
|
354
|
+
return data, get_svg_dimensions(data)
|
|
355
|
+
except ET.XMLSyntaxError as ex:
|
|
356
|
+
LOGGER.warning("Failed to parse SVG data", exc_info=ex)
|
|
357
|
+
return data, None
|
|
358
|
+
except SVGParseError as ex:
|
|
359
|
+
LOGGER.warning("Failed to extract dimensions from SVG data", exc_info=ex)
|
|
360
|
+
return data, None
|