1 #!/usr/bin/env python 2 3 """ 4 Table of contents macro. 5 6 Copyright (C) 2018, 2019, 2022 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from moinformat.macros.common import Macro 23 from moinformat.tree.moin import Block, Container, Heading, Link, LinkLabel, \ 24 List, ListItem, Text 25 from moinformat.utils.links import parse_link_target 26 27 def in_range(min_level, level, max_level): 28 29 """ 30 Test that 'min_level' <= 'level' <= 'max_level', only imposing tests 31 involving limits not set to None. 32 """ 33 34 return (min_level is None or min_level <= level) and \ 35 (max_level is None or level <= max_level) 36 37 def above_minimum(min_level, level, max_level): 38 39 """ 40 Test that 'min_level' < 'level' <= 'max_level', only imposing tests 41 involving limits not set to None. 42 """ 43 44 return (min_level is None or min_level < level) and \ 45 (max_level is None or level <= max_level) 46 47 class TableOfContents(Macro): 48 49 "A table of contents macro." 50 51 name = "TableOfContents" 52 53 def evaluate(self): 54 55 "Evaluate the macro, producing a table of contents." 56 57 arglist = [] 58 _defaults = [None] * 2 59 60 for arg, default in map(None, self.node.args, _defaults): 61 if arg is not None: 62 try: 63 arg = max(1, int(arg.strip())) 64 except ValueError: 65 arg = None 66 arglist.append(arg) 67 68 self.make_table(arglist[0], arglist[1]) 69 70 def make_table(self, min_level=None, max_level=None): 71 72 """ 73 Make a table of contents with the given 'min_level' and 'max_level' of 74 headings. 75 """ 76 77 headings = [] 78 self.find_headings(self.doc, headings) 79 80 if not headings: 81 return 82 83 # Common list features. 84 85 marker = "1." 86 space = " " 87 num = "1" 88 89 # Start with no lists, no current item. 90 91 lists = [] 92 item = None 93 level = 0 94 95 for heading in headings: 96 new_level = heading.level 97 98 # Create new lists if the level increases. 99 100 if new_level > level: 101 while level < new_level: 102 level += 1 103 104 # Ignore levels outside the range of interest. 105 106 if not in_range(min_level, level, max_level): 107 continue 108 109 # Determine whether the heading should be generated at this 110 # level or whether there are intermediate levels being 111 # produced. 112 113 nodes = level == new_level and self.get_entry(heading) or [] 114 indent = level - 1 115 116 # Create a new item for the heading or sublists. 117 118 new_item = ListItem(nodes, indent, marker, space, None) 119 120 # Either revive an existing list. 121 122 if level == min_level and lists: 123 new_list = lists[-1] 124 new_items = new_list.nodes 125 126 # Or make a list and add an item to it. 127 128 else: 129 new_items = [] 130 new_list = List(new_items) 131 132 # Add the list to the current item, if any. 133 134 if item: 135 item.nodes.append(new_list) 136 137 # Record the new list. 138 139 lists.append(new_list) 140 141 # Add the item to the new or revived list. 142 143 new_items.append(new_item) 144 145 # Reference the new list's items and current item. 146 147 items = new_items 148 item = new_item 149 150 else: 151 # Retrieve an existing list if the level decreases. 152 153 if new_level < level: 154 while level > new_level: 155 156 # Retain a list at the minimum level. 157 158 if above_minimum(min_level, level, max_level): 159 lists.pop() 160 161 level -= 1 162 163 # Obtain the existing list and the current item. 164 165 items = lists[-1].nodes 166 item = items[-1] 167 168 # Add the heading as an item. 169 170 if in_range(min_level, level, max_level): 171 172 indent = level - 1 173 nodes = self.get_entry(heading) 174 175 item = ListItem(nodes, indent, marker, space, None) 176 items.append(item) 177 178 # Augment the macro node with the top-level list. 179 180 self.insert_table(lists and lists[0] or None) 181 182 def insert_table(self, content): 183 184 "Insert the given 'content' into the document." 185 186 macro = self.node 187 parent = macro.parent 188 region = macro.region 189 190 # Set the children of this macro with the generated content. 191 192 macro.nodes = content and [content] 193 macro.inline = False 194 195 # Split any block containing the macro into preceding and following 196 # parts. 197 198 if isinstance(parent, Block): 199 following = parent.split_at(macro) 200 201 # Insert any non-empty following block. 202 203 if not following.whitespace_only(): 204 region.insert_after(parent, following) 205 206 # Insert the new content. 207 208 region.insert_after(parent, macro) 209 210 # Remove any empty preceding block. 211 212 if parent.whitespace_only(): 213 region.remove(parent) 214 215 def find_headings(self, node, headings): 216 217 "Find headings under 'node', adding them to the 'headings' list." 218 219 if node.nodes: 220 for n in node.nodes: 221 if isinstance(n, Heading): 222 headings.append(n) 223 elif isinstance(n, Container): 224 self.find_headings(n, headings) 225 226 def get_entry(self, heading): 227 228 "Return nodes for an entry involving 'heading'." 229 230 target = "#%s" % heading.identifier 231 link_target = parse_link_target(target, self.metadata) 232 link_label = LinkLabel(heading.nodes[:]) 233 234 return [Link([link_label], link_target), Text("\n")] 235 236 macro = TableOfContents 237 238 # vim: tabstop=4 expandtab shiftwidth=4