markdown-to-confluence 0.5.3__py3-none-any.whl → 0.5.4__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/publisher.py CHANGED
@@ -23,6 +23,65 @@ from .xml import is_xml_equal, unwrap_substitute
23
23
  LOGGER = logging.getLogger(__name__)
24
24
 
25
25
 
26
+ class _MissingType:
27
+ pass
28
+
29
+
30
+ _MissingDefault = _MissingType()
31
+
32
+
33
+ class ParentCatalog:
34
+ "Maintains a catalog of child-parent relationships."
35
+
36
+ _api: ConfluenceSession
37
+ _child_to_parent: dict[str, str | None]
38
+ _known: set[str]
39
+
40
+ def __init__(self, api: ConfluenceSession) -> None:
41
+ self._api = api
42
+ self._child_to_parent = {}
43
+ self._known = set()
44
+
45
+ def add_known(self, page_id: str) -> None:
46
+ """
47
+ Adds a new well-known page such as the root page or a page paired with a Markdown file using an explicit page ID.
48
+ """
49
+
50
+ self._known.add(page_id)
51
+
52
+ def add_parent(self, *, page_id: str, parent_id: str | None) -> None:
53
+ """
54
+ Adds a new child-parent relationship.
55
+
56
+ This method is useful to persist information acquired by a previous API call.
57
+ """
58
+
59
+ self._child_to_parent[page_id] = parent_id
60
+
61
+ def is_traceable(self, page_id: str) -> bool:
62
+ """
63
+ Verifies if a page traces back to a well-known root page.
64
+
65
+ :param page_id: The page to check.
66
+ """
67
+
68
+ if page_id in self._known:
69
+ return True
70
+
71
+ known_parent_id = self._child_to_parent.get(page_id, _MissingDefault)
72
+ if not isinstance(known_parent_id, _MissingType):
73
+ parent_id = known_parent_id
74
+ else:
75
+ page = self._api.get_page_properties(page_id)
76
+ parent_id = page.parentId
77
+ self._child_to_parent[page_id] = parent_id
78
+
79
+ if parent_id is None:
80
+ return False
81
+
82
+ return self.is_traceable(parent_id)
83
+
84
+
26
85
  class SynchronizingProcessor(Processor):
27
86
  """
28
87
  Synchronizes a single Markdown page or a directory of Markdown pages with Confluence.
@@ -59,14 +118,18 @@ class SynchronizingProcessor(Processor):
59
118
  elif root_id is not None:
60
119
  real_id = root_id
61
120
  else:
62
- raise NotImplementedError("condition not exhaustive")
121
+ raise NotImplementedError("condition not exhaustive for synchronizing tree")
63
122
 
64
- self._synchronize_subtree(tree, real_id)
123
+ catalog = ParentCatalog(self.api)
124
+ catalog.add_known(real_id.page_id)
125
+ self._synchronize_subtree(tree, real_id, catalog)
65
126
 
66
- def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID) -> None:
127
+ def _synchronize_subtree(self, node: DocumentNode, parent_id: ConfluencePageID, catalog: ParentCatalog) -> None:
67
128
  if node.page_id is not None:
68
129
  # verify if page exists
69
130
  page = self.api.get_page_properties(node.page_id)
131
+ catalog.add_known(page.id)
132
+ catalog.add_parent(page_id=page.id, parent_id=page.parentId)
70
133
  update = False
71
134
  else:
72
135
  if node.title is not None:
@@ -77,20 +140,26 @@ class SynchronizingProcessor(Processor):
77
140
  digest = self._generate_hash(node.absolute_path)
78
141
  title = f"{node.absolute_path.stem} [{digest}]"
79
142
 
80
- if self.options.title_prefix is not None:
81
- title = f"{self.options.title_prefix} {title}"
143
+ title = self._get_extended_title(title)
82
144
 
83
145
  # look up page by (possibly auto-generated) title
84
146
  page = self.api.get_or_create_page(title, parent_id.page_id)
147
+ catalog.add_parent(page_id=page.id, parent_id=page.parentId)
85
148
 
86
149
  if page.status is ConfluenceStatus.ARCHIVED:
87
- # user has archived a page with this (auto-generated) title
88
- raise PageError(f"unable to update archived page with ID {page.id}")
150
+ # user has archived a page with this (possibly auto-generated) title
151
+ raise PageError(f"unable to update archived page with ID {page.id} when synchronizing {node.absolute_path}")
152
+
153
+ if not catalog.is_traceable(page.id):
154
+ raise PageError(
155
+ f"expected: page with ID {page.id} to be a descendant of the root page or one of the pages paired with a Markdown file using an explicit "
156
+ f"page ID when synchronizing {node.absolute_path}"
157
+ )
89
158
 
90
159
  update = True
91
160
 
92
161
  space_key = self.api.space_id_to_key(page.spaceId)
93
- if update:
162
+ if update and not self.options.skip_update:
94
163
  self._update_markdown(
95
164
  node.absolute_path,
96
165
  page_id=page.id,
@@ -106,7 +175,7 @@ class SynchronizingProcessor(Processor):
106
175
  self.page_metadata.add(node.absolute_path, data)
107
176
 
108
177
  for child_node in node.children():
109
- self._synchronize_subtree(child_node, ConfluencePageID(page.id))
178
+ self._synchronize_subtree(child_node, ConfluencePageID(page.id), catalog)
110
179
 
111
180
  @override
112
181
  def _update_page(self, page_id: ConfluencePageID, document: ConfluenceDocument, path: Path) -> None:
@@ -136,19 +205,7 @@ class SynchronizingProcessor(Processor):
136
205
  content = document.xhtml()
137
206
  LOGGER.debug("Generated Confluence Storage Format document:\n%s", content)
138
207
 
139
- title = None
140
- if document.title is not None:
141
- meta = self.page_metadata.get(path)
142
- if meta is not None and meta.title != document.title:
143
- conflicting_page_id = self.api.page_exists(document.title, space_id=self.api.space_key_to_id(meta.space_key))
144
- if conflicting_page_id is None:
145
- title = document.title
146
- else:
147
- LOGGER.info(
148
- "Document title of %s conflicts with Confluence page title of %s",
149
- path,
150
- conflicting_page_id,
151
- )
208
+ title = self._get_unique_title(document, path)
152
209
 
153
210
  # fetch existing page
154
211
  page = self.api.get_page(page_id.page_id)
@@ -179,6 +236,41 @@ class SynchronizingProcessor(Processor):
179
236
  if document.properties is not None:
180
237
  self.api.update_content_properties_for_page(page_id.page_id, [ConfluenceContentProperty(key, value) for key, value in document.properties.items()])
181
238
 
239
+ def _get_extended_title(self, title: str) -> str:
240
+ """
241
+ Returns a title with the title prefix applied (if any).
242
+ """
243
+
244
+ if self.options.title_prefix is not None:
245
+ return f"{self.options.title_prefix} {title}"
246
+ else:
247
+ return title
248
+
249
+ def _get_unique_title(self, document: ConfluenceDocument, path: Path) -> str | None:
250
+ """
251
+ Determines the (new) document title to assign to the Confluence page.
252
+
253
+ Ensures that the title is unique across the Confluence space.
254
+ """
255
+
256
+ # document has no title (neither in front-matter nor as unique top-level heading)
257
+ if document.title is None:
258
+ return None
259
+
260
+ # add configured title prefix
261
+ title = self._get_extended_title(document.title)
262
+
263
+ # compare current document title with title discovered during directory traversal
264
+ meta = self.page_metadata.get(path)
265
+ if meta is not None and meta.title != title:
266
+ # title has changed, check if new title is available
267
+ page_id = self.api.page_exists(title, space_id=self.api.space_key_to_id(meta.space_key))
268
+ if page_id is not None:
269
+ LOGGER.info("Unrelated Confluence page with ID %s has the same inferred title as the Markdown file: %s", page_id, path)
270
+ return None
271
+
272
+ return title
273
+
182
274
  def _update_markdown(self, path: Path, *, page_id: str, space_key: str) -> None:
183
275
  """
184
276
  Writes the Confluence page ID and space key at the beginning of the Markdown file.
md2conf/scanner.py CHANGED
@@ -75,10 +75,16 @@ class Scanner:
75
75
  Extracts essential properties from a Markdown document.
76
76
  """
77
77
 
78
- # parse file
79
78
  with open(absolute_path, "r", encoding="utf-8") as f:
80
79
  text = f.read()
81
80
 
81
+ return self.parse(text)
82
+
83
+ def parse(self, text: str) -> ScannedDocument:
84
+ """
85
+ Extracts essential properties from a Markdown document.
86
+ """
87
+
82
88
  # extract Confluence page ID
83
89
  page_id, text = extract_value(r"<!--\s+confluence[-_]page[-_]id:\s*(\d+)\s+-->", text)
84
90
 
md2conf/svg.py CHANGED
@@ -9,6 +9,7 @@ Copyright 2022-2026, Levente Hunyadi
9
9
  import logging
10
10
  import re
11
11
  from pathlib import Path
12
+ from typing import overload
12
13
 
13
14
  import lxml.etree as ET
14
15
 
@@ -19,6 +20,10 @@ LOGGER = logging.getLogger(__name__)
19
20
  SVG_NAMESPACE = "http://www.w3.org/2000/svg"
20
21
 
21
22
 
23
+ class SVGParseError(RuntimeError):
24
+ pass
25
+
26
+
22
27
  def _check_svg(root: ElementType) -> bool:
23
28
  "Tests if the element is a plain or scoped SVG element."
24
29
 
@@ -31,7 +36,7 @@ def _check_svg(root: ElementType) -> bool:
31
36
  return qname.localname == "svg" and (not qname.namespace or qname.namespace == SVG_NAMESPACE)
32
37
 
33
38
 
34
- def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int | None]:
39
+ def _extract_dimensions_from_root(root: ElementType) -> tuple[int, int] | None:
35
40
  """
36
41
  Extracts width and height from an SVG root element.
37
42
 
@@ -40,11 +45,11 @@ def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int |
40
45
  2. The viewBox attribute if width/height are not specified
41
46
 
42
47
  :param root: The root element of the SVG document.
43
- :returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
48
+ :returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
44
49
  """
45
50
 
46
51
  if not _check_svg(root):
47
- return None, None
52
+ raise SVGParseError("SVG file does not have an <svg> root element")
48
53
 
49
54
  width_attr = root.get("width")
50
55
  height_attr = root.get("height")
@@ -52,69 +57,86 @@ def _extract_dimensions_from_root(root: ElementType) -> tuple[int | None, int |
52
57
  width = _parse_svg_length(width_attr) if width_attr else None
53
58
  height = _parse_svg_length(height_attr) if height_attr else None
54
59
 
55
- # If width/height not specified, try to derive from viewBox
60
+ # if width/height not specified, try to derive from view-box
61
+ if width is None or height is None:
62
+ viewbox_attr = root.get("viewBox")
63
+ if viewbox_attr:
64
+ viewbox = _parse_viewbox(viewbox_attr)
65
+ if viewbox is not None:
66
+ vb_width, vb_height = viewbox
67
+ if width is not None:
68
+ height = width * vb_height // vb_width
69
+ elif height is not None:
70
+ width = height * vb_width // vb_height
71
+ else:
72
+ width = vb_width
73
+ height = vb_height
74
+
56
75
  if width is None or height is None:
57
- viewbox = root.get("viewBox")
58
- if viewbox:
59
- vb_width, vb_height = _parse_viewbox(viewbox)
60
- if width is None:
61
- width = vb_width
62
- if height is None:
63
- height = vb_height
76
+ return None
64
77
 
65
78
  return width, height
66
79
 
67
80
 
68
- def get_svg_dimensions(path: Path) -> tuple[int | None, int | None]:
81
+ @overload
82
+ def get_svg_dimensions(svg: Path) -> tuple[int, int] | None:
69
83
  """
70
84
  Extracts width and height from an SVG file.
71
85
 
72
86
  Attempts to read dimensions from:
73
- 1. Explicit width/height attributes on the root <svg> element
74
- 2. The viewBox attribute if width/height are not specified
75
-
76
- :param path: Path to the SVG file.
77
- :returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
78
- """
79
87
 
80
- try:
81
- tree = ET.parse(str(path))
82
- root = tree.getroot()
83
- width, height = _extract_dimensions_from_root(root)
84
- if width is None and height is None:
85
- LOGGER.warning("SVG file %s does not have an <svg> root element", path)
86
- return width, height
88
+ 1. Explicit `width` and `height` attributes on the root `<svg>` element
89
+ 2. The `viewBox` attribute if `width` or `height` is not specified
87
90
 
88
- except ET.XMLSyntaxError as ex:
89
- LOGGER.warning("Failed to parse SVG file %s: %s", path, ex)
90
- return None, None
91
- except Exception as ex:
92
- LOGGER.warning("Unexpected error reading SVG dimensions from %s: %s", path, ex)
93
- return None, None
91
+ :param svg: Path to the SVG file.
92
+ :returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
93
+ """
94
+ ...
94
95
 
95
96
 
96
- def get_svg_dimensions_from_bytes(data: bytes) -> tuple[int | None, int | None]:
97
+ @overload
98
+ def get_svg_dimensions(svg: bytes | str) -> tuple[int, int] | None:
97
99
  """
98
100
  Extracts width and height from SVG data in memory.
99
101
 
100
102
  Attempts to read dimensions from:
101
- 1. Explicit width/height attributes on the root <svg> element
102
- 2. The viewBox attribute if width/height are not specified
103
-
104
- :param data: The SVG content as bytes.
105
- :returns: A tuple of (width, height) in pixels, or (None, None) if dimensions cannot be determined.
106
- """
107
103
 
108
- try:
109
- root = ET.fromstring(data)
110
- return _extract_dimensions_from_root(root)
104
+ 1. Explicit `width` and `height` attributes on the root `<svg>` element
105
+ 2. The `viewBox` attribute if `width` or `height` is not specified
111
106
 
112
- except ET.XMLSyntaxError as ex:
113
- LOGGER.warning("Failed to parse SVG data: %s", ex)
114
- return None, None
115
- except Exception as ex:
116
- LOGGER.warning("Unexpected error reading SVG dimensions from data: %s", ex)
117
- return None, None
107
+ :param svg: The SVG content as bytes.
108
+ :returns: A tuple of (width, height) in pixels, or `None` if dimensions cannot be determined.
109
+ """
110
+ ...
111
+
112
+
113
+ def get_svg_dimensions(svg: Path | bytes | str) -> tuple[int, int] | None:
114
+ if isinstance(svg, Path):
115
+ path = svg
116
+ try:
117
+ tree = ET.parse(path)
118
+ root = tree.getroot()
119
+ return _extract_dimensions_from_root(root)
120
+ except OSError as ex:
121
+ LOGGER.warning("Failed to open SVG file: %s", path, exc_info=ex)
122
+ return None
123
+ except ET.XMLSyntaxError as ex:
124
+ LOGGER.warning("Failed to parse SVG file: %s", path, exc_info=ex)
125
+ return None
126
+ except SVGParseError as ex:
127
+ LOGGER.warning("Failed to extract dimensions from SVG file: %s", path, exc_info=ex)
128
+ return None
129
+ else:
130
+ data = svg
131
+ try:
132
+ root = ET.fromstring(data)
133
+ return _extract_dimensions_from_root(root)
134
+ except ET.XMLSyntaxError as ex:
135
+ LOGGER.warning("Failed to parse SVG data", exc_info=ex)
136
+ return None
137
+ except SVGParseError as ex:
138
+ LOGGER.warning("Failed to extract dimensions from SVG data", exc_info=ex)
139
+ return None
118
140
 
119
141
 
120
142
  def _serialize_svg_opening_tag(root: ElementType) -> str:
@@ -128,7 +150,7 @@ def _serialize_svg_opening_tag(root: ElementType) -> str:
128
150
  # Build the opening tag from element name and attributes
129
151
  root_tag = root.tag
130
152
  if not isinstance(root_tag, str):
131
- raise TypeError("expected: tag names as `str`")
153
+ raise SVGParseError("expected: tag names as `str`")
132
154
  tag_name = ET.QName(root_tag).localname
133
155
  parts = [f"<{tag_name}"]
134
156
 
@@ -180,64 +202,56 @@ def fix_svg_dimensions(data: bytes) -> bytes:
180
202
  :returns: The modified SVG content with explicit dimensions, or original data if modification fails.
181
203
  """
182
204
 
183
- try:
184
- text = data.decode("utf-8")
185
-
186
- # Skip SVGs with foreignObject - Confluence has issues rendering
187
- # foreignObject content when explicit width/height are set on the SVG
188
- if "<foreignObject" in text:
189
- LOGGER.debug("Skipping dimension fix for SVG with foreignObject elements")
190
- return data
191
-
192
- # Parse the SVG to extract root element attributes
193
- root = ET.fromstring(data)
194
-
195
- # Verify it's an SVG element
196
- if not _check_svg(root):
197
- return data
198
-
199
- # Check if we need to fix (has width="100%" or similar percentage)
200
- width_attr = root.get("width")
201
- if width_attr != "100%":
202
- # Check if it already has a valid numeric width
203
- if width_attr is not None and _parse_svg_length(width_attr) is not None:
204
- return data # Already has numeric width
205
+ # Skip SVGs with foreignObject - Confluence has issues rendering
206
+ # foreignObject content when explicit width/height are set on the SVG
207
+ if b"<foreignObject" in data:
208
+ LOGGER.debug("Skipping dimension fix for SVG with foreignObject elements")
209
+ return data
205
210
 
206
- # Get viewBox dimensions
207
- viewbox = root.get("viewBox")
208
- if not viewbox:
209
- return data
211
+ # Parse the SVG to extract root element attributes
212
+ root = ET.fromstring(data)
210
213
 
211
- vb_width, vb_height = _parse_viewbox(viewbox)
212
- if vb_width is None or vb_height is None:
213
- return data
214
+ # Verify it's an SVG element
215
+ if not _check_svg(root):
216
+ return data
214
217
 
215
- # Extract the original opening tag from the text
216
- svg_tag_match = re.search(r"<svg\b[^>]*>", text)
217
- if not svg_tag_match:
218
- return data
218
+ # Check if we need to fix (has width="100%" or similar percentage)
219
+ width_attr = root.get("width")
220
+ if width_attr != "100%":
221
+ # Check if it already has a valid numeric width
222
+ if width_attr is not None and _parse_svg_length(width_attr) is not None:
223
+ return data # Already has numeric width
224
+
225
+ # Get viewBox dimensions
226
+ viewbox_attr = root.get("viewBox")
227
+ if not viewbox_attr:
228
+ return data
219
229
 
220
- original_tag = svg_tag_match.group(0)
230
+ viewbox = _parse_viewbox(viewbox_attr)
231
+ if viewbox is None:
232
+ return data
233
+ vb_width, vb_height = viewbox
221
234
 
222
- # Modify the root element's attributes
223
- root.set("width", str(vb_width))
235
+ # Extract the original opening tag from the text
236
+ svg_tag_match = re.search(rb"<svg\b[^>]*>", data)
237
+ if not svg_tag_match:
238
+ return data
224
239
 
225
- # Set height if missing or if it's a percentage
226
- height_attr = root.get("height")
227
- if height_attr is None or height_attr == "100%":
228
- root.set("height", str(vb_height))
240
+ original_tag = svg_tag_match.group(0)
229
241
 
230
- # Serialize just the opening tag with modified attributes
231
- new_tag = _serialize_svg_opening_tag(root)
242
+ # Modify the root element's attributes
243
+ root.set("width", str(vb_width))
232
244
 
233
- # Replace the original opening tag with the new one
234
- text = text.replace(original_tag, new_tag, 1)
245
+ # Set height if missing or if it's a percentage
246
+ height_attr = root.get("height")
247
+ if height_attr is None or height_attr == "100%":
248
+ root.set("height", str(vb_height))
235
249
 
236
- return text.encode("utf-8")
250
+ # Serialize just the opening tag with modified attributes
251
+ new_tag = _serialize_svg_opening_tag(root).encode("utf-8")
237
252
 
238
- except Exception as ex:
239
- LOGGER.warning("Unexpected error fixing SVG dimensions: %s", ex)
240
- return data
253
+ # Replace the original opening tag with the new one
254
+ return data.replace(original_tag, new_tag, 1)
241
255
 
242
256
 
243
257
  def _parse_svg_length(value: str) -> int | None:
@@ -294,32 +308,32 @@ def _parse_svg_length(value: str) -> int | None:
294
308
  return int(round(pixels))
295
309
 
296
310
 
297
- def _parse_viewbox(viewbox: str) -> tuple[int | None, int | None]:
311
+ def _parse_viewbox(viewbox: str) -> tuple[int, int] | None:
298
312
  """
299
313
  Parses an SVG viewBox attribute and extracts width and height.
300
314
 
301
315
  :param viewbox: The viewBox string (e.g., "0 0 100 200").
302
- :returns: A tuple of (width, height) in pixels, or (None, None) if parsing fails.
316
+ :returns: A tuple of (width, height) in pixels, or `None` if parsing fails.
303
317
  """
304
318
 
305
319
  if not viewbox:
306
- return None, None
320
+ return None
307
321
 
308
322
  # viewBox format: "min-x min-y width height"
309
323
  # Values can be separated by whitespace and/or commas
310
324
  parts = re.split(r"[\s,]+", viewbox.strip())
311
325
  if len(parts) != 4:
312
- return None, None
326
+ return None
313
327
 
314
328
  try:
315
329
  width = int(round(float(parts[2])))
316
330
  height = int(round(float(parts[3])))
317
331
  return width, height
318
332
  except ValueError:
319
- return None, None
333
+ return None
320
334
 
321
335
 
322
- def fix_svg_get_dimensions(image_data: bytes) -> tuple[bytes, int | None, int | None]:
336
+ def fix_svg_get_dimensions(data: bytes) -> tuple[bytes, tuple[int, int] | None]:
323
337
  """
324
338
  Post-processes SVG diagram data by fixing dimensions and extracting metadata.
325
339
 
@@ -328,14 +342,19 @@ def fix_svg_get_dimensions(image_data: bytes) -> tuple[bytes, int | None, int |
328
342
  1. fixes SVG dimensions (converts percentage-based to explicit pixels), and
329
343
  2. extracts width/height from the SVG.
330
344
 
331
- :param image_data: Raw SVG data as bytes.
345
+ :param data: Raw SVG data as bytes.
332
346
  :returns: Tuple of update raw data, image width, image height.
333
347
  """
334
348
 
335
- # fix SVG to have explicit width/height instead of percentages
336
- image_data = fix_svg_dimensions(image_data)
337
-
338
- # extract dimensions from the fixed SVG
339
- width, height = get_svg_dimensions_from_bytes(image_data)
349
+ try:
350
+ # fix SVG to have explicit width/height instead of percentages
351
+ data = fix_svg_dimensions(data)
340
352
 
341
- return image_data, width, height
353
+ # extract dimensions from the fixed SVG
354
+ return data, get_svg_dimensions(data)
355
+ except ET.XMLSyntaxError as ex:
356
+ LOGGER.warning("Failed to parse SVG data", exc_info=ex)
357
+ return data, None
358
+ except SVGParseError as ex:
359
+ LOGGER.warning("Failed to extract dimensions from SVG data", exc_info=ex)
360
+ return data, None
md2conf/toc.py CHANGED
@@ -6,7 +6,9 @@ Copyright 2022-2026, Levente Hunyadi
6
6
  :see: https://github.com/hunyadi/md2conf
7
7
  """
8
8
 
9
+ import re
9
10
  from dataclasses import dataclass
11
+ from typing import Iterable, Iterator
10
12
 
11
13
 
12
14
  @dataclass(eq=True)
@@ -86,3 +88,73 @@ class TableOfContentsBuilder:
86
88
  return self.tree[0].text
87
89
  else:
88
90
  return None
91
+
92
+
93
+ _FENCED_CODE_REGEXP = re.compile(r"^\s*(?:`{3,}|~{3,})", re.MULTILINE)
94
+ _ATX_HEADING_REGEXP = re.compile(r"^(#{1,6})\s+(.*?)$", re.MULTILINE)
95
+ _SETEXT_HEADING_REGEXP = re.compile(r"^(=+|-+)\s*?$", re.MULTILINE)
96
+
97
+
98
+ def headings(lines: Iterable[str]) -> Iterator[tuple[int, str]]:
99
+ fence_marker: str | None = None
100
+ heading_text: str | None = None
101
+
102
+ for line in lines:
103
+ # fenced code blocks
104
+ fence_match = _FENCED_CODE_REGEXP.match(line)
105
+ if fence_match:
106
+ marker = fence_match.group()
107
+ if fence_marker is None:
108
+ fence_marker = marker
109
+ elif marker == fence_marker:
110
+ fence_marker = None
111
+ heading_text = None
112
+ continue
113
+
114
+ if fence_marker is not None:
115
+ heading_text = None
116
+ continue
117
+
118
+ # ATX headings
119
+ atx = _ATX_HEADING_REGEXP.match(line)
120
+ if atx is not None:
121
+ level = len(atx.group(1))
122
+ title = atx.group(2).rstrip().rstrip("#").rstrip() # remove decorative text: `## Section 1.2 ##`
123
+ yield level, title
124
+
125
+ heading_text = None
126
+ continue
127
+
128
+ # Setext headings
129
+ setext = _SETEXT_HEADING_REGEXP.match(line)
130
+ if setext is not None and heading_text is not None:
131
+ match setext.group(1)[0:1]:
132
+ case "=":
133
+ level = 1
134
+ case "-":
135
+ level = 2
136
+ case _:
137
+ level = 0
138
+ yield level, heading_text.rstrip()
139
+
140
+ heading_text = None
141
+ continue
142
+
143
+ # candidate for Setext title
144
+ heading_text = line
145
+
146
+
147
+ def unique_title(content: str) -> str | None:
148
+ """
149
+ Returns a proposed document title.
150
+
151
+ The proposed title is text of the top-level heading if and only if that heading is unique.
152
+
153
+ :returns: Title text, or `None` if no title can be inferred.
154
+ """
155
+
156
+ builder = TableOfContentsBuilder()
157
+ for heading in headings(content.splitlines(keepends=True)):
158
+ level, text = heading
159
+ builder.add(level, text)
160
+ return builder.get_title()