markdown-to-confluence 0.5.2__py3-none-any.whl → 0.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/METADATA +80 -4
  2. markdown_to_confluence-0.5.3.dist-info/RECORD +55 -0
  3. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/licenses/LICENSE +1 -1
  4. md2conf/__init__.py +2 -2
  5. md2conf/__main__.py +42 -24
  6. md2conf/api.py +27 -8
  7. md2conf/attachment.py +72 -0
  8. md2conf/coalesce.py +43 -0
  9. md2conf/collection.py +1 -1
  10. md2conf/{extra.py → compatibility.py} +1 -1
  11. md2conf/converter.py +232 -649
  12. md2conf/csf.py +13 -11
  13. md2conf/drawio/__init__.py +0 -0
  14. md2conf/drawio/extension.py +116 -0
  15. md2conf/{drawio.py → drawio/render.py} +1 -1
  16. md2conf/emoticon.py +3 -3
  17. md2conf/environment.py +2 -2
  18. md2conf/extension.py +78 -0
  19. md2conf/external.py +49 -0
  20. md2conf/formatting.py +135 -0
  21. md2conf/frontmatter.py +70 -0
  22. md2conf/image.py +127 -0
  23. md2conf/latex.py +4 -183
  24. md2conf/local.py +8 -8
  25. md2conf/markdown.py +1 -1
  26. md2conf/matcher.py +1 -1
  27. md2conf/mermaid/__init__.py +0 -0
  28. md2conf/mermaid/config.py +20 -0
  29. md2conf/mermaid/extension.py +109 -0
  30. md2conf/{mermaid.py → mermaid/render.py} +10 -38
  31. md2conf/mermaid/scanner.py +55 -0
  32. md2conf/metadata.py +1 -1
  33. md2conf/{domain.py → options.py} +73 -16
  34. md2conf/plantuml/__init__.py +0 -0
  35. md2conf/plantuml/config.py +20 -0
  36. md2conf/plantuml/extension.py +158 -0
  37. md2conf/plantuml/render.py +139 -0
  38. md2conf/plantuml/scanner.py +56 -0
  39. md2conf/png.py +202 -0
  40. md2conf/processor.py +32 -11
  41. md2conf/publisher.py +14 -18
  42. md2conf/scanner.py +31 -128
  43. md2conf/serializer.py +2 -2
  44. md2conf/svg.py +24 -2
  45. md2conf/text.py +1 -1
  46. md2conf/toc.py +1 -1
  47. md2conf/uri.py +1 -1
  48. md2conf/xml.py +1 -1
  49. markdown_to_confluence-0.5.2.dist-info/RECORD +0 -36
  50. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/WHEEL +0 -0
  51. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/entry_points.txt +0 -0
  52. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/top_level.txt +0 -0
  53. {markdown_to_confluence-0.5.2.dist-info → markdown_to_confluence-0.5.3.dist-info}/zip-safe +0 -0
md2conf/converter.py CHANGED
@@ -1,13 +1,12 @@
1
1
  """
2
2
  Publish Markdown files to Confluence wiki.
3
3
 
4
- Copyright 2022-2025, Levente Hunyadi
4
+ Copyright 2022-2026, Levente Hunyadi
5
5
 
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
- import dataclasses
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, Literal
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 drawio, mermaid
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 .domain import ConfluenceDocumentOptions, ConfluencePageID
28
+ from .drawio.extension import DrawioExtension
29
29
  from .emoticon import emoji_to_emoticon
30
30
  from .environment import PageError
31
- from .extra import override, path_relative_to
32
- from .latex import get_png_dimensions, remove_png_chunks, render_latex
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 MermaidConfigProperties
36
+ from .mermaid.extension import MermaidExtension
35
37
  from .metadata import ConfluenceSiteMetadata
36
- from .scanner import MermaidScanner, ScannedDocument, Scanner
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 root directory)
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 template.replace(
59
- "%{filepath}",
60
- path.as_posix(),
61
- ).replace(
62
- "%{filename}",
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: ConfluenceConverterOptions
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
- images: list[ImageData]
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: ConfluenceConverterOptions,
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.images = []
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.images.append(ImageData(absolute_path))
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.alignment),
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
- if absolute_path.name.endswith(".drawio.png") or absolute_path.name.endswith(".drawio.svg"):
745
- return self._transform_drawio_image(absolute_path, attrs)
746
- elif absolute_path.name.endswith(".drawio.xml") or absolute_path.name.endswith(".drawio"):
747
- return self._transform_drawio(absolute_path, attrs)
748
- elif absolute_path.name.endswith(".mmd") or absolute_path.name.endswith(".mermaid"):
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 = get_png_dimensions(data=image_data)
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.alignment),
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.embedded_files[image_filename] = EmbeddedFileData(image_data, content)
1441
- image = self._create_attached_image(image_filename, attrs)
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.alignment),
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.alignment),
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
- # <p>...</p>
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
- # </div>
1807
- #
1808
- # --- OR ---
1809
- #
1810
- # <div class="admonition note">
1811
- # <p>...</p>
1812
- # </div>
1813
- elif "admonition" in classes:
1814
- return self._transform_admonition(child)
1815
-
1816
- # <blockquote>...</blockquote>
1817
- elif child.tag == "blockquote":
1818
- # Alerts in GitHub
1819
- # <blockquote>
1820
- # <p>[!TIP] ...</p>
1821
- # </blockquote>
1822
- if len(child) > 0 and child[0].tag == "p" and child[0].text is not None and child[0].text.startswith("[!"):
1823
- return self._transform_github_alert(child)
1824
-
1825
- # Alerts in GitLab
1826
- # <blockquote>
1827
- # <p>DISCLAIMER: ...</p>
1828
- # </blockquote>
1829
- elif len(child) > 0 and child[0].tag == "p" and element_text_starts_with_any(child[0], ["FLAG:", "NOTE:", "WARNING:", "DISCLAIMER:"]):
1830
- return self._transform_gitlab_alert(child)
1831
-
1832
- # <details markdown="1">
1833
- # <summary>...</summary>
1834
- # ...
1835
- # </details>
1836
- elif child.tag == "details" and len(child) > 1 and child[0].tag == "summary":
1837
- return self._transform_collapsed(child)
1838
-
1839
- # <ol>...</ol>
1840
- elif child.tag == "ol":
1841
- # Confluence adds the attribute `start` for every ordered list
1842
- child.set("start", "1")
1843
- return None
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
- # <ul>
1846
- # <li>[ ] ...</li>
1847
- # <li>[x] ...</li>
1848
- # </ul>
1849
- elif child.tag == "ul":
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
- return None
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
- elif child.tag == "li":
1856
- normalize_inline(child)
1857
- return None
1457
+ return None
1458
+
1459
+ case "li":
1460
+ normalize_inline(child)
1461
+ return None
1858
1462
 
1859
- # <pre><code class="language-java"> ... </code></pre>
1860
- elif child.tag == "pre" and len(child) == 1 and child[0].tag == "code":
1861
- return self._transform_code_block(child[0])
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
- # <table>...</table>
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
- # <img src="..." alt="..." />
1871
- elif child.tag == "img":
1872
- return self._transform_image(FormattingContext.INLINE, child)
1484
+ # <img src="..." alt="..." />
1485
+ case "img":
1486
+ return self._transform_image(FormattingContext.INLINE, child)
1873
1487
 
1874
- # <a href="..."> ... </a>
1875
- elif child.tag == "a":
1876
- return self._transform_link(child)
1488
+ # <a href="..."> ... </a>
1489
+ case "a":
1490
+ return self._transform_link(child)
1877
1491
 
1878
- # <mark>...</mark>
1879
- elif child.tag == "mark":
1880
- return self._transform_mark(child)
1492
+ # <mark>...</mark>
1493
+ case "mark":
1494
+ return self._transform_mark(child)
1881
1495
 
1882
- # <span>...</span>
1883
- elif child.tag == "span":
1884
- classes = child.get("class", "").split(" ")
1496
+ # <span>...</span>
1497
+ case "span":
1498
+ classes = child.get("class", "").split(" ")
1885
1499
 
1886
- # <span class="arithmatex">...</span>
1887
- if "arithmatex" in classes:
1888
- return self._transform_inline_math(child)
1500
+ # <span class="arithmatex">...</span>
1501
+ if "arithmatex" in classes:
1502
+ return self._transform_inline_math(child)
1889
1503
 
1890
- # <sup id="fnref:NAME"><a class="footnote-ref" href="#fn:NAME">1</a></sup>
1891
- # Multiple references: <sup id="fnref2:NAME">...</sup>, <sup id="fnref3:NAME">...</sup>
1892
- elif child.tag == "sup" and re.match(r"^fnref\d*:", child.get("id", "")):
1893
- self._transform_footnote_ref(child)
1894
- return None
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
- # <input type="date" value="1984-01-01" />
1897
- elif child.tag == "input" and child.get("type", "") == "date":
1898
- return HTML("time", {"datetime": child.get("value", "")})
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
- # <ins>...</ins>
1901
- elif child.tag == "ins":
1902
- # Confluence prefers <u> over <ins> for underline, and replaces <ins> with <u>
1903
- child.tag = "u"
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
- # <x-emoji data-shortname="wink" data-unicode="1f609">😉</x-emoji>
1906
- elif child.tag == "x-emoji":
1907
- return self._transform_emoji(child)
1519
+ # <x-emoji data-shortname="wink" data-unicode="1f609">😉</x-emoji>
1520
+ case "x-emoji":
1521
+ return self._transform_emoji(child)
1908
1522
 
1909
- # <h1>...</h1>
1910
- # <h2>...</h2> ...
1911
- m = re.match(r"^h([1-6])$", child.tag, flags=re.IGNORECASE)
1912
- if m is not None:
1913
- level = int(m.group(1))
1914
- title = element_to_text(child)
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
- if self.options.heading_anchors:
1918
- self._transform_heading(child)
1919
- return None
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: ConfluenceDocumentOptions
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: ConfluenceDocumentOptions,
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 document.page_id is not None:
1960
- page_id = ConfluencePageID(document.page_id)
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: ConfluenceDocumentOptions,
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 = document.generated_by or self.options.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 = ConfluenceConverterOptions(
2020
- **{field.name: getattr(self.options, field.name) for field in dataclasses.fields(ConfluenceConverterOptions)}
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 = document.title or converter.toc.get_title()
2039
- self.labels = document.tags
2040
- self.properties = document.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 document.title is None and self.title is not None:
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("/", "_")