The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# $Id$
$VERSION{''.__FILE__} = '$Revision$';
#
# >>Title::     HTML Format Driver
#
# >>Copyright::
# Copyright (c) 1992-1999, Ian Clatworthy (ianc@mincom.com).
# You may distribute under the terms specified in the LICENSE file.
#
# >>History::
# -----------------------------------------------------------------------
# Date      Who     Change
# 29-Feb-96 ianc    SDF 2.000
# -----------------------------------------------------------------------
#
# >>Purpose::
# This library provides an [[SDF_DRIVER]] which generates
# [[HTML]] files.
#
# >>Description::
#
# >>Limitations::
# Lists which have ordered items, then unordered items, then
# ordered items all at the same level are output as three
# separate lists. As a result, the numbering in the third list
# restarts even if you don't want it to.
#
# After hypertext jumps have been added throughout a paragraph,
# we should go back over the paragraph and unnest any nested jumps.
#
# >>Resources::
#
# >>Implementation::
#

##### Constants #####

# Mapping table for characters
%_HTML_CHAR = (
    'bullet',       '.',
    'c',            '© ',
    'cent',         '¢ ',
    'dagger',       '^',
    'doubledagger', '#',
    'emdash',       '--',
    'endash',       '-',
    'emspace',      '  ',
    'enspace',      '  ',
    'lbrace',       '{',
    'lbracket',     '[',
    'nbdash',       '-',
    'nbspace',      '  ',
    'nl',           '<BR>',
    'pound',        '&#163; ',
    'r',            '&#174; ',
    'rbrace',       '}',
    'rbracket',     ']',
    'tab',          '&#9; ',
    'tm',           '&#153; ',       # not sure about this
    'yen',          '&#165; ',
);

# Directive mapping table
%_HTML_HANDLER = (
    'tuning',         '_HtmlHandlerTuning',
    'endtuning',      '_HtmlHandlerEndTuning',
    'table',            '_HtmlHandlerTable',
    'row',              '_HtmlHandlerRow',
    'cell',             '_HtmlHandlerCell',
    'endtable',         '_HtmlHandlerEndTable',
    'import',           '_HtmlHandlerImport',
    'inline',           '_HtmlHandlerInline',
    'output',           '_HtmlHandlerOutput',
    'object',           '_HtmlHandlerObject',
    'stylesheet',       '_HtmlHandlerStyleSheet',
    'div',              '_HtmlHandlerDiv',
    'enddiv',           '_HtmlHandlerEndDiv',
);

# Phrase directive mapping table
%_HTML_PHRASE_HANDLER = (
    'char',             '_HtmlPhraseHandlerChar',
    'import',           '_HtmlPhraseHandlerImport',
    'inline',           '_HtmlPhraseHandlerInline',
    'variable',         '_HtmlPhraseHandlerVariable',
);

# Table states
$_HTML_INTABLE = 1;
$_HTML_INROW   = 2;
$_HTML_INCELL  = 3;

# Attribute types - this is used to decide if an attribute is legal,
# and if it is, whether to quote the value (string) or not.
# 'class' types get made into a class attribute.
%_HTML_ATTR_TYPES = (
    'align',    'string',
    'alt',      'string',
    'border',   'integer',
    'class',    'string',
    'changed',  'class',
);

# Attributes mapping to styles
%_HTML_STYLE_MAP = (
    'family',   'font-family: %s',
    'size',     'font-size: %s',
    'bold',     'font-weight: bold',
    'italics',  'font-style: italic',
    'underline','text-decoration: underline',
    'color',    'color: %s',
    'bgcolor',  'background-color: %s',
#   'align',    'text-align: %s',
    'first',    'text-indent: %s',
    'left',     'margin-left: %s',
    'right',    'margin-right: %s',
);

##### Variables #####

# Table/cell states
@_html_tbl_state = ();
@_html_tbl_endtokens = ();
@_html_tbl_previndent = ();
$_html_cell_paracnt = ();

# Stack of topic file offsets and filenames
@_html_topic_offset = ();
@_html_topic_file = ();

# Current topic and level
$_html_topic = '';
$_html_topic_level = 0;

# File/text combinations which start a new topic
%_html_topic_start = ();

# File/text lookup to a jump target
%_html_jump_id = ();

## Ordered list state
#$_html_in_olist = 0;

# Topic counter for building derived topic names
$_html_topic_cntr = 0;

# Meta information for this file
@_html_meta = ();

# Links this file
@_html_links = ();

# Stylesheet for this file
@_html_stylesheet = ();

# Counts for each class attribute
%_html_class_count = ();

# Division stack (contents is the name (i.e. class) of opened divisions)
@_html_divs = ();

# Title division text
@_html_title_div = ();

##### Routines #####

#
# >>Description::
# {{Y:HtmlFormat}} is an SDF driver which outputs HTML.
#
sub HtmlFormat {
    local(*data) = @_;
    local(@result);
    local(@contents);
    local(@data2, @contents2, %var2, @result2);
    local($msg_cursor, %msg_counts);
    local($main);
    local(@topics_table, @jumps_table);

    # Init global data
    $_html_topic = '';
    $_html_topic_level = 0;
    $_html_topic_cntr = 0;
    %_html_topic_start = ();
    %_html_jump_id = ();
    @_html_meta = ();
    @_html_links = ();
    @_html_stylesheet = ();
    %_html_class_count = ();
    @_html_divs = ();
    @_html_title_div = ();

    # If we're building topics, save the data for a second pass later
    if ($SDF_USER'var{'HTML_TOPICS_MODE'}) {
        @data2 = @data;

        # Get the current message cursor - we skip the second pass
        # if errors are found
        $msg_cursor = &AppMsgNextIndex();

    }

    # Format the paragraphs
    @contents = ();
    @result = &_HtmlFormatSection(*data, *contents);

    # Save away any unclosed topics
    while (@_html_topic_file) {
        &_HtmlHandlerOutput(*result, '-');
    }

    # Build the final result.
    ## Note that we must do this AFTER the subtopics stuff in order
    ## to get the next/previous topic data needed for the default
    ## header/footer.
    @result = &_HtmlFinalise(*result, *contents);

    # If there were no problems in the first pass,
    # build the sub-topics, if requested
    %msg_counts = &AppMsgCounts($msg_cursor);
    if ($msg_counts{'error'} || $msg_counts{'abort'} || $msg_counts{'fatal'} ) {
        # do nothing
    }
    elsif ($SDF_USER'var{'HTML_TOPICS_MODE'}) {

        $main = $SDF_USER'var{'DOC_BASE'};
        @topics_table = ();
        @jumps_table = ();
        &_HtmlBuildTopicsData($main, *topics_table, *jumps_table);

        # Save the topics and jump data, so users can (eventually) rebuild
        # just a single topic.
        if ($SDF_USER'var{'HTML_SDJ'}) {
            &_HtmlSaveTopicsData($main, *topics_table, *jumps_table);
        }

        # Initialise things ready for the next pass
        %var2 = %convert_var;      # get the original set of variables
        $var2{'HTML_MAIN_TITLE'} = $SDF_USER'var{'DOC_TITLE'};
        $var2{'HTML_URL_CONTENTS'} = $SDF_USER'var{'DOC_BASE'} . ".html";
        $var2{'HTML_TOPICS_MODE'} = 0;
        $var2{'HTML_SUBTOPICS_MODE'} = 1;
        &SdfInit(*var2);
        &SDF_USER'topics_Filter(*topics_table, 'data', 1);
        &SDF_USER'jumps_Filter(*jumps_table, 'data', 1);

        # Build the sub-topics
        @contents2 = ();
#printf "DATA2:\n%s\nENDDATA2\n", join("\n", @data2);
        @result2 = &_HtmlFormatSection(*data2, *contents2);

        # Save away any unclosed topics
        while (@_html_topic_file) {
            &_HtmlHandlerOutput(*result2, '-');
        }
    }

    # Return the result
    return @result;
}

#
# >>_Description::
# {{Y:_HtmlBuildTopicsData}} builds the topics data
# needed for sub-topic building.
#
sub _HtmlBuildTopicsData {
    local($main, *topics_table, *jumps_table) = @_;
#   local();
    local($topic, $level, $label, $next, $prev, $up, %last_at);
    local($jump, $physical);

    # Ensure that the main topic is first and that it has the highest level
    #if ($SDF_USER'topics[0] eq $main) {
    #    $SDF_USER'levels[0] = 0;
    #}
    #else {
    #    unshift(@SDF_USER'topics, pop(@SDF_USER'topics));
    #    pop(@SDF_USER'levels);
    #    unshift(@SDF_USER'levels, 0);
    #}
    unshift(@SDF_USER'topics, $main);
    unshift(@SDF_USER'levels, 0);

    # Build the topics table
    @topics_table = ("Topic|Label|Level|Next|Prev|Up");
    $prev = $SDF_USER'topics[$#SDF_USER'topics];
    %last_at = ();
    for ($i = 0; $i <= $#SDF_USER'topics; $i++) {
        $topic = $SDF_USER'topics[$i];
        $level = $SDF_USER'levels[$i];
        $label = $SDF_USER'topic_label{$topic};
        $next  = $i < $#SDF_USER'topics ? $SDF_USER'topics[$i + 1] : $SDF_USER'topics[0];
        $up    = $last_at{$level - 1};
        push(@topics_table, "$topic|$label|$level|$next|$prev|$up");

        # Save state for later iterations
        $prev = $topic;
        $last_at{$level} = $topic;
    }

    # Build the jumps table
    @jumps_table = ("Jump|Physical");
    for $jump (sort keys %SDF_USER'jump) {
        $physical = $SDF_USER'jump{$jump};
        push(@jumps_table, "$jump|$physical");
    }
}

#
# >>_Description::
# {{Y:_HtmlSaveTopicsData}} dumps topic and jump data to a file.
#
sub _HtmlSaveTopicsData {
    local($main, *topics_table, *jumps_table) = @_;
#   local();
    local($file);

    # Save the topic and jump data
    $file = &NameJoin('', $main, 'sdj');
    unless (open(SDM, ">$file")) {
        &AppMsg("warning", "unable to update topics file '$file'");
    }
    else {
        # Output a warning message at the top
        print SDM "# WARNING: This file is automatically generated\n";
        print SDM "# by SDF, so any changes you make will be lost!\n";

        # Dump the topics data
        print SDM "\n";
        print SDM "!block topics; data\n";
        print SDM join("\n", @topics_table), "\n";
        print SDM "!endblock\n";

        # Dump the jumps data
        print SDM "\n";
        print SDM "!block jumps\n";
        print SDM join("\n", @jumps_table), "\n";
        print SDM "!endblock\n";

        # Close the file
        close(SDM);
    }
}

#
# >>_Description::
# {{Y:_HtmlFormatSection}} formats a set of SDF paragraphs into HTML.
# If a parameter is passed to contents, then that array is populated
# with a generated Table of Contents. If {{division}} is set, the
# result is placed in a DIV element with that class.
#
sub _HtmlFormatSection {
    local(*data, *contents, $division) = @_;
    local(@result);
    local($prev_tag, $prev_indent);
    local($para_tag, $para_text, %para_attrs);
    local($directive);

    ## Reset the ordered list state. I'm not absolutely sure that
    ## this is the best place to do this, but TJH had it here
    ## and I trust him (most of the time :-)
    #$_html_in_olist = 0;

    # Process the paragraphs
    @result = $division eq '' ? () : ("<DIV CLASS=\"$division\">");
    $prev_tag = '';
    $prev_indent = '';
    while (($para_text, $para_tag, %para_attrs) = &SdfNextPara(*data)) {

        # handle directives
        if ($para_tag =~ /^__(\w+)$/) {
            $directive = $_HTML_HANDLER{$1};
            if (defined &$directive) {
                &$directive(*result, $para_text, %para_attrs);
            }
            else {
                &AppTrace("html", 5, "ignoring internal directive '$1'");
            }
            next;
        }

        # Add the paragraph
        &_HtmlParaAdd(*result, $para_tag, $para_text, *para_attrs, $prev_tag,
          $prev_indent, *contents);
    }

    # Do this stuff before starting next loop iteration
    continue {
        $prev_tag = $para_tag;
        $prev_indent = $para_attrs{'in'};
    }

    ## Filter out the dummy tag we use to get lists right
    #for ($i = 0; $i < $#result; $i++) {
    #    $result[$i] =~ s#</?xL>##g;
    #}

    # Close off the DIV element, if any
    push(@result, "</DIV>") if $division ne '';

    # Return result
    return @result;
}
       
#
# >>_Description::
# {{Y:_HtmlParaAdd}} adds a paragraph.
#
sub _HtmlParaAdd {
    local(*result, $para_tag, $para_text, *para_attrs, $prev_tag, $prev_indent, *contents) = @_;
#   local();
    local($is_example);
    local($para_fmt);
    local($para_override);
    local($para);
    local($hdg_level);
    local($toc_jump);
    local($label);
    local($indent);
    local($list_tag);
    local($note_line_width);
    local($note_attrs);

    # Get the example flag
    $is_example = $SDF_USER'parastyles_category{$para_tag} eq 'example';

    # Enumerated lists are the same as list paragraphs at the previous level,
    # except that we bold the text
    if ($para_tag =~ /^LI(\d)$/) {
        $para_tag = $1 > 1 ? "L" . ($1 - 1) : 'N';
        $para_attrs{'bold'} = 1;
    }

    # Get the target format name
    $para_fmt = $SDF_USER'parastyles_to{$para_tag};
    $para_fmt = $is_example ? 'PRE' : 'P' if $para_fmt eq '';

    # Map the attributes
    &SdfAttrMap(*para_attrs, 'html', *SDF_USER'paraattrs_to,
      *SDF_USER'paraattrs_map, *SDF_USER'paraattrs_attrs,
      $SDF_USER'parastyles_attrs{$para_tag});

    # Build the Table of Contents as we go
    $toc_jump = '';
    if ($para_tag =~ /^([HAP])(\d)$/) {
        $hdg_level = $2;
        my $orig_para_text = $para_text;
        $para_text = &SdfHeadingPrefix($1, $2) . $para_text;
        if ($SDF_USER'var{'HTML_SUBTOPICS_MODE'}) {
            $para_fmt = "H" . substr($para_attrs{'orig_style'}, 1);
        }
        else {
            $para_fmt = "H" . $hdg_level;
        }
        if ($hdg_level <= $SDF_USER'var{'DOC_TOC'} && !$para_attrs{'notoc'}) {

            # Build a plain list in SDF. If we're building topics and we're
            # building the contents, make sure the jumps go to the right spot.
            if ($SDF_USER'var{'HTML_TOPICS_MODE'}) {
                #$toc_jump = &NameJoin('', $SDF_USER'var{'FILE_BASE'}, "html");
                #if ($SDF_USER'topic_label{$SDF_USER'var{'FILE_BASE'}} ne $para_text) {
                #    $toc_jump .= "#" . $para_attrs{'id'};
                #}
                $toc_jump = $_html_jump_id{$SDF_USER'var{'FILE_BASE'},$orig_para_text};
            }
            else {
                $toc_jump = "#" . $para_attrs{'id'};
                $toc_jump = "#HDR" . ($#contents + 1) if $toc_jump eq '#';
            }
            #$toc_jump =~ s/(['\\])/\\$1/g;
            #push(@contents, "L${hdg_level}" . "[jump='$toc_jump']$para_text");
            push(@contents, &SdfJoin("L${hdg_level}", $para_text,
                "jump", $toc_jump));
        }
    }

    # Handle lists
    elsif ($para_tag =~ /^(L[FUN]?)(\d)$/) {
        $para_attrs{'in'} = $2;
        if ($1 eq 'LU') {
            $para_fmt = 'UL';
        }
        elsif ($1 eq 'L') {
            $para_fmt = 'UL PLAIN';
        }
        else {
            $para_fmt = 'OL';
        }
    }

    # Handle user-defined formatting
    if ($para_attrs{'out_style'}) {
        $para_fmt = $para_attrs{'out_style'};
        delete $para_attrs{'out_style'};
    }

    # Prepend the label, if any (replacing tabs with spaces)
    $label = $para_attrs{'label'};
    $label = 'Note: ' if ($para_tag eq 'Note' || $para_tag eq 'NB') &&
             $label eq '';
    $label =~ s/\\t/ /g;
    $para_text = "{{2:$label}}$para_text" if $label ne '';

    # Indent examples, if necessary
    if ($is_example && $para_attrs{'in'}) {
        $para_text = " " x ($para_attrs{'in'} * 4) . $para_text;
        delete $para_attrs{'in'};
    }

    # Format the paragraph body
    if ($para_attrs{'verbatim'}) {
        $para = &_HtmlEscape($para_text);
        delete $para_attrs{'verbatim'};
    }
    else {
        $para = &_HtmlParaText($para_text);
    }

    # Add surrounding lines for a note
    $note_line_width = 80;              # Hard-coded for now
    $note_attrs = "WIDTH=\"$note_line_width%\" ALIGN=\"Left\"";
    if ($para_tag eq 'Note') {
        $para = "<HR $note_attrs>\n$para\n<HR $note_attrs>";
    }
    elsif ($para_tag eq 'NB') {
        $para = "<HR $note_attrs>\n$para";
    }
    elsif ($para_tag eq 'NE') {
        $para = "<HR $note_attrs>";
    }

    # Empty cells look ugly so the hack below
    # puts a space in empty paragraphs inside cells.
    # Unfortunately, this means truly empty paragraphs
    # inside cells are not handled. Is this an issue?
    $para = '&nbsp;' if $para eq '' && @_html_tbl_state;

    ## Examples with change bars currently come out as separate
    ## paragraphs - this fixes the problem, for now
    #delete $para_attrs{'changed'} if $para_attrs{'changed'};

    # Build result
    $indent = $para_attrs{'in'};
    #if ($is_example && $para_tag eq $prev_tag && !%para_attrs) {
    if ($is_example && $para_tag eq $prev_tag) {
        &_HtmlParaAppend(*result, $para);
    }
    elsif ($indent && $prev_indent != 0) {
        $item = &_HtmlElement($para_fmt, $para, %para_attrs);
        &_HtmlItemAppend(*result, $item, $indent, $prev_indent, $para_tag,
          $prev_tag, *para_attrs);
    }

    # If the first paragraph inside a table cell is a plain paragraph,
    # then we do not surrounded it by <P> and </P> as
    # Netscape then outputs too much whitespace.
    elsif (@_html_tbl_state && $_html_cell_paracnt++ == 0 && $para_fmt eq 'P') {
        push(@result, $para);
    }
    else {
        $para = &_HtmlElement($para_fmt, $para, %para_attrs);

        # Handle lists which begin at an indent greater than 1
        $list_tag = substr($para, 1, 2) if $indent;
        while (--$indent > 0) {
            $para = "<$list_tag>$para</$list_tag>";
        }

        # Prepend the table of contents jump id, if necessary
        if ($toc_jump =~ /^#HDR\d+$/) {
            $para = "<A NAME=\"$toc_jump\"> </A>\n$para";
        }
        push(@result, $para);
    }
}

#
# >>_Description::
# {{Y:_HtmlParaText}} converts SDF paragraph text into HTML.
# 
sub _HtmlParaText {
    local($para_text) = @_;
    local($para);
    local($state);
    local($sect_type, $char_tag, $text, %sect_attrs);
    local($url);
    local($added_anchors);
    local(@char_fonts);
    local($char_font);
    local($directive);
    local($char_attrs);

    # Process the text
    $para = '';
    $state = 0;
    while (($sect_type, $text, $char_tag, %sect_attrs) =
      &SdfNextSection(*para_text, *state)) {

        # Build the paragraph
        if ($sect_type eq 'string') {
            $para .= &_HtmlEscape($text);
        }

        elsif ($sect_type eq 'phrase') {

            # Expand out link phrases
            if ($char_tag eq 'L') {
                ($text, $url) = &SDF_USER'ExpandLink($text);
                $sect_attrs{'jump'} = $url;
            }

            # Escape any special characters
            $text = &_HtmlEscape($text);

            # Expand non-breaking spaces, if necessary
            if ($char_tag eq 'S') {
                $text =~ s/ /&nbsp;/g;
            }

            # Empty cells look ugly so the hack below
            # puts a space in empty phrases inside cells.
            # Unfortunately, this means truly empty phrases
            # inside cells are not handled. Is this an issue?
            $text = '&nbsp;' if $text eq '' && @_html_tbl_state;

            # If this is a jump, ignore the style (i.e. make it 'as-is')
            #$char_tag = 'A' if $sect_attrs{'jump'} ne '';

            # Add hypertext stuff
            $added_anchors = &_HtmlAddAnchors(*text, *sect_attrs);

            # Process formatting attributes
            &SdfAttrMap(*sect_attrs, 'html', *SDF_USER'phraseattrs_to,
              *SDF_USER'phraseattrs_map, *SDF_USER'phraseattrs_attrs,
              $SDF_USER'phrasestyles_attrs{$char_tag});
            $char_attrs = &_HtmlAttr(*sect_attrs);
#print STDERR "char_attrs is $char_attrs<\n";

            # Map the font
            $char_font = $SDF_USER'phrasestyles_to{$char_tag};
            $char_font = $char_tag if $char_font eq '' && !$added_anchors;

            # If attributes are specified for an SDF font, use a SPAN
            if ($char_font =~ /^SDF/ && $char_attrs ne '') {
                $char_font = 'SPAN';
            }

            # Add the text for this phrase
            push(@char_fonts, $char_font);
            if ($char_font ne '' && $char_font !~ /^SDF/) {
                $para .= "<$char_font$char_attrs>$text";
            }
            else {
                $para .= $text;
            }
        }

        elsif ($sect_type eq 'phrase_end') {
            $char_font = pop(@char_fonts);
            $para .= "</$char_font>" if $char_font ne '' && $char_font !~ /^SDF/;
        }

        elsif ($sect_type eq 'special') {
            $directive = $_HTML_PHRASE_HANDLER{$char_tag};
            if (defined &$directive) {
                &$directive(*para, $text, %sect_attrs);
            }
            else {
                &AppMsg("warning", "ignoring special phrase '$1' in HTML driver");
            }
        }

        else {
            &AppMsg("warning", "unknown section type '$sect_type' in HTML driver");
        }
    }

    # Return result
    return $para;
}

#
# >>_Description::
# {{Y:_HtmlFinalise}} generates the final HTML file.
#
sub _HtmlFinalise {
    local(*body, *contents) = @_;
#   local(@result);
    local($title, @sdf_title, @title);
    local($version, @head);
    local($body);

    # Build the BODY opening stuff
    $body = "BODY";
    $body .= sprintf(' BACKGROUND="%s"', $SDF_USER'var{"HTML_BG_IMAGE"}) if
                                  defined($SDF_USER'var{"HTML_BG_IMAGE"});
    $body .= sprintf(' BGPROPERTIES="FIXED"') if $SDF_USER'var{"HTML_BG_FIXED"};
    $body .= sprintf(' BGCOLOR="%s"', $SDF_USER'var{"HTML_BG_COLOR"}) if
                               defined($SDF_USER'var{"HTML_BG_COLOR"});
    $body .= sprintf(' TEXT="%s"',    $SDF_USER'var{"HTML_TEXT_COLOR"}) if
                               defined($SDF_USER'var{"HTML_TEXT_COLOR"});
    $body .= sprintf(' LINK="%s"',    $SDF_USER'var{"HTML_LINK_COLOR"}) if
                               defined($SDF_USER'var{"HTML_LINK_COLOR"});
    $body .= sprintf(' VLINK="%s"',   $SDF_USER'var{"HTML_VLINK_COLOR"}) if
                               defined($SDF_USER'var{"HTML_VLINK_COLOR"});

    # Convert the title, if any, to HTML
    $title = $SDF_USER'var{'HTML_TITLE'};
    $title = $SDF_USER'var{'DOC_TITLE'} if !defined($title);
    if ($title) {
        @sdf_title = ("TITLE:$title");
        @title = &_HtmlFormatSection(*sdf_title, *dummy);
    }
    else {
        @title = ();
    }

    # Prepend some useful things to the stylesheet, if applicable
    if ($_html_class_count{'changed'}) {
        my $changed_color = $SDF_USER'var{'HTML_CHANGED_COLOR'};
        unshift(@_html_stylesheet,
          ".changed {background-color: $changed_color}");
    }

    # Build the HEAD element (and append BODY opening)
    $version = $SDF_USER'var{'SDF_VERSION'};
    @head = (
        '<!doctype html public "-//W30//DTD W3 HTML 2.0//EN">',
        '',
        '<HTML>',
        '',
        "<!-- This file was generated using SDF $version by",
        '     Ian Clatworthy (ianc@mincom.com). SDF is freely',
        '     available from http://www.mincom.com/mtr/sdf. -->',
        '',
        '<HEAD>',
    );
    push(@head, @title) if @title;
    push(@head, @_html_meta) if @_html_meta;
    push(@head, @_html_links) if @_html_links;
    if (@_html_stylesheet) {
        push(@head,
            '<STYLE TYPE="text/css">',
            '<!--',
            @_html_stylesheet,
            '-->',
            '</STYLE>',
        );
    }
    push(@head, '</HEAD>', "<$body>", '');

    # Build the body contents, unless we're generating an input file for
    # the HTMLDOC package
    unless ($SDF_USER'var{'HTMLDOC'}) {
        &_HtmlFinaliseBodyContents(*body, *contents);
    }

    # Return result
    push(@body, '', '</BODY>', '</HTML>');
    return (@head, @body);
}

#
# >>_Description::
# {{Y:_HtmlFinaliseBodyContents}} generates the final BODY contents.
#
sub _HtmlFinaliseBodyContents {
    local(*body, *contents) = @_;
#   local(@result);
    local($macro, @header, @footer);
    local(@dummy);
    local(@html_contents);
    #local($rec, $toc_posn);

    # Wrap the main body in a main division
    unshift(@body, '<DIV CLASS="main">');
    push(@body, '</DIV>');

    # Prepend the Table of Contents, if any
    if (@contents) {

        # Finish formatting the table of contents
        # Note: we use a filter so that experts can override things!
        &SDF_USER'toc_html_Filter(*contents);

        # Now convert it to HTML
        @html_contents = &_HtmlFormatSection(*contents, *dummy, 'contents');

        # If this is a MAIN document, make the body the contents
        # (i.e. ditch the contents). Otherwise, prepend it.
        if ($SDF_USER'var{'HTML_TOPICS_MODE'}) {
            @body = @html_contents;
        }
        else {
            unshift(@body, join("\n", @html_contents));
        }
    }

    # If this is not a topic, prepend the title division, if any
    unless ($SDF_USER'var{'HTML_SUBTOPICS_MODE'}) {
        unshift(@body, @_html_title_div);
    }

    # Convert the header, if any, to HTML
    $macro = 'HTML_HEADER';
    if ($SDF_USER'var{'HTML_SUBTOPICS_MODE'} &&
        $SDF_USER'macro{'HTML_TOPIC_HEADER'}) {
        $macro = 'HTML_TOPIC_HEADER';
    }
    if ($SDF_USER'macro{$macro} ne '') {
        @header = ("!$macro");
        unshift(@body, &_HtmlFormatSection(*header, *dummy, 'header'));
    }

    # Convert the footer, if any, to HTML
    $macro = 'HTML_FOOTER';
    if ($SDF_USER'var{'HTML_SUBTOPICS_MODE'} &&
        $SDF_USER'macro{'HTML_TOPIC_FOOTER'}) {
        $macro = 'HTML_TOPIC_FOOTER';
    }
    if ($SDF_USER'macro{$macro} ne '') {
        @footer = ("!$macro");
        push(@body, &_HtmlFormatSection(*footer, *dummy, 'footer'));
    }

    # Add the pre-header and post-footer, if any
    my $pre_header = $SDF_USER'var{'HTML_PRE_HEADER'};
    unshift(@body, $pre_header) if $pre_header ne '';
    my $post_footer = $SDF_USER'var{'HTML_POST_FOOTER'};
    push(@body, $post_footer) if $post_footer ne '';
}

#
# >>_Description::
# {{Y:_HtmlEscape}} escapes special symbols in HTML text.
# 
sub _HtmlEscape {
    local($text) = @_;
#   local($result);
    local($old_match_flag);

    # Enable multi-line matching
    $old_match_flag = $*;
    $* = 1;

    # Escape the symbols
    $text =~ s/\&/&amp;/g;
    $text =~ s/\</&lt;/g;
    $text =~ s/\>/&gt;/g;
    $text =~ s/\"/&quot;/g;

    # Reset multi-line matching flag
    $* = $old_match_flag;

    # Return result
    $text;
}

#
# >>_Description::
# {{Y:_HtmlAttr}} formats a set of attributes into HTML.
# 
sub _HtmlAttr {
    local(*attrs) = @_;
    local($html);
    local($attr, $value, $type);
    local($style);
    local($style_map);

    # get the specified style info, if any
    $style = $attrs{'style'};
    delete $attr{'style'};

    for $attr (sort keys %attrs) {

        # get the attribute value
        $value = $attrs{$attr};

        # get the attribute type & map to style info
        if ($attr =~ s/^html\.//) {
            $type = $_HTML_ATTR_TYPES{$attr};
            $type = "string" if $type eq '';
        }
        else {
            $type = $_HTML_ATTR_TYPES{$attr};
#print STDERR "attr: $attr, type: $type<\n";
        }
        $style_map = $_HTML_STYLE_MAP{$attr};
        if ($style_map ne '') {
            $style .= '; ' if $style ne '';
#           if ($attr eq 'align') {
#               if ($value eq '1' || $value eq 'Full') {
#                   $value = 'justify';
#               }
#               else {
#                   $value = "\l$value";
#               }
#           }
            $style .= sprintf($style_map, $value);
        }
        next unless $type;

        # build the result (using uppercase attribute names)
        if ($type eq 'string') {
            $attr =~ tr/a-z/A-Z/;
            $value = &_HtmlEscape($value);
            $html .= " $attr=\"" . $value . '"';
        }
        elsif ($type eq 'class') {
            $value = $attr;
            $attr = 'CLASS';
            $html .= " $attr=\"" . $value . '"';
        }
        else {
            $attr =~ tr/a-z/A-Z/;
            $html .= " $attr=$value";
        }

        # Keep some stats on classes used so we can conditionally
        # add things to the stylesheet later
        $_html_class_count{$value}++ if $attr eq 'CLASS';
    }

    # Add the style info, if any
    if ($style ne '') {
            $html .= " STYLE=\"" . &_HtmlEscape($style) . '"';
    }
        
    # Return result
    $html;
}

#
# >>_Description::
# {{Y:_HtmlElement}} formats a HTML element from a
# tag, text and set of attributes.
#
sub _HtmlElement {
    local($tag, $text, %attr) = @_;
#   local($html);

    # For preformatted sections, tags go on separate lines
    $text = "\n$text\n" if $tag eq 'PRE';

    # Add hypertext stuff
    &_HtmlAddAnchors(*text, *attr);

    # Bold the text, if requested
    if ($attr{'bold'}) {
        $text = "<B>$text</B>";
    }

    # For list items, add the item stuff
    #$text = "\n<LI>$text" if $tag =~ /^[UOx]L$/;
    $text = "\n<LI>$text" if $tag =~ /^[UO]L$/;
    if ($tag eq 'UL PLAIN') {
        $tag = 'UL';
        $text = "\n$text";
    }

    # Return result
    if ($tag eq 'HR') {
        return "<$tag>$text";
    }
    else {
        return "<$tag" . &_HtmlAttr(*attr) . ">$text</$tag>";
    }
}

#
# >>_Description::
# {{Y:_HtmlAddAnchors}} adds hypertext jumps and ids to a section of text.
# of text. It returns true if anchors were added.
# 
sub _HtmlAddAnchors {
    local(*text, *attr) = @_;
    local($result);
    local($value);
    local($user_ext);
    local($old_match_flag);

    # Enable multi-line matching
    $old_match_flag = $*;
    $* = 1;

    # For hypertext jumps, surround the text. If the
    # text contains a jump, the existing jump is removed.
    if ($attr{'jump'} ne '') {

        # Get the jump value. If an extension other than html is
        # requested, change the jump value accordingly. Also,
        # we make sure than any special characters are escaped.
        $value = $attr{'jump'};
        $user_ext = $SDF_USER'var{'HTML_EXT'};
        if ($user_ext) {
            $value =~ s/\.html/.$user_ext/;
        }
        $value = &_HtmlEscape($value);

        $text =~ s/\<A HREF\=[^>]+\>(.*)\<\/A\>/$1/;
        $text = "<A HREF=\"$value\">$text</A>";
        delete $attr{'jump'};
        $result++;
    }

    # For hypertext ids, surround the text if it doesn't already contain
    # a jump. Otherwise, prefix the text with a dummy target so that
    # jump and id definitions don't clash.
    if ($attr{'id'} ne '') {
        $value = &_HtmlEscape($attr{'id'});
        if ($text =~ /\<A /) {
            $text = "<A NAME=\"$value\"> </A>$text";
        }
        else {
            $text = "<A NAME=\"$value\">$text</A>";
        }
        delete $attr{'id'};
        $result++;
    }

    # Reset multi-line matching flag
    $* = $old_match_flag;

    # Return result
    return $result;
}

#
# >>_Description::
# {{Y:_HtmlParaAppend}} merges {{para}} into the last paragraph
# in {{@result}}. Both paragraphs are assumed to be PREformatted.
#
sub _HtmlParaAppend {
    local(*result, $para) = @_;
#   local();

    substr($result[$#result], -6) = "$para\n</PRE>";
}

#
# >>_Description::
# {{Y:_HtmlItemAppend}} merges a list item {{item}} into the current
# output. The item before is assumed to be a list item too.
#
sub _HtmlItemAppend {
    local(*result, $item, $indent, $prev_indent, $para_tag, $prev_tag, *para_attrs) = @_;
#   local();
    local($type, $prev_type);
    local($posn, $end_tokens);

    # Get the list type and previous type
    $type = substr($item, 1, 2);
    $prev_type = substr($result[$#result], -3, 2);

    # Indent is increasing
    if ($indent > $prev_indent) {
        $posn = -5 * $prev_indent;
        while (++$prev_indent < $indent) {
            $item = "<$type>$item</$type>";
        }
    }

    # Indent is descreasing or the same
    else {
        # plain items are compatible with both ordered and unordered lists, so
        # we need to handle them separately
        if (substr($item, 4, 5) ne "\n<LI>") {
            $posn = -5 * $indent;
            $item = substr($item, 4, length($item) - 9);
            $item = "\n<BR>$item";

            ## If the previous tag is the same but the indents differ,
            ## this is an enumerated list item so prepend another newline.
            #if ($para_tag eq $prev_tag && $prev_indent != $indent) {
            #    $item = "\n<BR>$item";
            #}
        }

        # handle items of an existing list
        elsif ($type eq $prev_type) {
            $posn = -5 * $indent;
            $item = substr($item, 4, length($item) - 9);
        }

        # item is not compatible with the current list - start a new one
        else {
            $indent--;
            $posn = $indent ? (-5 * $indent) : length($result[$#result]);
        }
    }

    # Merge the item
    $end_tokens = substr($result[$#result], $posn);
    substr($result[$#result], $posn) = "$item$end_tokens";
}

#
# >>_Description::
# {{Y:_HtmlHandlerTuning}} handles the 'tuning' directive.
#
sub _HtmlHandlerTuning {
    local(*outbuffer, $style, %attr) = @_;
#   local();

    # do nothing
}

#
# >>_Description::
# {{Y:_HtmlHandlerEndTuning}} handles the 'endtuning' directive.
#
sub _HtmlHandlerEndTuning {
    local(*outbuffer, $style, %attr) = @_;
#   local();

    # do nothing
}

#
# >>_Description::
# {{Y:_HtmlHandlerTable}} handles the 'table' directive.
#
sub _HtmlHandlerTable {
    local(*outbuffer, $columns, %attr) = @_;
#   local();
    local($indent, $previous_indent, $posn, $begin_tokens, $end_tokens);
    local($header);
    local($tbl_title);

    # Handle tables inside a list
    # Note: the previous indent is available as a dynamically
    # scoped variable in &HtmlFormatSection
    $indent = $attr{'listitem'};
    $begin_tokens = '';
    $end_tokens = '';
    if ($indent) {
        $previous_indent = $prev_indent;    # get dynamically scoped var
        if ($indent > $previous_indent) {
            $posn = -5 * $previous_indent;
            while ($previous_indent++ < $indent) {
                $begin_tokens .= "<UL>";
                $end_tokens .= "</UL>";
            }
        }
        else {
            $posn = -$indent * 5;
        }
        if ($posn < 0) {
            $end_tokens .= substr($outbuffer[$#outbuffer], $posn);
            substr($outbuffer[$#outbuffer], $posn) = $begin_tokens;
        }
        else {
            push(@outbuffer, $begin_tokens);
        }
    }
    
    # Update the state
    push(@_html_tbl_state, $_HTML_INTABLE);
    push(@_html_tbl_endtokens, $end_tokens);
    push(@_html_tbl_previndent, $indent);

    # Build the header
    $header = ' CLASS="' . &_HtmlEscape($attr{'style'}) . '"';
    if ($attr{'style'} ne 'plain') {
        $header .= " BORDER";
    }
    if (defined($attr{'cellspacing'})) {
        $header .= " CELLSPACING='$attr{'cellspacing'}'";
    }
    if (defined($attr{'cellpadding'})) {
        $header .= " CELLPADDING='$attr{'cellpadding'}'";
    }
    if ($attr{'align'}) {
        my $align = $attr{'align'};
        $align = 'Left'  if $align eq 'Inner';
        $align = 'Right' if $align eq 'Outer';
        $header .= " ALIGN='$align'";
    }
    if ($attr{'bgcolor'}) {
        $header .= " BGCOLOR='$attr{'bgcolor'}'";
    }

    # Update the output buffer
    push(@outbuffer, "<TABLE" . $header . ">");

    # Add the title, if any
    $tbl_title = $attr{'title'};
    if ($tbl_title ne '') {
        push(@outbuffer, "<CAPTION ALIGN=top>" . $tbl_title . "</CAPTION>");
    }
}

#
# >>_Description::
# {{Y:_HtmlHandlerRow}} handles the 'row' directive.
#
sub _HtmlHandlerRow {
    local(*outbuffer, $text, %attr) = @_;
#   local();
    local($state);

    # Finalise the old cell/row, if any
    $state = $_html_tbl_state[$#_html_tbl_state];
    if ($state eq $_HTML_INCELL) {
        push(@outbuffer, "</TD>", "</TR>");
    }
    elsif ($state eq $_HTML_INROW) {
        push(@outbuffer, "</TR>");
    }

    # Update the state
    $_html_tbl_state[$#_html_tbl_state] = $_HTML_INROW;

    # Update the output buffer
    if ($text ne '' && $text ne 'Body') {
        push(@outbuffer, "<TR CLASS=\"\l$text\">");
    }
    else {
        push(@outbuffer, "<TR>");
    }
}

#
# >>_Description::
# {{Y:_HtmlHandlerCell}} handles the 'cell' directive.
#
sub _HtmlHandlerCell {
    local(*outbuffer, $text, %attr) = @_;
#   local();
    local($state);
    local($header);

    # Reset the paragraph counter for this cell
    $_html_cell_paracnt = ();

    # If the cell is hidden, output nothing
    return if $attr{'hidden'};

    # Finalise the old cell, if any
    $state = $_html_tbl_state[$#_html_tbl_state];
    if ($state eq $_HTML_INCELL) {
        push(@outbuffer, "</TD>");
    }

    # Update the state
    $_html_tbl_state[$#_html_tbl_state] = $_HTML_INCELL;

    # Build the header
    $header = '';
    if (defined($attr{'align'})) {
        $header .= " ALIGN='$attr{'align'}'";
    }
    if (defined($attr{'valign'})) {
        $header .= " VALIGN='$attr{'valign'}'";
    }
    if ($attr{'cols'} != 1) {
        $header .= " COLSPAN='$attr{'cols'}'";
    }
    if ($attr{'rows'} != 1) {
        $header .= " ROWSPAN='$attr{'rows'}'";
    }
    if (defined($attr{'nowrap'})) {
        $header .= " NOWRAP";
    }
    if (defined($attr{'bgcolor'})) {
        $header .= " BGCOLOR='$attr{'bgcolor'}'";
    }

    # Update the output buffer
    push(@outbuffer, "<TD$header>");
}

#
# >>_Description::
# {{Y:_HtmlHandlerEndTable}} handles the 'endtable' directive.
#
sub _HtmlHandlerEndTable {
    local(*outbuffer, $text, %attr) = @_;
#   local();
    local($state);
    local($end_tokens);

    # Finalise the table
    $state = pop(@_html_tbl_state);
    if ($state eq $_HTML_INCELL) {
        push(@outbuffer, "</TD>", "</TR>");
    }
    elsif ($state eq $_HTML_INROW) {
        push(@outbuffer, "</TR>");
    }
    push(@outbuffer, "</TABLE>");

    # Terminate the list, if any
    $end_tokens = pop(@_html_tbl_endtokens);
    push(@outbuffer, $end_tokens);

    # Restore the previous indent. We do this by hacking the
    # %para_attrs hash dynamically scoped in &HtmlFormatSection. :-(
    $para_attrs{'in'} = pop(@_html_tbl_previndent);
}

#
# >>_Description::
# {{Y:_HtmlHandlerImport}} handles the import directive.
#
sub _HtmlHandlerImport {
    local(*outbuffer, $filepath, %attr) = @_;
#   local();
    local($para);

    # Build the result
    &_HtmlPhraseHandlerImport(*para, $filepath, %attr);
    push(@outbuffer, &_HtmlElement('P', $para));
}

#
# >>_Description::
# {{Y:_HtmlHandlerInline}} handles the inline directive.
#
sub _HtmlHandlerInline {
    local(*outbuffer, $text, %attr) = @_;
#   local();

    # Check we can handle this format
    my $target = $attr{'target'};
    return unless $target eq 'html';

    # Build the result
    push(@outbuffer, $text);
}

#
# >>_Description::
# {{Y:_HtmlHandlerOutput}} handles the output directive.
#
sub _HtmlHandlerOutput {
    local(*outbuffer, $text, %attr) = @_;
#   local();
    local($offset, @topic_data, @dummy_contents);
    local($file);
    local($this_topic);

    # Finalise the current topic, if requested
    if ($text eq '-') {
        # If there is no current topic, do nothing
        return unless @_html_topic_offset;

        # Generate the html for the topic
        $offset = pop(@_html_topic_offset);
        @topic_data = splice(@outbuffer, $offset + 1);
#printf "TOPIC:\n%s\nENDTOPIC\n", join("\n", @topic_data);
        @topic_data = &_HtmlFinalise(*topic_data, *dummy_contents);

        # Output the topic
        $file = pop(@_html_topic_file);
#print STDERR "offset: $offset, file: $file.\n";
        unless (open(TOPIC, ">$file")) {
            &AppMsg("error", "unable to write to topic file '$file'");
            return;
        }
        print TOPIC join("\n", @topic_data), "\n";
        close(TOPIC);
    }

    # Otherwise, save the output filename and the current offset
    else {
        push(@_html_topic_file, $text);
        push(@_html_topic_offset, $#outbuffer);
    }

    # Update the current topic name (without the extension)
    $this_topic = $_html_topic_file[$#_html_topic_file];
    $this_topic =~ s/\.html$//;
    $SDF_USER'var{'HTML_TOPIC'} = $this_topic;
#print STDERR "HTML_TOPIC: $this_topic.\n";
}

#
# >>_Description::
# {{Y:_HtmlHandlerObject}} handles the 'object' directive.
#
sub _HtmlHandlerObject {
    local(*outbuffer, $text, %attrs) = @_;
#   local();

    if ($text eq 'meta') {
        push(@_html_meta, "<META " .
            "NAME=\""    . &_HtmlEscape($attrs{'Name'})    . '" ' .
            "CONTENT=\"" . &_HtmlEscape($attrs{'Content'}) . '">');
    }
    if ($text eq 'link') {
        push(@_html_links, "<LINK " .
            "REL=\""  . &_HtmlEscape($attrs{'Relationship'}) . '" ' .
            "TYPE=\"" . &_HtmlEscape($attrs{'Type'})         . '" ' .
            "HREF=\"" . &_HtmlEscape($attrs{'Jump'})         . '">');
    }
}

#
# >>_Description::
# {{Y:_HtmlHandlerStyleSheet}} handles the 'stylesheet' directive.
#
sub _HtmlHandlerStyleSheet {
    local(*outbuffer, $text, %attrs) = @_;
#   local();

        push(@_html_stylesheet, $text);
}

#
# >>_Description::
# {{Y:_HtmlHandlerDiv}} handles the 'div' directive.
#
sub _HtmlHandlerDiv {
    local(*outbuffer, $text, %attrs) = @_;
#   local();

    push(@_html_divs, $text);
    push(@outbuffer, '<DIV CLASS="' . &_HtmlEscape($text) . '">');
}

#
# >>_Description::
# {{Y:_HtmlHandlerEndDiv}} handles the 'enddiv' directive.
#
sub _HtmlHandlerEndDiv {
    local(*outbuffer, $text, %attrs) = @_;
#   local();

    push(@outbuffer, '</DIV>');

    # If we've just finished building the title, save it away
    # and re-initialise the buffer
    if (pop(@_html_divs) eq 'title') {
        @_html_title_div = @outbuffer;
        @outbuffer = ();
    }
}

#
# >>_Description::
# {{Y:_HtmlPhraseHandlerChar}} handles the 'char' phrase directive.
#
sub _HtmlPhraseHandlerChar {
    local(*para, $text, %attr) = @_;
#   local();

    # Map the symbolic names
    if (defined($_HTML_CHAR{$text})) {
        $para .= $_HTML_CHAR{$text};
    }
    else {
        # Numbers are ISO character codes
        $para .= $text =~ /\D/ ? "&$text;" : "&#$text;";
    }
}

#
# >>_Description::
# {{Y:_HtmlPhraseHandlerImport}} handles the 'import' phrase directive.
#
sub _HtmlPhraseHandlerImport {
    local(*para, $filepath, %attr) = @_;
#   local();
    local($name, $value);
    local($jump);
    local($pre, $post);

    if ( $attr{'align'} eq 'center' ) {
    	$pre='<CENTER>';
    	$post='</CENTER>';
    } else {
    	$pre='';
    	$post='';
    }

    # Map the attributes to HTML
    while (($name, $value) = each %attr) {
        # Simple for now
        delete $attr{$name} if $name eq 'fullname';
        delete $attr{$name} if $name eq 'width';
        delete $attr{$name} if $name eq 'height';
    }

    # Build the result
    $para .= $pre;
    if ($attr{'jump'} ne '') {
        $jump = $attr{'jump'};
        delete $attr{'jump'};

        # Disable the border unless it is explicitly asked for
        $attr{'border'} = 0 unless $attr{'border'};

        $para .= "<A HREF=\"$jump\">" .
                 "<IMG SRC=\"$filepath\"". &_HtmlAttr(*attr) . "></A>";
    }
    else {
        $para .= "<IMG SRC=\"$filepath\"". &_HtmlAttr(*attr) . ">";
    }
    $para .= $post;
}

#
# >>_Description::
# {{Y:_HtmlPhraseHandlerInline}} handles the 'inline' phrase directive.
#
sub _HtmlPhraseHandlerInline {
    local(*para, $text, %attr) = @_;
#   local();

    # Build the result
    $para .= $text;
}

#
# >>_Description::
# {{Y:_HtmlPhraseHandlerVariable}} handles the 'variable' phrase directive.
#
sub _HtmlPhraseHandlerVariable {
    local(*para, $text, %attr) = @_;
#   local();

    # do nothing
}
package SDF_USER;

#
# >>Description::
# {{Y:HtmlTopicsModeHeading}} is an event processing routine for
# headings when topics mode is enabled.
#
sub HtmlTopicsModeHeading {
#   local() = @_;
#   local();
    local($level, $file_base);
    local($jump);
    local($topic_base, $topic_file);
    local(@prepend);
    local($new_level, $close_count, $i);
    local($title);

    # As the heading for the main document might be built in the
    # "front" component, we explicitly ignore headings in sdm files
    return if $var{'FILE_EXT'} eq 'sdm';

    # If this heading doesn't have an id, ignore it
    return if $attr{'noid'};

    # Get the heading level and containing file
    $level = substr($style, 1, 1);
    $file_base = $var{'FILE_BASE'};

    # When processing the main file:
    # * detect the first heading in each SDF file or a
    #   certain level heading as a topic boundary (and save
    #   the heading text as the label for that topic)
    # * save the file each section lives in, so that
    #   section jumps work as expected
    if ($var{'HTML_TOPICS_MODE'}) {
        if (! $topic_label{$file_base} && $file_base ne $var{'DOC_BASE'}) {
            $'_html_topic = $file_base;
            push(@levels, $level);
            push(@topics, $'_html_topic);
            $topic_label{$'_html_topic} = $text;
            $topic_level{$'_html_topic} = $level;
            $jump = $'_html_topic . ".html";
            $'_html_topic_start{$file_base,$text} = $'_html_topic;
        }
        elsif ($level <= $var{'OPT_SPLIT_LEVEL'}) {
            if ($'_html_topic_start{$file_base,$text}) {
                &'AppMsg("warning", "file base '$file_base' & topic heading '$text' combination is not unique'");
                return;
            }
            $'_html_topic = &HtmlTopicName($var{'DOC_BASE'});
            push(@levels, $level);
            push(@topics, $'_html_topic);
            $topic_label{$'_html_topic} = $text;
            $topic_level{$'_html_topic} = $level;
            $jump = $'_html_topic . ".html";
            $'_html_topic_start{$file_base,$text} = $'_html_topic;
        }
        else {
            if ($attr{'id'} ne '') {
                $jump = $'_html_topic . ".html#" . $attr{"id"};
            }
            else {
                $jump = $'_html_topic . ".html#" . &TextToId($text);
            }
        }

        # Save the jump for this file/text combination.
        # This is used for TOC generation.
        $'_html_jump_id{$file_base,$text} = $jump;

        # Save the place to jump to for this text.
        # The jump table is used to resolve SECT jumps (in topics mode).
        if ($jump{$text} eq '') {
            $jump{$text} = $jump;
            $jump_level{$text} = $level;
        }
        else {
            # Override the jump if the new jump is more important
            if ($level < $jump_level{$text}) {
                $jump{$text} = $jump;
                $jump_level{$text} = $level;
            }
        }
    }

    # Otherwise, we're creating sub-topics
    else {
        # If this heading starts a topic:
        # * prepend the necessary output directives
        # * make it the title
        # * prevent a line above it by setting the notoc attribute.
        $topic_base = $'_html_topic_start{$file_base,$text};
        if ($topic_base) {
            $topic_file = "$topic_base.html";
            @prepend = ();
            $new_level = $topic_level{$topic_base};
            $close_count = $'_html_topic_level - $new_level + 1;
            $'_html_topic_level = $new_level;
            for ($i = 0; $i < $close_count; $i++) {
                push(@prepend, "!output '-'");
            }
            $title = $text;
            $title =~ s/(['\\])/\\$1/g;
            push(@prepend,
                "[jump='$topic_file'] $text",
                "!output '$topic_file'",
                #"!define HTML_TOPIC '$topic_base'",
                "!define DOC_TITLE '$title'",
                "!HTML_BUILD_TITLE");
            &PrependText(@prepend);
            $attr{'notoc'} = 1;
        }
    }
}

# Generate a name for a topic
sub HtmlTopicName {
    local($base) = @_;
    local($tname);

    $'_html_topic_cntr++;
    $tname = $var{'HTML_TOPIC_PATTERN'};
    $tname = '$b_$n' if $tname eq '';
    $tname =~ s/\$b/$base/g;
    $tname =~ s/\$n/$'_html_topic_cntr/;
    return $tname;
}

# package return value
1;