markdown-to-confluence 0.5.3__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.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/METADATA +182 -157
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/RECORD +26 -26
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +42 -21
- md2conf/api.py +3 -2
- md2conf/converter.py +8 -8
- md2conf/drawio/extension.py +3 -3
- md2conf/extension.py +4 -0
- md2conf/external.py +25 -8
- md2conf/image.py +10 -9
- md2conf/mermaid/render.py +1 -1
- md2conf/options.py +2 -0
- md2conf/plantuml/extension.py +6 -6
- md2conf/plantuml/render.py +6 -7
- md2conf/png.py +10 -6
- md2conf/processor.py +24 -3
- md2conf/publisher.py +114 -22
- md2conf/scanner.py +7 -1
- md2conf/svg.py +128 -109
- md2conf/toc.py +72 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/licenses/LICENSE +0 -0
- {markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.3.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/publisher.py
CHANGED
|
@@ -23,6 +23,65 @@ from .xml import is_xml_equal, unwrap_substitute
|
|
|
23
23
|
LOGGER = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
class _MissingType:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_MissingDefault = _MissingType()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParentCatalog:
|
|
34
|
+
"Maintains a catalog of child-parent relationships."
|
|
35
|
+
|
|
36
|
+
_api: ConfluenceSession
|
|
37
|
+
_child_to_parent: dict[str, str | None]
|
|
38
|
+
_known: set[str]
|
|
39
|
+
|
|
40
|
+
def __init__(self, api: ConfluenceSession) -> None:
|
|
41
|
+
self._api = api
|
|
42
|
+
self._child_to_parent = {}
|
|
43
|
+
self._known = set()
|
|
44
|
+
|
|
45
|
+
def add_known(self, page_id: str) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Adds a new well-known page such as the root page or a page paired with a Markdown file using an explicit page ID.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
self._known.add(page_id)
|
|
51
|
+
|
|
52
|
+
def add_parent(self, *, page_id: str, parent_id: str | None) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Adds a new child-parent relationship.
|
|
55
|
+
|
|
56
|
+
This method is useful to persist information acquired by a previous API call.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
self._child_to_parent[page_id] = parent_id
|
|
60
|
+
|
|
61
|
+
def is_traceable(self, page_id: str) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Verifies if a page traces back to a well-known root page.
|
|
64
|
+
|
|
65
|
+
:param page_id: The page to check.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
if page_id in self._known:
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
known_parent_id = self._child_to_parent.get(page_id, _MissingDefault)
|
|
72
|
+
if not isinstance(known_parent_id, _MissingType):
|
|
73
|
+
parent_id = known_parent_id
|
|
74
|
+
else:
|
|
75
|
+
page = self._api.get_page_properties(page_id)
|
|
76
|
+
parent_id = page.parentId
|
|
77
|
+
self._child_to_parent[page_id] = parent_id
|
|
78
|
+
|
|
79
|
+
if parent_id is None:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return self.is_traceable(parent_id)
|
|
83
|
+
|
|
84
|
+
|
|
26
85
|
class SynchronizingProcessor(Processor):
|
|
27
86
|
"""
|
|
28
87
|
Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
|
|
@@ -59,14 +118,18 @@ class SynchronizingProcessor(Processor):
|
|
|
59
118
|
elif root_id is not None:
|
|
60
119
|
real_id = root_id
|
|
61
120
|
else:
|
|
62
|
-
raise NotImplementedError("condition not exhaustive")
|
|
121
|
+
raise NotImplementedError("condition not exhaustive for synchronizing tree")
|
|
63
122
|
|
|
64
|
-
self.
|
|
123
|
+
catalog = ParentCatalog(self.api)
|
|
124
|
+
catalog.add_known(real_id.page_id)
|
|
125
|
+
self._synchronize_subtree(tree, real_id, catalog)
|
|
65
126
|
|
|
66
|
-
def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID) -> None:
|
|
127
|
+
def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID, catalog: ParentCatalog) -> None:
|
|
67
128
|
if node.page_id is not None:
|
|
68
129
|
# verify if page exists
|
|
69
130
|
page = self.api.get_page_properties(node.page_id)
|
|
131
|
+
catalog.add_known(page.id)
|
|
132
|
+
catalog.add_parent(page_id=page.id, parent_id=page.parentId)
|
|
70
133
|
update = False
|
|
71
134
|
else:
|
|
72
135
|
if node.title is not None:
|
|
@@ -77,20 +140,26 @@ class SynchronizingProcessor(Processor):
|
|
|
77
140
|
digest = self._generate_hash(node.absolute_path)
|
|
78
141
|
title = f"{node.absolute_path.stem} [{digest}]"
|
|
79
142
|
|
|
80
|
-
|
|
81
|
-
title = f"{self.options.title_prefix} {title}"
|
|
143
|
+
title = self._get_extended_title(title)
|
|
82
144
|
|
|
83
145
|
# look up page by (possibly auto-generated) title
|
|
84
146
|
page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
147
|
+
catalog.add_parent(page_id=page.id, parent_id=page.parentId)
|
|
85
148
|
|
|
86
149
|
if page.status is ConfluenceStatus.ARCHIVED:
|
|
87
|
-
# user has archived a page with this (auto-generated) title
|
|
88
|
-
raise PageError(f"unable to update archived page with ID {page.id}")
|
|
150
|
+
# user has archived a page with this (possibly auto-generated) title
|
|
151
|
+
raise PageError(f"unable to update archived page with ID {page.id} when synchronizing {node.absolute_path}")
|
|
152
|
+
|
|
153
|
+
if not catalog.is_traceable(page.id):
|
|
154
|
+
raise PageError(
|
|
155
|
+
f"expected: page with ID {page.id} to be a descendant of the root page or one of the pages paired with a Markdown file using an explicit "
|
|
156
|
+
f"page ID when synchronizing {node.absolute_path}"
|
|
157
|
+
)
|
|
89
158
|
|
|
90
159
|
update = True
|
|
91
160
|
|
|
92
161
|
space_key = self.api.space_id_to_key(page.spaceId)
|
|
93
|
-
if update:
|
|
162
|
+
if update and not self.options.skip_update:
|
|
94
163
|
self._update_markdown(
|
|
95
164
|
node.absolute_path,
|
|
96
165
|
page_id=page.id,
|
|
@@ -106,7 +175,7 @@ class SynchronizingProcessor(Processor):
|
|
|
106
175
|
self.page_metadata.add(node.absolute_path, data)
|
|
107
176
|
|
|
108
177
|
for child_node in node.children():
|
|
109
|
-
self._synchronize_subtree(child_node, ConfluencePageID(page.id))
|
|
178
|
+
self._synchronize_subtree(child_node, ConfluencePageID(page.id), catalog)
|
|
110
179
|
|
|
111
180
|
@override
|
|
112
181
|
def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
|
|
@@ -136,19 +205,7 @@ class SynchronizingProcessor(Processor):
|
|
|
136
205
|
content = document.xhtml()
|
|
137
206
|
LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
|
|
138
207
|
|
|
139
|
-
title =
|
|
140
|
-
if document.title is not None:
|
|
141
|
-
meta = self.page_metadata.get(path)
|
|
142
|
-
if meta is not None and meta.title != document.title:
|
|
143
|
-
conflicting_page_id = self.api.page_exists(document.title, space_id=self.api.space_key_to_id(meta.space_key))
|
|
144
|
-
if conflicting_page_id is None:
|
|
145
|
-
title = document.title
|
|
146
|
-
else:
|
|
147
|
-
LOGGER.info(
|
|
148
|
-
"Document title of %s conflicts with Confluence page title of %s",
|
|
149
|
-
path,
|
|
150
|
-
conflicting_page_id,
|
|
151
|
-
)
|
|
208
|
+
title = self._get_unique_title(document, path)
|
|
152
209
|
|
|
153
210
|
# fetch existing page
|
|
154
211
|
page = self.api.get_page(page_id.page_id)
|
|
@@ -179,6 +236,41 @@ class SynchronizingProcessor(Processor):
|
|
|
179
236
|
if document.properties is not None:
|
|
180
237
|
self.api.update_content_properties_for_page(page_id.page_id, [ConfluenceContentProperty(key, value) for key, value in document.properties.items()])
|
|
181
238
|
|
|
239
|
+
def _get_extended_title(self, title: str) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Returns a title with the title prefix applied (if any).
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
if self.options.title_prefix is not None:
|
|
245
|
+
return f"{self.options.title_prefix} {title}"
|
|
246
|
+
else:
|
|
247
|
+
return title
|
|
248
|
+
|
|
249
|
+
def _get_unique_title(self, document: ConfluenceDocument, path: Path) -> str | None:
|
|
250
|
+
"""
|
|
251
|
+
Determines the (new) document title to assign to the Confluence page.
|
|
252
|
+
|
|
253
|
+
Ensures that the title is unique across the Confluence space.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
# document has no title (neither in front-matter nor as unique top-level heading)
|
|
257
|
+
if document.title is None:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# add configured title prefix
|
|
261
|
+
title = self._get_extended_title(document.title)
|
|
262
|
+
|
|
263
|
+
# compare current document title with title discovered during directory traversal
|
|
264
|
+
meta = self.page_metadata.get(path)
|
|
265
|
+
if meta is not None and meta.title != title:
|
|
266
|
+
# title has changed, check if new title is available
|
|
267
|
+
page_id = self.api.page_exists(title, space_id=self.api.space_key_to_id(meta.space_key))
|
|
268
|
+
if page_id is not None:
|
|
269
|
+
LOGGER.info("Unrelated Confluence page with ID %s has the same inferred title as the Markdown file: %s", page_id, path)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
return title
|
|
273
|
+
|
|
182
274
|
def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
|
|
183
275
|
"""
|
|
184
276
|
Writes the Confluence page ID and space key at the beginning of the Markdown file.
|
md2conf/scanner.py
CHANGED
|
@@ -75,10 +75,16 @@ class Scanner:
|
|
|
75
75
|
Extracts essential properties from a Markdown document.
|
|
76
76
|
"""
|
|
77
77
|
|
|
78
|
-
# parse file
|
|
79
78
|
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
80
79
|
text = f.read()
|
|
81
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
|
+
|
|
82
88
|
# extract Confluence page ID
|
|
83
89
|
page_id, text = extract_value(r"<!--\s+confluence[-_]page[-_]id:\s*(\d+)\s+-->", text)
|
|
84
90
|
|
md2conf/svg.py
CHANGED
|
@@ -9,6 +9,7 @@ Copyright 2022-2026, 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
|
|
61
|
+
if width is None or height is None:
|
|
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
|
+
|
|
56
75
|
if width is None or height is None:
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
87
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
103
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return _extract_dimensions_from_root(root)
|
|
104
|
+
1. Explicit `width` and `height` attributes on the root `<svg>` element
|
|
105
|
+
2. The `viewBox` attribute if `width` or `height` is not specified
|
|
111
106
|
|
|
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,32 +308,32 @@ 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
|
|
320
334
|
|
|
321
335
|
|
|
322
|
-
def fix_svg_get_dimensions(
|
|
336
|
+
def fix_svg_get_dimensions(data: bytes) -> tuple[bytes, tuple[int, int] | None]:
|
|
323
337
|
"""
|
|
324
338
|
Post-processes SVG diagram data by fixing dimensions and extracting metadata.
|
|
325
339
|
|
|
@@ -328,14 +342,19 @@ def fix_svg_get_dimensions(image_data: bytes) -> tuple[bytes, int | None, int |
|
|
|
328
342
|
1. fixes SVG dimensions (converts percentage-based to explicit pixels), and
|
|
329
343
|
2. extracts width/height from the SVG.
|
|
330
344
|
|
|
331
|
-
:param
|
|
345
|
+
:param data: Raw SVG data as bytes.
|
|
332
346
|
:returns: Tuple of update raw data, image width, image height.
|
|
333
347
|
"""
|
|
334
348
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
# extract dimensions from the fixed SVG
|
|
339
|
-
width, height = get_svg_dimensions_from_bytes(image_data)
|
|
349
|
+
try:
|
|
350
|
+
# fix SVG to have explicit width/height instead of percentages
|
|
351
|
+
data = fix_svg_dimensions(data)
|
|
340
352
|
|
|
341
|
-
|
|
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
|
md2conf/toc.py
CHANGED
|
@@ -6,7 +6,9 @@ Copyright 2022-2026, Levente Hunyadi
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import re
|
|
9
10
|
from dataclasses import dataclass
|
|
11
|
+
from typing import Iterable, Iterator
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
@dataclass(eq=True)
|
|
@@ -86,3 +88,73 @@ class TableOfContentsBuilder:
|
|
|
86
88
|
return self.tree[0].text
|
|
87
89
|
else:
|
|
88
90
|
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_FENCED_CODE_REGEXP = re.compile(r"^\s*(?:`{3,}|~{3,})", re.MULTILINE)
|
|
94
|
+
_ATX_HEADING_REGEXP = re.compile(r"^(#{1,6})\s+(.*?)$", re.MULTILINE)
|
|
95
|
+
_SETEXT_HEADING_REGEXP = re.compile(r"^(=+|-+)\s*?$", re.MULTILINE)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def headings(lines: Iterable[str]) -> Iterator[tuple[int, str]]:
|
|
99
|
+
fence_marker: str | None = None
|
|
100
|
+
heading_text: str | None = None
|
|
101
|
+
|
|
102
|
+
for line in lines:
|
|
103
|
+
# fenced code blocks
|
|
104
|
+
fence_match = _FENCED_CODE_REGEXP.match(line)
|
|
105
|
+
if fence_match:
|
|
106
|
+
marker = fence_match.group()
|
|
107
|
+
if fence_marker is None:
|
|
108
|
+
fence_marker = marker
|
|
109
|
+
elif marker == fence_marker:
|
|
110
|
+
fence_marker = None
|
|
111
|
+
heading_text = None
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if fence_marker is not None:
|
|
115
|
+
heading_text = None
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# ATX headings
|
|
119
|
+
atx = _ATX_HEADING_REGEXP.match(line)
|
|
120
|
+
if atx is not None:
|
|
121
|
+
level = len(atx.group(1))
|
|
122
|
+
title = atx.group(2).rstrip().rstrip("#").rstrip() # remove decorative text: `## Section 1.2 ##`
|
|
123
|
+
yield level, title
|
|
124
|
+
|
|
125
|
+
heading_text = None
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Setext headings
|
|
129
|
+
setext = _SETEXT_HEADING_REGEXP.match(line)
|
|
130
|
+
if setext is not None and heading_text is not None:
|
|
131
|
+
match setext.group(1)[0:1]:
|
|
132
|
+
case "=":
|
|
133
|
+
level = 1
|
|
134
|
+
case "-":
|
|
135
|
+
level = 2
|
|
136
|
+
case _:
|
|
137
|
+
level = 0
|
|
138
|
+
yield level, heading_text.rstrip()
|
|
139
|
+
|
|
140
|
+
heading_text = None
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# candidate for Setext title
|
|
144
|
+
heading_text = line
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unique_title(content: str) -> str | None:
|
|
148
|
+
"""
|
|
149
|
+
Returns a proposed document title.
|
|
150
|
+
|
|
151
|
+
The proposed title is text of the top-level heading if and only if that heading is unique.
|
|
152
|
+
|
|
153
|
+
:returns: Title text, or `None` if no title can be inferred.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
builder = TableOfContentsBuilder()
|
|
157
|
+
for heading in headings(content.splitlines(keepends=True)):
|
|
158
|
+
level, text = heading
|
|
159
|
+
builder.add(level, text)
|
|
160
|
+
return builder.get_title()
|
|
File without changes
|
{markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{markdown_to_confluence-0.5.3.dist-info → markdown_to_confluence-0.5.4.dist-info}/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|