MoinLight

Annotated moinformat/macros/toc.py

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