1 #!/usr/bin/env python 2 3 """ 4 Table of contents macro. 5 6 Copyright (C) 2018 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, List, \ 24 ListItem, Text 25 26 def in_range(min_level, level, max_level): 27 28 """ 29 Test that 'min_level' <= 'level' <= 'max_level', only imposing tests 30 involving limits not set to None. 31 """ 32 33 return (min_level is None or min_level <= level) and \ 34 (max_level is None or level <= max_level) 35 36 def above_minimum(min_level, level, max_level): 37 38 """ 39 Test that 'min_level' < 'level' <= 'max_level', only imposing tests 40 involving limits not set to None. 41 """ 42 43 return (min_level is None or min_level < level) and \ 44 (max_level is None or level <= max_level) 45 46 class TableOfContents(Macro): 47 48 "A table of contents macro." 49 50 name = "TableOfContents" 51 52 def evaluate(self): 53 54 "Evaluate the macro, producing a table of contents." 55 56 arglist = [] 57 _defaults = [None] * 2 58 59 for arg, default in map(None, self.node.args, _defaults): 60 if arg is not None: 61 try: 62 arg = max(1, int(arg.strip())) 63 except ValueError: 64 arg = None 65 arglist.append(arg) 66 67 self.make_table(arglist[0], arglist[1]) 68 69 def make_table(self, min_level=None, max_level=None): 70 71 """ 72 Make a table of contents with the given 'min_level' and 'max_level' of 73 headings. 74 """ 75 76 headings = [] 77 self.find_headings(self.doc, headings) 78 79 if not headings: 80 return 81 82 # Common list features. 83 84 marker = "1." 85 space = " " 86 num = "1" 87 88 # Start with no lists, no current item. 89 90 lists = [] 91 item = None 92 level = 0 93 94 for heading in headings: 95 new_level = heading.level 96 97 # Create new lists if the level increases. 98 99 if new_level > level: 100 while level < new_level: 101 level += 1 102 103 # Ignore levels outside the range of interest. 104 105 if not in_range(min_level, level, max_level): 106 continue 107 108 # Determine whether the heading should be generated at this 109 # level or whether there are intermediate levels being 110 # produced. 111 112 nodes = level == new_level and self.get_entry(heading) or [] 113 indent = level - 1 114 115 # Create a new item for the heading or sublists. 116 117 new_item = ListItem(nodes, indent, marker, space, None) 118 119 # Either revive an existing list. 120 121 if level == min_level and lists: 122 new_list = lists[-1] 123 new_items = new_list.nodes 124 125 # Or make a list and add an item to it. 126 127 else: 128 new_items = [] 129 new_list = List(new_items, indent, marker, num) 130 131 # Add the list to the current item, if any. 132 133 if item: 134 item.nodes.append(new_list) 135 136 # Record the new list. 137 138 lists.append(new_list) 139 140 # Add the item to the new or revived list. 141 142 new_items.append(new_item) 143 144 # Reference the new list's items and current item. 145 146 items = new_items 147 item = new_item 148 149 else: 150 # Retrieve an existing list if the level decreases. 151 152 if new_level < level: 153 while level > new_level: 154 155 # Retain a list at the minimum level. 156 157 if above_minimum(min_level, level, max_level): 158 lists.pop() 159 160 level -= 1 161 162 # Obtain the existing list and the current item. 163 164 items = lists[-1].nodes 165 item = items[-1] 166 167 # Add the heading as an item. 168 169 if in_range(min_level, level, max_level): 170 171 indent = level - 1 172 nodes = self.get_entry(heading) 173 174 item = ListItem(nodes, indent, marker, space, None) 175 items.append(item) 176 177 # Replace the macro node with the top-level list. 178 179 self.insert_table(lists[0]) 180 181 def insert_table(self, content): 182 183 "Insert the given 'content' into the document." 184 185 macro = self.node 186 parent = macro.parent 187 region = macro.region 188 189 # Replace the macro if it is not inside a block. 190 # NOTE: This attempts to avoid blocks being used in inline-only contexts 191 # NOTE: but may not be successful in every case. 192 193 if not isinstance(parent, Block) or parent is region: 194 parent.replace(macro, content) 195 196 # Split any block containing the macro into preceding and following 197 # parts. 198 199 else: 200 following = parent.split_at(macro) 201 202 # Insert any non-empty following block. 203 204 if not following.whitespace_only(): 205 region.insert_after(parent, following) 206 207 # Insert the new content. 208 209 region.insert_after(parent, content) 210 211 # Remove any empty preceding block. 212 213 if parent.whitespace_only(): 214 region.remove(parent) 215 216 def find_headings(self, node, headings): 217 218 "Find headings under 'node', adding them to the 'headings' list." 219 220 if node.nodes: 221 for n in node.nodes: 222 if isinstance(n, Heading): 223 headings.append(n) 224 elif isinstance(n, Container): 225 self.find_headings(n, headings) 226 227 def get_entry(self, heading): 228 229 "Return nodes for an entry involving 'heading'." 230 231 return [Link(heading.nodes[:], "#%s" % heading.identifier), Text("\n")] 232 233 macro = TableOfContents 234 235 # vim: tabstop=4 expandtab shiftwidth=4