markdown-to-confluence 0.4.4__py3-none-any.whl → 0.4.6__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.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/METADATA +83 -33
- markdown_to_confluence-0.4.6.dist-info/RECORD +34 -0
- {markdown_to_confluence-0.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +1 -1
- md2conf/__main__.py +35 -39
- md2conf/api.py +90 -20
- md2conf/converter.py +585 -300
- md2conf/csf.py +66 -0
- md2conf/domain.py +2 -0
- md2conf/drawio.py +18 -14
- md2conf/emoticon.py +22 -0
- md2conf/latex.py +245 -0
- md2conf/local.py +2 -2
- md2conf/markdown.py +3 -1
- md2conf/mermaid.py +38 -29
- md2conf/processor.py +1 -1
- md2conf/{application.py → publisher.py} +28 -19
- md2conf/scanner.py +46 -0
- md2conf/text.py +54 -0
- md2conf/xml.py +37 -0
- markdown_to_confluence-0.4.4.dist-info/RECORD +0 -31
- {markdown_to_confluence-0.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.4.4.dist-info → markdown_to_confluence-0.4.6.dist-info}/zip-safe +0 -0
- /md2conf/{properties.py → environment.py} +0 -0
md2conf/csf.py
CHANGED
|
@@ -149,3 +149,69 @@ def elements_to_string(root: ET._Element) -> str:
|
|
|
149
149
|
return m.group(1)
|
|
150
150
|
else:
|
|
151
151
|
raise ValueError("expected: Confluence content")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_block_like(elem: ET._Element) -> bool:
|
|
155
|
+
return elem.tag in ["div", "li", "ol", "p", "pre", "td", "th", "ul"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def normalize_inline(elem: ET._Element) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Ensures that inline elements are direct children of an eligible block element.
|
|
161
|
+
|
|
162
|
+
The following transformations are applied:
|
|
163
|
+
|
|
164
|
+
* consecutive inline elements and text nodes that are the direct children of the parent element are wrapped into a `<p>`,
|
|
165
|
+
* block elements are left intact,
|
|
166
|
+
* leading and trailing whitespace in each block element is removed.
|
|
167
|
+
|
|
168
|
+
The above steps transform an element tree such as
|
|
169
|
+
```
|
|
170
|
+
<li> to <em>be</em>, <ol/> not to <em>be</em> </li>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
into another element tree such as
|
|
174
|
+
```
|
|
175
|
+
<li><p>to <em>be</em>,</p><ol/><p>not to <em>be</em></p></li>
|
|
176
|
+
```
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
if not is_block_like(elem):
|
|
180
|
+
raise ValueError(f"expected: block element; got: {elem.tag!s}")
|
|
181
|
+
|
|
182
|
+
contents: list[ET._Element] = []
|
|
183
|
+
|
|
184
|
+
paragraph = HTML.p()
|
|
185
|
+
contents.append(paragraph)
|
|
186
|
+
if elem.text:
|
|
187
|
+
paragraph.text = elem.text
|
|
188
|
+
elem.text = None
|
|
189
|
+
|
|
190
|
+
for child in elem:
|
|
191
|
+
if is_block_like(child):
|
|
192
|
+
contents.append(child)
|
|
193
|
+
paragraph = HTML.p()
|
|
194
|
+
contents.append(paragraph)
|
|
195
|
+
if child.tail:
|
|
196
|
+
paragraph.text = child.tail
|
|
197
|
+
child.tail = None
|
|
198
|
+
else:
|
|
199
|
+
paragraph.append(child)
|
|
200
|
+
|
|
201
|
+
for item in contents:
|
|
202
|
+
# remove lead whitespace in the block element
|
|
203
|
+
if item.text:
|
|
204
|
+
item.text = item.text.lstrip()
|
|
205
|
+
if len(item) > 0:
|
|
206
|
+
# remove tail whitespace in the last child of the block element
|
|
207
|
+
last = item[-1]
|
|
208
|
+
if last.tail:
|
|
209
|
+
last.tail = last.tail.rstrip()
|
|
210
|
+
else:
|
|
211
|
+
# remove tail whitespace directly in the block element content
|
|
212
|
+
if item.text:
|
|
213
|
+
item.text = item.text.rstrip()
|
|
214
|
+
|
|
215
|
+
# ignore empty elements
|
|
216
|
+
if item.tag != "p" or len(item) > 0 or item.text:
|
|
217
|
+
elem.append(item)
|
md2conf/domain.py
CHANGED
|
@@ -30,6 +30,7 @@ class ConfluenceDocumentOptions:
|
|
|
30
30
|
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
31
31
|
:param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
|
|
32
32
|
:param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
|
|
33
|
+
:param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
|
|
33
34
|
:param diagram_output_format: Target image format for diagrams.
|
|
34
35
|
:param webui_links: When true, convert relative URLs to Confluence Web UI links.
|
|
35
36
|
"""
|
|
@@ -42,5 +43,6 @@ class ConfluenceDocumentOptions:
|
|
|
42
43
|
prefer_raster: bool = True
|
|
43
44
|
render_drawio: bool = False
|
|
44
45
|
render_mermaid: bool = False
|
|
46
|
+
render_latex: bool = False
|
|
45
47
|
diagram_output_format: Literal["png", "svg"] = "png"
|
|
46
48
|
webui_links: bool = False
|
md2conf/drawio.py
CHANGED
|
@@ -9,9 +9,9 @@ Copyright 2022-2025, Levente Hunyadi
|
|
|
9
9
|
import base64
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
|
-
import os.path
|
|
13
12
|
import shutil
|
|
14
13
|
import subprocess
|
|
14
|
+
import tempfile
|
|
15
15
|
import typing
|
|
16
16
|
import zlib
|
|
17
17
|
from pathlib import Path
|
|
@@ -153,7 +153,8 @@ def extract_xml_from_png(png_data: bytes) -> ET._Element:
|
|
|
153
153
|
offset += 8
|
|
154
154
|
|
|
155
155
|
if offset + length + 4 > len(png_data):
|
|
156
|
-
|
|
156
|
+
chunk_name = chunk_type.decode("ascii", errors="replace")
|
|
157
|
+
raise DrawioError(f"corrupted PNG: incomplete data for chunk {chunk_name}")
|
|
157
158
|
|
|
158
159
|
# read chunk data
|
|
159
160
|
chunk_data = png_data[offset : offset + length]
|
|
@@ -169,7 +170,7 @@ def extract_xml_from_png(png_data: bytes) -> ET._Element:
|
|
|
169
170
|
# format: keyword\0text
|
|
170
171
|
null_pos = chunk_data.find(b"\x00")
|
|
171
172
|
if null_pos < 0:
|
|
172
|
-
raise DrawioError("corrupted PNG: tEXt chunk missing keyword")
|
|
173
|
+
raise DrawioError("corrupted PNG: `tEXt` chunk missing keyword or data")
|
|
173
174
|
|
|
174
175
|
keyword = chunk_data[:null_pos].decode("latin1")
|
|
175
176
|
if keyword != "mxfile":
|
|
@@ -236,17 +237,21 @@ def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "
|
|
|
236
237
|
if executable is None:
|
|
237
238
|
raise DrawioError("draw.io executable not found")
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
# create a temporary file and get its file descriptor and path
|
|
241
|
+
fd, target = tempfile.mkstemp(prefix="drawio_", suffix=f".{output_format}")
|
|
240
242
|
|
|
241
|
-
cmd = [executable, "--export", "--format", output_format, "--output", target]
|
|
242
|
-
if output_format == "png":
|
|
243
|
-
cmd.extend(["--scale", "2", "--transparent"])
|
|
244
|
-
elif output_format == "svg":
|
|
245
|
-
cmd.append("--embed-svg-images")
|
|
246
|
-
cmd.append(str(source))
|
|
247
|
-
|
|
248
|
-
LOGGER.debug("Executing: %s", " ".join(cmd))
|
|
249
243
|
try:
|
|
244
|
+
# close the descriptor, just use the filename
|
|
245
|
+
os.close(fd)
|
|
246
|
+
|
|
247
|
+
cmd = [executable, "--export", "--format", output_format, "--output", target]
|
|
248
|
+
if output_format == "png":
|
|
249
|
+
cmd.extend(["--scale", "2", "--transparent"])
|
|
250
|
+
elif output_format == "svg":
|
|
251
|
+
cmd.append("--embed-svg-images")
|
|
252
|
+
cmd.append(str(source))
|
|
253
|
+
|
|
254
|
+
LOGGER.debug("Executing: %s", " ".join(cmd))
|
|
250
255
|
proc = subprocess.Popen(
|
|
251
256
|
cmd,
|
|
252
257
|
stdout=subprocess.PIPE,
|
|
@@ -267,5 +272,4 @@ def render_diagram(source: Path, output_format: typing.Literal["png", "svg"] = "
|
|
|
267
272
|
return f.read()
|
|
268
273
|
|
|
269
274
|
finally:
|
|
270
|
-
|
|
271
|
-
os.remove(target)
|
|
275
|
+
os.remove(target)
|
md2conf/emoticon.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
_EMOJI_TO_EMOTICON = {
|
|
10
|
+
"grinning": "laugh",
|
|
11
|
+
"heart": "heart",
|
|
12
|
+
"slight_frown": "sad",
|
|
13
|
+
"slight_smile": "smile",
|
|
14
|
+
"stuck_out_tongue": "cheeky",
|
|
15
|
+
"thumbsdown": "thumbs-down",
|
|
16
|
+
"thumbsup": "thumbs-up",
|
|
17
|
+
"wink": "wink",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emoji_to_emoticon(shortname: str) -> str:
|
|
22
|
+
return _EMOJI_TO_EMOTICON.get(shortname) or "blue-star"
|
md2conf/latex.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2025, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from struct import unpack
|
|
13
|
+
from typing import BinaryIO, Iterable, Literal, Optional, Union, overload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_latex(expression: str, *, format: Literal["png", "svg"] = "png", dpi: int = 100, font_size: int = 12) -> bytes:
|
|
17
|
+
"""
|
|
18
|
+
Generates a PNG or SVG image of a LaTeX math expression using `matplotlib` for rendering.
|
|
19
|
+
|
|
20
|
+
:param expression: A LaTeX math expression, e.g., r'\frac{a}{b}'.
|
|
21
|
+
:param format: Output image format.
|
|
22
|
+
:param dpi: Output image resolution (if applicable).
|
|
23
|
+
:param font_size: Font size of the LaTeX text (if applicable).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
with BytesIO() as f:
|
|
27
|
+
_render_latex(expression, f, format=format, dpi=dpi, font_size=font_size)
|
|
28
|
+
return f.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if importlib.util.find_spec("matplotlib") is None:
|
|
32
|
+
LATEX_ENABLED = False
|
|
33
|
+
|
|
34
|
+
def _render_latex(expression: str, f: BinaryIO, *, format: Literal["png", "svg"], dpi: int, font_size: int) -> None:
|
|
35
|
+
raise RuntimeError("matplotlib not installed; run: `pip install matplotlib`")
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
import matplotlib
|
|
39
|
+
import matplotlib.pyplot as plt
|
|
40
|
+
|
|
41
|
+
matplotlib.rcParams["mathtext.fontset"] = "cm" # change font to "Computer Modern"
|
|
42
|
+
|
|
43
|
+
LATEX_ENABLED = True
|
|
44
|
+
|
|
45
|
+
def _render_latex(expression: str, f: BinaryIO, *, format: Literal["png", "svg"], dpi: int, font_size: int) -> None:
|
|
46
|
+
# create a figure with no axis
|
|
47
|
+
fig = plt.figure(dpi=dpi)
|
|
48
|
+
|
|
49
|
+
# transparent background
|
|
50
|
+
fig.patch.set_alpha(0)
|
|
51
|
+
|
|
52
|
+
# add LaTeX text
|
|
53
|
+
fig.text(x=0, y=0, s=f"${expression}$", fontsize=font_size)
|
|
54
|
+
|
|
55
|
+
# save the image
|
|
56
|
+
fig.savefig(
|
|
57
|
+
f,
|
|
58
|
+
transparent=True,
|
|
59
|
+
format=format,
|
|
60
|
+
bbox_inches="tight",
|
|
61
|
+
pad_inches=0.0,
|
|
62
|
+
metadata={"Title": expression} if format == "png" else None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# close the figure to free memory
|
|
66
|
+
plt.close(fig)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def get_png_dimensions(*, data: bytes) -> tuple[int, int]: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@overload
|
|
74
|
+
def get_png_dimensions(*, path: Union[str, Path]) -> tuple[int, int]: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_png_dimensions(*, data: Optional[bytes] = None, path: Union[str, Path, None] = None) -> tuple[int, int]:
|
|
78
|
+
"""
|
|
79
|
+
Returns the width and height of a PNG image inspecting its header.
|
|
80
|
+
|
|
81
|
+
:param data: PNG image data.
|
|
82
|
+
:param path: Path to the PNG image file.
|
|
83
|
+
:returns: A tuple of the image's width and height in pixels.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
if data is not None and path is not None:
|
|
87
|
+
raise TypeError("expected: either `data` or `path`; got: both")
|
|
88
|
+
elif data is not None:
|
|
89
|
+
with BytesIO(data) as f:
|
|
90
|
+
return _get_png_dimensions(f)
|
|
91
|
+
elif path is not None:
|
|
92
|
+
with open(path, "rb") as f:
|
|
93
|
+
return _get_png_dimensions(f)
|
|
94
|
+
else:
|
|
95
|
+
raise TypeError("expected: either `data` or `path`; got: neither")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@overload
|
|
99
|
+
def remove_png_chunks(names: Iterable[str], *, source_data: bytes) -> bytes: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@overload
|
|
103
|
+
def remove_png_chunks(names: Iterable[str], *, source_path: Union[str, Path]) -> bytes: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@overload
|
|
107
|
+
def remove_png_chunks(names: Iterable[str], *, source_data: bytes, target_path: Union[str, Path]) -> None: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@overload
|
|
111
|
+
def remove_png_chunks(names: Iterable[str], *, source_path: Union[str, Path], target_path: Union[str, Path]) -> None: ...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def remove_png_chunks(
|
|
115
|
+
names: Iterable[str], *, source_data: Optional[bytes] = None, source_path: Union[str, Path, None] = None, target_path: Union[str, Path, None] = None
|
|
116
|
+
) -> Optional[bytes]:
|
|
117
|
+
"""
|
|
118
|
+
Rewrites a PNG file by removing chunks with the specified names.
|
|
119
|
+
|
|
120
|
+
:param source_data: PNG image data.
|
|
121
|
+
:param source_path: Path to the file to read from.
|
|
122
|
+
:param target_path: Path to the file to write to.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
if source_data is not None and source_path is not None:
|
|
126
|
+
raise TypeError("expected: either `source_data` or `source_path`; got: both")
|
|
127
|
+
elif source_data is not None:
|
|
128
|
+
|
|
129
|
+
def source_reader() -> BinaryIO:
|
|
130
|
+
return BytesIO(source_data)
|
|
131
|
+
elif source_path is not None:
|
|
132
|
+
|
|
133
|
+
def source_reader() -> BinaryIO:
|
|
134
|
+
return open(source_path, "rb")
|
|
135
|
+
else:
|
|
136
|
+
raise TypeError("expected: either `source_data` or `source_path`; got: neither")
|
|
137
|
+
|
|
138
|
+
if target_path is None:
|
|
139
|
+
with source_reader() as source_file, BytesIO() as memory_file:
|
|
140
|
+
_remove_png_chunks(names, source_file, memory_file)
|
|
141
|
+
return memory_file.getvalue()
|
|
142
|
+
else:
|
|
143
|
+
with source_reader() as source_file, open(target_path, "wb") as target_file:
|
|
144
|
+
_remove_png_chunks(names, source_file, target_file)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _Chunk:
|
|
149
|
+
__slots__ = ("length", "name", "data", "crc")
|
|
150
|
+
|
|
151
|
+
length: int
|
|
152
|
+
name: bytes
|
|
153
|
+
data: bytes
|
|
154
|
+
crc: bytes
|
|
155
|
+
|
|
156
|
+
def __init__(self, length: int, name: bytes, data: bytes, crc: bytes):
|
|
157
|
+
self.length = length
|
|
158
|
+
self.name = name
|
|
159
|
+
self.data = data
|
|
160
|
+
self.crc = crc
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_signature(f: BinaryIO) -> None:
|
|
164
|
+
"Reads and checks PNG signature (first 8 bytes)."
|
|
165
|
+
|
|
166
|
+
signature = f.read(8)
|
|
167
|
+
if signature != b"\x89PNG\r\n\x1a\n":
|
|
168
|
+
raise ValueError("not a valid PNG file")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _read_chunk(f: BinaryIO) -> Optional[_Chunk]:
|
|
172
|
+
"Reads and parses a PNG chunk such as `IHDR` or `tEXt`."
|
|
173
|
+
|
|
174
|
+
length_bytes = f.read(4)
|
|
175
|
+
if not length_bytes:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
if len(length_bytes) != 4:
|
|
179
|
+
raise ValueError("insufficient bytes to read chunk length")
|
|
180
|
+
|
|
181
|
+
length = int.from_bytes(length_bytes, "big")
|
|
182
|
+
|
|
183
|
+
data_length = 4 + length + 4
|
|
184
|
+
data_bytes = f.read(data_length)
|
|
185
|
+
if len(data_bytes) != data_length:
|
|
186
|
+
raise ValueError(f"insufficient bytes to read chunk data of length {length}")
|
|
187
|
+
|
|
188
|
+
chunk_type = data_bytes[0:4]
|
|
189
|
+
chunk_data = data_bytes[4:-4]
|
|
190
|
+
crc = data_bytes[-4:]
|
|
191
|
+
|
|
192
|
+
return _Chunk(length, chunk_type, chunk_data, crc)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write_chunk(f: BinaryIO, chunk: _Chunk) -> None:
|
|
196
|
+
f.write(chunk.length.to_bytes(4, "big"))
|
|
197
|
+
f.write(chunk.name)
|
|
198
|
+
f.write(chunk.data)
|
|
199
|
+
f.write(chunk.crc)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _get_png_dimensions(source_file: BinaryIO) -> tuple[int, int]:
|
|
203
|
+
"""
|
|
204
|
+
Returns the width and height of a PNG image inspecting its header.
|
|
205
|
+
|
|
206
|
+
:param source_file: A binary file opened for reading that contains PNG image data.
|
|
207
|
+
:returns: A tuple of the image's width and height in pixels.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
_read_signature(source_file)
|
|
211
|
+
|
|
212
|
+
# validate IHDR chunk
|
|
213
|
+
ihdr = _read_chunk(source_file)
|
|
214
|
+
if ihdr is None:
|
|
215
|
+
raise ValueError("missing IHDR chunk")
|
|
216
|
+
|
|
217
|
+
if ihdr.length != 13:
|
|
218
|
+
raise ValueError("invalid chunk length")
|
|
219
|
+
if ihdr.name != b"IHDR":
|
|
220
|
+
raise ValueError(f"expected: IHDR chunk; got: {ihdr.name!r}")
|
|
221
|
+
|
|
222
|
+
(width, height, bit_depth, color_type, compression, filter, interlace) = unpack(">IIBBBBB", ihdr.data)
|
|
223
|
+
return width, height
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _remove_png_chunks(names: Iterable[str], source_file: BinaryIO, target_file: BinaryIO) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Rewrites a PNG file by removing chunks with the specified names.
|
|
229
|
+
|
|
230
|
+
:param source_file: A binary file opened for reading that contains PNG image data.
|
|
231
|
+
:param target_file: A binary file opened for writing to receive PNG image data.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
exclude_set = set(name.encode("ascii") for name in names)
|
|
235
|
+
|
|
236
|
+
_read_signature(source_file)
|
|
237
|
+
target_file.write(b"\x89PNG\r\n\x1a\n")
|
|
238
|
+
|
|
239
|
+
while True:
|
|
240
|
+
chunk = _read_chunk(source_file)
|
|
241
|
+
if chunk is None:
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
if chunk.name not in exclude_set:
|
|
245
|
+
_write_chunk(target_file, chunk)
|
md2conf/local.py
CHANGED
|
@@ -83,9 +83,9 @@ class LocalProcessor(Processor):
|
|
|
83
83
|
os.makedirs(csf_dir, exist_ok=True)
|
|
84
84
|
with open(csf_path, "w", encoding="utf-8") as f:
|
|
85
85
|
f.write(content)
|
|
86
|
-
for name,
|
|
86
|
+
for name, file_data in document.embedded_files.items():
|
|
87
87
|
with open(csf_dir / name, "wb") as f:
|
|
88
|
-
f.write(data)
|
|
88
|
+
f.write(file_data.data)
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
class LocalProcessorFactory(ProcessorFactory):
|
md2conf/markdown.py
CHANGED
|
@@ -76,9 +76,11 @@ _CONVERTER = markdown.Markdown(
|
|
|
76
76
|
"markdown.extensions.tables",
|
|
77
77
|
"md_in_html",
|
|
78
78
|
"pymdownx.arithmatex",
|
|
79
|
+
"pymdownx.caret",
|
|
79
80
|
"pymdownx.emoji",
|
|
80
81
|
"pymdownx.highlight", # required by `pymdownx.superfences`
|
|
81
82
|
"pymdownx.magiclink",
|
|
83
|
+
"pymdownx.mark",
|
|
82
84
|
"pymdownx.superfences",
|
|
83
85
|
"pymdownx.tilde",
|
|
84
86
|
"sane_lists",
|
|
@@ -86,7 +88,7 @@ _CONVERTER = markdown.Markdown(
|
|
|
86
88
|
extension_configs={
|
|
87
89
|
"footnotes": {"BACKLINK_TITLE": ""},
|
|
88
90
|
"pymdownx.arithmatex": {"generic": True, "preview": False, "tex_inline_wrap": ["", ""], "tex_block_wrap": ["", ""]},
|
|
89
|
-
"pymdownx.emoji": {"emoji_generator": _emoji_generator
|
|
91
|
+
"pymdownx.emoji": {"emoji_generator": _emoji_generator},
|
|
90
92
|
"pymdownx.highlight": {
|
|
91
93
|
"use_pygments": False,
|
|
92
94
|
},
|
md2conf/mermaid.py
CHANGED
|
@@ -11,11 +11,23 @@ import os
|
|
|
11
11
|
import os.path
|
|
12
12
|
import shutil
|
|
13
13
|
import subprocess
|
|
14
|
-
from
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal, Optional
|
|
15
16
|
|
|
16
17
|
LOGGER = logging.getLogger(__name__)
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
@dataclass
|
|
21
|
+
class MermaidConfigProperties:
|
|
22
|
+
"""
|
|
23
|
+
Configuration options for rendering Mermaid diagrams.
|
|
24
|
+
|
|
25
|
+
:param scale: Scaling factor for the rendered diagram.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
scale: Optional[float] = None
|
|
29
|
+
|
|
30
|
+
|
|
19
31
|
def is_docker() -> bool:
|
|
20
32
|
"True if the application is running in a Docker container."
|
|
21
33
|
|
|
@@ -44,49 +56,46 @@ def has_mmdc() -> bool:
|
|
|
44
56
|
return shutil.which(executable) is not None
|
|
45
57
|
|
|
46
58
|
|
|
47
|
-
def render_diagram(source: str, output_format: Literal["png", "svg"] = "png") -> bytes:
|
|
59
|
+
def render_diagram(source: str, output_format: Literal["png", "svg"] = "png", config: Optional[MermaidConfigProperties] = None) -> bytes:
|
|
48
60
|
"Generates a PNG or SVG image from a Mermaid diagram source."
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
if config is None:
|
|
63
|
+
config = MermaidConfigProperties()
|
|
51
64
|
|
|
52
65
|
cmd = [
|
|
53
66
|
get_mmdc(),
|
|
54
67
|
"--input",
|
|
55
68
|
"-",
|
|
56
69
|
"--output",
|
|
57
|
-
|
|
70
|
+
"-",
|
|
58
71
|
"--outputFormat",
|
|
59
72
|
output_format,
|
|
60
73
|
"--backgroundColor",
|
|
61
74
|
"transparent",
|
|
62
75
|
"--scale",
|
|
63
|
-
|
|
76
|
+
str(config.scale or 2),
|
|
64
77
|
]
|
|
65
78
|
root = os.path.dirname(__file__)
|
|
66
79
|
if is_docker():
|
|
67
80
|
cmd.extend(["-p", os.path.join(root, "puppeteer-config.json")])
|
|
68
81
|
LOGGER.debug("Executing: %s", " ".join(cmd))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
finally:
|
|
91
|
-
if os.path.exists(filename):
|
|
92
|
-
os.remove(filename)
|
|
82
|
+
|
|
83
|
+
proc = subprocess.Popen(
|
|
84
|
+
cmd,
|
|
85
|
+
stdout=subprocess.PIPE,
|
|
86
|
+
stdin=subprocess.PIPE,
|
|
87
|
+
stderr=subprocess.PIPE,
|
|
88
|
+
text=False,
|
|
89
|
+
)
|
|
90
|
+
stdout, stderr = proc.communicate(input=source.encode("utf-8"))
|
|
91
|
+
if proc.returncode:
|
|
92
|
+
messages = [f"failed to convert Mermaid diagram; exit code: {proc.returncode}"]
|
|
93
|
+
console_output = stdout.decode("utf-8")
|
|
94
|
+
if console_output:
|
|
95
|
+
messages.append(f"output:\n{console_output}")
|
|
96
|
+
console_error = stderr.decode("utf-8")
|
|
97
|
+
if console_error:
|
|
98
|
+
messages.append(f"error:\n{console_error}")
|
|
99
|
+
raise RuntimeError("\n".join(messages))
|
|
100
|
+
|
|
101
|
+
return stdout
|
md2conf/processor.py
CHANGED
|
@@ -16,9 +16,9 @@ from typing import Iterable, Optional
|
|
|
16
16
|
from .collection import ConfluencePageCollection
|
|
17
17
|
from .converter import ConfluenceDocument
|
|
18
18
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
19
|
+
from .environment import ArgumentError
|
|
19
20
|
from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
|
|
20
21
|
from .metadata import ConfluenceSiteMetadata
|
|
21
|
-
from .properties import ArgumentError
|
|
22
22
|
from .scanner import Scanner
|
|
23
23
|
|
|
24
24
|
LOGGER = logging.getLogger(__name__)
|
|
@@ -12,13 +12,13 @@ from typing import Optional
|
|
|
12
12
|
|
|
13
13
|
from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
|
|
14
14
|
from .converter import ConfluenceDocument, attachment_name, get_volatile_attributes, get_volatile_elements
|
|
15
|
-
from .csf import elements_from_string
|
|
15
|
+
from .csf import AC_ATTR, elements_from_string
|
|
16
16
|
from .domain import ConfluenceDocumentOptions, ConfluencePageID
|
|
17
|
+
from .environment import PageError
|
|
17
18
|
from .extra import override, path_relative_to
|
|
18
19
|
from .metadata import ConfluencePageMetadata
|
|
19
20
|
from .processor import Converter, DocumentNode, Processor, ProcessorFactory
|
|
20
|
-
from .
|
|
21
|
-
from .xml import is_xml_equal
|
|
21
|
+
from .xml import is_xml_equal, unwrap_substitute
|
|
22
22
|
|
|
23
23
|
LOGGER = logging.getLogger(__name__)
|
|
24
24
|
|
|
@@ -73,20 +73,23 @@ class SynchronizingProcessor(Processor):
|
|
|
73
73
|
# verify if page exists
|
|
74
74
|
page = self.api.get_page_properties(node.page_id)
|
|
75
75
|
update = False
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
else:
|
|
77
|
+
if node.title is not None:
|
|
78
|
+
# use title extracted from source metadata
|
|
79
|
+
title = node.title
|
|
80
|
+
else:
|
|
81
|
+
# assign an auto-generated title
|
|
82
|
+
digest = self._generate_hash(node.absolute_path)
|
|
83
|
+
title = f"{node.absolute_path.stem} [{digest}]"
|
|
84
|
+
|
|
85
|
+
# look up page by (possibly auto-generated) title
|
|
86
|
+
page = self.api.get_or_create_page(title, parent_id.page_id)
|
|
79
87
|
|
|
80
88
|
if page.status is ConfluenceStatus.ARCHIVED:
|
|
89
|
+
# user has archived a page with this (auto-generated) title
|
|
81
90
|
raise PageError(f"unable to update archived page with ID {page.id}")
|
|
82
91
|
|
|
83
92
|
update = True
|
|
84
|
-
else:
|
|
85
|
-
# always create a new page
|
|
86
|
-
digest = self._generate_hash(node.absolute_path)
|
|
87
|
-
title = f"{node.absolute_path.stem} [{digest}]"
|
|
88
|
-
page = self.api.create_page(parent_id.page_id, title, "")
|
|
89
|
-
update = True
|
|
90
93
|
|
|
91
94
|
space_key = self.api.space_id_to_key(page.spaceId)
|
|
92
95
|
if update:
|
|
@@ -116,18 +119,20 @@ class SynchronizingProcessor(Processor):
|
|
|
116
119
|
"""
|
|
117
120
|
|
|
118
121
|
base_path = path.parent
|
|
119
|
-
for
|
|
122
|
+
for image_data in document.images:
|
|
120
123
|
self.api.upload_attachment(
|
|
121
124
|
page_id.page_id,
|
|
122
|
-
attachment_name(path_relative_to(
|
|
123
|
-
attachment_path=
|
|
125
|
+
attachment_name(path_relative_to(image_data.path, base_path)),
|
|
126
|
+
attachment_path=image_data.path,
|
|
127
|
+
comment=image_data.description,
|
|
124
128
|
)
|
|
125
129
|
|
|
126
|
-
for name,
|
|
130
|
+
for name, file_data in document.embedded_files.items():
|
|
127
131
|
self.api.upload_attachment(
|
|
128
132
|
page_id.page_id,
|
|
129
133
|
name,
|
|
130
|
-
raw_data=data,
|
|
134
|
+
raw_data=file_data.data,
|
|
135
|
+
comment=file_data.description,
|
|
131
136
|
)
|
|
132
137
|
|
|
133
138
|
content = document.xhtml()
|
|
@@ -152,10 +157,14 @@ class SynchronizingProcessor(Processor):
|
|
|
152
157
|
if not title: # empty or `None`
|
|
153
158
|
title = page.title
|
|
154
159
|
|
|
160
|
+
# discard comments
|
|
161
|
+
tree = elements_from_string(page.content)
|
|
162
|
+
unwrap_substitute(AC_ATTR("inline-comment-marker"), tree)
|
|
163
|
+
|
|
155
164
|
# check if page has any changes
|
|
156
165
|
if page.title != title or not is_xml_equal(
|
|
157
166
|
document.root,
|
|
158
|
-
|
|
167
|
+
tree,
|
|
159
168
|
skip_attributes=get_volatile_attributes(),
|
|
160
169
|
skip_elements=get_volatile_elements(),
|
|
161
170
|
):
|
|
@@ -209,7 +218,7 @@ class SynchronizingProcessorFactory(ProcessorFactory):
|
|
|
209
218
|
return SynchronizingProcessor(self.api, self.options, root_dir)
|
|
210
219
|
|
|
211
220
|
|
|
212
|
-
class
|
|
221
|
+
class Publisher(Converter):
|
|
213
222
|
"""
|
|
214
223
|
The entry point for Markdown to Confluence conversion.
|
|
215
224
|
|