markdown-to-confluence 0.4.5__py3-none-any.whl → 0.4.7__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.
md2conf/converter.py CHANGED
@@ -21,21 +21,26 @@ from urllib.parse import ParseResult, quote_plus, urlparse
21
21
 
22
22
  import lxml.etree as ET
23
23
  from strong_typing.core import JsonType
24
+ from strong_typing.exception import JsonTypeError
24
25
 
25
26
  from . import drawio, mermaid
26
27
  from .collection import ConfluencePageCollection
27
28
  from .csf import AC_ATTR, AC_ELEM, HTML, RI_ATTR, RI_ELEM, ParseError, elements_from_strings, elements_to_string, normalize_inline
28
29
  from .domain import ConfluenceDocumentOptions, ConfluencePageID
30
+ from .emoticon import emoji_to_emoticon
31
+ from .environment import PageError
29
32
  from .extra import override, path_relative_to
30
33
  from .latex import get_png_dimensions, remove_png_chunks, render_latex
31
34
  from .markdown import markdown_to_html
35
+ from .mermaid import MermaidConfigProperties
32
36
  from .metadata import ConfluenceSiteMetadata
33
- from .properties import PageError
34
- from .scanner import ScannedDocument, Scanner
37
+ from .scanner import MermaidScanner, ScannedDocument, Scanner
35
38
  from .toc import TableOfContentsBuilder
36
39
  from .uri import is_absolute_url, to_uuid_urn
37
40
  from .xml import element_to_text
38
41
 
42
+ ElementType = ET._Element # pyright: ignore [reportPrivateUsage]
43
+
39
44
 
40
45
  def get_volatile_attributes() -> list[str]:
41
46
  "Returns a list of volatile attributes that frequently change as a Confluence storage format XHTML document is updated."
@@ -177,7 +182,7 @@ _LANGUAGES = {
177
182
 
178
183
 
179
184
  class NodeVisitor(ABC):
180
- def visit(self, node: ET._Element) -> None:
185
+ def visit(self, node: ElementType) -> None:
181
186
  "Recursively visits all descendants of this node."
182
187
 
183
188
  if len(node) < 1:
@@ -197,7 +202,7 @@ class NodeVisitor(ABC):
197
202
  self.visit(source)
198
203
 
199
204
  @abstractmethod
200
- def transform(self, child: ET._Element) -> Optional[ET._Element]: ...
205
+ def transform(self, child: ElementType) -> Optional[ElementType]: ...
201
206
 
202
207
 
203
208
  def title_to_identifier(title: str) -> str:
@@ -209,7 +214,7 @@ def title_to_identifier(title: str) -> str:
209
214
  return s
210
215
 
211
216
 
212
- def element_text_starts_with_any(node: ET._Element, prefixes: list[str]) -> bool:
217
+ def element_text_starts_with_any(node: ElementType, prefixes: list[str]) -> bool:
213
218
  "True if the text contained in an element starts with any of the specified prefix strings."
214
219
 
215
220
  if node.text is None:
@@ -217,7 +222,7 @@ def element_text_starts_with_any(node: ET._Element, prefixes: list[str]) -> bool
217
222
  return starts_with_any(node.text, prefixes)
218
223
 
219
224
 
220
- def is_placeholder_for(node: ET._Element, name: str) -> bool:
225
+ def is_placeholder_for(node: ElementType, name: str) -> bool:
221
226
  """
222
227
  Identifies a Confluence widget placeholder, e.g. `[[_TOC_]]` or `[[_LISTING_]]`.
223
228
 
@@ -244,6 +249,15 @@ class FormattingContext(enum.Enum):
244
249
  INLINE = "inline"
245
250
 
246
251
 
252
+ @enum.unique
253
+ class ImageAlignment(enum.Enum):
254
+ "Determines whether to align block-level images to center, left or right."
255
+
256
+ CENTER = "center"
257
+ LEFT = "left"
258
+ RIGHT = "right"
259
+
260
+
247
261
  @dataclass
248
262
  class ImageAttributes:
249
263
  """
@@ -255,6 +269,7 @@ class ImageAttributes:
255
269
  :param alt: Alternate text.
256
270
  :param title: Title text (a.k.a. image tooltip).
257
271
  :param caption: Caption text (shown below figure).
272
+ :param alignment: Alignment for block-level images.
258
273
  """
259
274
 
260
275
  context: FormattingContext
@@ -263,6 +278,7 @@ class ImageAttributes:
263
278
  alt: Optional[str]
264
279
  title: Optional[str]
265
280
  caption: Optional[str]
281
+ alignment: ImageAlignment = ImageAlignment.CENTER
266
282
 
267
283
  def __post_init__(self) -> None:
268
284
  if self.caption is None and self.context is FormattingContext.BLOCK:
@@ -271,8 +287,16 @@ class ImageAttributes:
271
287
  def as_dict(self) -> dict[str, str]:
272
288
  attributes: dict[str, str] = {}
273
289
  if self.context is FormattingContext.BLOCK:
274
- attributes[AC_ATTR("align")] = "center"
275
- attributes[AC_ATTR("layout")] = "center"
290
+ if self.alignment is ImageAlignment.LEFT:
291
+ attributes[AC_ATTR("align")] = "left"
292
+ attributes[AC_ATTR("layout")] = "align-start"
293
+ elif self.alignment is ImageAlignment.RIGHT:
294
+ attributes[AC_ATTR("align")] = "right"
295
+ attributes[AC_ATTR("layout")] = "align-end"
296
+ else:
297
+ attributes[AC_ATTR("align")] = "center"
298
+ attributes[AC_ATTR("layout")] = "center"
299
+
276
300
  if self.width is not None:
277
301
  attributes[AC_ATTR("original-width")] = str(self.width)
278
302
  if self.height is not None:
@@ -308,8 +332,12 @@ class ImageAttributes:
308
332
  raise NotImplementedError("match not exhaustive for enumeration")
309
333
 
310
334
 
311
- ImageAttributes.EMPTY_BLOCK = ImageAttributes(FormattingContext.BLOCK, None, None, None, None, None)
312
- ImageAttributes.EMPTY_INLINE = ImageAttributes(FormattingContext.INLINE, None, None, None, None, None)
335
+ ImageAttributes.EMPTY_BLOCK = ImageAttributes(
336
+ FormattingContext.BLOCK, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
337
+ )
338
+ ImageAttributes.EMPTY_INLINE = ImageAttributes(
339
+ FormattingContext.INLINE, width=None, height=None, alt=None, title=None, caption=None, alignment=ImageAlignment.CENTER
340
+ )
313
341
 
314
342
 
315
343
  @dataclass
@@ -327,6 +355,8 @@ class ConfluenceConverterOptions:
327
355
  :param render_latex: Whether to pre-render LaTeX formulas into PNG/SVG images.
328
356
  :param diagram_output_format: Target image format for diagrams.
329
357
  :param webui_links: When true, convert relative URLs to Confluence Web UI links.
358
+ :param alignment: Alignment for block-level images and formulas.
359
+ :param use_panel: Whether to transform admonitions and alerts into a Confluence custom panel.
330
360
  """
331
361
 
332
362
  ignore_invalid_url: bool = False
@@ -337,6 +367,8 @@ class ConfluenceConverterOptions:
337
367
  render_latex: bool = False
338
368
  diagram_output_format: Literal["png", "svg"] = "png"
339
369
  webui_links: bool = False
370
+ alignment: Literal["center", "left", "right"] = "center"
371
+ use_panel: bool = False
340
372
 
341
373
 
342
374
  @dataclass
@@ -351,6 +383,43 @@ class EmbeddedFileData:
351
383
  description: Optional[str] = None
352
384
 
353
385
 
386
+ @dataclass
387
+ class ConfluencePanel:
388
+ emoji: str
389
+ emoji_shortname: str
390
+ background_color: str
391
+ from_class: ClassVar[dict[str, "ConfluencePanel"]]
392
+
393
+ def __init__(self, emoji: str, emoji_shortname: str, background_color: str) -> None:
394
+ self.emoji = emoji
395
+ self.emoji_shortname = emoji_shortname
396
+ self.background_color = background_color
397
+
398
+ @property
399
+ def emoji_unicode(self) -> str:
400
+ return "-".join(f"{ord(ch):x}" for ch in self.emoji)
401
+
402
+ @property
403
+ def emoji_html(self) -> str:
404
+ return "".join(f"&#{ord(ch)};" for ch in self.emoji)
405
+
406
+
407
+ ConfluencePanel.from_class = {
408
+ "attention": ConfluencePanel("❗", "exclamation", "#F9F9F9"), # rST admonition
409
+ "caution": ConfluencePanel("❌", "x", "#FFEBE9"),
410
+ "danger": ConfluencePanel("☠️", "skull_crossbones", "#FFE5E5"), # rST admonition
411
+ "disclaimer": ConfluencePanel("❗", "exclamation", "#F9F9F9"), # GitLab
412
+ "error": ConfluencePanel("❌", "x", "#FFEBE9"), # rST admonition
413
+ "flag": ConfluencePanel("🚩", "triangular_flag_on_post", "#FDECEA"), # GitLab
414
+ "hint": ConfluencePanel("💡", "bulb", "#DAFBE1"), # rST admonition
415
+ "info": ConfluencePanel("ℹ️", "information_source", "#DDF4FF"),
416
+ "note": ConfluencePanel("📝", "pencil", "#DDF4FF"),
417
+ "tip": ConfluencePanel("💡", "bulb", "#DAFBE1"),
418
+ "important": ConfluencePanel("❗", "exclamation", "#FBEFFF"),
419
+ "warning": ConfluencePanel("⚠️", "warning", "#FFF8C5"),
420
+ }
421
+
422
+
354
423
  class ConfluenceStorageFormatConverter(NodeVisitor):
355
424
  "Transforms a plain HTML tree into Confluence Storage Format."
356
425
 
@@ -389,7 +458,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
389
458
  self.site_metadata = site_metadata
390
459
  self.page_metadata = page_metadata
391
460
 
392
- def _transform_heading(self, heading: ET._Element) -> None:
461
+ def _transform_heading(self, heading: ElementType) -> None:
393
462
  """
394
463
  Adds anchors to headings in the same document (if *heading anchors* is enabled).
395
464
 
@@ -425,15 +494,19 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
425
494
  anchor.tail = heading.text
426
495
  heading.text = None
427
496
 
428
- def _warn_or_raise(self, msg: str) -> None:
497
+ def _anchor_warn_or_raise(self, anchor: ElementType, msg: str) -> None:
429
498
  "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."
430
499
 
431
500
  if self.options.ignore_invalid_url:
432
501
  LOGGER.warning(msg)
502
+ if anchor.text:
503
+ anchor.text = "❌ " + anchor.text
504
+ elif len(anchor) > 0:
505
+ anchor.text = "❌ "
433
506
  else:
434
507
  raise DocumentError(msg)
435
508
 
436
- def _transform_link(self, anchor: ET._Element) -> Optional[ET._Element]:
509
+ def _transform_link(self, anchor: ElementType) -> Optional[ElementType]:
437
510
  """
438
511
  Transforms links (HTML anchor `<a>`).
439
512
 
@@ -478,7 +551,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
478
551
 
479
552
  # look up the absolute path in the page metadata dictionary to discover the relative path within Confluence that should be used
480
553
  if not is_directory_within(absolute_path, self.root_dir):
481
- self._warn_or_raise(f"relative URL {url} points to outside root path: {self.root_dir}")
554
+ self._anchor_warn_or_raise(anchor, f"relative URL {url} points to outside root path: {self.root_dir}")
482
555
  return None
483
556
 
484
557
  if absolute_path.suffix == ".md":
@@ -486,14 +559,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
486
559
  else:
487
560
  return self._transform_attachment_link(anchor, absolute_path)
488
561
 
489
- def _transform_page_link(self, anchor: ET._Element, relative_url: ParseResult, absolute_path: Path) -> Optional[ET._Element]:
562
+ def _transform_page_link(self, anchor: ElementType, relative_url: ParseResult, absolute_path: Path) -> Optional[ElementType]:
490
563
  """
491
564
  Transforms links to other Markdown documents (Confluence pages).
492
565
  """
493
566
 
494
567
  link_metadata = self.page_metadata.get(absolute_path)
495
568
  if link_metadata is None:
496
- self._warn_or_raise(f"unable to find matching page for URL: {relative_url.geturl()}")
569
+ self._anchor_warn_or_raise(anchor, f"unable to find matching page for URL: {relative_url.geturl()}")
497
570
  return None
498
571
 
499
572
  relative_path = os.path.relpath(absolute_path, self.base_dir)
@@ -523,13 +596,13 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
523
596
  anchor.set("href", transformed_url.geturl())
524
597
  return None
525
598
 
526
- def _transform_attachment_link(self, anchor: ET._Element, absolute_path: Path) -> Optional[ET._Element]:
599
+ def _transform_attachment_link(self, anchor: ElementType, absolute_path: Path) -> Optional[ElementType]:
527
600
  """
528
601
  Transforms links to document binaries such as PDF, DOCX or XLSX.
529
602
  """
530
603
 
531
604
  if not absolute_path.exists():
532
- self._warn_or_raise(f"relative URL points to non-existing file: {absolute_path}")
605
+ self._anchor_warn_or_raise(anchor, f"relative URL points to non-existing file: {absolute_path}")
533
606
  return None
534
607
 
535
608
  file_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
@@ -545,7 +618,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
545
618
  )
546
619
  return link_wrapper
547
620
 
548
- def _transform_status(self, color: str, caption: str) -> ET._Element:
621
+ def _transform_status(self, color: str, caption: str) -> ElementType:
549
622
  macro_id = str(uuid.uuid4())
550
623
  attributes = {
551
624
  AC_ATTR("name"): "status",
@@ -578,7 +651,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
578
651
  ),
579
652
  )
580
653
 
581
- def _transform_image(self, context: FormattingContext, image: ET._Element) -> ET._Element:
654
+ def _transform_image(self, context: FormattingContext, image: ElementType) -> ElementType:
582
655
  "Inserts an attached or external image."
583
656
 
584
657
  src = image.get("src")
@@ -594,7 +667,9 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
594
667
  height = image.get("height")
595
668
  pixel_width = int(width) if width is not None and width.isdecimal() else None
596
669
  pixel_height = int(height) if height is not None and height.isdecimal() else None
597
- attrs = ImageAttributes(context, pixel_width, pixel_height, alt, title, None)
670
+ attrs = ImageAttributes(
671
+ context, width=pixel_width, height=pixel_height, alt=alt, title=title, caption=None, alignment=ImageAlignment(self.options.alignment)
672
+ )
598
673
 
599
674
  if is_absolute_url(src):
600
675
  return self._transform_external_image(src, attrs)
@@ -603,7 +678,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
603
678
 
604
679
  absolute_path = self._verify_image_path(path)
605
680
  if absolute_path is None:
606
- return self._create_missing(path, attrs.caption)
681
+ return self._create_missing(path, attrs)
607
682
 
608
683
  if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
609
684
  return self._transform_drawio_image(absolute_path, attrs)
@@ -614,10 +689,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
614
689
  else:
615
690
  return self._transform_attached_image(absolute_path, attrs)
616
691
 
617
- def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ET._Element:
692
+ def _transform_external_image(self, url: str, attrs: ImageAttributes) -> ElementType:
618
693
  "Emits Confluence Storage Format XHTML for an external image."
619
694
 
620
- elements: list[ET._Element] = []
695
+ elements: list[ElementType] = []
621
696
  elements.append(
622
697
  RI_ELEM(
623
698
  "url",
@@ -630,6 +705,14 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
630
705
 
631
706
  return AC_ELEM("image", attrs.as_dict(), *elements)
632
707
 
708
+ def _warn_or_raise(self, msg: str) -> None:
709
+ "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."
710
+
711
+ if self.options.ignore_invalid_url:
712
+ LOGGER.warning(msg)
713
+ else:
714
+ raise DocumentError(msg)
715
+
633
716
  def _verify_image_path(self, path: Path) -> Optional[Path]:
634
717
  "Checks whether an image path is safe to use."
635
718
 
@@ -646,7 +729,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
646
729
 
647
730
  return absolute_path
648
731
 
649
- def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
732
+ def _transform_attached_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
650
733
  "Emits Confluence Storage Format XHTML for an attached raster or vector image."
651
734
 
652
735
  if self.options.prefer_raster and absolute_path.suffix == ".svg":
@@ -659,7 +742,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
659
742
  image_name = attachment_name(path_relative_to(absolute_path, self.base_dir))
660
743
  return self._create_attached_image(image_name, attrs)
661
744
 
662
- def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
745
+ def _transform_drawio(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
663
746
  "Emits Confluence Storage Format XHTML for a draw.io diagram."
664
747
 
665
748
  if not absolute_path.name.endswith(".drawio.xml") and not absolute_path.name.endswith(".drawio"):
@@ -676,7 +759,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
676
759
  image_filename = attachment_name(relative_path)
677
760
  return self._create_drawio(image_filename, attrs)
678
761
 
679
- def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
762
+ def _transform_drawio_image(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
680
763
  "Emits Confluence Storage Format XHTML for a draw.io diagram embedded in a PNG or SVG image."
681
764
 
682
765
  if not absolute_path.name.endswith(".drawio.png") and not absolute_path.name.endswith(".drawio.svg"):
@@ -692,10 +775,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
692
775
 
693
776
  return self._create_drawio(image_filename, attrs)
694
777
 
695
- def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ET._Element:
778
+ def _create_attached_image(self, image_name: str, attrs: ImageAttributes) -> ElementType:
696
779
  "An image embedded into the page, linking to an attachment."
697
780
 
698
- elements: list[ET._Element] = []
781
+ elements: list[ElementType] = []
699
782
  elements.append(
700
783
  RI_ELEM(
701
784
  "attachment",
@@ -708,10 +791,10 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
708
791
 
709
792
  return AC_ELEM("image", attrs.as_dict(), *elements)
710
793
 
711
- def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ET._Element:
794
+ def _create_drawio(self, filename: str, attrs: ImageAttributes) -> ElementType:
712
795
  "A draw.io diagram embedded into the page, linking to an attachment."
713
796
 
714
- parameters: list[ET._Element] = [
797
+ parameters: list[ElementType] = [
715
798
  AC_ELEM(
716
799
  "parameter",
717
800
  {AC_ATTR("name"): "diagramName"},
@@ -749,32 +832,35 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
749
832
  *parameters,
750
833
  )
751
834
 
752
- def _create_missing(self, path: Path, caption: Optional[str]) -> ET._Element:
835
+ def _create_missing(self, path: Path, attrs: ImageAttributes) -> ElementType:
753
836
  "A warning panel for a missing image."
754
837
 
755
- message = HTML.p("Missing image: ", HTML.code(path.as_posix()))
756
- if caption is not None:
757
- content = [
758
- AC_ELEM(
759
- "parameter",
760
- {AC_ATTR("name"): "title"},
761
- caption,
762
- ),
763
- AC_ELEM("rich-text-body", {}, message),
764
- ]
765
- else:
766
- content = [AC_ELEM("rich-text-body", {}, message)]
838
+ if attrs.context is FormattingContext.BLOCK:
839
+ message = HTML.p("❌ Missing image: ", HTML.code(path.as_posix()))
840
+ if attrs.caption is not None:
841
+ content = [
842
+ AC_ELEM(
843
+ "parameter",
844
+ {AC_ATTR("name"): "title"},
845
+ attrs.caption,
846
+ ),
847
+ AC_ELEM("rich-text-body", {}, message),
848
+ ]
849
+ else:
850
+ content = [AC_ELEM("rich-text-body", {}, message)]
767
851
 
768
- return AC_ELEM(
769
- "structured-macro",
770
- {
771
- AC_ATTR("name"): "warning",
772
- AC_ATTR("schema-version"): "1",
773
- },
774
- *content,
775
- )
852
+ return AC_ELEM(
853
+ "structured-macro",
854
+ {
855
+ AC_ATTR("name"): "warning",
856
+ AC_ATTR("schema-version"): "1",
857
+ },
858
+ *content,
859
+ )
860
+ else:
861
+ return HTML.span({"style": "color: rgb(255,86,48);"}, "❌ ", HTML.code(path.as_posix()))
776
862
 
777
- def _transform_code_block(self, code: ET._Element) -> ET._Element:
863
+ def _transform_code_block(self, code: ElementType) -> ElementType:
778
864
  "Transforms a code block."
779
865
 
780
866
  if language_class := code.get("class"):
@@ -811,7 +897,16 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
811
897
  AC_ELEM("plain-text-body", ET.CDATA(content)),
812
898
  )
813
899
 
814
- def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ET._Element:
900
+ def _extract_mermaid_config(self, content: str) -> Optional[MermaidConfigProperties]:
901
+ """Extract scale from Mermaid YAML front matter configuration."""
902
+ try:
903
+ properties = MermaidScanner().read(content)
904
+ return properties.config
905
+ except JsonTypeError as ex:
906
+ LOGGER.warning("Failed to extract Mermaid properties: %s", ex)
907
+ return None
908
+
909
+ def _transform_external_mermaid(self, absolute_path: Path, attrs: ImageAttributes) -> ElementType:
815
910
  "Emits Confluence Storage Format XHTML for a Mermaid diagram read from an external file."
816
911
 
817
912
  if not absolute_path.name.endswith(".mmd") and not absolute_path.name.endswith(".mermaid"):
@@ -821,7 +916,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
821
916
  if self.options.render_mermaid:
822
917
  with open(absolute_path, "r", encoding="utf-8") as f:
823
918
  content = f.read()
824
- image_data = mermaid.render_diagram(content, self.options.diagram_output_format)
919
+ config = self._extract_mermaid_config(content)
920
+ image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
825
921
  image_filename = attachment_name(relative_path.with_suffix(f".{self.options.diagram_output_format}"))
826
922
  self.embedded_files[image_filename] = EmbeddedFileData(image_data, attrs.alt)
827
923
  return self._create_attached_image(image_filename, attrs)
@@ -830,11 +926,12 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
830
926
  mermaid_filename = attachment_name(relative_path)
831
927
  return self._create_mermaid_embed(mermaid_filename)
832
928
 
833
- def _transform_fenced_mermaid(self, content: str) -> ET._Element:
929
+ def _transform_fenced_mermaid(self, content: str) -> ElementType:
834
930
  "Emits Confluence Storage Format XHTML for a Mermaid diagram defined in a fenced code block."
835
931
 
836
932
  if self.options.render_mermaid:
837
- image_data = mermaid.render_diagram(content, self.options.diagram_output_format)
933
+ config = self._extract_mermaid_config(content)
934
+ image_data = mermaid.render_diagram(content, self.options.diagram_output_format, config=config)
838
935
  image_hash = hashlib.md5(image_data).hexdigest()
839
936
  image_filename = attachment_name(f"embedded_{image_hash}.{self.options.diagram_output_format}")
840
937
  self.embedded_files[image_filename] = EmbeddedFileData(image_data)
@@ -846,7 +943,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
846
943
  self.embedded_files[mermaid_filename] = EmbeddedFileData(mermaid_data)
847
944
  return self._create_mermaid_embed(mermaid_filename)
848
945
 
849
- def _create_mermaid_embed(self, filename: str) -> ET._Element:
946
+ def _create_mermaid_embed(self, filename: str) -> ElementType:
850
947
  "A Mermaid diagram, linking to an attachment that captures the Mermaid source."
851
948
 
852
949
  local_id = str(uuid.uuid4())
@@ -878,7 +975,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
878
975
  AC_ELEM("parameter", {AC_ATTR("name"): "revision"}, "1"),
879
976
  )
880
977
 
881
- def _transform_toc(self, code: ET._Element) -> ET._Element:
978
+ def _transform_toc(self, code: ElementType) -> ElementType:
882
979
  "Creates a table of contents, constructed from headings in the document."
883
980
 
884
981
  return AC_ELEM(
@@ -892,7 +989,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
892
989
  AC_ELEM("parameter", {AC_ATTR("name"): "style"}, "default"),
893
990
  )
894
991
 
895
- def _transform_listing(self, code: ET._Element) -> ET._Element:
992
+ def _transform_listing(self, code: ElementType) -> ElementType:
896
993
  "Creates a list of child pages."
897
994
 
898
995
  return AC_ELEM(
@@ -905,7 +1002,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
905
1002
  AC_ELEM("parameter", {AC_ATTR("name"): "allChildren"}, "true"),
906
1003
  )
907
1004
 
908
- def _transform_admonition(self, elem: ET._Element) -> ET._Element:
1005
+ def _transform_admonition(self, elem: ElementType) -> ElementType:
909
1006
  """
910
1007
  Creates an info, tip, note or warning panel from a Markdown admonition.
911
1008
 
@@ -918,45 +1015,51 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
918
1015
 
919
1016
  # <div class="admonition note">
920
1017
  class_list = elem.get("class", "").split(" ")
921
- class_name: Optional[str] = None
922
- if "info" in class_list:
923
- class_name = "info"
924
- elif "tip" in class_list:
925
- class_name = "tip"
926
- elif "note" in class_list:
927
- class_name = "note"
928
- elif "warning" in class_list:
929
- class_name = "warning"
930
-
931
- if class_name is None:
932
- raise DocumentError(f"unsupported admonition label: {class_list}")
1018
+ class_list.remove("admonition")
1019
+ if len(class_list) > 1:
1020
+ raise DocumentError(f"too many admonition types: {class_list}")
1021
+ elif len(class_list) < 1:
1022
+ raise DocumentError("missing specific admonition type")
1023
+ admonition = class_list[0]
933
1024
 
934
1025
  for e in elem:
935
1026
  self.visit(e)
936
1027
 
937
1028
  # <p class="admonition-title">Note</p>
938
1029
  if "admonition-title" in elem[0].get("class", "").split(" "):
939
- content = [
940
- AC_ELEM(
941
- "parameter",
942
- {AC_ATTR("name"): "title"},
943
- elem[0].text or "",
944
- ),
945
- AC_ELEM("rich-text-body", {}, *list(elem[1:])),
946
- ]
1030
+ content = [HTML.p(HTML.strong(elem[0].text or "")), *list(elem[1:])]
947
1031
  else:
948
- content = [AC_ELEM("rich-text-body", {}, *list(elem))]
1032
+ content = list(elem)
949
1033
 
950
- return AC_ELEM(
951
- "structured-macro",
952
- {
953
- AC_ATTR("name"): class_name,
954
- AC_ATTR("schema-version"): "1",
955
- },
956
- *content,
957
- )
1034
+ if self.options.use_panel:
1035
+ return self._transform_panel(content, admonition)
1036
+ else:
1037
+ admonition_to_csf = {
1038
+ "attention": "note",
1039
+ "caution": "warning",
1040
+ "danger": "warning",
1041
+ "error": "warning",
1042
+ "hint": "tip",
1043
+ "important": "note",
1044
+ "info": "info",
1045
+ "note": "info",
1046
+ "tip": "tip",
1047
+ "warning": "note",
1048
+ }
1049
+ class_name = admonition_to_csf.get(admonition)
1050
+ if class_name is None:
1051
+ raise DocumentError(f"unsupported admonition type: {admonition}")
1052
+
1053
+ return AC_ELEM(
1054
+ "structured-macro",
1055
+ {
1056
+ AC_ATTR("name"): class_name,
1057
+ AC_ATTR("schema-version"): "1",
1058
+ },
1059
+ AC_ELEM("rich-text-body", {}, *content),
1060
+ )
958
1061
 
959
- def _transform_github_alert(self, blockquote: ET._Element) -> ET._Element:
1062
+ def _transform_github_alert(self, blockquote: ElementType) -> ElementType:
960
1063
  """
961
1064
  Creates a GitHub-style panel, normally triggered with a block-quote starting with a capitalized string such as `[!TIP]`.
962
1065
  """
@@ -968,30 +1071,29 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
968
1071
  if content.text is None:
969
1072
  raise DocumentError("empty content")
970
1073
 
971
- class_name: Optional[str] = None
972
- skip = 0
973
-
974
1074
  pattern = re.compile(r"^\[!([A-Z]+)\]\s*")
975
1075
  match = pattern.match(content.text)
976
- if match:
977
- skip = len(match.group(0))
978
- alert = match.group(1)
979
- if alert == "NOTE":
980
- class_name = "note"
981
- elif alert == "TIP":
982
- class_name = "tip"
983
- elif alert == "IMPORTANT":
984
- class_name = "tip"
985
- elif alert == "WARNING":
986
- class_name = "warning"
987
- elif alert == "CAUTION":
988
- class_name = "warning"
989
- else:
1076
+ if not match:
1077
+ raise DocumentError("not a GitHub alert")
1078
+
1079
+ # remove alert indicator prefix
1080
+ content.text = content.text[len(match.group(0)) :]
1081
+
1082
+ for e in blockquote:
1083
+ self.visit(e)
1084
+
1085
+ alert = match.group(1)
1086
+ if self.options.use_panel:
1087
+ return self._transform_panel(list(blockquote), alert.lower())
1088
+ else:
1089
+ alert_to_csf = {"NOTE": "info", "TIP": "tip", "IMPORTANT": "note", "WARNING": "note", "CAUTION": "warning"}
1090
+ class_name = alert_to_csf.get(alert)
1091
+ if class_name is None:
990
1092
  raise DocumentError(f"unsupported GitHub alert: {alert}")
991
1093
 
992
- return self._transform_alert(blockquote, class_name, skip)
1094
+ return self._transform_alert(blockquote, class_name)
993
1095
 
994
- def _transform_gitlab_alert(self, blockquote: ET._Element) -> ET._Element:
1096
+ def _transform_gitlab_alert(self, blockquote: ElementType) -> ElementType:
995
1097
  """
996
1098
  Creates a classic GitLab-style panel.
997
1099
 
@@ -1006,58 +1108,91 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1006
1108
  if content.text is None:
1007
1109
  raise DocumentError("empty content")
1008
1110
 
1009
- class_name: Optional[str] = None
1010
- skip = 0
1011
-
1012
1111
  pattern = re.compile(r"^(FLAG|NOTE|WARNING|DISCLAIMER):\s*")
1013
1112
  match = pattern.match(content.text)
1014
- if match:
1015
- skip = len(match.group(0))
1016
- alert = match.group(1)
1017
- if alert == "FLAG":
1018
- class_name = "note"
1019
- elif alert == "NOTE":
1020
- class_name = "note"
1021
- elif alert == "WARNING":
1022
- class_name = "warning"
1023
- elif alert == "DISCLAIMER":
1024
- class_name = "info"
1025
- else:
1113
+ if not match:
1114
+ raise DocumentError("not a GitLab alert")
1115
+
1116
+ # remove alert indicator prefix
1117
+ content.text = content.text[len(match.group(0)) :]
1118
+
1119
+ for e in blockquote:
1120
+ self.visit(e)
1121
+
1122
+ alert = match.group(1)
1123
+ if self.options.use_panel:
1124
+ return self._transform_panel(list(blockquote), alert.lower())
1125
+ else:
1126
+ alert_to_csf = {"FLAG": "note", "NOTE": "info", "WARNING": "note", "DISCLAIMER": "info"}
1127
+ class_name = alert_to_csf.get(alert)
1128
+ if class_name is None:
1026
1129
  raise DocumentError(f"unsupported GitLab alert: {alert}")
1027
1130
 
1028
- return self._transform_alert(blockquote, class_name, skip)
1131
+ return self._transform_alert(blockquote, class_name)
1029
1132
 
1030
- def _transform_alert(self, blockquote: ET._Element, class_name: Optional[str], skip: int) -> ET._Element:
1133
+ def _transform_alert(self, blockquote: ElementType, class_name: str) -> ElementType:
1031
1134
  """
1032
- Creates an info, tip, note or warning panel from a GitHub or GitLab alert.
1135
+ Creates an `info`, `tip`, `note` or `warning` panel from a GitHub or GitLab alert.
1136
+
1137
+ Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros `info`, `tip`, `note`, or `warning`.
1138
+
1139
+ Confusingly, these structured macros have completely different alternate names on the UI, namely: *Info*, *Success*, *Warning* and *Error* (in this
1140
+ order). In other words, to get what is shown as *Error* on the UI, you have to pass `warning` in CSF, and to get *Success*, you have to pass `tip`.
1141
+
1142
+ Confluence UI also has an additional panel type called *Note*. *Note* is not a structured macro but corresponds to a different element tree, wrapped in
1143
+ an element `ac:adf-extension`:
1144
+
1145
+ ```
1146
+ <ac:adf-node type="panel">
1147
+ <ac:adf-attribute key="panel-type">note</ac:adf-attribute>
1148
+ <ac:adf-content>
1149
+ <p><strong>A note</strong></p>
1150
+ <p>This is a panel showing a note.</p>
1151
+ </ac:adf-content>
1152
+ </ac:adf-node>
1153
+ ```
1033
1154
 
1034
- Transforms GitHub alert or GitLab alert syntax into one of the Confluence structured macros *info*, *tip*, *note*, or *warning*.
1155
+ As of today, *md2conf* does not generate `ac:adf-extension` output, including *Note* and *Custom panel* (which shows an emoji selected by the user).
1156
+
1157
+ :param blockquote: HTML element tree to transform to Confluence Storage Format (CSF).
1158
+ :param class_name: Corresponds to `name` attribute for CSF `structured-macro`.
1035
1159
 
1036
1160
  :see: https://docs.github.com/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
1037
1161
  :see: https://docs.gitlab.com/ee/development/documentation/styleguide/#alert-boxes
1038
1162
  """
1039
1163
 
1040
- content = blockquote[0]
1041
- if content.text is None:
1042
- raise DocumentError("empty content")
1164
+ return AC_ELEM(
1165
+ "structured-macro",
1166
+ {
1167
+ AC_ATTR("name"): class_name,
1168
+ AC_ATTR("schema-version"): "1",
1169
+ },
1170
+ AC_ELEM("rich-text-body", {}, *list(blockquote)),
1171
+ )
1043
1172
 
1044
- if class_name is None:
1045
- raise DocumentError("not an alert")
1173
+ def _transform_panel(self, content: list[ElementType], class_name: str) -> ElementType:
1174
+ "Transforms a blockquote into a themed panel."
1046
1175
 
1047
- for e in blockquote:
1048
- self.visit(e)
1176
+ panel = ConfluencePanel.from_class.get(class_name)
1177
+ if panel is None:
1178
+ raise DocumentError(f"unsupported panel class: {class_name}")
1049
1179
 
1050
- content.text = content.text[skip:]
1180
+ macro_id = str(uuid.uuid4())
1051
1181
  return AC_ELEM(
1052
1182
  "structured-macro",
1053
1183
  {
1054
- AC_ATTR("name"): class_name,
1184
+ AC_ATTR("name"): "panel",
1055
1185
  AC_ATTR("schema-version"): "1",
1186
+ AC_ATTR("macro-id"): macro_id,
1056
1187
  },
1057
- AC_ELEM("rich-text-body", {}, *list(blockquote)),
1188
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIcon"}, f":{panel.emoji_shortname}:"),
1189
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconId"}, panel.emoji_unicode),
1190
+ AC_ELEM("parameter", {AC_ATTR("name"): "panelIconText"}, panel.emoji),
1191
+ AC_ELEM("parameter", {AC_ATTR("name"): "bgColor"}, panel.background_color),
1192
+ AC_ELEM("rich-text-body", {}, *content),
1058
1193
  )
1059
1194
 
1060
- def _transform_section(self, details: ET._Element) -> ET._Element:
1195
+ def _transform_collapsed(self, details: ElementType) -> ElementType:
1061
1196
  """
1062
1197
  Creates a collapsed section.
1063
1198
 
@@ -1106,7 +1241,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1106
1241
  AC_ELEM("rich-text-body", {}, *list(details)),
1107
1242
  )
1108
1243
 
1109
- def _transform_emoji(self, elem: ET._Element) -> ET._Element:
1244
+ def _transform_emoji(self, elem: ElementType) -> ElementType:
1110
1245
  """
1111
1246
  Inserts an inline emoji character.
1112
1247
  """
@@ -1115,18 +1250,22 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1115
1250
  unicode = elem.get("data-unicode", None)
1116
1251
  alt = elem.text or ""
1117
1252
 
1253
+ # emoji with a matching emoticon:
1118
1254
  # <ac:emoticon ac:name="wink" ac:emoji-shortname=":wink:" ac:emoji-id="1f609" ac:emoji-fallback="&#128521;"/>
1255
+ #
1256
+ # emoji without a corresponding emoticon:
1257
+ # <ac:emoticon ac:name="blue-star" ac:emoji-shortname=":shield:" ac:emoji-id="1f6e1" ac:emoji-fallback="&#128737;"/>
1119
1258
  return AC_ELEM(
1120
1259
  "emoticon",
1121
1260
  {
1122
- AC_ATTR("name"): shortname,
1261
+ AC_ATTR("name"): emoji_to_emoticon(shortname),
1123
1262
  AC_ATTR("emoji-shortname"): f":{shortname}:",
1124
1263
  AC_ATTR("emoji-id"): unicode,
1125
1264
  AC_ATTR("emoji-fallback"): alt,
1126
1265
  },
1127
1266
  )
1128
1267
 
1129
- def _transform_mark(self, mark: ET._Element) -> ET._Element:
1268
+ def _transform_mark(self, mark: ElementType) -> ElementType:
1130
1269
  """
1131
1270
  Adds inline highlighting to text.
1132
1271
  """
@@ -1141,7 +1280,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1141
1280
  span.text = mark.text
1142
1281
  return span
1143
1282
 
1144
- def _transform_latex(self, elem: ET._Element, context: FormattingContext) -> ET._Element:
1283
+ def _transform_latex(self, elem: ElementType, context: FormattingContext) -> ElementType:
1145
1284
  """
1146
1285
  Creates an image rendering of a LaTeX formula with Matplotlib.
1147
1286
  """
@@ -1154,7 +1293,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1154
1293
  if self.options.diagram_output_format == "png":
1155
1294
  width, height = get_png_dimensions(data=image_data)
1156
1295
  image_data = remove_png_chunks(["pHYs"], source_data=image_data)
1157
- attrs = ImageAttributes(context, width, height, content, None, "")
1296
+ attrs = ImageAttributes(context, width=width, height=height, alt=content, title=None, caption="", alignment=ImageAlignment(self.options.alignment))
1158
1297
  else:
1159
1298
  attrs = ImageAttributes.empty(context)
1160
1299
 
@@ -1164,7 +1303,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1164
1303
  image = self._create_attached_image(image_filename, attrs)
1165
1304
  return image
1166
1305
 
1167
- def _transform_inline_math(self, elem: ET._Element) -> ET._Element:
1306
+ def _transform_inline_math(self, elem: ElementType) -> ElementType:
1168
1307
  """
1169
1308
  Creates an inline LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1170
1309
 
@@ -1195,11 +1334,11 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1195
1334
  {AC_ATTR("name"): "body"},
1196
1335
  content,
1197
1336
  ),
1198
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1337
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1199
1338
  )
1200
1339
  return macro
1201
1340
 
1202
- def _transform_block_math(self, elem: ET._Element) -> ET._Element:
1341
+ def _transform_block_math(self, elem: ElementType) -> ElementType:
1203
1342
  """
1204
1343
  Creates a block-level LaTeX formula using the Confluence extension "LaTeX Math for Confluence - Math Formula & Equations".
1205
1344
 
@@ -1232,15 +1371,15 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1232
1371
  {AC_ATTR("name"): "body"},
1233
1372
  content,
1234
1373
  ),
1235
- AC_ELEM("parameter", {AC_ATTR("name"): "align"}, "center"),
1374
+ AC_ELEM("parameter", {AC_ATTR("name"): "align"}, self.options.alignment),
1236
1375
  )
1237
1376
 
1238
- def _transform_footnote_ref(self, elem: ET._Element) -> None:
1377
+ def _transform_footnote_ref(self, elem: ElementType) -> None:
1239
1378
  """
1240
1379
  Transforms a footnote reference.
1241
1380
 
1242
1381
  ```
1243
- <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">1</a></sup>
1382
+ <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">REF</a></sup>
1244
1383
  ```
1245
1384
  """
1246
1385
 
@@ -1292,7 +1431,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1292
1431
  elem.append(ref_anchor)
1293
1432
  elem.append(def_link)
1294
1433
 
1295
- def _transform_footnote_def(self, elem: ET._Element) -> None:
1434
+ def _transform_footnote_def(self, elem: ElementType) -> None:
1296
1435
  """
1297
1436
  Transforms the footnote definition block.
1298
1437
 
@@ -1366,7 +1505,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1366
1505
  paragraph.text = None
1367
1506
  paragraph.append(ref_link)
1368
1507
 
1369
- def _transform_tasklist(self, elem: ET._Element) -> ET._Element:
1508
+ def _transform_tasklist(self, elem: ElementType) -> ElementType:
1370
1509
  """
1371
1510
  Transforms a list of tasks into an action widget.
1372
1511
 
@@ -1382,7 +1521,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1382
1521
  if not element_text_starts_with_any(item, ["[ ]", "[x]", "[X]"]):
1383
1522
  raise DocumentError("expected: each `<li>` in a task list starting with [ ] or [x]")
1384
1523
 
1385
- tasks: list[ET._Element] = []
1524
+ tasks: list[ElementType] = []
1386
1525
  for index, item in enumerate(elem, start=1):
1387
1526
  if item.text is None:
1388
1527
  raise NotImplementedError("pre-condition check not exhaustive")
@@ -1411,7 +1550,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1411
1550
  return AC_ELEM("task-list", {}, *tasks)
1412
1551
 
1413
1552
  @override
1414
- def transform(self, child: ET._Element) -> Optional[ET._Element]:
1553
+ def transform(self, child: ElementType) -> Optional[ElementType]:
1415
1554
  """
1416
1555
  Transforms an HTML element tree obtained from a Markdown document into a Confluence Storage Format element tree.
1417
1556
  """
@@ -1498,7 +1637,7 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1498
1637
  # ...
1499
1638
  # </details>
1500
1639
  elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
1501
- return self._transform_section(child)
1640
+ return self._transform_collapsed(child)
1502
1641
 
1503
1642
  # <ol>...</ol>
1504
1643
  elif child.tag == "ol":
@@ -1526,6 +1665,8 @@ class ConfluenceStorageFormatConverter(NodeVisitor):
1526
1665
 
1527
1666
  # <table>...</table>
1528
1667
  elif child.tag == "table":
1668
+ for td in child.iterdescendants("td", "th"):
1669
+ normalize_inline(td)
1529
1670
  child.set("data-layout", "default")
1530
1671
  return None
1531
1672
 
@@ -1602,7 +1743,7 @@ class ConfluenceDocument:
1602
1743
  embedded_files: dict[str, EmbeddedFileData]
1603
1744
 
1604
1745
  options: ConfluenceDocumentOptions
1605
- root: ET._Element
1746
+ root: ElementType
1606
1747
 
1607
1748
  @classmethod
1608
1749
  def create(
@@ -1648,10 +1789,10 @@ class ConfluenceDocument:
1648
1789
  lines.append(f"[STATUS-{color.upper()}]: {data_uri}")
1649
1790
  lines.append(document.text)
1650
1791
 
1651
- # convert to HTML
1792
+ # parse Markdown document and convert to HTML
1652
1793
  html = markdown_to_html("\n".join(lines))
1653
1794
 
1654
- # parse Markdown document
1795
+ # modify HTML as necessary
1655
1796
  if self.options.generated_by is not None:
1656
1797
  generated_by = document.generated_by or self.options.generated_by
1657
1798
  else:
@@ -1669,26 +1810,32 @@ class ConfluenceDocument:
1669
1810
  else:
1670
1811
  content = [html]
1671
1812
 
1813
+ # parse HTML into element tree
1672
1814
  try:
1673
1815
  self.root = elements_from_strings(content)
1674
1816
  except ParseError as ex:
1675
1817
  raise ConversionError(path) from ex
1676
1818
 
1677
- converter = ConfluenceStorageFormatConverter(
1678
- ConfluenceConverterOptions(**{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}),
1679
- path,
1680
- root_dir,
1681
- site_metadata,
1682
- page_metadata,
1819
+ # configure HTML-to-Confluence converter
1820
+ converter_options = ConfluenceConverterOptions(
1821
+ **{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}
1683
1822
  )
1823
+ if document.alignment is not None:
1824
+ converter_options.alignment = document.alignment
1825
+ converter = ConfluenceStorageFormatConverter(converter_options, path, root_dir, site_metadata, page_metadata)
1826
+
1827
+ # execute HTML-to-Confluence converter
1684
1828
  try:
1685
1829
  converter.visit(self.root)
1686
1830
  except DocumentError as ex:
1687
1831
  raise ConversionError(path) from ex
1832
+
1833
+ # extract information discovered by converter
1688
1834
  self.links = converter.links
1689
1835
  self.images = converter.images
1690
1836
  self.embedded_files = converter.embedded_files
1691
1837
 
1838
+ # assign global properties for document
1692
1839
  self.title = document.title or converter.toc.get_title()
1693
1840
  self.labels = document.tags
1694
1841
  self.properties = document.properties