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/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