python-substack 0.1.21__tar.gz → 0.1.23__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-substack
3
- Version: 0.1.21
3
+ Version: 0.1.23
4
4
  Summary: A Python wrapper around the Substack API.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -180,6 +180,21 @@ This is a paragraph with **bold** and *italic* text.
180
180
  """
181
181
  post.from_markdown(markdown_content, api=api)
182
182
 
183
+ # Markdown footnotes are supported too. References become inline anchors and
184
+ # definitions become footnote blocks, numbered by order of first appearance.
185
+ # Labels can be numbers or names (e.g. [^1] or [^source]).
186
+ footnote_markdown = """
187
+ A claim that needs support.[^1] Another, with a named label.[^source]
188
+
189
+ [^1]: The supporting detail, with a [link](https://example.com).
190
+ [^source]: Author, *Title* (2025).
191
+ """
192
+ post.from_markdown(footnote_markdown, api=api)
193
+
194
+ # Or build footnotes manually:
195
+ post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
196
+ post.footnote(1, "The note text, with **formatting** allowed.")
197
+
183
198
  draft = api.post_draft(post.get_draft())
184
199
 
185
200
  # set section (can only be done after first posting the draft)
@@ -156,6 +156,21 @@ This is a paragraph with **bold** and *italic* text.
156
156
  """
157
157
  post.from_markdown(markdown_content, api=api)
158
158
 
159
+ # Markdown footnotes are supported too. References become inline anchors and
160
+ # definitions become footnote blocks, numbered by order of first appearance.
161
+ # Labels can be numbers or names (e.g. [^1] or [^source]).
162
+ footnote_markdown = """
163
+ A claim that needs support.[^1] Another, with a named label.[^source]
164
+
165
+ [^1]: The supporting detail, with a [link](https://example.com).
166
+ [^source]: Author, *Title* (2025).
167
+ """
168
+ post.from_markdown(footnote_markdown, api=api)
169
+
170
+ # Or build footnotes manually:
171
+ post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
172
+ post.footnote(1, "The note text, with **formatting** allowed.")
173
+
159
174
  draft = api.post_draft(post.get_draft())
160
175
 
161
176
  # set section (can only be done after first posting the draft)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-substack"
3
- version = "0.1.21"
3
+ version = "0.1.23"
4
4
  description = "A Python wrapper around the Substack API."
5
5
  authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@
3
3
  __author__ = "Paolo Mazza"
4
4
  __email__ = "mazzapaolo2019@gmail.com"
5
5
  __license__ = "MIT License"
6
- __version__ = "0.1.21"
6
+ __version__ = "0.1.23"
7
7
  __url__ = "https://github.com/ma2za/python-substack"
8
8
  __download_url__ = "https://pypi.python.org/pypi/python-substack"
9
9
  __description__ = "A Python wrapper around the Substack API"
@@ -8,10 +8,32 @@ import json
8
8
  import re
9
9
  from typing import Dict, List
10
10
 
11
- __all__ = ["Post", "parse_inline"]
11
+ __all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
12
12
 
13
13
  from substack.exceptions import SectionNotExistsException
14
14
 
15
+ # Markdown footnotes: ``text.[^label]`` references and ``[^label]: definition`` lines.
16
+ FOOTNOTE_REFERENCE_PATTERN = re.compile(r"\[\^([^\]]+)\]")
17
+ FOOTNOTE_DEFINITION_PATTERN = re.compile(r"^\[\^([^\]]+)\]:\s?(.*)$")
18
+
19
+
20
+ def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
21
+ """Convert parse_inline() tokens to ProseMirror text nodes.
22
+
23
+ parse_inline() returns {"content": "text", "marks": [...]}.
24
+ ProseMirror expects {"type": "text", "text": "text", "marks": [...]}.
25
+ """
26
+ nodes = []
27
+ for token in tokens:
28
+ if not token or not token.get("content"):
29
+ continue
30
+ node = {"type": "text", "text": token["content"]}
31
+ marks = token.get("marks")
32
+ if marks:
33
+ node["marks"] = marks
34
+ nodes.append(node)
35
+ return nodes
36
+
15
37
 
16
38
  def parse_inline(text: str) -> List[Dict]:
17
39
  """
@@ -19,8 +41,11 @@ def parse_inline(text: str) -> List[Dict]:
19
41
  for use in the post content.
20
42
 
21
43
  Supported formatting:
44
+ - `code`: Text wrapped in backticks.
22
45
  - **Bold**: Text wrapped in double asterisks.
23
46
  - *Italic*: Text wrapped in single asterisks.
47
+ - ***Bold+Italic***: Text wrapped in triple asterisks.
48
+ - ~~Strikethrough~~: Text wrapped in double tildes.
24
49
  - [Links]: Text wrapped in square brackets followed by URL in parentheses.
25
50
 
26
51
  Args:
@@ -37,33 +62,50 @@ def parse_inline(text: str) -> List[Dict]:
37
62
  return []
38
63
 
39
64
  tokens = []
40
- # Process text character by character to handle nested formatting
41
- # We'll use regex to find all markdown patterns, then process them in order
42
65
 
43
- # Find all markdown patterns: links, bold, italic
44
- # Pattern order: links first (to avoid conflicts), then bold, then italic
66
+ # Pattern order matters: code > links > bold+italic > bold > italic > strikethrough
67
+ code_pattern = r'`([^`]+)`'
45
68
  link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
69
+ bold_italic_pattern = r'\*\*\*([^*]+)\*\*\*'
46
70
  bold_pattern = r'\*\*([^*]+)\*\*'
47
71
  italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
72
+ strikethrough_pattern = r'~~([^~]+)~~'
48
73
 
49
74
  # Find all matches with their positions
50
75
  matches = []
76
+
77
+ # Inline code FIRST -- content inside backticks must not be parsed for other formatting
78
+ for match in re.finditer(code_pattern, text):
79
+ matches.append((match.start(), match.end(), "code", match.group(1), None))
80
+
81
+ # Links
51
82
  for match in re.finditer(link_pattern, text):
52
83
  # Skip if it's an image link (starts with ![)
53
84
  # But do NOT skip normal links at position 0.
54
85
  if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
55
- matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
86
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
87
+ matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
88
+
89
+ # Bold+italic combo
90
+ for match in re.finditer(bold_italic_pattern, text):
91
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
92
+ matches.append((match.start(), match.end(), "bold_italic", match.group(1), None))
56
93
 
94
+ # Bold
57
95
  for match in re.finditer(bold_pattern, text):
58
- # Check if this range is already covered by a link
59
96
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
60
97
  matches.append((match.start(), match.end(), "bold", match.group(1), None))
61
98
 
99
+ # Italic
62
100
  for match in re.finditer(italic_pattern, text):
63
- # Check if this range is already covered by a link or bold
64
101
  if not any(start <= match.start() < end for start, end, _, _, _ in matches):
65
102
  matches.append((match.start(), match.end(), "italic", match.group(1), None))
66
103
 
104
+ # Strikethrough
105
+ for match in re.finditer(strikethrough_pattern, text):
106
+ if not any(start <= match.start() < end for start, end, _, _, _ in matches):
107
+ matches.append((match.start(), match.end(), "strikethrough", match.group(1), None))
108
+
67
109
  # Sort matches by position
68
110
  matches.sort(key=lambda x: x[0])
69
111
 
@@ -75,11 +117,21 @@ def parse_inline(text: str) -> List[Dict]:
75
117
  tokens.append({"content": text[last_pos:start]})
76
118
 
77
119
  # Add the formatted content
78
- if match_type == "link":
120
+ if match_type == "code":
121
+ tokens.append({
122
+ "content": content,
123
+ "marks": [{"type": "code"}]
124
+ })
125
+ elif match_type == "link":
79
126
  tokens.append({
80
127
  "content": content,
81
128
  "marks": [{"type": "link", "attrs": {"href": url}}]
82
129
  })
130
+ elif match_type == "bold_italic":
131
+ tokens.append({
132
+ "content": content,
133
+ "marks": [{"type": "strong"}, {"type": "em"}]
134
+ })
83
135
  elif match_type == "bold":
84
136
  tokens.append({
85
137
  "content": content,
@@ -90,6 +142,11 @@ def parse_inline(text: str) -> List[Dict]:
90
142
  "content": content,
91
143
  "marks": [{"type": "em"}]
92
144
  })
145
+ elif match_type == "strikethrough":
146
+ tokens.append({
147
+ "content": content,
148
+ "marks": [{"type": "strikethrough"}]
149
+ })
93
150
 
94
151
  last_pos = end
95
152
 
@@ -490,6 +547,186 @@ class Post:
490
547
 
491
548
  return self
492
549
 
550
+ def footnote_anchor(self, number: int):
551
+ """
552
+
553
+ Add an inline footnote reference (the superscript marker) to the last block.
554
+
555
+ Args:
556
+ number: The footnote number this anchor points to.
557
+
558
+ Returns:
559
+ Self for method chaining.
560
+
561
+ """
562
+ content = self.draft_body["content"][-1].get("content", [])
563
+ content += [{"type": "footnoteAnchor", "attrs": {"number": number}}]
564
+ self.draft_body["content"][-1]["content"] = content
565
+ return self
566
+
567
+ def footnote(self, number: int, content=None):
568
+ """
569
+
570
+ Append a footnote block (the note shown at the foot of the post).
571
+
572
+ Args:
573
+ number: The footnote number, matching a footnote_anchor.
574
+ content: Text string or list of inline token dicts. A plain string is
575
+ parsed for inline Markdown and may contain blank-line-separated
576
+ paragraphs; a parse_inline() token list or a list of ready text
577
+ nodes is also accepted (single paragraph).
578
+
579
+ Returns:
580
+ Self for method chaining.
581
+
582
+ """
583
+ paragraphs: List[Dict] = []
584
+ if isinstance(content, str):
585
+ # Blank lines separate paragraphs within the footnote.
586
+ for chunk in re.split(r"\n\s*\n", content):
587
+ chunk = chunk.strip()
588
+ if chunk:
589
+ paragraphs.append(
590
+ {"type": "paragraph", "content": tokens_to_text_nodes(parse_inline(chunk))}
591
+ )
592
+ elif isinstance(content, list):
593
+ # Accept either parse_inline tokens ({"content": ...}) or text nodes.
594
+ if content and content[0].get("type") == "text":
595
+ text_nodes = content
596
+ else:
597
+ text_nodes = tokens_to_text_nodes(content)
598
+ paragraphs.append({"type": "paragraph", "content": text_nodes})
599
+
600
+ if not paragraphs:
601
+ paragraphs = [{"type": "paragraph", "content": []}]
602
+
603
+ node: Dict = {
604
+ "type": "footnote",
605
+ "attrs": {"number": number},
606
+ "content": paragraphs,
607
+ }
608
+ self.draft_body["content"] = self.draft_body.get("content", []) + [node]
609
+ return self
610
+
611
+ @staticmethod
612
+ def _extract_footnote_definitions(markdown_content: str):
613
+ """
614
+
615
+ Pull ``[^label]: definition`` lines out of the Markdown.
616
+
617
+ Definitions may wrap onto indented continuation lines and may contain
618
+ multiple paragraphs (blank line followed by an indented block). Returns
619
+ the body with definitions removed plus a {label: definition_text} mapping,
620
+ where paragraphs are separated by a blank line.
621
+
622
+ """
623
+ lines = markdown_content.split("\n")
624
+ body_lines: List[str] = []
625
+ definitions: Dict[str, str] = {}
626
+ in_code_fence = False
627
+ i = 0
628
+ while i < len(lines):
629
+ # Track fenced code blocks so footnote-like lines inside them are
630
+ # left untouched.
631
+ if lines[i].lstrip().startswith("```"):
632
+ in_code_fence = not in_code_fence
633
+ body_lines.append(lines[i])
634
+ i += 1
635
+ continue
636
+ match = None if in_code_fence else FOOTNOTE_DEFINITION_PATTERN.match(lines[i])
637
+ if match:
638
+ label, first = match.group(1), match.group(2)
639
+ paragraphs: List[str] = []
640
+ current = [first.strip()] if first.strip() else []
641
+ i += 1
642
+ while i < len(lines):
643
+ line = lines[i]
644
+ if line.strip() == "":
645
+ # A blank line stays in the footnote only if the next
646
+ # non-empty line is indented (a further paragraph).
647
+ nxt = i + 1
648
+ if (
649
+ nxt < len(lines)
650
+ and lines[nxt].strip()
651
+ and lines[nxt][:1] in (" ", "\t")
652
+ ):
653
+ if current:
654
+ paragraphs.append(" ".join(current))
655
+ current = []
656
+ i += 1
657
+ continue
658
+ break
659
+ if line[:1] in (" ", "\t"):
660
+ current.append(line.strip())
661
+ i += 1
662
+ else:
663
+ break
664
+ if current:
665
+ paragraphs.append(" ".join(current))
666
+ definitions[label] = "\n\n".join(paragraphs)
667
+ else:
668
+ body_lines.append(lines[i])
669
+ i += 1
670
+ return "\n".join(body_lines), definitions
671
+
672
+ @staticmethod
673
+ def _number_footnotes(markdown_content: str, definitions: Dict[str, str]):
674
+ """Number footnotes by order of first inline reference in the body."""
675
+ order: List[str] = []
676
+ for match in FOOTNOTE_REFERENCE_PATTERN.finditer(markdown_content):
677
+ label = match.group(1)
678
+ if label in definitions and label not in order:
679
+ order.append(label)
680
+ # Defined-but-unreferenced footnotes go last, in definition order.
681
+ for label in definitions:
682
+ if label not in order:
683
+ order.append(label)
684
+ return {label: index + 1 for index, label in enumerate(order)}
685
+
686
+ def _inject_footnote_anchors(self, node: Dict, numbers_by_label: Dict[str, int]):
687
+ """Recursively replace ``[^label]`` in text nodes with footnoteAnchor nodes."""
688
+ # Never rewrite the contents of a code block.
689
+ if node.get("type") == "codeBlock":
690
+ return
691
+ content = node.get("content")
692
+ if not isinstance(content, list):
693
+ return
694
+ new_content: List[Dict] = []
695
+ for child in content:
696
+ text = child.get("text", "")
697
+ has_code_mark = any(
698
+ mark.get("type") == "code" for mark in (child.get("marks") or [])
699
+ )
700
+ if (
701
+ child.get("type") == "text"
702
+ and not has_code_mark
703
+ and FOOTNOTE_REFERENCE_PATTERN.search(text)
704
+ ):
705
+ marks = child.get("marks")
706
+ last = 0
707
+ for match in FOOTNOTE_REFERENCE_PATTERN.finditer(text):
708
+ label = match.group(1)
709
+ if label not in numbers_by_label:
710
+ continue # Unknown label: leave the literal text in place.
711
+ if match.start() > last:
712
+ segment = {"type": "text", "text": text[last:match.start()]}
713
+ if marks:
714
+ segment["marks"] = marks
715
+ new_content.append(segment)
716
+ new_content.append(
717
+ {"type": "footnoteAnchor", "attrs": {"number": numbers_by_label[label]}}
718
+ )
719
+ last = match.end()
720
+ if last < len(text):
721
+ segment = {"type": "text", "text": text[last:]}
722
+ if marks:
723
+ segment["marks"] = marks
724
+ new_content.append(segment)
725
+ else:
726
+ self._inject_footnote_anchors(child, numbers_by_label)
727
+ new_content.append(child)
728
+ node["content"] = new_content
729
+
493
730
  def from_markdown(self, markdown_content: str, api=None):
494
731
  """
495
732
  Parse Markdown content and add it to the post.
@@ -503,7 +740,13 @@ class Post:
503
740
  - Blockquotes: Lines starting with '>' (consecutive lines grouped)
504
741
  - Paragraphs: Regular text blocks
505
742
  - Bullet lists: Lines starting with '*' or '-'
506
- - Inline formatting: **bold** and *italic* within paragraphs
743
+ - Ordered lists: Lines starting with '1.', '2.', etc.
744
+ - Horizontal rules: Lines with ---, ***, or ___
745
+ - Inline formatting: **bold**, *italic*, ***bold+italic***, `code`, ~~strikethrough~~
746
+ - Footnotes: ``text.[^label]`` references plus ``[^label]: definition``
747
+ lines. References become inline anchors and definitions become
748
+ footnote blocks, numbered by order of first appearance. Labels may be
749
+ numbers or names (e.g. ``[^1]`` or ``[^agi-book]``).
507
750
 
508
751
  Args:
509
752
  markdown_content: Markdown string to parse and add to the post.
@@ -517,6 +760,13 @@ class Post:
517
760
  >>> post = Post("Title", "Subtitle", user_id)
518
761
  >>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
519
762
  """
763
+ # Footnotes: extract ``[^label]: ...`` definitions and number them by
764
+ # order of first reference before parsing the rest of the body.
765
+ markdown_content, footnote_definitions = self._extract_footnote_definitions(
766
+ markdown_content
767
+ )
768
+ footnote_numbers = self._number_footnotes(markdown_content, footnote_definitions)
769
+
520
770
  lines = markdown_content.split("\n")
521
771
  blocks = []
522
772
  current_block: List[str] = []
@@ -593,6 +843,11 @@ class Post:
593
843
  if not text_content:
594
844
  continue
595
845
 
846
+ # Check for horizontal rule: ---, ***, ___
847
+ if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content):
848
+ self.horizontal_rule()
849
+ continue
850
+
596
851
  # Process headings (lines starting with '#' characters)
597
852
  if text_content.startswith("#"):
598
853
  level = len(text_content) - len(text_content.lstrip("#"))
@@ -648,14 +903,15 @@ class Post:
648
903
 
649
904
  self.add({"type": "captionedImage", "src": image_url})
650
905
 
651
- # Process paragraphs, bullet lists, or blockquotes
906
+ # Process paragraphs, bullet lists, ordered lists, or blockquotes
652
907
  else:
653
908
  if "\n" in text_content:
654
- # Process each line, grouping consecutive bullets
655
- # into a single bullet_list node and consecutive
656
- # blockquote lines into a single blockquote node.
909
+ # Process each line, grouping consecutive bullets/ordered items
910
+ # into list nodes and consecutive blockquote lines into a
911
+ # single blockquote node.
657
912
  pending_bullets: List[List[Dict]] = []
658
913
  pending_quotes: List[str] = []
914
+ pending_ordered: List[List[Dict]] = []
659
915
 
660
916
  def flush_bullets():
661
917
  if not pending_bullets:
@@ -677,10 +933,7 @@ class Post:
677
933
  paragraphs: List[Dict] = []
678
934
  for quote_line in pending_quotes:
679
935
  tokens = parse_inline(quote_line)
680
- text_nodes = [
681
- {"type": "text", "text": t["content"]}
682
- for t in tokens if t
683
- ]
936
+ text_nodes = tokens_to_text_nodes(tokens)
684
937
  if text_nodes:
685
938
  paragraphs.append({"type": "paragraph", "content": text_nodes})
686
939
  node: Dict = {"type": "blockquote"}
@@ -689,20 +942,48 @@ class Post:
689
942
  self.draft_body["content"].append(node)
690
943
  pending_quotes.clear()
691
944
 
945
+ def flush_ordered():
946
+ if not pending_ordered:
947
+ return
948
+ list_items = []
949
+ for item_nodes in pending_ordered:
950
+ list_items.append({
951
+ "type": "list_item",
952
+ "content": [{"type": "paragraph", "content": item_nodes}],
953
+ })
954
+ self.draft_body["content"].append(
955
+ {"type": "ordered_list", "content": list_items}
956
+ )
957
+ pending_ordered.clear()
958
+
692
959
  for line in text_content.split("\n"):
693
960
  line = line.strip()
694
961
  if not line:
695
962
  flush_bullets()
963
+ flush_ordered()
696
964
  flush_quotes()
697
965
  continue
698
966
 
699
967
  # Check for blockquote marker
700
968
  if line.startswith("> ") or line == ">":
701
969
  flush_bullets()
970
+ flush_ordered()
702
971
  quote_text = line[2:] if line.startswith("> ") else ""
703
972
  pending_quotes.append(quote_text)
704
973
  continue
705
974
 
975
+ # Check for ordered list marker
976
+ ordered_match = re.match(r'^(\d+)\.\s+(.*)', line)
977
+ if ordered_match:
978
+ flush_bullets()
979
+ flush_quotes()
980
+ item_text = ordered_match.group(2).strip()
981
+ tokens = parse_inline(item_text)
982
+ text_nodes = tokens_to_text_nodes(tokens)
983
+ if text_nodes:
984
+ pending_ordered.append(text_nodes)
985
+ continue
986
+
706
987
  # Check for bullet marker
707
988
  bullet_text = None
708
989
  if line.startswith("* "):
@@ -713,33 +994,56 @@ class Post:
713
994
  bullet_text = line[1:].strip()
714
995
 
715
996
  if bullet_text is not None:
997
+ flush_ordered()
716
998
  flush_quotes()
717
999
  tokens = parse_inline(bullet_text)
718
- if tokens:
719
- pending_bullets.append(tokens)
1000
+ text_nodes = tokens_to_text_nodes(tokens)
1001
+ if text_nodes:
1002
+ pending_bullets.append(text_nodes)
720
1003
  else:
721
1004
  flush_bullets()
1005
+ flush_ordered()
722
1006
  flush_quotes()
723
1007
  tokens = parse_inline(line)
724
1008
  self.add({"type": "paragraph", "content": tokens})
725
1009
 
726
1010
  flush_bullets()
1011
+ flush_ordered()
727
1012
  flush_quotes()
728
1013
  else:
729
- # Single line — could be a blockquote or paragraph
1014
+ # Single line — blockquote, ordered list, or paragraph
730
1015
  if text_content.startswith("> ") or text_content == ">":
731
1016
  quote_text = text_content[2:] if text_content.startswith("> ") else ""
732
1017
  tokens = parse_inline(quote_text)
733
- text_nodes = [
734
- {"type": "text", "text": t["content"]}
735
- for t in tokens if t
736
- ]
1018
+ text_nodes = tokens_to_text_nodes(tokens)
737
1019
  para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
738
1020
  self.draft_body["content"] = self.draft_body.get("content", []) + [
739
1021
  {"type": "blockquote", "content": [para]}
740
1022
  ]
1023
+
1024
+ elif re.match(r'^(\d+)\.\s+(.*)', text_content):
1025
+ ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content)
1026
+ item_text = ordered_match.group(2).strip()
1027
+ tokens = parse_inline(item_text)
1028
+ text_nodes = tokens_to_text_nodes(tokens)
1029
+ if text_nodes:
1030
+ list_item = {
1031
+ "type": "list_item",
1032
+ "content": [{"type": "paragraph", "content": text_nodes}],
1033
+ }
1034
+ self.draft_body["content"].append(
1035
+ {"type": "ordered_list", "content": [list_item]}
1036
+ )
1037
+
741
1038
  else:
742
1039
  tokens = parse_inline(text_content)
743
1040
  self.add({"type": "paragraph", "content": tokens})
744
1041
 
1042
+ # Footnotes: turn ``[^label]`` references into inline anchors, then append
1043
+ # the footnote blocks in numbered order.
1044
+ if footnote_numbers:
1045
+ self._inject_footnote_anchors(self.draft_body, footnote_numbers)
1046
+ for label, number in sorted(footnote_numbers.items(), key=lambda item: item[1]):
1047
+ self.footnote(number, footnote_definitions[label])
1048
+
745
1049
  return self