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.
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +160 -11
- markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +94 -29
- md2conf/api.py +55 -10
- 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 +417 -590
- 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 +7 -186
- 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/options.py +116 -0
- 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 +17 -18
- md2conf/scanner.py +31 -128
- md2conf/serializer.py +2 -2
- md2conf/svg.py +341 -0
- 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.1.dist-info/RECORD +0 -35
- md2conf/domain.py +0 -52
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.1.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/options.py
ADDED
|
@@ -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 dataclasses
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConfluencePageID:
|
|
16
|
+
page_id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ImageLayoutOptions:
|
|
21
|
+
"""
|
|
22
|
+
Image layout options on a Confluence page.
|
|
23
|
+
|
|
24
|
+
:param alignment: Alignment for block-level images and formulas.
|
|
25
|
+
:param max_width: Maximum display width for images [px]. Wider images are scaled down for page display. Original size kept for full-size viewing.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
alignment: Literal["center", "left", "right"] | None = None
|
|
29
|
+
max_width: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TableLayoutOptions:
|
|
34
|
+
"""
|
|
35
|
+
Table layout options on a Confluence page.
|
|
36
|
+
|
|
37
|
+
:param width: Maximum table width in pixels.
|
|
38
|
+
:param display_mode: Whether to use fixed or responsive column widths.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
width: int | None = None
|
|
42
|
+
display_mode: Literal["fixed", "responsive"] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LayoutOptions:
|
|
47
|
+
"""
|
|
48
|
+
Layout options for content on a Confluence page.
|
|
49
|
+
|
|
50
|
+
Layout options can be overridden in Markdown front-matter.
|
|
51
|
+
|
|
52
|
+
:param image: Image layout options.
|
|
53
|
+
:param table: Table layout options.
|
|
54
|
+
:param alignment: Default alignment (unless overridden with more specific setting).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
image: ImageLayoutOptions = dataclasses.field(default_factory=ImageLayoutOptions)
|
|
58
|
+
table: TableLayoutOptions = dataclasses.field(default_factory=TableLayoutOptions)
|
|
59
|
+
alignment: Literal["center", "left", "right"] | None = None
|
|
60
|
+
|
|
61
|
+
def get_image_alignment(self) -> Literal["center", "left", "right"]:
|
|
62
|
+
return self.image.alignment or self.alignment or "center"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ConverterOptions:
|
|
67
|
+
"""
|
|
68
|
+
Options for converting an HTML tree into Confluence Storage Format.
|
|
69
|
+
|
|
70
|
+
:param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
|
|
71
|
+
conversion rules for the identifier.
|
|
72
|
+
:param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
|
|
73
|
+
plain text; when false, raise an exception.
|
|
74
|
+
:param skip_title_heading: Whether to remove the first heading from document body when used as page title.
|
|
75
|
+
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
76
|
+
:param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
|
|
77
|
+
:param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
|
|
78
|
+
:param render_plantuml: Whether to pre-render PlantUML diagrams into PNG/SVG images.
|
|
79
|
+
:param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
|
|
80
|
+
:param diagram_output_format: Target image format for diagrams.
|
|
81
|
+
:param webui_links: When true, convert relative URLs to Confluence Web UI links.
|
|
82
|
+
:param use_panel: Whether to transform admonitions and alerts into a Confluence custom panel.
|
|
83
|
+
:param layout: Layout options for content on a Confluence page.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
heading_anchors: bool = False
|
|
87
|
+
ignore_invalid_url: bool = False
|
|
88
|
+
skip_title_heading: bool = False
|
|
89
|
+
prefer_raster: bool = True
|
|
90
|
+
render_drawio: bool = False
|
|
91
|
+
render_mermaid: bool = False
|
|
92
|
+
render_plantuml: bool = False
|
|
93
|
+
render_latex: bool = False
|
|
94
|
+
diagram_output_format: Literal["png", "svg"] = "png"
|
|
95
|
+
webui_links: bool = False
|
|
96
|
+
use_panel: bool = False
|
|
97
|
+
layout: LayoutOptions = dataclasses.field(default_factory=LayoutOptions)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class DocumentOptions:
|
|
102
|
+
"""
|
|
103
|
+
Options that control the generated page content.
|
|
104
|
+
|
|
105
|
+
:param root_page_id: Confluence page to assume root page role for publishing a directory of Markdown files.
|
|
106
|
+
:param keep_hierarchy: Whether to maintain source directory structure when exporting to Confluence.
|
|
107
|
+
:param title_prefix: String to prepend to Confluence page title for each published page.
|
|
108
|
+
:param generated_by: Text to use as the generated-by prompt (or `None` to omit a prompt).
|
|
109
|
+
:param converter: Options for converting an HTML tree into Confluence Storage Format.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
root_page_id: ConfluencePageID | None = None
|
|
113
|
+
keep_hierarchy: bool = False
|
|
114
|
+
title_prefix: str | None = None
|
|
115
|
+
generated_by: str | None = "This page has been generated with a tool."
|
|
116
|
+
converter: ConverterOptions = dataclasses.field(default_factory=ConverterOptions)
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
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 dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PlantUMLConfigProperties:
|
|
14
|
+
"""
|
|
15
|
+
Configuration options for rendering PlantUML diagrams.
|
|
16
|
+
|
|
17
|
+
:param scale: Scaling factor for the rendered diagram.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
scale: float | None = None
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import lxml.etree as ET
|
|
15
|
+
from cattrs import BaseValidationError
|
|
16
|
+
|
|
17
|
+
from md2conf.attachment import EmbeddedFileData, ImageData, attachment_name
|
|
18
|
+
from md2conf.compatibility import override, path_relative_to
|
|
19
|
+
from md2conf.csf import AC_ATTR, AC_ELEM
|
|
20
|
+
from md2conf.extension import MarketplaceExtension
|
|
21
|
+
from md2conf.formatting import ImageAttributes
|
|
22
|
+
from md2conf.svg import get_svg_dimensions_from_bytes
|
|
23
|
+
|
|
24
|
+
from .config import PlantUMLConfigProperties
|
|
25
|
+
from .render import compress_plantuml_data, has_plantuml, render_diagram
|
|
26
|
+
from .scanner import PlantUMLScanner
|
|
27
|
+
|
|
28
|
+
ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
|
|
29
|
+
|
|
30
|
+
LOGGER = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlantUMLExtension(MarketplaceExtension):
|
|
34
|
+
@override
|
|
35
|
+
def matches_image(self, absolute_path: Path) -> bool:
|
|
36
|
+
return absolute_path.name.endswith((".puml", ".plantuml"))
|
|
37
|
+
|
|
38
|
+
@override
|
|
39
|
+
def matches_fenced(self, language: str, content: str) -> bool:
|
|
40
|
+
return language == "plantuml"
|
|
41
|
+
|
|
42
|
+
def _extract_plantuml_config(self, content: str) -> PlantUMLConfigProperties | None:
|
|
43
|
+
"Extract config from PlantUML YAML front matter configuration."
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
properties = PlantUMLScanner().read(content)
|
|
47
|
+
return properties.config
|
|
48
|
+
except BaseValidationError as ex:
|
|
49
|
+
LOGGER.warning("Failed to extract PlantUML properties: %s", ex)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def transform_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
54
|
+
relative_path = path_relative_to(absolute_path, self.base_dir)
|
|
55
|
+
|
|
56
|
+
# read PlantUML source
|
|
57
|
+
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
58
|
+
content = f.read()
|
|
59
|
+
|
|
60
|
+
return self._transform_plantuml(content, attrs, relative_path)
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
def transform_fenced(self, content: str) -> ElementType:
|
|
64
|
+
return self._transform_plantuml(content, ImageAttributes.EMPTY_BLOCK)
|
|
65
|
+
|
|
66
|
+
def _transform_plantuml(self, content: str, attrs: ImageAttributes, relative_path: Path | None = None) -> ElementType:
|
|
67
|
+
"""
|
|
68
|
+
Emits Confluence Storage Format XHTML for a PlantUML diagram read from an external file or defined in a fenced code block.
|
|
69
|
+
|
|
70
|
+
When `render_plantuml` is enabled, renders as an image attachment. Otherwise, uses a structured macro with embedded SVG and compressed source.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
if self.options.render:
|
|
74
|
+
# render diagram as image file (PNG or SVG based on diagram output format)
|
|
75
|
+
config = self._extract_plantuml_config(content)
|
|
76
|
+
image_data = render_diagram(content, self.generator.options.output_format, config=config)
|
|
77
|
+
return self.generator.transform_attached_data(image_data, attrs, relative_path)
|
|
78
|
+
else:
|
|
79
|
+
if relative_path is not None:
|
|
80
|
+
absolute_path = self.base_dir / relative_path
|
|
81
|
+
self.attachments.add_image(ImageData(absolute_path, attrs.alt))
|
|
82
|
+
|
|
83
|
+
# use `structured-macro` with SVG attachment
|
|
84
|
+
if has_plantuml():
|
|
85
|
+
# render to SVG for structured macro (macro requires SVG)
|
|
86
|
+
config = self._extract_plantuml_config(content)
|
|
87
|
+
image_data = render_diagram(content, "svg", config=config)
|
|
88
|
+
|
|
89
|
+
# extract dimensions from SVG
|
|
90
|
+
width, height = get_svg_dimensions_from_bytes(image_data)
|
|
91
|
+
|
|
92
|
+
# generate SVG filename and add as attachment
|
|
93
|
+
if relative_path is not None:
|
|
94
|
+
svg_filename = attachment_name(relative_path.with_suffix(".svg"))
|
|
95
|
+
self.attachments.add_embed(svg_filename, EmbeddedFileData(image_data, attrs.alt))
|
|
96
|
+
else:
|
|
97
|
+
plantuml_hash = hashlib.md5(content.encode("utf-8")).hexdigest()
|
|
98
|
+
svg_filename = attachment_name(f"embedded_{plantuml_hash}.svg")
|
|
99
|
+
self.attachments.add_embed(svg_filename, EmbeddedFileData(image_data))
|
|
100
|
+
|
|
101
|
+
return self._create_plantuml_macro(content, svg_filename, width, height)
|
|
102
|
+
else:
|
|
103
|
+
return self._create_plantuml_macro(content)
|
|
104
|
+
|
|
105
|
+
def _create_plantuml_macro(self, source: str, filename: str | None = None, width: int | None = None, height: int | None = None) -> ElementType:
|
|
106
|
+
"""
|
|
107
|
+
A PlantUML diagram using a `structured-macro` with embedded data.
|
|
108
|
+
|
|
109
|
+
Generates a macro compatible with "PlantUML Diagrams for Confluence" app.
|
|
110
|
+
|
|
111
|
+
:see: https://stratus-addons.atlassian.net/wiki/spaces/PDFC/pages/1839333377
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
local_id = str(uuid.uuid4())
|
|
115
|
+
macro_id = str(uuid.uuid4())
|
|
116
|
+
|
|
117
|
+
# Compress PlantUML source for embedding
|
|
118
|
+
compressed_data = compress_plantuml_data(source)
|
|
119
|
+
|
|
120
|
+
# Build mandatory parameters
|
|
121
|
+
parameters: list[ElementType] = [
|
|
122
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "data"}, compressed_data),
|
|
123
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "compressed"}, "true"),
|
|
124
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
|
|
125
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "toolbar"}, "bottom"),
|
|
126
|
+
]
|
|
127
|
+
if filename is not None:
|
|
128
|
+
parameters.append(AC_ELEM("parameter", {AC_ATTR("name"): "filename"}, filename))
|
|
129
|
+
|
|
130
|
+
# add optional dimension parameters if available
|
|
131
|
+
if width is not None:
|
|
132
|
+
parameters.append(
|
|
133
|
+
AC_ELEM(
|
|
134
|
+
"parameter",
|
|
135
|
+
{AC_ATTR("name"): "originalWidth"},
|
|
136
|
+
str(width),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
if height is not None:
|
|
140
|
+
parameters.append(
|
|
141
|
+
AC_ELEM(
|
|
142
|
+
"parameter",
|
|
143
|
+
{AC_ATTR("name"): "originalHeight"},
|
|
144
|
+
str(height),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return AC_ELEM(
|
|
149
|
+
"structured-macro",
|
|
150
|
+
{
|
|
151
|
+
AC_ATTR("name"): "plantumlcloud",
|
|
152
|
+
AC_ATTR("schema-version"): "1",
|
|
153
|
+
"data-layout": "default",
|
|
154
|
+
AC_ATTR("local-id"): local_id,
|
|
155
|
+
AC_ATTR("macro-id"): macro_id,
|
|
156
|
+
},
|
|
157
|
+
*parameters,
|
|
158
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
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 base64
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import zlib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
|
|
19
|
+
import md2conf
|
|
20
|
+
from md2conf.external import execute_subprocess
|
|
21
|
+
|
|
22
|
+
from .config import PlantUMLConfigProperties
|
|
23
|
+
|
|
24
|
+
LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_plantuml_jar_path() -> Path:
|
|
28
|
+
"""
|
|
29
|
+
Returns the expected path to `plantuml.jar`.
|
|
30
|
+
|
|
31
|
+
Priority:
|
|
32
|
+
|
|
33
|
+
1. value of environment variable `PLANTUML_JAR`
|
|
34
|
+
2. path to `plantuml.jar` if found in parent directory of module `md2conf`
|
|
35
|
+
3. path to `plantuml.jar` if found in current directory
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# check environment variable
|
|
39
|
+
env_jar = os.environ.get("PLANTUML_JAR")
|
|
40
|
+
if env_jar:
|
|
41
|
+
return Path(env_jar)
|
|
42
|
+
|
|
43
|
+
# check parent directory of module `md2conf`
|
|
44
|
+
base_path = Path(md2conf.__file__).parent.parent
|
|
45
|
+
jar_path = base_path / "plantuml.jar"
|
|
46
|
+
if jar_path.exists():
|
|
47
|
+
return jar_path
|
|
48
|
+
|
|
49
|
+
# check current directory
|
|
50
|
+
return Path("plantuml.jar")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_plantuml_command() -> list[str]:
|
|
54
|
+
"""
|
|
55
|
+
Returns the command to invoke PlantUML.
|
|
56
|
+
|
|
57
|
+
:raises RuntimeError: Raised when `plantuml.jar` is not found.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
env_cmd = os.environ.get("PLANTUML_CMD")
|
|
61
|
+
if env_cmd:
|
|
62
|
+
LOGGER.debug(f"Using PlantUML command: {env_cmd}")
|
|
63
|
+
return shlex.split(env_cmd)
|
|
64
|
+
|
|
65
|
+
jar_path = _get_plantuml_jar_path()
|
|
66
|
+
if jar_path.is_file():
|
|
67
|
+
LOGGER.debug(f"Using PlantUML JAR at: {jar_path}")
|
|
68
|
+
return ["java", "-jar", str(jar_path)]
|
|
69
|
+
|
|
70
|
+
# JAR not found - fail with helpful message
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"PlantUML JAR not found. Download `plantuml.jar` from https://github.com/plantuml/plantuml/releases and set the PLANTUML_JAR environment variable."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def has_plantuml() -> bool:
|
|
77
|
+
"""True if PlantUML JAR is available and Java is installed."""
|
|
78
|
+
|
|
79
|
+
jar_path = _get_plantuml_jar_path()
|
|
80
|
+
|
|
81
|
+
# Check if we have JAR file and Java is available
|
|
82
|
+
return jar_path.is_file() and shutil.which("java") is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_diagram(
|
|
86
|
+
source: str,
|
|
87
|
+
output_format: Literal["png", "svg"] = "png",
|
|
88
|
+
config: PlantUMLConfigProperties | None = None,
|
|
89
|
+
) -> bytes:
|
|
90
|
+
"Generates a PNG or SVG image from a PlantUML diagram source."
|
|
91
|
+
|
|
92
|
+
if config is None:
|
|
93
|
+
config = PlantUMLConfigProperties()
|
|
94
|
+
|
|
95
|
+
# Build command for PlantUML with pipe mode
|
|
96
|
+
# -pipe: read from stdin and write to stdout
|
|
97
|
+
# -t<format>: output format (png or svg)
|
|
98
|
+
# -charset utf-8: ensure UTF-8 encoding
|
|
99
|
+
cmd = _get_plantuml_command()
|
|
100
|
+
cmd.extend(
|
|
101
|
+
[
|
|
102
|
+
"-pipe",
|
|
103
|
+
f"-t{output_format}",
|
|
104
|
+
"-charset",
|
|
105
|
+
"utf-8",
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Add scale if specified
|
|
110
|
+
if config.scale is not None:
|
|
111
|
+
cmd.extend(["-scale", str(config.scale)])
|
|
112
|
+
|
|
113
|
+
return execute_subprocess(cmd, source.encode("utf-8"), application="PlantUML")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compress_plantuml_data(source: str) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Compress PlantUML source for embedding in plantumlcloud macro.
|
|
119
|
+
|
|
120
|
+
Implements the encoding used by PlantUML Diagrams for Confluence:
|
|
121
|
+
|
|
122
|
+
1. URI encode the source
|
|
123
|
+
2. Deflate with raw deflate (zlib)
|
|
124
|
+
3. Base64 encode
|
|
125
|
+
|
|
126
|
+
:param source: PlantUML diagram source code.
|
|
127
|
+
:returns: Compressed and encoded data suitable for macro data parameter.
|
|
128
|
+
:see: https://stratus-addons.atlassian.net/wiki/spaces/PDFC/pages/1839333377
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# Step 1: URI encode
|
|
132
|
+
encoded = quote(source, safe="")
|
|
133
|
+
|
|
134
|
+
# Step 2: Deflate with raw deflate (remove zlib header/trailer)
|
|
135
|
+
# zlib.compress() adds 2-byte header and 4-byte trailer
|
|
136
|
+
deflated = zlib.compress(encoded.encode("utf-8"))[2:-4]
|
|
137
|
+
|
|
138
|
+
# Step 3: Base64 encode
|
|
139
|
+
return base64.b64encode(deflated).decode("ascii")
|
|
@@ -0,0 +1,56 @@
|
|
|
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 dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from md2conf.frontmatter import extract_frontmatter_object
|
|
12
|
+
|
|
13
|
+
from .config import PlantUMLConfigProperties
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PlantUMLProperties:
|
|
18
|
+
"""
|
|
19
|
+
An object that holds the front-matter properties structure
|
|
20
|
+
for PlantUML diagrams.
|
|
21
|
+
|
|
22
|
+
:param title: The title of the diagram.
|
|
23
|
+
:param config: Configuration options for rendering.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
title: str | None = None
|
|
27
|
+
config: PlantUMLConfigProperties | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PlantUMLScanner:
|
|
31
|
+
"""
|
|
32
|
+
Extracts properties from the JSON/YAML front-matter of a PlantUML diagram.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def read(self, content: str) -> PlantUMLProperties:
|
|
36
|
+
"""
|
|
37
|
+
Extracts rendering preferences from a PlantUML front-matter content.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
---
|
|
41
|
+
title: Class diagram
|
|
42
|
+
config:
|
|
43
|
+
scale: 1
|
|
44
|
+
---
|
|
45
|
+
@startuml
|
|
46
|
+
class Example
|
|
47
|
+
@enduml
|
|
48
|
+
```
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
properties, _ = extract_frontmatter_object(PlantUMLProperties, content)
|
|
52
|
+
if properties is not None:
|
|
53
|
+
config = properties.config or PlantUMLConfigProperties()
|
|
54
|
+
return PlantUMLProperties(title=properties.title, config=config)
|
|
55
|
+
|
|
56
|
+
return PlantUMLProperties()
|
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
|