markdown-to-confluence 0.5.1__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.
Files changed (54) hide show
  1. {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +160 -11
  2. markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
  3. {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
  4. md2conf/__init__.py +2 -2
  5. md2conf/__main__.py +94 -29
  6. md2conf/api.py +55 -10
  7. md2conf/attachment.py +72 -0
  8. md2conf/coalesce.py +43 -0
  9. md2conf/collection.py +1 -1
  10. md2conf/{extra.py → compatibility.py} +1 -1
  11. md2conf/converter.py +417 -590
  12. md2conf/csf.py +13 -11
  13. md2conf/drawio/__init__.py +0 -0
  14. md2conf/drawio/extension.py +116 -0
  15. md2conf/{drawio.py → drawio/render.py} +1 -1
  16. md2conf/emoticon.py +3 -3
  17. md2conf/environment.py +2 -2
  18. md2conf/extension.py +78 -0
  19. md2conf/external.py +49 -0
  20. md2conf/formatting.py +135 -0
  21. md2conf/frontmatter.py +70 -0
  22. md2conf/image.py +127 -0
  23. md2conf/latex.py +7 -186
  24. md2conf/local.py +8 -8
  25. md2conf/markdown.py +1 -1
  26. md2conf/matcher.py +1 -1
  27. md2conf/mermaid/__init__.py +0 -0
  28. md2conf/mermaid/config.py +20 -0
  29. md2conf/mermaid/extension.py +109 -0
  30. md2conf/{mermaid.py → mermaid/render.py} +10 -38
  31. md2conf/mermaid/scanner.py +55 -0
  32. md2conf/metadata.py +1 -1
  33. md2conf/options.py +116 -0
  34. md2conf/plantuml/__init__.py +0 -0
  35. md2conf/plantuml/config.py +20 -0
  36. md2conf/plantuml/extension.py +158 -0
  37. md2conf/plantuml/render.py +139 -0
  38. md2conf/plantuml/scanner.py +56 -0
  39. md2conf/png.py +202 -0
  40. md2conf/processor.py +32 -11
  41. md2conf/publisher.py +17 -18
  42. md2conf/scanner.py +31 -128
  43. md2conf/serializer.py +2 -2
  44. md2conf/svg.py +341 -0
  45. md2conf/text.py +1 -1
  46. md2conf/toc.py +1 -1
  47. md2conf/uri.py +1 -1
  48. md2conf/xml.py +1 -1
  49. markdown_to_confluence-0.5.1.dist-info/RECORD +0 -35
  50. md2conf/domain.py +0 -52
  51. {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
  52. {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
  53. {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
  54. {markdown_to_confluence-0.5.1.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-2025, Levente Hunyadi
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
- R = TypeVar("R")
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
- resource_path = resources.files(__package__).joinpath("entities.dtd")
55
- with resources.as_file(resource_path) as dtd_path:
56
- return func(dtd_path)
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
- return with_entities(lambda dtd_path: _elements_from_strings(dtd_path, items))
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
- return with_entities(lambda dtd_path: _content_to_string(dtd_path, content))
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
+ )
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
md2conf/emoticon.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
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-2025, Levente Hunyadi
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 ConfluenceConnectionProperties:
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)