markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +80 -4
- markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
- md2conf/__init__.py +2 -2
- md2conf/__main__.py +42 -24
- md2conf/api.py +27 -8
- md2conf/attachment.py +72 -0
- md2conf/coalesce.py +43 -0
- md2conf/collection.py +1 -1
- md2conf/{extra.py → compatibility.py} +1 -1
- md2conf/converter.py +232 -649
- md2conf/csf.py +13 -11
- md2conf/drawio/__init__.py +0 -0
- md2conf/drawio/extension.py +116 -0
- md2conf/{drawio.py → drawio/render.py} +1 -1
- md2conf/emoticon.py +3 -3
- md2conf/environment.py +2 -2
- md2conf/extension.py +78 -0
- md2conf/external.py +49 -0
- md2conf/formatting.py +135 -0
- md2conf/frontmatter.py +70 -0
- md2conf/image.py +127 -0
- md2conf/latex.py +4 -183
- md2conf/local.py +8 -8
- md2conf/markdown.py +1 -1
- md2conf/matcher.py +1 -1
- md2conf/mermaid/__init__.py +0 -0
- md2conf/mermaid/config.py +20 -0
- md2conf/mermaid/extension.py +109 -0
- md2conf/{mermaid.py → mermaid/render.py} +10 -38
- md2conf/mermaid/scanner.py +55 -0
- md2conf/metadata.py +1 -1
- md2conf/{domain.py → options.py} +73 -16
- md2conf/plantuml/__init__.py +0 -0
- md2conf/plantuml/config.py +20 -0
- md2conf/plantuml/extension.py +158 -0
- md2conf/plantuml/render.py +139 -0
- md2conf/plantuml/scanner.py +56 -0
- md2conf/png.py +202 -0
- md2conf/processor.py +32 -11
- md2conf/publisher.py +14 -18
- md2conf/scanner.py +31 -128
- md2conf/serializer.py +2 -2
- md2conf/svg.py +24 -2
- md2conf/text.py +1 -1
- md2conf/toc.py +1 -1
- md2conf/uri.py +1 -1
- md2conf/xml.py +1 -1
- markdown_to_confluence-0.5.2.dist-info/RECORD +0 -36
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
- {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/converter.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Publish Markdown files to Confluence wiki.
|
|
3
3
|
|
|
4
|
-
Copyright 2022-
|
|
4
|
+
Copyright 2022-2026, Levente Hunyadi
|
|
5
5
|
|
|
6
6
|
:see: https://github.com/hunyadi/md2conf
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
import enum
|
|
9
|
+
import copy
|
|
11
10
|
import hashlib
|
|
12
11
|
import logging
|
|
13
12
|
import os.path
|
|
@@ -16,26 +15,31 @@ import uuid
|
|
|
16
15
|
from abc import ABC, abstractmethod
|
|
17
16
|
from dataclasses import dataclass
|
|
18
17
|
from pathlib import Path
|
|
19
|
-
from typing import ClassVar
|
|
18
|
+
from typing import ClassVar
|
|
20
19
|
from urllib.parse import ParseResult, quote_plus, urlparse
|
|
21
20
|
|
|
22
21
|
import lxml.etree as ET
|
|
23
|
-
from cattrs import BaseValidationError
|
|
24
22
|
|
|
25
|
-
from . import
|
|
23
|
+
from .attachment import AttachmentCatalog, EmbeddedFileData, ImageData, attachment_name
|
|
24
|
+
from .coalesce import coalesce
|
|
26
25
|
from .collection import ConfluencePageCollection
|
|
26
|
+
from .compatibility import override, path_relative_to
|
|
27
27
|
from .csf import AC_ATTR, AC_ELEM, HTML, RI_ATTR, RI_ELEM, ParseError, elements_from_strings, elements_to_string, normalize_inline
|
|
28
|
-
from .
|
|
28
|
+
from .drawio.extension import DrawioExtension
|
|
29
29
|
from .emoticon import emoji_to_emoticon
|
|
30
30
|
from .environment import PageError
|
|
31
|
-
from .
|
|
32
|
-
from .
|
|
31
|
+
from .extension import ExtensionOptions, MarketplaceExtension
|
|
32
|
+
from .formatting import FormattingContext, ImageAlignment, ImageAttributes
|
|
33
|
+
from .image import ImageGenerator, ImageGeneratorOptions
|
|
34
|
+
from .latex import render_latex
|
|
33
35
|
from .markdown import markdown_to_html
|
|
34
|
-
from .mermaid import
|
|
36
|
+
from .mermaid.extension import MermaidExtension
|
|
35
37
|
from .metadata import ConfluenceSiteMetadata
|
|
36
|
-
from .
|
|
38
|
+
from .options import ConfluencePageID, ConverterOptions, DocumentOptions
|
|
39
|
+
from .plantuml.extension import PlantUMLExtension
|
|
40
|
+
from .png import extract_png_dimensions, remove_png_chunks
|
|
41
|
+
from .scanner import ScannedDocument, Scanner
|
|
37
42
|
from .serializer import JsonType
|
|
38
|
-
from .svg import fix_svg_dimensions, get_svg_dimensions, get_svg_dimensions_from_bytes
|
|
39
43
|
from .toc import TableOfContentsBuilder
|
|
40
44
|
from .uri import is_absolute_url, to_uuid_urn
|
|
41
45
|
from .xml import element_to_text
|
|
@@ -47,20 +51,21 @@ def apply_generated_by_template(template: str, path: Path) -> str:
|
|
|
47
51
|
"""Apply template substitution to the generated_by string.
|
|
48
52
|
|
|
49
53
|
Supported placeholders:
|
|
50
|
-
- %{filepath}: Full path to the file (relative to the
|
|
54
|
+
- %{filepath}: Full path to the file (relative to the source directory)
|
|
51
55
|
- %{filename}: Just the filename
|
|
56
|
+
- %{filedir}: Dirname of the full path to the file (relative to the source directory)
|
|
57
|
+
- %{filestem}: Just the filename without the extension
|
|
52
58
|
|
|
53
59
|
:param template: The template string with placeholders
|
|
54
60
|
:param path: The path to the file being converted
|
|
55
61
|
:returns: The template string with placeholders replaced
|
|
56
62
|
"""
|
|
57
63
|
|
|
58
|
-
return
|
|
59
|
-
"%{filepath}",
|
|
60
|
-
path.
|
|
61
|
-
|
|
62
|
-
"%{
|
|
63
|
-
path.name,
|
|
64
|
+
return (
|
|
65
|
+
template.replace("%{filepath}", path.as_posix())
|
|
66
|
+
.replace("%{filename}", path.name)
|
|
67
|
+
.replace("%{filedir}", path.parent.as_posix())
|
|
68
|
+
.replace("%{filestem}", path.stem)
|
|
64
69
|
)
|
|
65
70
|
|
|
66
71
|
|
|
@@ -166,7 +171,6 @@ _LANGUAGES = {
|
|
|
166
171
|
"kotlin": "kotlin",
|
|
167
172
|
"livescript": "livescript",
|
|
168
173
|
"lua": "lua",
|
|
169
|
-
"mermaid": "mermaid",
|
|
170
174
|
"mathematica": "mathematica",
|
|
171
175
|
"matlab": "matlab",
|
|
172
176
|
"objectivec": "objectivec",
|
|
@@ -271,170 +275,6 @@ def is_placeholder_for(node: ElementType, name: str) -> bool:
|
|
|
271
275
|
return True
|
|
272
276
|
|
|
273
277
|
|
|
274
|
-
@enum.unique
|
|
275
|
-
class FormattingContext(enum.Enum):
|
|
276
|
-
"Identifies the formatting context for the element."
|
|
277
|
-
|
|
278
|
-
BLOCK = "block"
|
|
279
|
-
INLINE = "inline"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
@enum.unique
|
|
283
|
-
class ImageAlignment(enum.Enum):
|
|
284
|
-
"Determines whether to align block-level images to center, left or right."
|
|
285
|
-
|
|
286
|
-
CENTER = "center"
|
|
287
|
-
LEFT = "left"
|
|
288
|
-
RIGHT = "right"
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
@dataclass
|
|
292
|
-
class ImageAttributes:
|
|
293
|
-
"""
|
|
294
|
-
Attributes applied to an `<img>` element.
|
|
295
|
-
|
|
296
|
-
:param context: Identifies the formatting context for the element (block or inline).
|
|
297
|
-
:param width: Natural image width in pixels.
|
|
298
|
-
:param height: Natural image height in pixels.
|
|
299
|
-
:param alt: Alternate text.
|
|
300
|
-
:param title: Title text (a.k.a. image tooltip).
|
|
301
|
-
:param caption: Caption text (shown below figure).
|
|
302
|
-
:param alignment: Alignment for block-level images.
|
|
303
|
-
:param display_width: Constrained display width in pixels (if different from natural width).
|
|
304
|
-
"""
|
|
305
|
-
|
|
306
|
-
context: FormattingContext
|
|
307
|
-
width: int | None
|
|
308
|
-
height: int | None
|
|
309
|
-
alt: str | None
|
|
310
|
-
title: str | None
|
|
311
|
-
caption: str | None
|
|
312
|
-
alignment: ImageAlignment = ImageAlignment.CENTER
|
|
313
|
-
display_width: int | None = None
|
|
314
|
-
|
|
315
|
-
def __post_init__(self) -> None:
|
|
316
|
-
if self.caption is None and self.context is FormattingContext.BLOCK:
|
|
317
|
-
self.caption = self.title or self.alt
|
|
318
|
-
|
|
319
|
-
def as_dict(self) -> dict[str, str]:
|
|
320
|
-
attributes: dict[str, str] = {}
|
|
321
|
-
if self.context is FormattingContext.BLOCK:
|
|
322
|
-
if self.alignment is ImageAlignment.LEFT:
|
|
323
|
-
attributes[AC_ATTR("align")] = "left"
|
|
324
|
-
attributes[AC_ATTR("layout")] = "align-start"
|
|
325
|
-
elif self.alignment is ImageAlignment.RIGHT:
|
|
326
|
-
attributes[AC_ATTR("align")] = "right"
|
|
327
|
-
attributes[AC_ATTR("layout")] = "align-end"
|
|
328
|
-
else:
|
|
329
|
-
attributes[AC_ATTR("align")] = "center"
|
|
330
|
-
attributes[AC_ATTR("layout")] = "center"
|
|
331
|
-
|
|
332
|
-
if self.width is not None:
|
|
333
|
-
attributes[AC_ATTR("original-width")] = str(self.width)
|
|
334
|
-
if self.height is not None:
|
|
335
|
-
attributes[AC_ATTR("original-height")] = str(self.height)
|
|
336
|
-
if self.width is not None:
|
|
337
|
-
attributes[AC_ATTR("custom-width")] = "true"
|
|
338
|
-
# Use display_width if set, otherwise use natural width
|
|
339
|
-
effective_width = self.display_width or self.width
|
|
340
|
-
attributes[AC_ATTR("width")] = str(effective_width)
|
|
341
|
-
|
|
342
|
-
elif self.context is FormattingContext.INLINE:
|
|
343
|
-
if self.width is not None:
|
|
344
|
-
attributes[AC_ATTR("width")] = str(self.width)
|
|
345
|
-
if self.height is not None:
|
|
346
|
-
attributes[AC_ATTR("height")] = str(self.height)
|
|
347
|
-
else:
|
|
348
|
-
raise NotImplementedError("match not exhaustive for enumeration")
|
|
349
|
-
|
|
350
|
-
if self.alt is not None:
|
|
351
|
-
attributes.update({AC_ATTR("alt"): self.alt})
|
|
352
|
-
if self.title is not None:
|
|
353
|
-
attributes.update({AC_ATTR("title"): self.title})
|
|
354
|
-
return attributes
|
|
355
|
-
|
|
356
|
-
EMPTY_BLOCK: ClassVar["ImageAttributes"]
|
|
357
|
-
EMPTY_INLINE: ClassVar["ImageAttributes"]
|
|
358
|
-
|
|
359
|
-
@classmethod
|
|
360
|
-
def empty(cls, context: FormattingContext) -> "ImageAttributes":
|
|
361
|
-
if context is FormattingContext.BLOCK:
|
|
362
|
-
return cls.EMPTY_BLOCK
|
|
363
|
-
elif context is FormattingContext.INLINE:
|
|
364
|
-
return cls.EMPTY_INLINE
|
|
365
|
-
else:
|
|
366
|
-
raise NotImplementedError("match not exhaustive for enumeration")
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
ImageAttributes.EMPTY_BLOCK = ImageAttributes(
|
|
370
|
-
FormattingContext.BLOCK, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
|
|
371
|
-
)
|
|
372
|
-
ImageAttributes.EMPTY_INLINE = ImageAttributes(
|
|
373
|
-
FormattingContext.INLINE, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
@dataclass
|
|
378
|
-
class ConfluenceConverterOptions:
|
|
379
|
-
"""
|
|
380
|
-
Options for converting an HTML tree into Confluence storage format.
|
|
381
|
-
|
|
382
|
-
:param ignore_invalid_url: When true, ignore invalid URLs in input, emit a warning and replace the anchor with
|
|
383
|
-
plain text; when false, raise an exception.
|
|
384
|
-
:param heading_anchors: When true, emit a structured macro *anchor* for each section heading using GitHub
|
|
385
|
-
conversion rules for the identifier.
|
|
386
|
-
:param skip_title_heading: Whether to remove the first heading from document body when used as page title.
|
|
387
|
-
:param prefer_raster: Whether to choose PNG files over SVG files when available.
|
|
388
|
-
:param render_drawio: Whether to pre-render (or use the pre-rendered version of) draw.io diagrams.
|
|
389
|
-
:param render_mermaid: Whether to pre-render Mermaid diagrams into PNG/SVG images.
|
|
390
|
-
:param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
|
|
391
|
-
:param diagram_output_format: Target image format for diagrams.
|
|
392
|
-
:param webui_links: When true, convert relative URLs to Confluence Web UI links.
|
|
393
|
-
:param alignment: Alignment for block-level images and formulas.
|
|
394
|
-
:param max_image_width: Maximum display width for images in pixels.
|
|
395
|
-
:param use_panel: Whether to transform admonitions and alerts into a Confluence custom panel.
|
|
396
|
-
"""
|
|
397
|
-
|
|
398
|
-
ignore_invalid_url: bool = False
|
|
399
|
-
heading_anchors: bool = False
|
|
400
|
-
skip_title_heading: bool = False
|
|
401
|
-
prefer_raster: bool = True
|
|
402
|
-
render_drawio: bool = False
|
|
403
|
-
render_mermaid: bool = False
|
|
404
|
-
render_latex: bool = False
|
|
405
|
-
diagram_output_format: Literal["png", "svg"] = "png"
|
|
406
|
-
webui_links: bool = False
|
|
407
|
-
alignment: Literal["center", "left", "right"] = "center"
|
|
408
|
-
max_image_width: int | None = None
|
|
409
|
-
use_panel: bool = False
|
|
410
|
-
|
|
411
|
-
def calculate_display_width(self, natural_width: int | None) -> int | None:
|
|
412
|
-
"""
|
|
413
|
-
Calculate the display width for an image, applying max_image_width constraint if set.
|
|
414
|
-
|
|
415
|
-
:param natural_width: The natural width of the image in pixels.
|
|
416
|
-
:returns: The constrained display width, or None if no constraint is needed.
|
|
417
|
-
"""
|
|
418
|
-
|
|
419
|
-
if natural_width is None or self.max_image_width is None:
|
|
420
|
-
return None
|
|
421
|
-
if natural_width <= self.max_image_width:
|
|
422
|
-
return None # no constraint needed, image is already within limits
|
|
423
|
-
return self.max_image_width
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
@dataclass
|
|
427
|
-
class ImageData:
|
|
428
|
-
path: Path
|
|
429
|
-
description: str | None = None
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
@dataclass
|
|
433
|
-
class EmbeddedFileData:
|
|
434
|
-
data: bytes
|
|
435
|
-
description: str | None = None
|
|
436
|
-
|
|
437
|
-
|
|
438
278
|
@dataclass
|
|
439
279
|
class ConfluencePanel:
|
|
440
280
|
emoji: str
|
|
@@ -475,20 +315,22 @@ ConfluencePanel.from_class = {
|
|
|
475
315
|
class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
476
316
|
"Transforms a plain HTML tree into Confluence Storage Format."
|
|
477
317
|
|
|
478
|
-
options:
|
|
318
|
+
options: ConverterOptions
|
|
479
319
|
path: Path
|
|
480
320
|
base_dir: Path
|
|
481
321
|
root_dir: Path
|
|
482
322
|
toc: TableOfContentsBuilder
|
|
483
323
|
links: list[str]
|
|
484
|
-
|
|
485
|
-
embedded_files: dict[str, EmbeddedFileData]
|
|
324
|
+
attachments: AttachmentCatalog
|
|
486
325
|
site_metadata: ConfluenceSiteMetadata
|
|
487
326
|
page_metadata: ConfluencePageCollection
|
|
488
327
|
|
|
328
|
+
image_generator: ImageGenerator
|
|
329
|
+
extensions: list[MarketplaceExtension]
|
|
330
|
+
|
|
489
331
|
def __init__(
|
|
490
332
|
self,
|
|
491
|
-
options:
|
|
333
|
+
options: ConverterOptions,
|
|
492
334
|
path: Path,
|
|
493
335
|
root_dir: Path,
|
|
494
336
|
site_metadata: ConfluenceSiteMetadata,
|
|
@@ -505,11 +347,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
505
347
|
self.root_dir = root_dir
|
|
506
348
|
self.toc = TableOfContentsBuilder()
|
|
507
349
|
self.links = []
|
|
508
|
-
self.
|
|
509
|
-
self.embedded_files = {}
|
|
350
|
+
self.attachments = AttachmentCatalog()
|
|
510
351
|
self.site_metadata = site_metadata
|
|
511
352
|
self.page_metadata = page_metadata
|
|
512
353
|
|
|
354
|
+
self.image_generator = ImageGenerator(
|
|
355
|
+
self.base_dir,
|
|
356
|
+
self.attachments,
|
|
357
|
+
ImageGeneratorOptions(self.options.diagram_output_format, self.options.prefer_raster, self.options.layout.image.max_width),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
self.extensions = [
|
|
361
|
+
DrawioExtension(self.image_generator, ExtensionOptions(render=self.options.render_drawio)),
|
|
362
|
+
MermaidExtension(self.image_generator, ExtensionOptions(render=self.options.render_mermaid)),
|
|
363
|
+
PlantUMLExtension(self.image_generator, ExtensionOptions(render=self.options.render_plantuml)),
|
|
364
|
+
]
|
|
365
|
+
|
|
513
366
|
def _transform_heading(self, heading: ElementType) -> None:
|
|
514
367
|
"""
|
|
515
368
|
Adds anchors to headings in the same document (if *heading anchors* is enabled).
|
|
@@ -660,7 +513,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
660
513
|
return None
|
|
661
514
|
|
|
662
515
|
file_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
663
|
-
self.
|
|
516
|
+
self.attachments.add_image(ImageData(absolute_path))
|
|
664
517
|
|
|
665
518
|
link_body = AC_ELEM("link-body", {}, *list(anchor))
|
|
666
519
|
link_body.text = anchor.text
|
|
@@ -728,8 +581,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
728
581
|
alt=alt,
|
|
729
582
|
title=title,
|
|
730
583
|
caption=None,
|
|
731
|
-
alignment=ImageAlignment(self.options.
|
|
732
|
-
display_width=self.options.calculate_display_width(pixel_width),
|
|
584
|
+
alignment=ImageAlignment(self.options.layout.get_image_alignment()),
|
|
733
585
|
)
|
|
734
586
|
|
|
735
587
|
if is_absolute_url(src):
|
|
@@ -741,14 +593,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
741
593
|
if absolute_path is None:
|
|
742
594
|
return self._create_missing(path, attrs)
|
|
743
595
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
return self._transform_external_mermaid(absolute_path, attrs)
|
|
750
|
-
else:
|
|
751
|
-
return self._transform_attached_image(absolute_path, attrs)
|
|
596
|
+
for extension in self.extensions:
|
|
597
|
+
if extension.matches_image(absolute_path):
|
|
598
|
+
return extension.transform_image(absolute_path, attrs)
|
|
599
|
+
|
|
600
|
+
return self.image_generator.transform_attached_image(absolute_path, attrs)
|
|
752
601
|
|
|
753
602
|
def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ElementType:
|
|
754
603
|
"Emits Confluence Storage Format XHTML for an external image."
|
|
@@ -764,7 +613,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
764
613
|
if attrs.caption:
|
|
765
614
|
elements.append(AC_ELEM("caption", attrs.caption))
|
|
766
615
|
|
|
767
|
-
return AC_ELEM("image", attrs.as_dict(), *elements)
|
|
616
|
+
return AC_ELEM("image", attrs.as_dict(max_width=self.options.layout.image.max_width), *elements)
|
|
768
617
|
|
|
769
618
|
def _warn_or_raise(self, msg: str) -> None:
|
|
770
619
|
"Emit a warning or raise an exception when a path points to a resource that doesn't exist or is outside of the permitted hierarchy."
|
|
@@ -793,132 +642,6 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
793
642
|
|
|
794
643
|
return absolute_path
|
|
795
644
|
|
|
796
|
-
def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
797
|
-
"Emits Confluence Storage Format XHTML for an attached raster or vector image."
|
|
798
|
-
|
|
799
|
-
if self.options.prefer_raster and absolute_path.suffix == ".svg":
|
|
800
|
-
# prefer PNG over SVG; Confluence displays SVG in wrong size, and text labels are truncated
|
|
801
|
-
png_file = absolute_path.with_suffix(".png")
|
|
802
|
-
if png_file.exists():
|
|
803
|
-
absolute_path = png_file
|
|
804
|
-
|
|
805
|
-
# infer SVG dimensions if not already specified
|
|
806
|
-
if absolute_path.suffix == ".svg" and attrs.width is None and attrs.height is None:
|
|
807
|
-
svg_width, svg_height = get_svg_dimensions(absolute_path)
|
|
808
|
-
if svg_width is not None:
|
|
809
|
-
attrs = ImageAttributes(
|
|
810
|
-
context=attrs.context,
|
|
811
|
-
width=svg_width,
|
|
812
|
-
height=svg_height,
|
|
813
|
-
alt=attrs.alt,
|
|
814
|
-
title=attrs.title,
|
|
815
|
-
caption=attrs.caption,
|
|
816
|
-
alignment=attrs.alignment,
|
|
817
|
-
display_width=self.options.calculate_display_width(svg_width),
|
|
818
|
-
)
|
|
819
|
-
|
|
820
|
-
self.images.append(ImageData(absolute_path, attrs.alt))
|
|
821
|
-
image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
|
|
822
|
-
return self._create_attached_image(image_name, attrs)
|
|
823
|
-
|
|
824
|
-
def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
825
|
-
"Emits Confluence Storage Format XHTML for a draw.io diagram."
|
|
826
|
-
|
|
827
|
-
if not absolute_path.name.endswith(".drawio.xml") and not absolute_path.name.endswith(".drawio"):
|
|
828
|
-
raise DocumentError("invalid image format; expected: `*.drawio.xml` or `*.drawio`")
|
|
829
|
-
|
|
830
|
-
relative_path = path_relative_to(absolute_path, self.base_dir)
|
|
831
|
-
if self.options.render_drawio:
|
|
832
|
-
image_data = drawio.render_diagram(absolute_path, self.options.diagram_output_format)
|
|
833
|
-
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
|
|
834
|
-
self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
|
|
835
|
-
return self._create_attached_image(image_filename, attrs)
|
|
836
|
-
else:
|
|
837
|
-
self.images.append(ImageData(absolute_path, attrs.alt))
|
|
838
|
-
image_filename = attachment_name(relative_path)
|
|
839
|
-
return self._create_drawio(image_filename, attrs)
|
|
840
|
-
|
|
841
|
-
def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
842
|
-
"Emits Confluence Storage Format XHTML for a draw.io diagram embedded in a PNG or SVG image."
|
|
843
|
-
|
|
844
|
-
if not absolute_path.name.endswith(".drawio.png") and not absolute_path.name.endswith(".drawio.svg"):
|
|
845
|
-
raise DocumentError("invalid image format; expected: `*.drawio.png` or `*.drawio.svg`")
|
|
846
|
-
|
|
847
|
-
if self.options.render_drawio:
|
|
848
|
-
return self._transform_attached_image(absolute_path, attrs)
|
|
849
|
-
else:
|
|
850
|
-
# extract embedded editable diagram and upload as *.drawio
|
|
851
|
-
image_data = drawio.extract_diagram(absolute_path)
|
|
852
|
-
image_filename = attachment_name(path_relative_to(absolute_path.with_suffix(".xml"), self.base_dir))
|
|
853
|
-
self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
|
|
854
|
-
|
|
855
|
-
return self._create_drawio(image_filename, attrs)
|
|
856
|
-
|
|
857
|
-
def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
|
|
858
|
-
"An image embedded into the page, linking to an attachment."
|
|
859
|
-
|
|
860
|
-
elements: list[ElementType] = []
|
|
861
|
-
elements.append(
|
|
862
|
-
RI_ELEM(
|
|
863
|
-
"attachment",
|
|
864
|
-
# refers to an attachment uploaded alongside the page
|
|
865
|
-
{RI_ATTR("filename"): image_name},
|
|
866
|
-
)
|
|
867
|
-
)
|
|
868
|
-
if attrs.caption:
|
|
869
|
-
elements.append(AC_ELEM("caption", attrs.caption))
|
|
870
|
-
|
|
871
|
-
return AC_ELEM("image", attrs.as_dict(), *elements)
|
|
872
|
-
|
|
873
|
-
def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ElementType:
|
|
874
|
-
"A draw.io diagram embedded into the page, linking to an attachment."
|
|
875
|
-
|
|
876
|
-
parameters: list[ElementType] = [
|
|
877
|
-
AC_ELEM(
|
|
878
|
-
"parameter",
|
|
879
|
-
{AC_ATTR("name"): "diagramName"},
|
|
880
|
-
filename,
|
|
881
|
-
),
|
|
882
|
-
]
|
|
883
|
-
if attrs.width is not None:
|
|
884
|
-
parameters.append(
|
|
885
|
-
AC_ELEM(
|
|
886
|
-
"parameter",
|
|
887
|
-
{AC_ATTR("name"): "width"},
|
|
888
|
-
str(attrs.width),
|
|
889
|
-
),
|
|
890
|
-
)
|
|
891
|
-
if attrs.height is not None:
|
|
892
|
-
parameters.append(
|
|
893
|
-
AC_ELEM(
|
|
894
|
-
"parameter",
|
|
895
|
-
{AC_ATTR("name"): "height"},
|
|
896
|
-
str(attrs.height),
|
|
897
|
-
),
|
|
898
|
-
)
|
|
899
|
-
if attrs.alignment is ImageAlignment.CENTER:
|
|
900
|
-
parameters.append(
|
|
901
|
-
AC_ELEM(
|
|
902
|
-
"parameter",
|
|
903
|
-
{AC_ATTR("name"): "pCenter"},
|
|
904
|
-
str(1),
|
|
905
|
-
),
|
|
906
|
-
)
|
|
907
|
-
|
|
908
|
-
local_id = str(uuid.uuid4())
|
|
909
|
-
macro_id = str(uuid.uuid4())
|
|
910
|
-
return AC_ELEM(
|
|
911
|
-
"structured-macro",
|
|
912
|
-
{
|
|
913
|
-
AC_ATTR("name"): "drawio",
|
|
914
|
-
AC_ATTR("schema-version"): "1",
|
|
915
|
-
"data-layout": "default",
|
|
916
|
-
AC_ATTR("local-id"): local_id,
|
|
917
|
-
AC_ATTR("macro-id"): macro_id,
|
|
918
|
-
},
|
|
919
|
-
*parameters,
|
|
920
|
-
)
|
|
921
|
-
|
|
922
645
|
def _create_missing(self, path: Path, attrs: ImageAttributes) -> ElementType:
|
|
923
646
|
"A warning panel for a missing image."
|
|
924
647
|
|
|
@@ -950,6 +673,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
950
673
|
def _transform_code_block(self, code: ElementType) -> ElementType:
|
|
951
674
|
"Transforms a code block."
|
|
952
675
|
|
|
676
|
+
content: str = code.text or ""
|
|
677
|
+
content = content.rstrip()
|
|
678
|
+
|
|
953
679
|
if language_class := code.get("class"):
|
|
954
680
|
if m := re.match("^language-(.*)$", language_class):
|
|
955
681
|
language_name = m.group(1)
|
|
@@ -960,16 +686,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
960
686
|
|
|
961
687
|
# translate name to standard name for (programming) language
|
|
962
688
|
if language_name is not None:
|
|
689
|
+
for extension in self.extensions:
|
|
690
|
+
if extension.matches_fenced(language_name, content):
|
|
691
|
+
return extension.transform_fenced(content)
|
|
692
|
+
|
|
963
693
|
language_id = _LANGUAGES.get(language_name)
|
|
964
694
|
else:
|
|
965
695
|
language_id = None
|
|
966
696
|
|
|
967
|
-
content: str = code.text or ""
|
|
968
|
-
content = content.rstrip()
|
|
969
|
-
|
|
970
|
-
if language_id == "mermaid":
|
|
971
|
-
return self._transform_fenced_mermaid(content)
|
|
972
|
-
|
|
973
697
|
return AC_ELEM(
|
|
974
698
|
"structured-macro",
|
|
975
699
|
{
|
|
@@ -984,126 +708,6 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
984
708
|
AC_ELEM("plain-text-body", ET.CDATA(content)),
|
|
985
709
|
)
|
|
986
710
|
|
|
987
|
-
def _extract_mermaid_config(self, content: str) -> MermaidConfigProperties | None:
|
|
988
|
-
"""Extract scale from Mermaid YAML front matter configuration."""
|
|
989
|
-
try:
|
|
990
|
-
properties = MermaidScanner().read(content)
|
|
991
|
-
return properties.config
|
|
992
|
-
except BaseValidationError as ex:
|
|
993
|
-
LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
|
|
994
|
-
return None
|
|
995
|
-
|
|
996
|
-
def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
|
|
997
|
-
"Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
|
|
998
|
-
|
|
999
|
-
if not absolute_path.name.endswith(".mmd") and not absolute_path.name.endswith(".mermaid"):
|
|
1000
|
-
raise DocumentError("invalid image format; expected: `*.mmd` or `*.mermaid`")
|
|
1001
|
-
|
|
1002
|
-
relative_path = path_relative_to(absolute_path, self.base_dir)
|
|
1003
|
-
if self.options.render_mermaid:
|
|
1004
|
-
with open(absolute_path, "r", encoding="utf-8") as f:
|
|
1005
|
-
content = f.read()
|
|
1006
|
-
config = self._extract_mermaid_config(content)
|
|
1007
|
-
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
1008
|
-
|
|
1009
|
-
# Extract dimensions and fix SVG if that's the output format
|
|
1010
|
-
if self.options.diagram_output_format == "svg":
|
|
1011
|
-
# Fix SVG to have explicit width/height instead of percentages
|
|
1012
|
-
image_data = fix_svg_dimensions(image_data)
|
|
1013
|
-
|
|
1014
|
-
if attrs.width is None and attrs.height is None:
|
|
1015
|
-
svg_width, svg_height = get_svg_dimensions_from_bytes(image_data)
|
|
1016
|
-
if svg_width is not None or svg_height is not None:
|
|
1017
|
-
attrs = ImageAttributes(
|
|
1018
|
-
context=attrs.context,
|
|
1019
|
-
width=svg_width,
|
|
1020
|
-
height=svg_height,
|
|
1021
|
-
alt=attrs.alt,
|
|
1022
|
-
title=attrs.title,
|
|
1023
|
-
caption=attrs.caption,
|
|
1024
|
-
alignment=attrs.alignment,
|
|
1025
|
-
display_width=self.options.calculate_display_width(svg_width),
|
|
1026
|
-
)
|
|
1027
|
-
|
|
1028
|
-
image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
|
|
1029
|
-
self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
|
|
1030
|
-
|
|
1031
|
-
return self._create_attached_image(image_filename, attrs)
|
|
1032
|
-
else:
|
|
1033
|
-
self.images.append(ImageData(absolute_path, attrs.alt))
|
|
1034
|
-
mermaid_filename = attachment_name(relative_path)
|
|
1035
|
-
return self._create_mermaid_embed(mermaid_filename)
|
|
1036
|
-
|
|
1037
|
-
def _transform_fenced_mermaid(self, content: str) -> ElementType:
|
|
1038
|
-
"Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
|
|
1039
|
-
|
|
1040
|
-
if self.options.render_mermaid:
|
|
1041
|
-
config = self._extract_mermaid_config(content)
|
|
1042
|
-
image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
|
|
1043
|
-
|
|
1044
|
-
# Extract dimensions and fix SVG if that's the output format
|
|
1045
|
-
attrs = ImageAttributes.EMPTY_BLOCK
|
|
1046
|
-
if self.options.diagram_output_format == "svg":
|
|
1047
|
-
# Fix SVG to have explicit width/height instead of percentages
|
|
1048
|
-
image_data = fix_svg_dimensions(image_data)
|
|
1049
|
-
|
|
1050
|
-
svg_width, svg_height = get_svg_dimensions_from_bytes(image_data)
|
|
1051
|
-
if svg_width is not None or svg_height is not None:
|
|
1052
|
-
attrs = ImageAttributes(
|
|
1053
|
-
context=FormattingContext.BLOCK,
|
|
1054
|
-
width=svg_width,
|
|
1055
|
-
height=svg_height,
|
|
1056
|
-
alt=None,
|
|
1057
|
-
title=None,
|
|
1058
|
-
caption=None,
|
|
1059
|
-
alignment=ImageAlignment(self.options.alignment),
|
|
1060
|
-
display_width=self.options.calculate_display_width(svg_width),
|
|
1061
|
-
)
|
|
1062
|
-
|
|
1063
|
-
image_hash = hashlib.md5(image_data).hexdigest()
|
|
1064
|
-
image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
|
|
1065
|
-
self.embedded_files[image_filename] = EmbeddedFileData(image_data)
|
|
1066
|
-
|
|
1067
|
-
return self._create_attached_image(image_filename, attrs)
|
|
1068
|
-
else:
|
|
1069
|
-
mermaid_data = content.encode("utf-8")
|
|
1070
|
-
mermaid_hash = hashlib.md5(mermaid_data).hexdigest()
|
|
1071
|
-
mermaid_filename = attachment_name(f"embedded_{mermaid_hash}.mmd")
|
|
1072
|
-
self.embedded_files[mermaid_filename] = EmbeddedFileData(mermaid_data)
|
|
1073
|
-
return self._create_mermaid_embed(mermaid_filename)
|
|
1074
|
-
|
|
1075
|
-
def _create_mermaid_embed(self, filename: str) -> ElementType:
|
|
1076
|
-
"A Mermaid diagram, linking to an attachment that captures the Mermaid source."
|
|
1077
|
-
|
|
1078
|
-
local_id = str(uuid.uuid4())
|
|
1079
|
-
macro_id = str(uuid.uuid4())
|
|
1080
|
-
return AC_ELEM(
|
|
1081
|
-
"structured-macro",
|
|
1082
|
-
{
|
|
1083
|
-
AC_ATTR("name"): "mermaid-cloud",
|
|
1084
|
-
AC_ATTR("schema-version"): "1",
|
|
1085
|
-
"data-layout": "default",
|
|
1086
|
-
AC_ATTR("local-id"): local_id,
|
|
1087
|
-
AC_ATTR("macro-id"): macro_id,
|
|
1088
|
-
},
|
|
1089
|
-
AC_ELEM(
|
|
1090
|
-
"parameter",
|
|
1091
|
-
{AC_ATTR("name"): "filename"},
|
|
1092
|
-
filename,
|
|
1093
|
-
),
|
|
1094
|
-
AC_ELEM(
|
|
1095
|
-
"parameter",
|
|
1096
|
-
{AC_ATTR("name"): "toolbar"},
|
|
1097
|
-
"bottom",
|
|
1098
|
-
),
|
|
1099
|
-
AC_ELEM(
|
|
1100
|
-
"parameter",
|
|
1101
|
-
{AC_ATTR("name"): "zoom"},
|
|
1102
|
-
"fit",
|
|
1103
|
-
),
|
|
1104
|
-
AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
|
|
1105
|
-
)
|
|
1106
|
-
|
|
1107
711
|
def _transform_toc(self, code: ElementType) -> ElementType:
|
|
1108
712
|
"Creates a table of contents, constructed from headings in the document."
|
|
1109
713
|
|
|
@@ -1420,7 +1024,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1420
1024
|
|
|
1421
1025
|
image_data = render_latex(content, format=self.options.diagram_output_format)
|
|
1422
1026
|
if self.options.diagram_output_format == "png":
|
|
1423
|
-
width, height =
|
|
1027
|
+
width, height = extract_png_dimensions(data=image_data)
|
|
1424
1028
|
image_data = remove_png_chunks(["pHYs"], source_data=image_data)
|
|
1425
1029
|
attrs = ImageAttributes(
|
|
1426
1030
|
context,
|
|
@@ -1429,16 +1033,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1429
1033
|
alt=content,
|
|
1430
1034
|
title=None,
|
|
1431
1035
|
caption="",
|
|
1432
|
-
alignment=ImageAlignment(self.options.
|
|
1433
|
-
display_width=self.options.calculate_display_width(width),
|
|
1036
|
+
alignment=ImageAlignment(self.options.layout.get_image_alignment()),
|
|
1434
1037
|
)
|
|
1435
1038
|
else:
|
|
1436
1039
|
attrs = ImageAttributes.empty(context)
|
|
1437
1040
|
|
|
1438
1041
|
image_hash = hashlib.md5(image_data).hexdigest()
|
|
1439
1042
|
image_filename = attachment_name(f"formula_{image_hash}.{self.options.diagram_output_format}")
|
|
1440
|
-
self.
|
|
1441
|
-
image = self.
|
|
1043
|
+
self.attachments.add_embed(image_filename, EmbeddedFileData(image_data, content))
|
|
1044
|
+
image = self.image_generator.create_attached_image(image_filename, attrs)
|
|
1442
1045
|
return image
|
|
1443
1046
|
|
|
1444
1047
|
def _transform_inline_math(self, elem: ElementType) -> ElementType:
|
|
@@ -1472,7 +1075,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1472
1075
|
{AC_ATTR("name"): "body"},
|
|
1473
1076
|
content,
|
|
1474
1077
|
),
|
|
1475
|
-
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.
|
|
1078
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.layout.get_image_alignment()),
|
|
1476
1079
|
)
|
|
1477
1080
|
return macro
|
|
1478
1081
|
|
|
@@ -1509,7 +1112,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1509
1112
|
{AC_ATTR("name"): "body"},
|
|
1510
1113
|
content,
|
|
1511
1114
|
),
|
|
1512
|
-
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.
|
|
1115
|
+
AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.layout.get_image_alignment()),
|
|
1513
1116
|
)
|
|
1514
1117
|
|
|
1515
1118
|
def _transform_footnote_ref(self, elem: ElementType) -> None:
|
|
@@ -1761,162 +1364,174 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
|
|
|
1761
1364
|
if not isinstance(child.tag, str):
|
|
1762
1365
|
return None
|
|
1763
1366
|
|
|
1764
|
-
|
|
1765
|
-
if child.tag == "p":
|
|
1766
|
-
# <p><img src="..." /></p>
|
|
1767
|
-
if len(child) == 1 and not child.text and child[0].tag == "img" and not child[0].tail:
|
|
1768
|
-
return self._transform_image(FormattingContext.BLOCK, child[0])
|
|
1769
|
-
|
|
1770
|
-
# <p>[[<em>TOC</em>]]</p> (represented in Markdown as `[[_TOC_]]`)
|
|
1771
|
-
elif is_placeholder_for(child, "TOC"):
|
|
1772
|
-
return self._transform_toc(child)
|
|
1773
|
-
|
|
1774
|
-
# <p>[[<em>LISTING</em>]]</p> (represented in Markdown as `[[_LISTING_]]`)
|
|
1775
|
-
elif is_placeholder_for(child, "LISTING"):
|
|
1776
|
-
return self._transform_listing(child)
|
|
1777
|
-
|
|
1778
|
-
# <div>...</div>
|
|
1779
|
-
elif child.tag == "div":
|
|
1780
|
-
classes = child.get("class", "").split(" ")
|
|
1781
|
-
|
|
1782
|
-
# <div class="arithmatex">...</div>
|
|
1783
|
-
if "arithmatex" in classes:
|
|
1784
|
-
return self._transform_block_math(child)
|
|
1785
|
-
|
|
1786
|
-
# <div><ac:structured-macro ...>...</ac:structured-macro></div>
|
|
1787
|
-
elif "csf" in classes:
|
|
1788
|
-
if len(child) != 1:
|
|
1789
|
-
raise DocumentError("expected: single child in Confluence Storage Format block")
|
|
1790
|
-
|
|
1791
|
-
return child[0]
|
|
1792
|
-
|
|
1793
|
-
# <div class="footnote">
|
|
1794
|
-
# <hr/>
|
|
1795
|
-
# <ol>
|
|
1796
|
-
# <li id="fn:NAME"><p>TEXT <a class="footnote-backref" href="#fnref:NAME">↩</a></p></li>
|
|
1797
|
-
# </ol>
|
|
1798
|
-
# </div>
|
|
1799
|
-
elif "footnote" in classes:
|
|
1800
|
-
self._transform_footnote_def(child)
|
|
1801
|
-
return None
|
|
1802
|
-
|
|
1803
|
-
# <div class="admonition note">
|
|
1804
|
-
# <p class="admonition-title">Note</p>
|
|
1367
|
+
match child.tag:
|
|
1805
1368
|
# <p>...</p>
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
# <
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1369
|
+
case "p":
|
|
1370
|
+
# <p><img src="..." /></p>
|
|
1371
|
+
if len(child) == 1 and not child.text and child[0].tag == "img" and not child[0].tail:
|
|
1372
|
+
return self._transform_image(FormattingContext.BLOCK, child[0])
|
|
1373
|
+
|
|
1374
|
+
# <p>[[<em>TOC</em>]]</p> (represented in Markdown as `[[_TOC_]]`)
|
|
1375
|
+
elif is_placeholder_for(child, "TOC"):
|
|
1376
|
+
return self._transform_toc(child)
|
|
1377
|
+
|
|
1378
|
+
# <p>[[<em>LISTING</em>]]</p> (represented in Markdown as `[[_LISTING_]]`)
|
|
1379
|
+
elif is_placeholder_for(child, "LISTING"):
|
|
1380
|
+
return self._transform_listing(child)
|
|
1381
|
+
|
|
1382
|
+
# <div>...</div>
|
|
1383
|
+
case "div":
|
|
1384
|
+
classes = child.get("class", "").split(" ")
|
|
1385
|
+
|
|
1386
|
+
# <div class="arithmatex">...</div>
|
|
1387
|
+
if "arithmatex" in classes:
|
|
1388
|
+
return self._transform_block_math(child)
|
|
1389
|
+
|
|
1390
|
+
# <div><ac:structured-macro ...>...</ac:structured-macro></div>
|
|
1391
|
+
elif "csf" in classes:
|
|
1392
|
+
if len(child) != 1:
|
|
1393
|
+
raise DocumentError("expected: single child in Confluence Storage Format block")
|
|
1394
|
+
|
|
1395
|
+
return child[0]
|
|
1396
|
+
|
|
1397
|
+
# <div class="footnote">
|
|
1398
|
+
# <hr/>
|
|
1399
|
+
# <ol>
|
|
1400
|
+
# <li id="fn:NAME"><p>TEXT <a class="footnote-backref" href="#fnref:NAME">↩</a></p></li>
|
|
1401
|
+
# </ol>
|
|
1402
|
+
# </div>
|
|
1403
|
+
elif "footnote" in classes:
|
|
1404
|
+
self._transform_footnote_def(child)
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
# <div class="admonition note">
|
|
1408
|
+
# <p class="admonition-title">Note</p>
|
|
1409
|
+
# <p>...</p>
|
|
1410
|
+
# </div>
|
|
1411
|
+
#
|
|
1412
|
+
# --- OR ---
|
|
1413
|
+
#
|
|
1414
|
+
# <div class="admonition note">
|
|
1415
|
+
# <p>...</p>
|
|
1416
|
+
# </div>
|
|
1417
|
+
elif "admonition" in classes:
|
|
1418
|
+
return self._transform_admonition(child)
|
|
1419
|
+
|
|
1420
|
+
# <blockquote>...</blockquote>
|
|
1421
|
+
case "blockquote":
|
|
1422
|
+
# Alerts in GitHub
|
|
1423
|
+
# <blockquote>
|
|
1424
|
+
# <p>[!TIP] ...</p>
|
|
1425
|
+
# </blockquote>
|
|
1426
|
+
if len(child) > 0 and child[0].tag == "p" and child[0].text is not None and child[0].text.startswith("[!"):
|
|
1427
|
+
return self._transform_github_alert(child)
|
|
1428
|
+
|
|
1429
|
+
# Alerts in GitLab
|
|
1430
|
+
# <blockquote>
|
|
1431
|
+
# <p>DISCLAIMER: ...</p>
|
|
1432
|
+
# </blockquote>
|
|
1433
|
+
elif len(child) > 0 and child[0].tag == "p" and element_text_starts_with_any(child[0], ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"]):
|
|
1434
|
+
return self._transform_gitlab_alert(child)
|
|
1435
|
+
|
|
1436
|
+
# <details markdown="1">
|
|
1437
|
+
# <summary>...</summary>
|
|
1438
|
+
# ...
|
|
1439
|
+
# </details>
|
|
1440
|
+
case "details" if len(child) > 1 and child[0].tag == "summary":
|
|
1441
|
+
return self._transform_collapsed(child)
|
|
1844
1442
|
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
if len(child) > 0 and all(element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]) for item in child):
|
|
1851
|
-
return self._transform_tasklist(child)
|
|
1443
|
+
# <ol>...</ol>
|
|
1444
|
+
case "ol":
|
|
1445
|
+
# Confluence adds the attribute `start` for every ordered list
|
|
1446
|
+
child.set("start", "1")
|
|
1447
|
+
return None
|
|
1852
1448
|
|
|
1853
|
-
|
|
1449
|
+
# <ul>
|
|
1450
|
+
# <li>[ ] ...</li>
|
|
1451
|
+
# <li>[x] ...</li>
|
|
1452
|
+
# </ul>
|
|
1453
|
+
case "ul":
|
|
1454
|
+
if len(child) > 0 and all(element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]) for item in child):
|
|
1455
|
+
return self._transform_tasklist(child)
|
|
1854
1456
|
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1457
|
+
return None
|
|
1458
|
+
|
|
1459
|
+
case "li":
|
|
1460
|
+
normalize_inline(child)
|
|
1461
|
+
return None
|
|
1858
1462
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1463
|
+
# <pre><code class="language-java"> ... </code></pre>
|
|
1464
|
+
case "pre" if len(child) == 1 and child[0].tag == "code":
|
|
1465
|
+
return self._transform_code_block(child[0])
|
|
1466
|
+
|
|
1467
|
+
# <table>...</table>
|
|
1468
|
+
case "table":
|
|
1469
|
+
for td in child.iterdescendants("td", "th"):
|
|
1470
|
+
normalize_inline(td)
|
|
1471
|
+
match self.options.layout.alignment:
|
|
1472
|
+
case "left":
|
|
1473
|
+
layout = "align-start"
|
|
1474
|
+
case _:
|
|
1475
|
+
layout = "default"
|
|
1476
|
+
child.set("data-layout", layout)
|
|
1477
|
+
if self.options.layout.table.display_mode == "fixed":
|
|
1478
|
+
child.set("data-table-display-mode", "fixed")
|
|
1479
|
+
if self.options.layout.table.width:
|
|
1480
|
+
child.set("data-table-width", str(self.options.layout.table.width))
|
|
1862
1481
|
|
|
1863
|
-
|
|
1864
|
-
elif child.tag == "table":
|
|
1865
|
-
for td in child.iterdescendants("td", "th"):
|
|
1866
|
-
normalize_inline(td)
|
|
1867
|
-
child.set("data-layout", "default")
|
|
1868
|
-
return None
|
|
1482
|
+
return None
|
|
1869
1483
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1484
|
+
# <img src="..." alt="..." />
|
|
1485
|
+
case "img":
|
|
1486
|
+
return self._transform_image(FormattingContext.INLINE, child)
|
|
1873
1487
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1488
|
+
# <a href="..."> ... </a>
|
|
1489
|
+
case "a":
|
|
1490
|
+
return self._transform_link(child)
|
|
1877
1491
|
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1492
|
+
# <mark>...</mark>
|
|
1493
|
+
case "mark":
|
|
1494
|
+
return self._transform_mark(child)
|
|
1881
1495
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1496
|
+
# <span>...</span>
|
|
1497
|
+
case "span":
|
|
1498
|
+
classes = child.get("class", "").split(" ")
|
|
1885
1499
|
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1500
|
+
# <span class="arithmatex">...</span>
|
|
1501
|
+
if "arithmatex" in classes:
|
|
1502
|
+
return self._transform_inline_math(child)
|
|
1889
1503
|
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1504
|
+
# <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">1</a></sup>
|
|
1505
|
+
# Multiple references: <sup id="fnref2:NAME">...</sup>, <sup id="fnref3:NAME">...</sup>
|
|
1506
|
+
case "sup" if re.match(r"^fnref\d*:", child.get("id", "")):
|
|
1507
|
+
self._transform_footnote_ref(child)
|
|
1508
|
+
return None
|
|
1895
1509
|
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1510
|
+
# <input type="date" value="1984-01-01" />
|
|
1511
|
+
case "input" if child.get("type", "") == "date":
|
|
1512
|
+
return HTML("time", {"datetime": child.get("value", "")})
|
|
1899
1513
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1514
|
+
# <ins>...</ins>
|
|
1515
|
+
case "ins":
|
|
1516
|
+
# Confluence prefers <u> over <ins> for underline, and replaces <ins> with <u>
|
|
1517
|
+
child.tag = "u"
|
|
1904
1518
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1519
|
+
# <x-emoji data-shortname="wink" data-unicode="1f609">😉</x-emoji>
|
|
1520
|
+
case "x-emoji":
|
|
1521
|
+
return self._transform_emoji(child)
|
|
1908
1522
|
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
self.toc.add(level, title)
|
|
1523
|
+
# <h1>...</h1>
|
|
1524
|
+
# <h2>...</h2> ...
|
|
1525
|
+
case "h1" | "h2" | "h3" | "h4" | "h5" | "h6":
|
|
1526
|
+
level = int(child.tag[1:])
|
|
1527
|
+
title = element_to_text(child)
|
|
1528
|
+
self.toc.add(level, title)
|
|
1916
1529
|
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1530
|
+
if self.options.heading_anchors:
|
|
1531
|
+
self._transform_heading(child)
|
|
1532
|
+
return None
|
|
1533
|
+
case _:
|
|
1534
|
+
pass
|
|
1920
1535
|
|
|
1921
1536
|
return None
|
|
1922
1537
|
|
|
@@ -1940,14 +1555,14 @@ class ConfluenceDocument:
|
|
|
1940
1555
|
images: list[ImageData]
|
|
1941
1556
|
embedded_files: dict[str, EmbeddedFileData]
|
|
1942
1557
|
|
|
1943
|
-
options:
|
|
1558
|
+
options: DocumentOptions
|
|
1944
1559
|
root: ElementType
|
|
1945
1560
|
|
|
1946
1561
|
@classmethod
|
|
1947
1562
|
def create(
|
|
1948
1563
|
cls,
|
|
1949
1564
|
path: Path,
|
|
1950
|
-
options:
|
|
1565
|
+
options: DocumentOptions,
|
|
1951
1566
|
root_dir: Path,
|
|
1952
1567
|
site_metadata: ConfluenceSiteMetadata,
|
|
1953
1568
|
page_metadata: ConfluencePageCollection,
|
|
@@ -1955,9 +1570,10 @@ class ConfluenceDocument:
|
|
|
1955
1570
|
path = path.resolve(True)
|
|
1956
1571
|
|
|
1957
1572
|
document = Scanner().read(path)
|
|
1573
|
+
props = document.properties
|
|
1958
1574
|
|
|
1959
|
-
if
|
|
1960
|
-
page_id = ConfluencePageID(
|
|
1575
|
+
if props.page_id is not None:
|
|
1576
|
+
page_id = ConfluencePageID(props.page_id)
|
|
1961
1577
|
else:
|
|
1962
1578
|
# look up Confluence page ID in metadata
|
|
1963
1579
|
metadata = page_metadata.get(path)
|
|
@@ -1972,13 +1588,14 @@ class ConfluenceDocument:
|
|
|
1972
1588
|
self,
|
|
1973
1589
|
path: Path,
|
|
1974
1590
|
document: ScannedDocument,
|
|
1975
|
-
options:
|
|
1591
|
+
options: DocumentOptions,
|
|
1976
1592
|
root_dir: Path,
|
|
1977
1593
|
site_metadata: ConfluenceSiteMetadata,
|
|
1978
1594
|
page_metadata: ConfluencePageCollection,
|
|
1979
1595
|
) -> None:
|
|
1980
1596
|
"Converts a single Markdown document to Confluence Storage Format."
|
|
1981
1597
|
|
|
1598
|
+
props = document.properties
|
|
1982
1599
|
self.options = options
|
|
1983
1600
|
|
|
1984
1601
|
# register auxiliary URL substitutions
|
|
@@ -1992,7 +1609,7 @@ class ConfluenceDocument:
|
|
|
1992
1609
|
|
|
1993
1610
|
# modify HTML as necessary
|
|
1994
1611
|
if self.options.generated_by is not None:
|
|
1995
|
-
generated_by =
|
|
1612
|
+
generated_by = props.generated_by or self.options.generated_by
|
|
1996
1613
|
else:
|
|
1997
1614
|
generated_by = None
|
|
1998
1615
|
|
|
@@ -2016,11 +1633,9 @@ class ConfluenceDocument:
|
|
|
2016
1633
|
raise ConversionError(path) from ex
|
|
2017
1634
|
|
|
2018
1635
|
# configure HTML-to-Confluence converter
|
|
2019
|
-
converter_options =
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
if document.alignment is not None:
|
|
2023
|
-
converter_options.alignment = document.alignment
|
|
1636
|
+
converter_options = copy.deepcopy(self.options.converter)
|
|
1637
|
+
if props.layout is not None:
|
|
1638
|
+
converter_options.layout = coalesce(props.layout, converter_options.layout)
|
|
2024
1639
|
converter = ConfluenceStorageFormatConverter(converter_options, path, root_dir, site_metadata, page_metadata)
|
|
2025
1640
|
|
|
2026
1641
|
# execute HTML-to-Confluence converter
|
|
@@ -2031,19 +1646,19 @@ class ConfluenceDocument:
|
|
|
2031
1646
|
|
|
2032
1647
|
# extract information discovered by converter
|
|
2033
1648
|
self.links = converter.links
|
|
2034
|
-
self.images = converter.images
|
|
2035
|
-
self.embedded_files = converter.embedded_files
|
|
1649
|
+
self.images = converter.attachments.images
|
|
1650
|
+
self.embedded_files = converter.attachments.embedded_files
|
|
2036
1651
|
|
|
2037
1652
|
# assign global properties for document
|
|
2038
|
-
self.title =
|
|
2039
|
-
self.labels =
|
|
2040
|
-
self.properties =
|
|
1653
|
+
self.title = props.title or converter.toc.get_title()
|
|
1654
|
+
self.labels = props.tags
|
|
1655
|
+
self.properties = props.properties
|
|
2041
1656
|
|
|
2042
1657
|
# Remove the first heading if:
|
|
2043
1658
|
# 1. The option is enabled
|
|
2044
1659
|
# 2. Title was NOT from front-matter (document.title is None)
|
|
2045
1660
|
# 3. A title was successfully extracted from heading (self.title is not None)
|
|
2046
|
-
if converter_options.skip_title_heading and
|
|
1661
|
+
if converter_options.skip_title_heading and props.title is None and self.title is not None:
|
|
2047
1662
|
self._remove_first_heading()
|
|
2048
1663
|
|
|
2049
1664
|
def _remove_first_heading(self) -> None:
|
|
@@ -2094,35 +1709,3 @@ class ConfluenceDocument:
|
|
|
2094
1709
|
|
|
2095
1710
|
def xhtml(self) -> str:
|
|
2096
1711
|
return elements_to_string(self.root)
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
def attachment_name(ref: Path | str) -> str:
|
|
2100
|
-
"""
|
|
2101
|
-
Safe name for use with attachment uploads.
|
|
2102
|
-
|
|
2103
|
-
Mutates a relative path such that it meets Confluence's attachment naming requirements.
|
|
2104
|
-
|
|
2105
|
-
Allowed characters:
|
|
2106
|
-
|
|
2107
|
-
* Alphanumeric characters: 0-9, a-z, A-Z
|
|
2108
|
-
* Special characters: hyphen (-), underscore (_), period (.)
|
|
2109
|
-
"""
|
|
2110
|
-
|
|
2111
|
-
if isinstance(ref, Path):
|
|
2112
|
-
path = ref
|
|
2113
|
-
else:
|
|
2114
|
-
path = Path(ref)
|
|
2115
|
-
|
|
2116
|
-
if path.drive or path.root:
|
|
2117
|
-
raise ValueError(f"required: relative path; got: {ref}")
|
|
2118
|
-
|
|
2119
|
-
regexp = re.compile(r"[^\-0-9A-Za-z_.]", re.UNICODE)
|
|
2120
|
-
|
|
2121
|
-
def replace_part(part: str) -> str:
|
|
2122
|
-
if part == "..":
|
|
2123
|
-
return "PAR"
|
|
2124
|
-
else:
|
|
2125
|
-
return regexp.sub("_", part)
|
|
2126
|
-
|
|
2127
|
-
parts = [replace_part(p) for p in path.parts]
|
|
2128
|
-
return Path(*parts).as_posix().replace("/", "_")
|