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.
Files changed (53) hide show
  1. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +80 -4
  2. markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
  3. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
  4. md2conf/__init__.py +2 -2
  5. md2conf/__main__.py +42 -24
  6. md2conf/api.py +27 -8
  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 +232 -649
  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 +4 -183
  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/{domain.py → options.py} +73 -16
  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 +14 -18
  42. md2conf/scanner.py +31 -128
  43. md2conf/serializer.py +2 -2
  44. md2conf/svg.py +24 -2
  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.2.dist-info/RECORD +0 -36
  50. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
  51. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
  52. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
  53. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/png.py ADDED
@@ -0,0 +1,202 @@
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 io import BytesIO
10
+ from pathlib import Path
11
+ from struct import unpack
12
+ from typing import BinaryIO, Iterable, overload
13
+
14
+
15
+ class _Chunk:
16
+ "Data chunk in binary data as per the PNG image format."
17
+
18
+ __slots__ = ("length", "name", "data", "crc")
19
+
20
+ length: int
21
+ name: bytes
22
+ data: bytes
23
+ crc: bytes
24
+
25
+ def __init__(self, length: int, name: bytes, data: bytes, crc: bytes):
26
+ self.length = length
27
+ self.name = name
28
+ self.data = data
29
+ self.crc = crc
30
+
31
+
32
+ def _read_signature(f: BinaryIO) -> None:
33
+ "Reads and checks PNG signature (first 8 bytes)."
34
+
35
+ signature = f.read(8)
36
+ if signature != b"\x89PNG\r\n\x1a\n":
37
+ raise ValueError("not a valid PNG file")
38
+
39
+
40
+ def _read_chunk(f: BinaryIO) -> _Chunk | None:
41
+ "Reads and parses a PNG chunk such as `IHDR` or `tEXt`."
42
+
43
+ length_bytes = f.read(4)
44
+ if not length_bytes:
45
+ return None
46
+
47
+ if len(length_bytes) != 4:
48
+ raise ValueError("expected: 4 bytes storing chunk length")
49
+
50
+ length = int.from_bytes(length_bytes, "big")
51
+
52
+ data_length = 4 + length + 4
53
+ data_bytes = f.read(data_length)
54
+ actual_length = len(data_bytes)
55
+ if actual_length != data_length:
56
+ raise ValueError(f"expected: {length} bytes storing chunk data; got: {actual_length}")
57
+
58
+ chunk_type = data_bytes[0:4]
59
+ chunk_data = data_bytes[4:-4]
60
+ crc = data_bytes[-4:]
61
+
62
+ return _Chunk(length, chunk_type, chunk_data, crc)
63
+
64
+
65
+ def _extract_png_dimensions(source_file: BinaryIO) -> tuple[int, int]:
66
+ """
67
+ Returns the width and height of a PNG image inspecting its header.
68
+
69
+ :param source_file: A binary file opened for reading that contains PNG image data.
70
+ :returns: A tuple of the image's width and height in pixels.
71
+ """
72
+
73
+ _read_signature(source_file)
74
+
75
+ # validate IHDR (Image Header) chunk
76
+ ihdr = _read_chunk(source_file)
77
+ if ihdr is None:
78
+ raise ValueError("missing IHDR chunk")
79
+
80
+ if ihdr.length != 13:
81
+ raise ValueError("invalid chunk length")
82
+ if ihdr.name != b"IHDR":
83
+ raise ValueError(f"expected: IHDR chunk; got: {ihdr.name!r}")
84
+
85
+ (
86
+ width,
87
+ height,
88
+ bit_depth, # pyright: ignore[reportUnusedVariable]
89
+ color_type, # pyright: ignore[reportUnusedVariable]
90
+ compression, # pyright: ignore[reportUnusedVariable]
91
+ filter, # pyright: ignore[reportUnusedVariable]
92
+ interlace, # pyright: ignore[reportUnusedVariable]
93
+ ) = unpack(">IIBBBBB", ihdr.data) # spellchecker:disable-line
94
+ return width, height
95
+
96
+
97
+ @overload
98
+ def extract_png_dimensions(*, data: bytes) -> tuple[int, int]: ...
99
+
100
+
101
+ @overload
102
+ def extract_png_dimensions(*, path: str | Path) -> tuple[int, int]: ...
103
+
104
+
105
+ def extract_png_dimensions(*, data: bytes | None = None, path: str | Path | None = None) -> tuple[int, int]:
106
+ """
107
+ Returns the width and height of a PNG image inspecting its header.
108
+
109
+ :param data: PNG image data.
110
+ :param path: Path to the PNG image file.
111
+ :returns: A tuple of the image's width and height in pixels.
112
+ """
113
+
114
+ if data is not None and path is not None:
115
+ raise TypeError("expected: either `data` or `path`; got: both")
116
+ elif data is not None:
117
+ with BytesIO(data) as f:
118
+ return _extract_png_dimensions(f)
119
+ elif path is not None:
120
+ with open(path, "rb") as f:
121
+ return _extract_png_dimensions(f)
122
+ else:
123
+ raise TypeError("expected: either `data` or `path`; got: neither")
124
+
125
+
126
+ def _write_chunk(f: BinaryIO, chunk: _Chunk) -> None:
127
+ f.write(chunk.length.to_bytes(4, "big"))
128
+ f.write(chunk.name)
129
+ f.write(chunk.data)
130
+ f.write(chunk.crc)
131
+
132
+
133
+ def _remove_png_chunks(names: Iterable[str], source_file: BinaryIO, target_file: BinaryIO) -> None:
134
+ """
135
+ Rewrites a PNG file by removing chunks with the specified names.
136
+
137
+ :param source_file: A binary file opened for reading that contains PNG image data.
138
+ :param target_file: A binary file opened for writing to receive PNG image data.
139
+ """
140
+
141
+ exclude_set = set(name.encode("ascii") for name in names)
142
+
143
+ _read_signature(source_file)
144
+ target_file.write(b"\x89PNG\r\n\x1a\n")
145
+
146
+ while True:
147
+ chunk = _read_chunk(source_file)
148
+ if chunk is None:
149
+ break
150
+
151
+ if chunk.name not in exclude_set:
152
+ _write_chunk(target_file, chunk)
153
+
154
+
155
+ @overload
156
+ def remove_png_chunks(names: Iterable[str], *, source_data: bytes) -> bytes: ...
157
+
158
+
159
+ @overload
160
+ def remove_png_chunks(names: Iterable[str], *, source_path: str | Path) -> bytes: ...
161
+
162
+
163
+ @overload
164
+ def remove_png_chunks(names: Iterable[str], *, source_data: bytes, target_path: str | Path) -> None: ...
165
+
166
+
167
+ @overload
168
+ def remove_png_chunks(names: Iterable[str], *, source_path: str | Path, target_path: str | Path) -> None: ...
169
+
170
+
171
+ def remove_png_chunks(
172
+ names: Iterable[str], *, source_data: bytes | None = None, source_path: str | Path | None = None, target_path: str | Path | None = None
173
+ ) -> bytes | None:
174
+ """
175
+ Rewrites a PNG file by removing chunks with the specified names.
176
+
177
+ :param source_data: PNG image data.
178
+ :param source_path: Path to the file to read from.
179
+ :param target_path: Path to the file to write to.
180
+ """
181
+
182
+ if source_data is not None and source_path is not None:
183
+ raise TypeError("expected: either `source_data` or `source_path`; got: both")
184
+ elif source_data is not None:
185
+
186
+ def source_reader() -> BinaryIO:
187
+ return BytesIO(source_data)
188
+ elif source_path is not None:
189
+
190
+ def source_reader() -> BinaryIO:
191
+ return open(source_path, "rb")
192
+ else:
193
+ raise TypeError("expected: either `source_data` or `source_path`; got: neither")
194
+
195
+ if target_path is None:
196
+ with source_reader() as source_file, BytesIO() as memory_file:
197
+ _remove_png_chunks(names, source_file, memory_file)
198
+ return memory_file.getvalue()
199
+ else:
200
+ with source_reader() as source_file, open(target_path, "wb") as target_file:
201
+ _remove_png_chunks(names, source_file, target_file)
202
+ return None
md2conf/processor.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
  """
@@ -15,16 +15,18 @@ from typing import Iterable
15
15
 
16
16
  from .collection import ConfluencePageCollection
17
17
  from .converter import ConfluenceDocument
18
- from .domain import ConfluenceDocumentOptions, ConfluencePageID
19
18
  from .environment import ArgumentError
20
19
  from .matcher import DirectoryEntry, FileEntry, Matcher, MatcherOptions
21
20
  from .metadata import ConfluenceSiteMetadata
21
+ from .options import ConfluencePageID, DocumentOptions
22
22
  from .scanner import Scanner
23
23
 
24
24
  LOGGER = logging.getLogger(__name__)
25
25
 
26
26
 
27
27
  class DocumentNode:
28
+ "Represents a Markdown document in a hierarchy."
29
+
28
30
  absolute_path: Path
29
31
  page_id: str | None
30
32
  space_key: str | None
@@ -49,24 +51,42 @@ class DocumentNode:
49
51
  self._children = []
50
52
 
51
53
  def count(self) -> int:
54
+ "Number of descendants in the sub-tree spanned by this node (excluding the top-level node)."
55
+
52
56
  c = len(self._children)
53
57
  for child in self._children:
54
58
  c += child.count()
55
59
  return c
56
60
 
57
61
  def add_child(self, child: "DocumentNode") -> None:
62
+ "Adds a new node to the list of direct children."
63
+
58
64
  self._children.append(child)
59
65
 
60
66
  def children(self) -> Iterable["DocumentNode"]:
67
+ "Direct children of this node."
68
+
61
69
  for child in self._children:
62
70
  yield child
63
71
 
64
72
  def descendants(self) -> Iterable["DocumentNode"]:
73
+ """
74
+ Descendants of this node, part of its sub-tree.
75
+
76
+ Traversal follows depth-first search.
77
+ """
78
+
65
79
  for child in self._children:
66
80
  yield child
67
81
  yield from child.descendants()
68
82
 
69
83
  def all(self) -> Iterable["DocumentNode"]:
84
+ """
85
+ Descendants of this node, part of the sub-tree including the top-level node.
86
+
87
+ Traversal follows depth-first search.
88
+ """
89
+
70
90
  yield self
71
91
  for child in self._children:
72
92
  yield from child.all()
@@ -77,7 +97,7 @@ class Processor:
77
97
  Processes a single Markdown page or a directory of Markdown pages.
78
98
  """
79
99
 
80
- options: ConfluenceDocumentOptions
100
+ options: DocumentOptions
81
101
  site: ConfluenceSiteMetadata
82
102
  root_dir: Path
83
103
 
@@ -85,7 +105,7 @@ class Processor:
85
105
 
86
106
  def __init__(
87
107
  self,
88
- options: ConfluenceDocumentOptions,
108
+ options: DocumentOptions,
89
109
  site: ConfluenceSiteMetadata,
90
110
  root_dir: Path,
91
111
  ) -> None:
@@ -140,7 +160,7 @@ class Processor:
140
160
  self._update_page(page_id, document, path)
141
161
 
142
162
  @abstractmethod
143
- def _synchronize_tree(self, root: DocumentNode, root_id: ConfluencePageID | None) -> None:
163
+ def _synchronize_tree(self, tree: DocumentNode, root_id: ConfluencePageID | None) -> None:
144
164
  """
145
165
  Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
146
166
 
@@ -228,12 +248,13 @@ class Processor:
228
248
  # extract information from a Markdown document found in a local directory.
229
249
  document = Scanner().read(path)
230
250
 
251
+ props = document.properties
231
252
  return DocumentNode(
232
253
  absolute_path=path,
233
- page_id=document.page_id,
234
- space_key=document.space_key,
235
- title=document.title,
236
- synchronized=document.synchronized if document.synchronized is not None else True,
254
+ page_id=props.page_id,
255
+ space_key=props.space_key,
256
+ title=props.title,
257
+ synchronized=props.synchronized if props.synchronized is not None else True,
237
258
  )
238
259
 
239
260
  def _generate_hash(self, absolute_path: Path) -> str:
@@ -247,10 +268,10 @@ class Processor:
247
268
 
248
269
 
249
270
  class ProcessorFactory:
250
- options: ConfluenceDocumentOptions
271
+ options: DocumentOptions
251
272
  site: ConfluenceSiteMetadata
252
273
 
253
- def __init__(self, options: ConfluenceDocumentOptions, site: ConfluenceSiteMetadata) -> None:
274
+ def __init__(self, options: DocumentOptions, site: ConfluenceSiteMetadata) -> None:
254
275
  self.options = options
255
276
  self.site = site
256
277
 
md2conf/publisher.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
  """
@@ -10,12 +10,13 @@ import logging
10
10
  from pathlib import Path
11
11
 
12
12
  from .api import ConfluenceContentProperty, ConfluenceLabel, ConfluenceSession, ConfluenceStatus
13
- from .converter import ConfluenceDocument, attachment_name, get_volatile_attributes, get_volatile_elements
13
+ from .attachment import attachment_name
14
+ from .compatibility import override, path_relative_to
15
+ from .converter import ConfluenceDocument, get_volatile_attributes, get_volatile_elements
14
16
  from .csf import AC_ATTR, elements_from_string
15
- from .domain import ConfluenceDocumentOptions, ConfluencePageID
16
17
  from .environment import PageError
17
- from .extra import override, path_relative_to
18
18
  from .metadata import ConfluencePageMetadata
19
+ from .options import ConfluencePageID, DocumentOptions
19
20
  from .processor import Converter, DocumentNode, Processor, ProcessorFactory
20
21
  from .xml import is_xml_equal, unwrap_substitute
21
22
 
@@ -29,7 +30,7 @@ class SynchronizingProcessor(Processor):
29
30
 
30
31
  api: ConfluenceSession
31
32
 
32
- def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions, root_dir: Path) -> None:
33
+ def __init__(self, api: ConfluenceSession, options: DocumentOptions, root_dir: Path) -> None:
33
34
  """
34
35
  Initializes a new processor instance.
35
36
 
@@ -42,7 +43,7 @@ class SynchronizingProcessor(Processor):
42
43
  self.api = api
43
44
 
44
45
  @override
45
- def _synchronize_tree(self, root: DocumentNode, root_id: ConfluencePageID | None) -> None:
46
+ def _synchronize_tree(self, tree: DocumentNode, root_id: ConfluencePageID | None) -> None:
46
47
  """
47
48
  Creates the cross-reference index and synchronizes the directory tree structure with the Confluence page hierarchy.
48
49
 
@@ -51,21 +52,16 @@ class SynchronizingProcessor(Processor):
51
52
  Updates the original Markdown document to add tags to associate the document with its corresponding Confluence page.
52
53
  """
53
54
 
54
- if root.page_id is None and root_id is None:
55
- raise PageError(f"expected: root page ID in options, or explicit page ID in {root.absolute_path}")
56
- elif root.page_id is not None and root_id is not None:
57
- if root.page_id != root_id.page_id:
58
- raise PageError(f"mismatched inferred page ID of {root_id.page_id} and explicit page ID in {root.absolute_path}")
59
-
60
- real_id = root_id
55
+ if tree.page_id is None and root_id is None:
56
+ raise PageError(f"expected: root page ID in options, or explicit page ID in {tree.absolute_path}")
57
+ elif tree.page_id is not None:
58
+ real_id = ConfluencePageID(tree.page_id) # explicit page ID takes precedence
61
59
  elif root_id is not None:
62
60
  real_id = root_id
63
- elif root.page_id is not None:
64
- real_id = ConfluencePageID(root.page_id)
65
61
  else:
66
62
  raise NotImplementedError("condition not exhaustive")
67
63
 
68
- self._synchronize_subtree(root, real_id)
64
+ self._synchronize_subtree(tree, real_id)
69
65
 
70
66
  def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID) -> None:
71
67
  if node.page_id is not None:
@@ -212,7 +208,7 @@ class SynchronizingProcessor(Processor):
212
208
  class SynchronizingProcessorFactory(ProcessorFactory):
213
209
  api: ConfluenceSession
214
210
 
215
- def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions) -> None:
211
+ def __init__(self, api: ConfluenceSession, options: DocumentOptions) -> None:
216
212
  super().__init__(options, api.site)
217
213
  self.api = api
218
214
 
@@ -227,5 +223,5 @@ class Publisher(Converter):
227
223
  This is the class instantiated by the command-line application.
228
224
  """
229
225
 
230
- def __init__(self, api: ConfluenceSession, options: ConfluenceDocumentOptions) -> None:
226
+ def __init__(self, api: ConfluenceSession, options: DocumentOptions) -> None:
231
227
  super().__init__(SynchronizingProcessorFactory(api, options))
md2conf/scanner.py CHANGED
@@ -1,55 +1,34 @@
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
- import re
10
- import typing
11
9
  from dataclasses import dataclass
12
10
  from pathlib import Path
13
- from typing import Any, Literal, TypeVar
11
+ from typing import TypeVar
14
12
 
15
- import yaml
16
-
17
- from .mermaid import MermaidConfigProperties
13
+ from .coalesce import coalesce
14
+ from .frontmatter import extract_frontmatter_json, extract_value
15
+ from .options import LayoutOptions
18
16
  from .serializer import JsonType, json_to_object
19
17
 
20
18
  T = TypeVar("T")
21
19
 
22
20
 
23
- def extract_value(pattern: str, text: str) -> tuple[str | None, str]:
24
- values: list[str] = []
25
-
26
- def _repl_func(match: re.Match[str]) -> str:
27
- values.append(match.group(1))
28
- return ""
29
-
30
- text = re.sub(pattern, _repl_func, text, count=1, flags=re.ASCII)
31
- value = values[0] if values else None
32
- return value, text
33
-
34
-
35
- def extract_frontmatter_block(text: str) -> tuple[str | None, str]:
36
- "Extracts the front-matter from a Markdown document as a blob of unparsed text."
37
-
38
- return extract_value(r"(?ms)\A---$(.+?)^---$", text)
39
-
40
-
41
- def extract_frontmatter_properties(text: str) -> tuple[dict[str, JsonType] | None, str]:
42
- "Extracts the front-matter from a Markdown document as a dictionary."
43
-
44
- block, text = extract_frontmatter_block(text)
21
+ @dataclass
22
+ class AliasProperties:
23
+ """
24
+ An object that holds properties extracted from the front-matter of a Markdown document.
45
25
 
46
- properties: dict[str, Any] | None = None
47
- if block is not None:
48
- data = yaml.safe_load(block)
49
- if isinstance(data, dict):
50
- properties = typing.cast(dict[str, JsonType], data)
26
+ :param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
27
+ :param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
28
+ """
51
29
 
52
- return properties, text
30
+ confluence_page_id: str | None = None
31
+ confluence_space_key: str | None = None
53
32
 
54
33
 
55
34
  @dataclass
@@ -59,26 +38,22 @@ class DocumentProperties:
59
38
 
60
39
  :param page_id: Confluence page ID.
61
40
  :param space_key: Confluence space key.
62
- :param confluence_page_id: Confluence page ID. (Alternative name for JSON de-serialization.)
63
- :param confluence_space_key: Confluence space key. (Alternative name for JSON de-serialization.)
64
41
  :param generated_by: Text identifying the tool that generated the document.
65
42
  :param title: The title extracted from front-matter.
66
43
  :param tags: A list of tags (content labels) extracted from front-matter.
67
44
  :param synchronized: True if the document content is parsed and synchronized with Confluence.
68
45
  :param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
69
- :param alignment: Alignment for block-level images and formulas.
46
+ :param layout: Layout options for content on a Confluence page.
70
47
  """
71
48
 
72
49
  page_id: str | None = None
73
50
  space_key: str | None = None
74
- confluence_page_id: str | None = None
75
- confluence_space_key: str | None = None
76
51
  generated_by: str | None = None
77
52
  title: str | None = None
78
53
  tags: list[str] | None = None
79
54
  synchronized: bool | None = None
80
55
  properties: dict[str, JsonType] | None = None
81
- alignment: Literal["center", "left", "right"] | None = None
56
+ layout: LayoutOptions | None = None
82
57
 
83
58
 
84
59
  @dataclass
@@ -86,25 +61,11 @@ class ScannedDocument:
86
61
  """
87
62
  An object that holds properties extracted from a Markdown document, including remaining source text.
88
63
 
89
- :param page_id: Confluence page ID.
90
- :param space_key: Confluence space key.
91
- :param generated_by: Text identifying the tool that generated the document.
92
- :param title: The title extracted from front-matter.
93
- :param tags: A list of tags (content labels) extracted from front-matter.
94
- :param synchronized: True if the document content is parsed and synchronized with Confluence.
95
- :param properties: A dictionary of key-value pairs extracted from front-matter to apply as page properties.
96
- :param alignment: Alignment for block-level images and formulas.
64
+ :param properties: Properties extracted from the front-matter of a Markdown document.
97
65
  :param text: Text that remains after front-matter and inline properties have been extracted.
98
66
  """
99
67
 
100
- page_id: str | None
101
- space_key: str | None
102
- generated_by: str | None
103
- title: str | None
104
- tags: list[str] | None
105
- synchronized: bool | None
106
- properties: dict[str, JsonType] | None
107
- alignment: Literal["center", "left", "right"] | None
68
+ properties: DocumentProperties
108
69
  text: str
109
70
 
110
71
 
@@ -127,77 +88,19 @@ class Scanner:
127
88
  # extract 'generated-by' tag text
128
89
  generated_by, text = extract_value(r"<!--\s+generated[-_]by:\s*(.*)\s+-->", text)
129
90
 
130
- title: str | None = None
131
- tags: list[str] | None = None
132
- synchronized: bool | None = None
133
- properties: dict[str, JsonType] | None = None
134
- alignment: Literal["center", "left", "right"] | None = None
91
+ body_props = DocumentProperties(page_id=page_id, space_key=space_key, generated_by=generated_by)
135
92
 
136
93
  # extract front-matter
137
- data, text = extract_frontmatter_properties(text)
94
+ data, text = extract_frontmatter_json(text)
138
95
  if data is not None:
139
- p = json_to_object(DocumentProperties, data)
140
- page_id = page_id or p.confluence_page_id or p.page_id
141
- space_key = space_key or p.confluence_space_key or p.space_key
142
- generated_by = generated_by or p.generated_by
143
- title = p.title
144
- tags = p.tags
145
- synchronized = p.synchronized
146
- properties = p.properties
147
- alignment = p.alignment
148
-
149
- return ScannedDocument(
150
- page_id=page_id,
151
- space_key=space_key,
152
- generated_by=generated_by,
153
- title=title,
154
- tags=tags,
155
- synchronized=synchronized,
156
- properties=properties,
157
- alignment=alignment,
158
- text=text,
159
- )
160
-
161
-
162
- @dataclass
163
- class MermaidProperties:
164
- """
165
- An object that holds the front-matter properties structure for Mermaid diagrams.
166
-
167
- :param title: The title of the diagram.
168
- :param config: Configuration options for rendering.
169
- """
170
-
171
- title: str | None = None
172
- config: MermaidConfigProperties | None = None
173
-
174
-
175
- class MermaidScanner:
176
- """
177
- Extracts properties from the JSON/YAML front-matter of a Mermaid diagram.
178
- """
179
-
180
- def read(self, content: str) -> MermaidProperties:
181
- """
182
- Extracts rendering preferences from a Mermaid front-matter content.
183
-
184
- ```
185
- ---
186
- title: Tiny flow diagram
187
- config:
188
- scale: 1
189
- ---
190
- flowchart LR
191
- A[Component A] --> B[Component B]
192
- B --> C[Component C]
193
- ```
194
- """
195
-
196
- properties, _ = extract_frontmatter_properties(content)
197
- if properties is not None:
198
- front_matter = json_to_object(MermaidProperties, properties)
199
- config = front_matter.config or MermaidConfigProperties()
200
-
201
- return MermaidProperties(title=front_matter.title, config=config)
202
-
203
- return MermaidProperties()
96
+ frontmatter_props = json_to_object(DocumentProperties, data)
97
+ alias_props = json_to_object(AliasProperties, data)
98
+ if alias_props.confluence_page_id is not None:
99
+ frontmatter_props.page_id = alias_props.confluence_page_id
100
+ if alias_props.confluence_space_key is not None:
101
+ frontmatter_props.space_key = alias_props.confluence_space_key
102
+ props = coalesce(body_props, frontmatter_props)
103
+ else:
104
+ props = body_props
105
+
106
+ return ScannedDocument(properties=props, text=text)
md2conf/serializer.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
@@ -10,7 +10,7 @@ import sys
10
10
  from datetime import datetime
11
11
  from typing import TypeVar
12
12
 
13
- from cattrs.preconf.orjson import make_converter
13
+ from cattrs.preconf.orjson import make_converter # spellchecker:disable-line
14
14
 
15
15
  JsonType = None | bool | int | float | str | dict[str, "JsonType"] | list["JsonType"]
16
16
  JsonComposite = dict[str, "JsonType"] | list["JsonType"]
md2conf/svg.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
- SVG dimension extraction utilities.
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
  """
@@ -317,3 +317,25 @@ def _parse_viewbox(viewbox: str) -> tuple[int | None, int | None]:
317
317
  return width, height
318
318
  except ValueError:
319
319
  return None, None
320
+
321
+
322
+ def fix_svg_get_dimensions(image_data: bytes) -> tuple[bytes, int | None, int | None]:
323
+ """
324
+ Post-processes SVG diagram data by fixing dimensions and extracting metadata.
325
+
326
+ This handles the common pattern for SVG diagrams:
327
+
328
+ 1. fixes SVG dimensions (converts percentage-based to explicit pixels), and
329
+ 2. extracts width/height from the SVG.
330
+
331
+ :param image_data: Raw SVG data as bytes.
332
+ :returns: Tuple of update raw data, image width, image height.
333
+ """
334
+
335
+ # fix SVG to have explicit width/height instead of percentages
336
+ image_data = fix_svg_dimensions(image_data)
337
+
338
+ # extract dimensions from the fixed SVG
339
+ width, height = get_svg_dimensions_from_bytes(image_data)
340
+
341
+ return image_data, width, height