markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.3__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.3.dist-info}/METADATA +80 -4
- markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +42 -24
- md2conf/api.py +27 -8
- 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 +232 -649
- 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 +78 -0
- md2conf/external.py +49 -0
- md2conf/formatting.py +135 -0
- md2conf/frontmatter.py +70 -0
- md2conf/image.py +127 -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} +73 -16
- md2conf/plantuml/__init__.py +0 -0
- md2conf/plantuml/config.py +20 -0
- md2conf/plantuml/extension.py +158 -0
- md2conf/plantuml/render.py +139 -0
- md2conf/plantuml/scanner.py +56 -0
- md2conf/png.py +202 -0
- md2conf/processor.py +32 -11
- md2conf/publisher.py +14 -18
- md2conf/scanner.py +31 -128
- md2conf/serializer.py +2 -2
- md2conf/svg.py +24 -2
- md2conf/text.py +1 -1
- md2conf/toc.py +1 -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.3.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/csf.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
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
9
|
import importlib.resources as resources
|
|
10
10
|
import re
|
|
11
|
+
from collections.abc import Generator
|
|
12
|
+
from contextlib import contextmanager
|
|
11
13
|
from pathlib import Path
|
|
12
|
-
from typing import Callable, TypeVar
|
|
13
14
|
|
|
14
15
|
import lxml.etree as ET
|
|
15
16
|
from lxml.builder import ElementMaker
|
|
@@ -45,15 +46,14 @@ def RI_ATTR(name: str) -> str:
|
|
|
45
46
|
return _qname(_namespaces["ri"], name)
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def with_entities(func: Callable[[Path], R]) -> R:
|
|
49
|
+
@contextmanager
|
|
50
|
+
def entities() -> Generator[Path, None, None]:
|
|
52
51
|
"Invokes a callable in the context of an entity definition file."
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if __package__ is not None: # always true at run time
|
|
54
|
+
resource_path = resources.files(__package__).joinpath("entities.dtd")
|
|
55
|
+
with resources.as_file(resource_path) as dtd_path:
|
|
56
|
+
yield dtd_path
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def _elements_from_strings(dtd_path: Path, items: list[str]) -> ElementType:
|
|
@@ -102,7 +102,8 @@ def elements_from_strings(items: list[str]) -> ElementType:
|
|
|
102
102
|
:returns: An XML document as an element tree.
|
|
103
103
|
"""
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
with entities() as dtd_path:
|
|
106
|
+
return _elements_from_strings(dtd_path, items)
|
|
106
107
|
|
|
107
108
|
|
|
108
109
|
def elements_from_string(content: str) -> ElementType:
|
|
@@ -134,7 +135,8 @@ def content_to_string(content: str) -> str:
|
|
|
134
135
|
:returns: XML as a string.
|
|
135
136
|
"""
|
|
136
137
|
|
|
137
|
-
|
|
138
|
+
with entities() as dtd_path:
|
|
139
|
+
return _content_to_string(dtd_path, content)
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
def elements_to_string(root: ElementType) -> str:
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import lxml.etree as ET
|
|
13
|
+
|
|
14
|
+
from md2conf.attachment import EmbeddedFileData, ImageData, attachment_name
|
|
15
|
+
from md2conf.compatibility import override, path_relative_to
|
|
16
|
+
from md2conf.csf import AC_ATTR, AC_ELEM
|
|
17
|
+
from md2conf.extension import MarketplaceExtension
|
|
18
|
+
from md2conf.formatting import ImageAlignment, ImageAttributes
|
|
19
|
+
|
|
20
|
+
from .render import extract_diagram, render_diagram
|
|
21
|
+
|
|
22
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DrawioExtension(MarketplaceExtension):
|
|
26
|
+
@override
|
|
27
|
+
def matches_image(self, absolute_path: Path) -> bool:
|
|
28
|
+
return absolute_path.name.endswith((".drawio", ".drawio.png", ".drawio.svg", ".drawio.xml"))
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def matches_fenced(self, language: str, content: str) -> bool:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def transform_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
36
|
+
if absolute_path.name.endswith((".drawio.png", ".drawio.svg")):
|
|
37
|
+
return self._transform_drawio_image(absolute_path, attrs)
|
|
38
|
+
elif absolute_path.name.endswith((".drawio", ".drawio.xml")):
|
|
39
|
+
return self._transform_drawio(absolute_path, attrs)
|
|
40
|
+
else:
|
|
41
|
+
raise RuntimeError(f"unrecognized image format: {absolute_path.suffix}")
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def transform_fenced(self, content: str) -> ElementType:
|
|
45
|
+
raise RuntimeError("draw.io diagrams cannot be defined in fenced code blocks")
|
|
46
|
+
|
|
47
|
+
def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
48
|
+
relative_path = path_relative_to(absolute_path, self.base_dir)
|
|
49
|
+
if self.options.render:
|
|
50
|
+
image_data = render_diagram(absolute_path, self.generator.options.output_format)
|
|
51
|
+
return self.generator.transform_attached_data(image_data, attrs, relative_path)
|
|
52
|
+
else:
|
|
53
|
+
self.attachments.add_image(ImageData(absolute_path, attrs.alt))
|
|
54
|
+
image_filename = attachment_name(relative_path)
|
|
55
|
+
return self._create_drawio(image_filename, attrs)
|
|
56
|
+
|
|
57
|
+
def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
58
|
+
if self.options.render:
|
|
59
|
+
# already a PNG or SVG file (with embedded draw.io content)
|
|
60
|
+
return self.generator.transform_attached_image(absolute_path, attrs)
|
|
61
|
+
else:
|
|
62
|
+
# extract embedded editable diagram and upload as *.drawio
|
|
63
|
+
image_data = extract_diagram(absolute_path)
|
|
64
|
+
image_filename = attachment_name(path_relative_to(absolute_path.with_suffix(".xml"), self.base_dir))
|
|
65
|
+
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data, attrs.alt))
|
|
66
|
+
|
|
67
|
+
return self._create_drawio(image_filename, attrs)
|
|
68
|
+
|
|
69
|
+
def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ElementType:
|
|
70
|
+
"A draw.io diagram embedded into the page, linking to an attachment."
|
|
71
|
+
|
|
72
|
+
parameters: list[ElementType] = [
|
|
73
|
+
AC_ELEM(
|
|
74
|
+
"parameter",
|
|
75
|
+
{AC_ATTR("name"): "diagramName"},
|
|
76
|
+
filename,
|
|
77
|
+
),
|
|
78
|
+
]
|
|
79
|
+
if attrs.width is not None:
|
|
80
|
+
parameters.append(
|
|
81
|
+
AC_ELEM(
|
|
82
|
+
"parameter",
|
|
83
|
+
{AC_ATTR("name"): "width"},
|
|
84
|
+
str(attrs.width),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
if attrs.height is not None:
|
|
88
|
+
parameters.append(
|
|
89
|
+
AC_ELEM(
|
|
90
|
+
"parameter",
|
|
91
|
+
{AC_ATTR("name"): "height"},
|
|
92
|
+
str(attrs.height),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
if attrs.alignment is ImageAlignment.CENTER:
|
|
96
|
+
parameters.append(
|
|
97
|
+
AC_ELEM(
|
|
98
|
+
"parameter",
|
|
99
|
+
{AC_ATTR("name"): "pCenter"},
|
|
100
|
+
str(1),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
local_id = str(uuid.uuid4())
|
|
105
|
+
macro_id = str(uuid.uuid4())
|
|
106
|
+
return AC_ELEM(
|
|
107
|
+
"structured-macro",
|
|
108
|
+
{
|
|
109
|
+
AC_ATTR("name"): "drawio",
|
|
110
|
+
AC_ATTR("schema-version"): "1",
|
|
111
|
+
"data-layout": "default",
|
|
112
|
+
AC_ATTR("local-id"): local_id,
|
|
113
|
+
AC_ATTR("macro-id"): macro_id,
|
|
114
|
+
},
|
|
115
|
+
*parameters,
|
|
116
|
+
)
|
md2conf/emoticon.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
|
"""
|
|
@@ -12,8 +12,8 @@ _EMOJI_TO_EMOTICON = {
|
|
|
12
12
|
"slight_frown": "sad",
|
|
13
13
|
"slight_smile": "smile",
|
|
14
14
|
"stuck_out_tongue": "cheeky",
|
|
15
|
-
"thumbsdown": "thumbs-down",
|
|
16
|
-
"thumbsup": "thumbs-up",
|
|
15
|
+
"thumbsdown": "thumbs-down", # spellchecker:disable-line
|
|
16
|
+
"thumbsup": "thumbs-up", # spellchecker:disable-line
|
|
17
17
|
"wink": "wink",
|
|
18
18
|
}
|
|
19
19
|
|
md2conf/environment.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
|
"""
|
|
@@ -83,7 +83,7 @@ class ConfluenceSiteProperties:
|
|
|
83
83
|
self.space_key = opt_space_key
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
class
|
|
86
|
+
class ConnectionProperties:
|
|
87
87
|
"""
|
|
88
88
|
Properties related to connecting to Confluence.
|
|
89
89
|
|
md2conf/extension.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import lxml.etree as ET
|
|
14
|
+
|
|
15
|
+
from .attachment import AttachmentCatalog
|
|
16
|
+
from .formatting import ImageAttributes
|
|
17
|
+
from .image import ImageGenerator
|
|
18
|
+
|
|
19
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ExtensionOptions:
|
|
24
|
+
"""
|
|
25
|
+
Customizes how Confluence content is generated for a drawing or diagram.
|
|
26
|
+
|
|
27
|
+
:param render: Whether to pre-render the drawing or diagram into a PNG/SVG image.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
render: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MarketplaceExtension:
|
|
34
|
+
"""
|
|
35
|
+
Base class for integrating third-party Atlassian Marketplace extensions.
|
|
36
|
+
|
|
37
|
+
Derive from this class to generate custom Confluence Storage Format output for Markdown image references and fenced code blocks.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
generator: ImageGenerator
|
|
41
|
+
options: ExtensionOptions
|
|
42
|
+
|
|
43
|
+
def __init__(self, generator: ImageGenerator, options: ExtensionOptions) -> None:
|
|
44
|
+
self.generator = generator
|
|
45
|
+
self.options = options
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def base_dir(self) -> Path:
|
|
49
|
+
"Base directory for resolving relative links."
|
|
50
|
+
|
|
51
|
+
return self.generator.base_dir
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def attachments(self) -> AttachmentCatalog:
|
|
55
|
+
"Maintains a list of files and binary data to be uploaded to Confluence as attachments."
|
|
56
|
+
|
|
57
|
+
return self.generator.attachments
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def matches_image(self, absolute_path: Path) -> bool:
|
|
61
|
+
"True if the extension is able to process the external file."
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def matches_fenced(self, language: str, content: str) -> bool:
|
|
66
|
+
"True if the extension can process the fenced code block."
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def transform_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
71
|
+
"Emits Confluence Storage Format XHTML for a drawing or diagram linked as an image."
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def transform_fenced(self, content: str) -> ElementType:
|
|
76
|
+
"Emits Confluence Storage Format XHTML for a drawing or diagram defined in a fenced code block."
|
|
77
|
+
|
|
78
|
+
...
|
md2conf/external.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def execute_subprocess(command: Sequence[str], data: bytes, *, application: str | None = None) -> bytes:
|
|
17
|
+
"""
|
|
18
|
+
Executes a subprocess, feeding input to stdin, and capturing output from stdout.
|
|
19
|
+
|
|
20
|
+
This function handles the common pattern of:
|
|
21
|
+
|
|
22
|
+
1. executing a command with stdin/stdout/stderr pipes,
|
|
23
|
+
2. passing input data as binary (e.g. UTF-8 encoded),
|
|
24
|
+
3. capturing binary output,
|
|
25
|
+
4. error handling with exit codes and stderr.
|
|
26
|
+
|
|
27
|
+
:param command: Full command with arguments to execute.
|
|
28
|
+
:param data: Application input as `bytes`.
|
|
29
|
+
:param application: Human-readable application name for error messages (e.g., "Mermaid", "PlantUML").
|
|
30
|
+
:returns: Application output as `bytes`.
|
|
31
|
+
:raises RuntimeError: If the subprocess fails with non-zero exit code.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
LOGGER.debug("Executing: %s", " ".join(command))
|
|
35
|
+
|
|
36
|
+
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
37
|
+
stdout, stderr = proc.communicate(input=data)
|
|
38
|
+
|
|
39
|
+
if proc.returncode:
|
|
40
|
+
messages = [f"failed to execute {application or 'application'}; exit code: {proc.returncode}"]
|
|
41
|
+
console_output = stdout.decode("utf-8")
|
|
42
|
+
if console_output:
|
|
43
|
+
messages.append(f"output:\n{console_output}")
|
|
44
|
+
console_error = stderr.decode("utf-8")
|
|
45
|
+
if console_error:
|
|
46
|
+
messages.append(f"error:\n{console_error}")
|
|
47
|
+
raise RuntimeError("\n".join(messages))
|
|
48
|
+
|
|
49
|
+
return stdout
|
md2conf/formatting.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
from .csf import AC_ATTR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@enum.unique
|
|
17
|
+
class FormattingContext(enum.Enum):
|
|
18
|
+
"Identifies the formatting context for the element."
|
|
19
|
+
|
|
20
|
+
BLOCK = "block"
|
|
21
|
+
INLINE = "inline"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@enum.unique
|
|
25
|
+
class ImageAlignment(enum.Enum):
|
|
26
|
+
"Determines whether to align block-level images to center, left or right."
|
|
27
|
+
|
|
28
|
+
CENTER = "center"
|
|
29
|
+
LEFT = "left"
|
|
30
|
+
RIGHT = "right"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def display_width(*, width: int | None, max_width: int | None) -> int | None:
|
|
34
|
+
"""
|
|
35
|
+
Calculate the display width for an image, applying the maximum image width constraint if set.
|
|
36
|
+
|
|
37
|
+
:returns: The constrained display width, or None if no constraint is needed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
if width is None or max_width is None:
|
|
41
|
+
return None
|
|
42
|
+
if width <= max_width:
|
|
43
|
+
return None # no constraint needed, image is already within limits
|
|
44
|
+
return max_width
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ImageAttributes:
|
|
49
|
+
"""
|
|
50
|
+
Attributes applied to an `<img>` element.
|
|
51
|
+
|
|
52
|
+
:param context: Identifies the formatting context for the element (block or inline).
|
|
53
|
+
:param width: Natural image width in pixels.
|
|
54
|
+
:param height: Natural image height in pixels.
|
|
55
|
+
:param alt: Alternate text.
|
|
56
|
+
:param title: Title text (a.k.a. image tooltip).
|
|
57
|
+
:param caption: Caption text (shown below figure).
|
|
58
|
+
:param alignment: Alignment for block-level images.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
context: FormattingContext
|
|
62
|
+
width: int | None
|
|
63
|
+
height: int | None
|
|
64
|
+
alt: str | None
|
|
65
|
+
title: str | None
|
|
66
|
+
caption: str | None
|
|
67
|
+
alignment: ImageAlignment = ImageAlignment.CENTER
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if self.caption is None and self.context is FormattingContext.BLOCK:
|
|
71
|
+
self.caption = self.title or self.alt
|
|
72
|
+
|
|
73
|
+
def as_dict(self, *, max_width: int | None) -> dict[str, str]:
|
|
74
|
+
"""
|
|
75
|
+
Produces a key-value store of element attributes.
|
|
76
|
+
|
|
77
|
+
:param max_width: The desired maximum width of the image in pixels.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
attributes: dict[str, str] = {}
|
|
81
|
+
match self.context:
|
|
82
|
+
case FormattingContext.BLOCK:
|
|
83
|
+
match self.alignment:
|
|
84
|
+
case ImageAlignment.LEFT:
|
|
85
|
+
align = "left"
|
|
86
|
+
layout = "align-start"
|
|
87
|
+
case ImageAlignment.RIGHT:
|
|
88
|
+
align = "right"
|
|
89
|
+
layout = "align-end"
|
|
90
|
+
case ImageAlignment.CENTER:
|
|
91
|
+
align = "center"
|
|
92
|
+
layout = "center"
|
|
93
|
+
attributes[AC_ATTR("align")] = align
|
|
94
|
+
attributes[AC_ATTR("layout")] = layout
|
|
95
|
+
|
|
96
|
+
if self.width is not None:
|
|
97
|
+
attributes[AC_ATTR("original-width")] = str(self.width)
|
|
98
|
+
if self.height is not None:
|
|
99
|
+
attributes[AC_ATTR("original-height")] = str(self.height)
|
|
100
|
+
if self.width is not None:
|
|
101
|
+
attributes[AC_ATTR("custom-width")] = "true"
|
|
102
|
+
# Use display_width if set, otherwise use natural width
|
|
103
|
+
effective_width = display_width(width=self.width, max_width=max_width) or self.width
|
|
104
|
+
attributes[AC_ATTR("width")] = str(effective_width)
|
|
105
|
+
|
|
106
|
+
case FormattingContext.INLINE:
|
|
107
|
+
if self.width is not None:
|
|
108
|
+
attributes[AC_ATTR("width")] = str(self.width)
|
|
109
|
+
if self.height is not None:
|
|
110
|
+
attributes[AC_ATTR("height")] = str(self.height)
|
|
111
|
+
|
|
112
|
+
if self.alt is not None:
|
|
113
|
+
attributes.update({AC_ATTR("alt"): self.alt})
|
|
114
|
+
if self.title is not None:
|
|
115
|
+
attributes.update({AC_ATTR("title"): self.title})
|
|
116
|
+
return attributes
|
|
117
|
+
|
|
118
|
+
EMPTY_BLOCK: ClassVar["ImageAttributes"]
|
|
119
|
+
EMPTY_INLINE: ClassVar["ImageAttributes"]
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def empty(cls, context: FormattingContext) -> "ImageAttributes":
|
|
123
|
+
match context:
|
|
124
|
+
case FormattingContext.BLOCK:
|
|
125
|
+
return cls.EMPTY_BLOCK
|
|
126
|
+
case FormattingContext.INLINE:
|
|
127
|
+
return cls.EMPTY_INLINE
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
ImageAttributes.EMPTY_BLOCK = ImageAttributes(
|
|
131
|
+
FormattingContext.BLOCK, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
|
|
132
|
+
)
|
|
133
|
+
ImageAttributes.EMPTY_INLINE = ImageAttributes(
|
|
134
|
+
FormattingContext.INLINE, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
|
|
135
|
+
)
|
md2conf/frontmatter.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import typing
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .serializer import JsonType, json_to_object
|
|
16
|
+
|
|
17
|
+
D = TypeVar("D")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_value(pattern: str, text: str) -> tuple[str | None, str]:
|
|
21
|
+
"""
|
|
22
|
+
Extracts the value captured by the first group in a regular expression.
|
|
23
|
+
|
|
24
|
+
:returns: A tuple of (1) the value extracted and (2) remaining text without the captured text.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
expr = re.compile(pattern)
|
|
28
|
+
if expr.groups != 1:
|
|
29
|
+
raise ValueError("expected: a single group whose value to extract")
|
|
30
|
+
|
|
31
|
+
class _Matcher:
|
|
32
|
+
value: str | None = None
|
|
33
|
+
|
|
34
|
+
def __call__(self, match: re.Match[str]) -> str:
|
|
35
|
+
self.value = match.group(1)
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
matcher = _Matcher()
|
|
39
|
+
text = expr.sub(matcher, text, count=1)
|
|
40
|
+
return matcher.value, text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_frontmatter_block(text: str) -> tuple[str | None, str]:
|
|
44
|
+
"Extracts the front-matter from a Markdown document as a blob of unparsed text."
|
|
45
|
+
|
|
46
|
+
return extract_value(r"(?ms)\A---$(.+?)^---$", text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_frontmatter_json(text: str) -> tuple[dict[str, JsonType] | None, str]:
|
|
50
|
+
"Extracts the front-matter from a Markdown document as a dictionary."
|
|
51
|
+
|
|
52
|
+
block, text = extract_frontmatter_block(text)
|
|
53
|
+
|
|
54
|
+
properties: dict[str, Any] | None = None
|
|
55
|
+
if block is not None:
|
|
56
|
+
data = yaml.safe_load(block)
|
|
57
|
+
if isinstance(data, dict):
|
|
58
|
+
properties = typing.cast(dict[str, JsonType], data)
|
|
59
|
+
|
|
60
|
+
return properties, text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract_frontmatter_object(tp: type[D], text: str) -> tuple[D | None, str]:
|
|
64
|
+
properties, text = extract_frontmatter_json(text)
|
|
65
|
+
|
|
66
|
+
value_object: D | None = None
|
|
67
|
+
if properties is not None:
|
|
68
|
+
value_object = json_to_object(tp, properties)
|
|
69
|
+
|
|
70
|
+
return value_object, text
|
md2conf/image.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Publish Markdown files to Confluence wiki.
|
|
3
|
+
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
|
+
|
|
6
|
+
:see: https://github.com/hunyadi/md2conf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import lxml.etree as ET
|
|
15
|
+
|
|
16
|
+
from .attachment import AttachmentCatalog, EmbeddedFileData, ImageData, attachment_name
|
|
17
|
+
from .compatibility import path_relative_to
|
|
18
|
+
from .csf import AC_ELEM, RI_ATTR, RI_ELEM
|
|
19
|
+
from .formatting import ImageAttributes
|
|
20
|
+
from .png import extract_png_dimensions
|
|
21
|
+
from .svg import fix_svg_get_dimensions, get_svg_dimensions
|
|
22
|
+
|
|
23
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ImageGeneratorOptions:
|
|
28
|
+
"""
|
|
29
|
+
Configures how images are pre-rendered and what Confluence Storage Format output they produce.
|
|
30
|
+
|
|
31
|
+
:param output_format: Target image format for diagrams.
|
|
32
|
+
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
33
|
+
:param max_width: Maximum display width for images [px]. Wider images are scaled down for page display. Original size kept for full-size viewing.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
output_format: Literal["png", "svg"]
|
|
37
|
+
prefer_raster: bool
|
|
38
|
+
max_width: int | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ImageGenerator:
|
|
42
|
+
base_dir: Path
|
|
43
|
+
attachments: AttachmentCatalog
|
|
44
|
+
|
|
45
|
+
def __init__(self, base_dir: Path, attachments: AttachmentCatalog, options: ImageGeneratorOptions) -> None:
|
|
46
|
+
self.base_dir = base_dir
|
|
47
|
+
self.attachments = attachments
|
|
48
|
+
self.options = options
|
|
49
|
+
|
|
50
|
+
def transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
51
|
+
"Emits Confluence Storage Format XHTML for an attached raster or vector image."
|
|
52
|
+
|
|
53
|
+
if self.options.prefer_raster and absolute_path.suffix == ".svg":
|
|
54
|
+
# prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
|
|
55
|
+
png_file = absolute_path.with_suffix(".png")
|
|
56
|
+
if png_file.exists():
|
|
57
|
+
absolute_path = png_file
|
|
58
|
+
|
|
59
|
+
# infer SVG dimensions if not already specified
|
|
60
|
+
if absolute_path.suffix == ".svg" and attrs.width is None and attrs.height is None:
|
|
61
|
+
svg_width, svg_height = get_svg_dimensions(absolute_path)
|
|
62
|
+
if svg_width is not None:
|
|
63
|
+
attrs = ImageAttributes(
|
|
64
|
+
context=attrs.context,
|
|
65
|
+
width=svg_width,
|
|
66
|
+
height=svg_height,
|
|
67
|
+
alt=attrs.alt,
|
|
68
|
+
title=attrs.title,
|
|
69
|
+
caption=attrs.caption,
|
|
70
|
+
alignment=attrs.alignment,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.attachments.add_image(ImageData(absolute_path, attrs.alt))
|
|
74
|
+
image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
75
|
+
return self.create_attached_image(image_name, attrs)
|
|
76
|
+
|
|
77
|
+
def transform_attached_data(self, image_data: bytes, attrs: ImageAttributes, relative_path: Path | None = None) -> ElementType:
|
|
78
|
+
"Emits Confluence Storage Format XHTML for an attached raster or vector image."
|
|
79
|
+
|
|
80
|
+
# extract dimensions and update attributes based on format
|
|
81
|
+
width: int | None
|
|
82
|
+
height: int | None
|
|
83
|
+
match self.options.output_format:
|
|
84
|
+
case "svg":
|
|
85
|
+
image_data, width, height = fix_svg_get_dimensions(image_data)
|
|
86
|
+
case "png":
|
|
87
|
+
width, height = extract_png_dimensions(data=image_data)
|
|
88
|
+
|
|
89
|
+
# only update attributes if we successfully extracted dimensions and the base attributes don't already have explicit dimensions
|
|
90
|
+
if (width is not None or height is not None) and (attrs.width is None and attrs.height is None):
|
|
91
|
+
# create updated image attributes with extracted dimensions
|
|
92
|
+
attrs = ImageAttributes(
|
|
93
|
+
context=attrs.context,
|
|
94
|
+
width=width,
|
|
95
|
+
height=height,
|
|
96
|
+
alt=attrs.alt,
|
|
97
|
+
title=attrs.title,
|
|
98
|
+
caption=attrs.caption,
|
|
99
|
+
alignment=attrs.alignment,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# generate filename and add as attachment
|
|
103
|
+
if relative_path is not None:
|
|
104
|
+
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.output_format}"))
|
|
105
|
+
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data, attrs.alt))
|
|
106
|
+
else:
|
|
107
|
+
image_hash = hashlib.md5(image_data).hexdigest()
|
|
108
|
+
image_filename = attachment_name(f"embedded_{image_hash}.{self.options.output_format}")
|
|
109
|
+
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data))
|
|
110
|
+
|
|
111
|
+
return self.create_attached_image(image_filename, attrs)
|
|
112
|
+
|
|
113
|
+
def create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
|
|
114
|
+
"Emits Confluence Storage Format XHTML for an image embedded into the page, linking to an attachment."
|
|
115
|
+
|
|
116
|
+
elements: list[ElementType] = []
|
|
117
|
+
elements.append(
|
|
118
|
+
RI_ELEM(
|
|
119
|
+
"attachment",
|
|
120
|
+
# refers to an attachment uploaded alongside the page
|
|
121
|
+
{RI_ATTR("filename"): image_name},
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if attrs.caption:
|
|
125
|
+
elements.append(AC_ELEM("caption", attrs.caption))
|
|
126
|
+
|
|
127
|
+
return AC_ELEM("image", attrs.as_dict(max_width=self.options.max_width), *elements)
|