aneamal/html.php

<?php

/* Copyright 2010-2024 Martin Janecke <martin@aneamal.org>
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

// declare (strict_types = 1); // only during development

namespace prlbr\aneamal;

const vhtml = '31';

require __DIR__ . '/func.php';

class nml2html {

/* Constants
 */

// identifiers for different kinds of Aneamal files
private const aside = 'a';
private const embedded = 'e'; // code block with tag [a] above
private const footer = 'f';
private const header = 'h';
public const linked = 'l';    // file linked to with file tag like [a]->...
public const main = 'm';      // file requested by reader, loaded via main.php
private const quoted = 'q';      // quotation block
private const settings = 's'; // implied @meta.nml or declared as @meta: ->...
private const template = 't'; // /aneamal/a-.../index.nml referenced [a-...]

// identifiers for different kinds of metadata values
private const text = 0;
private const link = 1;
private const embd = 2;

// style for error messages and default URI for more error feedback
private const error_base_url = 'https://aneamal.org/error/';
private const error_style = 'background:#FA0;color:#000;padding:1ex 1em';

// the directory where to save preview images and their default size setting,
// see method Preview::coordinates in func.php for details
public const pixdir = '/aneamal/public/jpeg';
public const pixsize = '-640,-640';

// letters and digits, whitespace
private const alphanumeric = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
private const space = "\t\n\x20";

// maximum number of file_aneamal calls to prevent infinite recursion
private const max_inclusions = 256;

// default maximum size of text file included with [a], [h], [t], [b] etc.
private const default_textcap = 262144; // 2^18 Byte = 256 KiB

// string markup whose content is not interpreted as containing Aneamal markup;
// the array uses the format 'left mark' => 'right mark'
private const masks = ['|' => '|', '$' => '$', '{' => '}', '->`' => '`', '->!`' => '`'];

// brackets create strings that can be nested
private const brackets = ['(' => ')', '[' => ']'];

// heading/section-break marks and their corresponding levels
private const sectioners = ['===' => 1, '---' => 2, '- -' => 3];

// expandable-section heading marks and their corresponding levels
private const expanders = ['+++' => 2, '+ +' => 3];

// alignment mark => [kind, HTML classes]; different kinds can be combined
private const aligners = [
    ':..' => ['align', '_align _left'],
    '..:' => ['align', '_align _right'],
    ':.:' => ['align', '_align _justify'],
    '.:.' => ['align', '_align _center'],
    '::.' => ['align', '_align _mid _left'],
    '.::' => ['align', '_align _mid _right'],
    ': :' => ['allot', '_columns'],
    ':::' => ['allot', '_row'],
    ':.'  => ['float', '_float _port'],
    '.:'  => ['float', '_float _starboard'],
    '::'  => ['clear', '_clear'],
];

// predefined textual metadata, can be called in text as @version for example
private const default_textvars = [
    'version' => vmain === vhtml && vhtml === vfunc? vmain: vmain . '/' . vhtml . '/' . vfunc,
];

// the character & followed by a letter or digit is a custom mark, i.e. it can
// be defined by the author; & followed by some non-alphanumeric characters is
// predefined as follows
private const default_markvars = [
    '+' => "<span style='font-size:larger'>",
    ',' => "<span style='font-weight:normal'>",
    '-' => "<span style='font-size:smaller'>",
    ';' => '</span>',
];

// the number of links accepted for a given file type in linked file statements;
// the number that a given x-module accepts varies and is checked individually
private const max_links_for_type = [
    'a' => 1,
    'b' => 1,
    'd' => 1,
    'h' => 1,
    'i' => 2,
    'j' => 2,
    'p' => 1,
    'q' => 1,
    't' => 1,
    'v' => 3,
    'w' => 3,
    'x' => 0xface, // many
];

// values that are recognized in a comma separated list for @fix and represent
// provisional fixes for browser issues etc.; assigned numbers shall be powers
// of two to be used as flags; the recognized 'inherit' fix is not listed here
private const recognized_fixes = [
    'list-numbers' => 1,
    'previews' => 2,
];

// metadata names for use with links as values which have no special function
// in Aneamal but are recognized by Aneamal and published as HTML <link>
private const recognized_html_links = [
    // a selection from W3C and WHATWG standards
    'canonical' => "<link rel='canonical' href='%s'>",
    'icon'      => "<link rel='icon' href='%s'>",
    'license'   => "<link rel='license' href='%s'>",
    'next'      => "<link rel='next' href='%s'>",
    'prev'      => "<link rel='prev' href='%s'>",
    // further meta links
    'atom'      => "<link rel='alternate' type='application/atom+xml' href='%s'>",
    'me'        => "<link rel='me' href='%s'>",
    'rss'       => "<link rel='alternate' type='application/rss+xml' href='%s'>",
    'shortlink' => "<link rel='shortlink' href='%s'>",
    'up'        => "<link rel='up' href='%s'>",
];

// metadata names for use with text values which have no special function in
// Aneamal but are recognized by Aneamal and published as HTML <meta>
private const recognized_html_metas = [
    // a selection from W3C and WHATWG standards
    'author'      => "<meta name='author' content='%s'>",
    'description' => "<meta name='description' content='%s'>",
    'keywords'    => "<meta name='keywords' content='%s'>",
    // further meta names
    'publisher'   => "<meta name='publisher' content='%s'>",
    'robots'      => "<meta name='robots' content='%s'>",
    'translator'  => "<meta name='translator' content='%s'>",
    'viewport'    => "<meta name='viewport' content='%s'>",
];

// these metadata names may be declared more than once in the same file, while
// all others can only be declared once
private const metadata_multiples = [
    'htmlhead',
    'javascript',
    'script',
    'style',
    'stylesheet',
];

// these metadata names are aliases
private const metadata_aliases = [
    'classes'  => 'class',
    'language' => 'lang',
];


/* Properties
 */

// holds error messages, gets cleared after metadata and each block is processed
private array $errors = [];
// base URL for more information on error messages
private string|null $errormore = NULL;
// error message from @meta.nml or a manually assigned @meta file
private string $metaerror = '';

// local file path of the Aneamal root directory; no trailing slash
private string $root = __DIR__ . '/..';
// Aneamal root directory relative to the domain in an URL; no trailing slash
private string $home = '';
// Aneamal document's directory relative to the Aneamal root; no trailing slash
private string $dir = '';
// Aneamal filename on the disk in which the document resides
private string $filename = '';
// for example self::main, self::header, self::linked, self::embedded
private string $filekind = '';

// working copy of the Aneamal document's lines
private array $lines = [];

// custom marks; format 'mark' => 'HTML content'
private array $markvars = [];
// metadata; item format 'name' => [type => 'value'], type is either self::text,
// self::link or self::embd
private array $metavars = [];

// locally declared metadata names including custom marks; format 'name' => true
private array $metadecs = [];

// URIs identifying translations of the document; format 'langcode' => 'URI'
private array $altlangs = [];
// URIs to JavaScript files that shall be referenced
private array $javascripts = [];
// URIs to CSS files that shall be referenced
private array $stylesheets = [];

// URIs that are published in HTML <link rel> elements; format 'name' => 'URI'
private array $links = [];
// texts that are published im HTML <meta name> elements; format 'name' => 'URI'
private array $metas = [];

// HTML snippets to be put in the HTML <head>
private array $metahtmls = [];
// JavaScript snippets to be put at the end of the HTML <body>
private array $metascripts = [];
// CSS snippets to be put in the HTML <head>
private array $metastyles = [];

// manually declared metadata filename; empty string means don't autoload either
private string|null $meta = NULL;
// manually declared look filename; empty string means don't autoload either
private string|null $look = NULL;
// manually declared header filename; empty string means don't autoload either
private string|null $header = NULL;
// manually declared aside filename; empty string means don't autoload either
private string|null $aside = NULL;
// manually declared footer filename; empty string means don't autoload either
private string|null $footer = NULL;
// layout value 'manual' prevents autoloading header/aside/footer/look, 'blank'
// prevents any display, NULL is the default behaviour declared with 'auto'
private string|null $layout = NULL;

// BCP47 code for the document, but lowercased; empty string is explicit unknown
private string|null $lang = NULL;
// value 'rtl' iff the document is written in right-to-left script
private string|null $direction = NULL;

// classes for the HTML class attribute
private array $classes = [];
// value for the HTML role attribute; only used for linked/embedded documents
private string $role = '';
// value for HTML <title>; only used for the main document
private string $title = '';
// added at the end of the HTML <title>; possible use is branding via @meta.nml
private string $titletail = '';

// id of a HTML form, gets set when a first form element is encountered
private string|null $form = NULL;
// for questions and the corresponding answers posted via a form
private array $post = [];

// positive for lazy loading, 0 for eager loading of images/iframes; negative
// starts eagerly and then switches to lazy; NULL means not set/inherit; default
// behavior is negative
private int|null $lazy = NULL;
// image dimensions of generated preview for [j]-> inclusion in pixels: width
// and height separated by comma; positive values force the given size, negative
// values set a maximum; zero means that the value is not set
private string|null $pixels = NULL;

// metadata where the key is a module name and the value is configuration text
private array $modules = [];

// maximum size of text file included with [a], [h], [t], [b] etc. in bytes
private int|null $textcap = NULL;

// bit mask for provisional fixes for browser issues
private int|null $fixes = NULL;

// holds the HTML end tags for sections/subsections that have been started
private array $sections = [3 => '', 2 => ''];


/* Public methods
 */

/* This is the only constructor of the class. It preprocesses the Aneamal file,
 * e.g. processes sandwich markup, parses/removes metadata, removes comments
 * and makes sure the text conforms to UTF-8.
 *
 * $source:     the source text of an Aneamal file
 * $dir:        directory in which the parsed file is located relative to the
 *              Aneamal root, starting with a slash; needed to locate other
 *              files referenced from this
 * $home:       Aneamal root directory relative to the host in an URL; an empty
 *              string means the Aneamal root is the document root
 * $filename:   basename of the the file the $source is written in
 * $kind:       an integer identifying the kind of Aneamal document
 * $metavars:   metadata; item format 'name' => [type => 'value']
 * $markvars:   custom marks; format 'mark' => 'HTML content'
 */
public function __construct (
    string $source,
    string $dir,
    string $home,
    string $filename = '',
    string $kind = self::main,
    array $metavars = [], // ignored for $kind === self::main
    array $markvars = [], // ignored for $kind === self::main
)    // returns nothing
{
    // Enforce valid UTF-8 encoding and normalize line breaks. Embedded files
    // and quotation blocks have been checked with the containing file already.
    if ($kind !== self::embedded and $kind !== self::quoted):
        $source = normalize_text ($source);
    endif;

    // Remove trailing slashes from the directories:
    $home = rtrim ($home, '/');
    $dir = rtrim ($dir, '/');

    // In the following indented code, metadata is loaded from @meta.nml
    // automatically or from a file manually assigned in a @meta declaration
    // BEFORE the main Aneamal file's metadata is processed, because the latter
    // inherits data from the former. However, said optional @meta declaration
    // would occur in the LATTER! Solution: load @meta.nml automatically, but if
    // a @meta declaration in the main Aneamal file contradicts the prior
    // automatic loading, we reset all properties of this object and jump back
    // to the label Raptor, respecting the manual setting this time. An empty
    // string for $manual_meta means: load no meta file.
    $manual_meta = NULL;
    Raptor:

        // No metadata has been loaded from @meta.nml automatically in this run
        // of the loop yet.
        $loaded_meta_automatically = false;

        // canonicalized local file path of the Aneamal root directory
        $this->root = dirname (__DIR__);
        // URL path of the Aneamal root directory relative to the host
        $this->home = $home;

        // location, name and kind of the processed Aneamal file
        $this->dir = $dir;
        $this->filename = $filename;
        $this->filekind = $kind;

        // Load metadata from a metadata file for the main Aneamal file; inherit
        // metadata variables in case of other Aneamal files.
        if ($kind === self::main):
            if ($manual_meta === NULL):
                $loaded_meta_automatically = $this->load_settings ();
            elseif ($manual_meta !== ''):
                $this->load_settings ($manual_meta);
            endif;
        else:
            $this->metavars = $metavars;
            $this->markvars = $markvars;
        endif;

        // Process sandwich markup, remove comments and process and remove
        // metadata from this Aneamal file.
        $this->lines = explode ("\n", $source);
        $this->preprocess_lines ();
        $this->preprocess_comments_meta ();

        // Erase/Rewind once in main Aneamal file, if a manual @meta declaration
        // contradicts the early automatic @meta.nml loading.
        if ($kind === self::main and $this->meta !== $manual_meta):
            if ($loaded_meta_automatically || $this->meta !== ''):
                $manual_meta = $this->meta;
                $this->reset ();
                goto Raptor;
            endif;
        endif;

    // Default text direction is left-to-right except for embedded files and
    // quotation blocks, which inherit the direction of the containing document
    // in process_file_aneamal by default.
    if ($kind !== self::embedded and $kind !== self::quoted):
        $this->direction ??= 'ltr';
    endif;

    // The empty string stands for an unknown language as default. Aneamal files
    // other than the main Aneamal file inherit the settings of the parent
    // document in process_file_aneamal.
    if ($kind === self::main):
        $this->lang ??= '';
    endif;
}


/* Translates the blocks of this Aneamal file into HTML and returns the result.
 * This method is also responsible for printing error messages in between
 * blocks and for adding a HTML <form> element, if form controls such as
 * checkboxes or textboxes are used in this file.
 */
public function body (
):    string
{
    $html = $block = [];
    $from = NULL; // first line number in a block

    if ($this->metaerror):
        $html[] = $this->metaerror;
    endif;
    if ($this->errors):
        $html[] = $this->get_errors ('Errors in ghost markup');
    endif;

    // An empty line at the end of the file simplifies further processing.
    $this->lines[PHP_INT_MAX] = '';

    // Collect subsequent lines constituting a block, preserving line numbers;
    // translate each block to HTML when an empty line marks its end.
    foreach ($this->lines as $k => $line):
        if ($line !== ''):
            $from ??= $k;
            $block[$to = $k] = $line;
        elseif ($block):
            try {
                $html[] = $this->block ($block);
            } catch (\Throwable $e) {
                $this->error ('PHP exception in ' . strip_root ($e->getFile ()) . ', line ' . $e->getLine (), 237);
            }
            if ($this->errors):
                $html[] = $this->get_errors ('Errors in the previous block', $from, $to);
            endif;
            $from = NULL;
            $block = [];
        endif;
    endforeach;

    // Close expandable sections that extend to the end of the file.
    if ($close = $this->end_sections ()):
        $html[] = $close;
    endif;

    // Add a <form> element, if form controls have been used in the file.
    if (isset ($this->form)):
        $html[] = $this->form_element ();
    endif;

    return implode ($html);
}


/* Generates and returns the whole HTML webpage representing the Aneamal file
 * managed by this object, including implied header/footer/etc. files, styles
 * and scripts.
 */
public function document (
):    string
{
    $header = $main = $aside = $footer = $script = '';

    // hidden document
    if ($this->layout === 'blank'):
        return '';
    endif;

    // prepare a list of directories to automatically look for layout elements;
    // it is the current directory and parent directories up to the Aneamal root
    if ($this->layout === NULL):
        $directories = get_directories ($this->dir);
    endif;

    // get header
    if ($this->header !== NULL):
        if ($this->header !== ''):
            $header = $this->file_aneamal ($this->header, self::header);
        endif;
    elseif ($this->layout === NULL):
        foreach ($directories as $dir):
            if (is_readable ($filename = $this->root . $dir . '/@header.nml')):
                $header = $this->process_file_aneamal (file_get_contents ($filename), $dir, '@header.nml', self::header);
                break;
            endif;
        endforeach;
    endif;

    // build main content
    $main = "<main>\n" . $this->body () . "</main>\n";

    // get aside
    if ($this->aside !== NULL):
        if ($this->aside !== ''):
            $aside = $this->file_aneamal ($this->aside, self::aside);
        endif;
    elseif ($this->layout === NULL):
        foreach ($directories as $dir):
            if (is_readable ($filename = $this->root . $dir . '/@aside.nml')):
                $aside = $this->process_file_aneamal (file_get_contents ($filename), $dir, '@aside.nml', self::aside);
                break;
            endif;
        endforeach;
    endif;

    // get footer
    if ($this->footer !== NULL):
        if ($this->footer !== ''):
            $footer = $this->file_aneamal ($this->footer, self::footer);
        endif;
    elseif ($this->layout === NULL):
        foreach ($directories as $dir):
            if (is_readable ($filename = $this->root . $dir . '/@footer.nml')):
                $footer = $this->process_file_aneamal (file_get_contents ($filename), $dir, '@footer.nml', self::footer);
                break;
            endif;
        endforeach;
    endif;

    // add look (implied stylesheet)
    if ($this->look !== NULL):
        if ($this->look !== ''):
            array_unshift ($this->stylesheets, $this->look);
        endif;
    elseif ($this->layout === NULL):
        foreach ($directories as $dir):
            $canonical = $dir . '/@look.css';
            if (file_exists ($filename = $this->root . $canonical)):
                array_unshift ($this->stylesheets, $this->home . $canonical . '?' . (string) filemtime ($filename));
                break;
            endif;
        endforeach;
    endif;

    // add javascript
    foreach (array_unique ($this->javascripts) as $val):
        $script .= "<script src='" . encode_special_chars ($val) . "'></script>\n";
    endforeach;
    foreach (array_unique ($this->metascripts) as $val):
        $script .= "<script>\n{$val}\n</script>\n";
    endforeach;

    // build <head> content, must be generated after ->body, because the title
    // published in the head may be generated from the main heading in the body
    $head = $this->head ();

    // <html> and <body> attributes
    $dir = $class = $lang = '';
    if ($this->direction !== 'ltr'):
        $dir = " dir='{$this->direction}'";
    endif;
    if ($this->lang):
        $lang = " lang='{$this->lang}'";
    endif;
    if (!empty ($this->classes)):
        $class = " class='" . implode_html_attribute_value ($this->classes) . "'";
    endif;

    // build document
    return "<!doctype html>\n<html{$dir}{$lang}>\n{$head}<body{$class}>\n{$header}{$main}{$aside}{$footer}{$script}</body>\n</html>\n";
}


/* Private methods
 */

/* Identifies the end of a link in $string and returns its address. In Aneamal,
 * a link is marked with an arrow -> and &$index must initially provide the
 * location of the greater-than sign in that arrow. The arrow can be either
 * immediately followed by the address for a regular link or by one of a few
 * marks, e.g. @ in which case an URI already declared in metadata will be used.
 * At the end &$index will be set to the position of the last character of the
 * link in $string. (This function is used for linked file and in metadata
 * declarations; see function link for hyperlinks in text.)
 */
private function address (
    string $string,
    int &$index,
    bool $allow_meta = true
):    string|null
{
    // handle links without address at the end of the string
    if (!isset ($string[++$index])):

        return '';

    // few link modifiers available for hyperlinks are not available here
    elseif ($string[$index] === '!' or !$allow_meta and $string[$index] === '@'):

        $this->error ('Invalid link modifier: ' . $string[$index] . ' not allowed after -> in this case', 107);
        return NULL;

    // distinguish between links to a target in this document ...
    elseif ($string[$index] === '#'):

        if (isset ($string[++$index])):
            $group = $this->targeted ($string, $index);
            return $group === ''? '#': '#' . $this->fragment_identifier ($group);
        else:
            return '#'; // an empty target refers to the top of the page
        endif;

    // ... and links declared in metadata ...
    elseif ($string[$index] === '@'):

        if (!isset ($string[++$index])):
            $this->error ('Metadata name expected after ->@', 229);
            return NULL;
        endif;

        // retrieve the metadata name
        $name = stripslashes ($this->group ($string, $index));

        // return the address declared for the given metadata name
        if (isset ($this->metavars[$name][self::link])):
            return $this->metavars[$name][self::link];
        elseif (isset ($this->metavars[$name])):
            $this->error ('Metadata name not declared with link value: @' . $name, 247);
            return NULL;
        else:
            $this->error ('Metadata name after ->@ not declared: ' . $name, 230);
            return NULL;
        endif;

    // ... and shortened data URIs ...
    elseif ($string[$index] === ','):

        if (isset ($string[++$index])):
            return 'data:;charset=UTF-8;base64,' . stripslashes ($this->group ($string, $index, true));
        else:
            return 'data:;charset=UTF-8;base64,';
        endif;

    // ... and normal link
    else:

        return stripslashes ($this->group ($string, $index, true));

    endif;
}


/* Processes $lines of an Aneamal document which together form an Aneamal block
 * and returns the corresponding HTML code. The processing depends on the kind
 * of block. The kind is identified by block markup, usually to be found at the
 * start of the block. Headings are only allowed, if not $inside_note.
 * The keys of $lines are the corresponding line numbers from the Aneamal file.
 */
private function block (
    array $lines,
    bool $inside_note = false,
):    string
{
    // initial strings from the block to check what kind of block this is
    $intro = reset ($lines);
    $three = substr ($intro, 0, 3);
    $two   = substr ($three, 0, 2);

    // regular headings and section breaks
    if (isset (self::sectioners[$three])):
        if ($inside_note):
            $this->error ('Heading or section break in notes', 212); // also used below
        else:
            return $this->sectioner (implode ("\n", $lines));
        endif;
    endif;

    // expandable-section headings and breaks
    if (isset (self::expanders[$three])):
        if ($inside_note):
            $this->error ('Heading or section break in notes', 212); // also used above
        else:
            return $this->expander (implode ("\n", $lines));
        endif;
    endif;

    // notes consist of three or more underscores and a block below; they do end
    // (expandable) sections and subsections
    if ($three === '___'):
        unset ($lines[array_key_first ($lines)]);
        $sectiontags = $this->end_sections (2);
        if (!is_made_of ($intro, '_')):
            $this->error ('Invalid notes markup: expected only underscores in the first line', 75);
            return $sectiontags;
        elseif (empty ($lines)):
            $this->error ('Note missing: expected content below ___', 76);
            return $sectiontags;
        else:
            return $sectiontags . "<div role='note'>\n<hr>\n" . $this->block ($lines, true) . "</div>\n";
        endif;
    endif;

    // math block: starts and ends with double dollar signs
    if ($two === '$$'):
        return $this->math_block (implode ("\n", $lines));
    endif;

    // code block: every line starts with a vertical bar
    if ($intro[0] === '|'):
        $count = 0;
        foreach ($lines as &$line):
            if ($line[0] === '|'):
                $line = substr ($line, 1);
            else:
                return $this->embedded_file (implode ("\n", array_slice ($lines, 0, $count)), '', implode ("\n", array_slice ($lines, $count)));
            endif;
            ++$count;
        endforeach;
        return $this->embedded_file (implode ("\n", $lines));
    endif;

    // bulleted list, optionally indented with dots and whitespace
    if ($two === '<>' && $three !== '<><' or $intro[0] === '.' && str_starts_with (ltrim ($intro, ".\t\x20"), '<>')):
        return $this->bulleted_list ($lines);
    endif;

    // tagged list
    if ($intro[0] === '<'):
        return $this->tagged_list ($lines);
    endif;

    // file candidate, i.e. file or paragraph with bracketed token at its start
    // where the first byte inside the brackets is alphanumeric
    if (str_match ($intro, ['[', self::alphanumeric, '-:]'])):
        return $this->file (implode ("\n", $lines));
    endif;

    // block quotation
    if ($intro[0] === '>'):
        $text = $citation = '';
        $count = 0;
        foreach ($lines as $line):
            if ($line[0] === '>'):
                $text .= substr ($line, 1) . "\n";
            else:
                $citation = implode ("\n", array_slice ($lines, $count));
                break;
            endif;
            ++$count;
        endforeach;
        return $this->process_file_aneamal (
            substr ($text, 0, -1),
            $this->dir,
            $this->filename,
            self::quoted,
            NULL,
            $citation
        );
    endif;

    // alignment and other classes
    if ($three === ': .' or $three === '. :'):
        $this->error ("Reserved characters $three at paragraph start", 209);
    elseif (isset (self::aligners[$three]) or isset (self::aligners[$two])):
        return $this->classy_block ($lines, $inside_note);
    endif;

    // numbered list
    if ($pos = strcspn ($intro, "\t\x20") and $intro[$pos - 1] === '.'):
        if (!empty ($this->item_number (substr ($intro, 0, $pos), []))):
            return $this->numbered_list ($lines);
        endif;
    endif;

    // form fields
    $count = 0;
    foreach ($lines as $line):
        // option
        if ($line[0] === '{'):
            if ($count === 0):
                return $this->options ($lines);
            else:
                return $this->options (
                    array_slice ($lines, $count, NULL, true),
                    implode ("\n", array_slice ($lines, 0, $count))
                );
            endif;
        // textbox
        elseif (str_match ($line, ['[', '_=-', ':]'])):
            if ($count === 0):
                return $this->textboxes ($lines);
            else:
                return $this->textboxes (
                    array_slice ($lines, $count, NULL, true),
                    implode ("\n", array_slice ($lines, 0, $count))
                );
            endif;
        endif;
        ++$count;
    endforeach;

    // simple paragraph
    return '<p>' . $this->phrase (implode ("\n", $lines)) . "</p>\n";
}


/* Finds the right bracket in a $string that corresponds to the left bracket
 * whose position in the $string is initially given by &$index. If a
 * corresponding right bracket is found, &$index is set to its position and the
 * part of the $string between the brackets is returned. If no corresponding
 * bracket is found, NULL is returned as signal that the left bracket does not
 * mark a bracketed string.
 */
private function bracketed (
    string $string,
    int &$index
):    string|null
{
    // get the left bracket
    $left = $string[$index];

    // find the corresponding right bracket
    $pos = strpos_unmatched ($string, self::brackets[$left], $left, self::masks, $index + 1, '`');

    if ($pos === NULL):
        return NULL;
    endif;

    $group = substr ($string, $index + 1, $pos - $index - 1);
    $index = $pos;

    // remove slashed linebreaks and return the result
    return strip_slashed_breaks ($group);
}


/* Parses $lines that together form a bulleted list and returns it as HTML <ul>
 * list. Bulleted list items may be indented. The indentation level is marked by
 * dots. Space/tabs are allowed between the dots without having meaning.
 */
private function bulleted_list (
    array $lines
):    string
{
    $list = $item = '';
    $prelevel = $level = -1;

    foreach ($lines as $line):

        // append lines without rhombus to the previous item
        $bullet = strpos ($line, '<>');
        if ($bullet === false):
            $item .= "\n" . $line;
            continue;
        endif;

        // append lines whose rhombus isn't a bullet to the previous item
        $indent = substr ($line, 0, $bullet);
        if (strspn ($indent, ".\t\x20") < $bullet):
            $item .= "\n" . $line;
            continue;
        endif;

        // translate previous item and determine its indentation level
        $list .= $this->item ($item);
        $level = substr_count ($indent, '.');

        // inter-item markup
        if ($level === $prelevel):
            $list .= "</li>\n<li>";
        elseif ($level < $prelevel):
            $list .= str_repeat ("</li>\n</ul>\n", $prelevel - $level) . "</li>\n<li>";
        else:
            $list .= str_repeat ("\n<ul>\n<li class='_skip'>", $level - $prelevel - 1);
            $list .= "\n<ul>\n<li>";
        endif;

        // get current item
        $prelevel = $level;
        $item = substr ($line, $bullet + 2);
    endforeach;

    return substr ($list, 1) . $this->item ($item) . str_repeat ("</li>\n</ul>\n", $level + 1);
}


/* Translates alignment marks at the start of a block, which consists of $lines,
 * to HTML class names. The rest of the block is processed. If the block is an
 * expandable-section break or expandable-section heading, the class names are
 * passed on to its function, otherwise they are added with a HTML <div> here.
 * Expandable-sections are not permitted $inside_note.
 */
private function classy_block (
    array $lines,
    bool $inside_note
):    string
{
    $classes = [];

    while (!is_null ($k = array_key_first ($lines))):
        $three = substr ($lines[$k], 0, 3);

        // Alignment mark:
        if (isset (self::aligners[$mark = $three]) or isset (self::aligners[$mark = substr ($lines[$k], 0, 2)])):
            $length = strlen ($mark);

            // White space after an alignment mark is required and helps to
            // differentiate between .:. :: and .: .:: for example.
            if (isset ($lines[$k][$length]) and !str_contains ("\t\x20", $lines[$k][$length])):
                $this->error ('Missing whitespace after alignment mark ' . $mark, 211, $k);
            endif;

            // Get the HTML class name corresponding to the alignment mark, but
            // prevent contradictions like both left- and right-aligned.
            if (isset ($classes[self::aligners[$mark][0]])):
                $this->error ('Alignment of this kind already set: ' . self::aligners[$mark][0], 213, $k);
            else:
                $classes[self::aligners[$mark][0]] = self::aligners[$mark][1];
            endif;

            // Remove alignment mark:
            if ($lines[$k] === $mark):
                unset ($lines[$k]);
            else:
                $lines[$k] = ltrim (substr ($lines[$k], $length));
            endif;

        // Section headings apply alignment themselves. They are not allowed
        // inside notes though.
        elseif (isset (self::sectioners[$three]) and !$inside_note):
            return $this->sectioner (implode ("\n", $lines), ['class' => $classes]);
        elseif (isset (self::expanders[$three]) and !$inside_note):
            return $this->expander (implode ("\n", $lines), ['class' => $classes]);

        // Break out of the loop, if no alignment was found anymore.
        else:
            break;
        endif;

    endwhile;

    // Classes can be set without content, too.
    if (empty ($lines)):
        return "<div class='" . implode_html_attribute_value ($classes) . "'></div>\n";
    endif;

    // Process the aligned block:
    return "<div class='" . implode_html_attribute_value ($classes) . "'>\n" . $this->block ($lines, $inside_note) . "</div>\n";
}


/* Prepares $code for output as a HTML <code> element.
 */
private function code (
    string $code
):    string
{
    return '<code>' . str_replace ("\n", '<br>', encode_special_chars (stripslashes ($code))) . '</code>';
}


/* Generates a preview image from the original with the filename $from and saves
 * it as $jpg and optionally $jxl as well. The latter is expected to be in the
 * same directory as the former. The preview is created with size constraints
 * given as $width and $height. See Preview::coordinates () in func.php for a
 * description of these values. Returns true on success, otherwise false.
 * Success is only determined by the creation of $jpg, not $jxl.
 */
private function create_preview (
    int $width,
    int $height,
    string $from,
    string $jpg,
    string $jxl = '',
):    bool
{
    static $shutdown_function_registered = false;

    if (!is_writable ($dir = dirname ($jpg)) and is_dir ($dir) || !@mkdir ($dir, 0777, true)):
        $this->error ('Preview image folder not writable: ' . self::pixdir, 228);
        return false;
    endif;

    // compute preview
    try {
        if (PreviewImagick::supports ('JPG')):
            $preview = new PreviewImagick ($from, $width, $height);
        elseif (PreviewGD::supports ('JPG')):
            $preview = new PreviewGD ($from, $width, $height);
        else:
            $this->error ('PHP installation lacks graphics extension', 252);
            return false;
        endif;
    } catch (PreviewException $e) {
        $this->error ('Could not create preview image from: ' . basename ($from), 253);
        return false;
    }

    // save preview
    try {
        if ($jxl):
            $preview->save ($path = $jxl, 80);
        endif;
        $success = $preview->save ($path = $jpg, 83);
    } catch (PreviewException $e) {
        $this->error ('Could not save preview image at: ' . strip_root ($path), 254);
        return false;
    }

    // Creating many previews can take more time than max_execution_time allows,
    // causing a Fatal Error. We explain this likely timeout reason with a
    // shutdown function. It is registered after a succesful preview creation,
    // because we can only guarantee progress then and ask to try again.
    if ($success and !$shutdown_function_registered):
        register_shutdown_function (function () {
            if (connection_status () & 2): // timeout
                if (!headers_sent ()):
                    header ('HTTP/1.1 503 Service Unavailable');
                    header ('Retry-After: 120');
                endif;
                print "<p>Sorry for the timeout. ";
                print "This can happen while new previews of images are generated. ";
                print "The webpage will load quickly once they have been created. ";
                print "<b>Please reload the webpage.</b></p>\n";
            endif;
        });
        $shutdown_function_registered = true;
    endif;

    return $success;
}


/* Decodes a textual data $uri and returns its data converted to UTF-8, or NULL
 * on error.
 */
private function data_uri (
    string $uri
):    string|null
{
    // find the colon (end of URI scheme) and first comma (start of data)
    $colon = strpos ($uri, ':');
    $comma = strpos ($uri, ',', $colon);
    if (!$colon or !$comma):
        $this->error ('Invalid data URI', 226);
        return NULL;
    endif;

    // extract mediatype and data
    $type = substr ($uri, $colon + 1, $comma - $colon - 1);
    $data = rawurldecode (substr ($uri, $comma + 1));

    // base64-decode the data strictly, i.e. invalid characters are reported as
    // error with the exception of line breaks
    if (strtolower (substr ($type, -7)) === ';base64'):
        $type = substr ($type, 0, -7);
        $data = base64_decode (chunk_split (str_replace ("\n", '', $data)), true);
        if ($data === false):
            $this->error ('Corrupt base64 encoding', 62);
            return NULL;
        endif;
    endif;

    // find the data URI's character encoding; US-ASCII is the data-URI default
    $charset = get_mediatype_charset ($type) ?? 'US-ASCII';
    // transform the name of the encoding to its preferred mime name
    try {
        $encoding = mb_preferred_mime_name ($charset);
    } catch (\ValueError) {
        $encoding = false;
    }

    // convert the data to UTF-8
    if ($encoding === false): // means that the encoding is unknown to PHP
        $this->error ('Unrecognized character encoding: ' . $charset, 65);
        return substitute_nonascii ($data);
    elseif ($data === ''):
        return '';
    elseif (!mb_check_encoding ($data, $encoding)):
        $this->error ('Character encoding mismatch: expected ' . $encoding, 64);
        return substitute_nonascii ($data);
    elseif ($encoding !== 'UTF-8' and $encoding !== 'US-ASCII'):
        $data = mb_convert_encoding ($data, 'UTF-8', $encoding);
    endif;

    return $data;
}


/* A target at the start of a quotation block's or file's caption or math label
 * is used as identifier for the whole quotation block/file/math block. This
 * method recognizes a target by the character # at the start of a given
 * &$caption. If it exists, the target is detached from the &$caption, processed
 * and returned as HTML id attribute with leading space. If no target exists, an
 * empty string is returned.
 */
private function detach_caption_target (
    string &$caption
):    string
{
    if ($caption === '' or $caption[0] !== '#'):
        return '';
    endif;

    if (!isset ($caption[1])):
        $this->error ('Target text missing after initial #', 177);
        return $caption = '';
    endif;

    $index = 1;
    if ($caption[1] === '{'):
        $name = $this->fragment_identifier ($this->encurled ($caption, $index), true);
        $caption = ltrim (substr ($caption, $index + 1));
    else:
        $name = $this->fragment_identifier ($this->targeted ($caption, $index), true);
        $caption = ltrim (substr ($caption, 1));
    endif;

    return $name === ''? '': " id='$name'";
}


/* Prepares the $content of an embedded file with the right method for its type,
 * which is encoded in the file $token. See function parse_file_token in
 * func.php for a description of the $token. If the optional $caption is
 * provided, the processed content and caption are returned as an HTML <figure>,
 * otherwise the content is returned without <figure> element.
 */
private function embedded_file (
    string $content,
    string $token = '',
    string|null $caption = NULL
):    string
{
    [$type, $subtype, $clue] = parse_file_token ($token);

    // detach the optional id from the start of a caption
    $id = isset ($caption)? $this->detach_caption_target ($caption): '';

    // call the right method to process the given file type
    $file = match ($type) {
        NULL => '<pre><code>' . encode_special_chars ($content) . "</code></pre>\n",
        'a' => $this->process_file_aneamal ($content, $this->dir, $this->filename, self::embedded, $subtype),
        'b' => $this->process_file_tsv ($content),
        'd' => $this->process_file_tsv ($content, true),
        'h' => $content . "\n",
        'p' => $this->process_file_tsv ($content, false, true),
        'q' => $this->process_file_tsv ($content, true, true),
        't' => $this->process_file_text ($content, $subtype, $clue, $caption),
        default => NULL,
    };
    if ($file === NULL):
        $this->error ('Unrecognized file type: ' . $type, 146);
        return '';
    endif;

    // return the result
    if ($caption !== NULL):
        return "<figure{$id}>\n{$file}<figcaption>" . $this->phrase ($caption) . "</figcaption>\n</figure>\n";
    elseif ($id !== ''):
        return "<figure{$id}>\n{$file}</figure>\n";
    else:
        return $file;
    endif;
}


/* Parses an Aneamal $string and adds HTML tags at the start and end that
 * correspond to the given Aneamal $mark for emphasis, an inline note, a quoted
 * or a crossed out string before returning it.
 */
private function emphasis (
    string $string,
    string $mark
):    string
{
    return match ($mark) {
        '~'  => '<i>' . $this->phrase ($string) . '</i>',
        '*'  => '<b>' . $this->phrase ($string) . '</b>',
        '_'  => '<u>' . $this->phrase ($string) . '</u>',
        '"'  => '<q>' . $this->phrase ($string) . '</q>',
        '+-' => '<s>' . $this->phrase ($string) . '</s>',
        '=-' => '<small>' . $this->phrase ($string) . '</small>',
    };
}


/* Finds the right mark in a $string that corresponds to the identical left mark
 * whose position in the $string is initially given by &$index. Then &$index is
 * set to the position of the right mark (or to the last character, if no right
 * mark is found) and the part of the $string between the marks is returned. If
 * $no_masking is false, content inside $…$ and |…| (see self::masks for a full
 * list) is not taken into account in the search for the right mark.
 */
private function enclosed (
    string $string,
    int &$index,
    bool $no_masking = false
):    string
{
    // save the mark
    $mark = $string[$index];

    // find the next mark that is not slashed
    $pos = $no_masking? strpos_unslashed ($string, $mark, $index + 1): strpos_unmasked ($string, $mark, self::masks, $index + 1, '`');

    if ($pos === NULL):
        $group = substr ($string, $index + 1);
        $index = strlen ($string) - 1;
        $this->error ('String not closed: expected ' . $mark, 117);
    else:
        $group = substr ($string, $index + 1, $pos - $index - 1);
        $index = $pos;
    endif;

    // remove slashed linebreaks and return the result
    return strip_slashed_breaks ($group);
}


/* Finds the right 2-byte mark in a $string that corresponds to the mirrored
 * left mark whose position in the $string is initially given by &$index. Then
 * &$index is set to the position of the second byte in the right mark (or to
 * the last character, if no right mark is found) and the part of the $string
 * between the marks is returned.
 */
private function enclosed2 (
    string $string,
    int &$index
):    string
{
    // get the left and right marks
    $right = strrev ($left = substr ($string, $index, 2));

    // find the corresponding right cross
    $pos = strpos_unmatched ($string, $right, $left, self::masks, $index + 2, '`');

    if ($pos === NULL):
        $group = substr ($string, $index + 2);
        $index = strlen ($string) - 1;
        $this->error ('Crossed-out string or inline note not closed: expected ' . $right, 206);
    else:
        $group = substr ($string, $index + 2, $pos - $index - 2);
        $index = $pos + 1;
    endif;

    // remove slashed linebreaks and return the result
    return strip_slashed_breaks ($group);
}


/* Finds the right curly bracket that comes after the left curly bracket whose
 * position in a $string is initially given by &$index. Then &$index is set to
 * the position of the right curly bracket (or to the last character of the
 * string, if no right curly bracket is found) and the part of the $string
 * between the curly brackets is returned unaltered.
 */
private function encurled (
    string $string,
    int &$index
):    string
{
    $pos = strpos_unslashed ($string, '}', $index + 1);

    if ($pos === NULL):
        $group = substr ($string, $index + 1);
        $index = strlen ($string) - 1;
        $this->error ('Curly brackets not closed: expected }', 140);
    else:
        $group = substr ($string, $index + 1, $pos - $index - 1);
        $index = $pos;
    endif;

    return $group;
}


/* Headings marked up with +++ (with rank 2) and + + (with rank 3) hide
 * the contents of their implicitly associated sections by default, but they
 * can be toggled to be visible. This method marks the end of expandable
 * sections of a given or higher numbered, less important $rank, by returning
 * the necessary number of HTML </details> tags.
 */
private function end_sections (
    int $rank = 1
):    string
{
    $html = '';
    foreach ($this->sections as $r => $endtag):
        if ($endtag and $r >= $rank):
            $html .= $this->sections[$r] . "\n";
            $this->sections[$r] = '';
        endif;
    endforeach;
    return $html;
}


/* Stores information about an error that can be retrieved with method
 * get_errors. The error $code == 0 is used for errors that should be impossible
 * to occur in production. Possible errors have a $code > 1.
 */
private function error (
    string $message,
    int $code = 0,
    int|null $line = NULL
):    void
{
    $this->errors[] = [$message, $code, $line];
}


/* Processes a $block that represents an expandable-section break or
 * expandable-section heading and returns its HTML equivalent. Expandable
 * sections are displayed closed by default. But when a syntax error occurs in a
 * heading, the section is displayed open so that the error message can be found
 * more easily. $attributes contains HTML attributes such as classes that apply
 * to the section's heading and must be set on the HTML <summary> element.
 */
private function expander (
    string $block,
    array $attributes = []
):    string
{
    $start = substr ($block, 0, 3);
    $rank = self::expanders[$start];

    // handle expandable-section breaks
    if ($start === $block):
        if (empty ($this->sections[$rank])):
            $this->error ('Seamless section break without corresponding heading: ' . $start, 256);
        endif;
        if ($attributes):
            $this->error ('Alignment for seamless section break: ' . $start, 214);
        endif;
        return $this->end_sections ($rank);
    endif;

    // end previous expandable sections
    $sectiontags = $this->end_sections ($rank);

    // find the end of the expandable-section heading and extract its content
    $endpos = strrpos_unmasked ($block, $start, self::masks, 3);
    if ($endpos === NULL):
        $this->error ('Expandable-section heading not closed: expected ' . $start, 165);
        return $sectiontags;
    endif;
    $content = trim (strip_slashed_breaks (substr ($block, 3, $endpos - 3)));
    if ($content === ''):
        $this->error ('Expandable-section heading missing', 164);
    endif;

    // create markup for the new heading
    $heading = "<summary" . implode_html_attributes ($attributes) . ">\n<h$rank>";
    foreach (explode ("\n", $content) as $i => $line):
        if ($i > 0):
            $heading .= '<br><span>' . $this->phrase ($line) . '</span>';
        else:
            $class = prepare_html_id ($line);
            $heading .= $this->phrase ($line);
        endif;
    endforeach;
    $heading .= "</h$rank>\n</summary>\n";
    $this->sections[$rank] = '</details>';

    // check for unexpected text after the heading, but still in the same block
    if (strlen ($block) !== $endpos + 3):
        $this->error ('Blank line missing after expandable-section heading', 163);
    endif;

    $status = empty ($this->errors)? '': ' open';
    return $sectiontags . "<details class='$class'{$status}>\n" . $heading;
}


/* Processes an Aneamal $block that starts with a left square bracket and seems
 * to be a file token. It could be a file token either followed by a link to a
 * file or by a code block, in which case the block is an embedded file. If
 * there is neither link nor code block or the bracketed string does not match
 * the format of a file token (see function parse_file_token in func.php), the
 * block is a simple paragraph. In either case, the appropriate HTML code is
 * returned.
 */
private function file (
    string $block,
    bool $is_list_item = false
):    string
{
    if (is_null ($token_end = strpos_unslashed ($block, ']', 2))):
        $this->error ('Malformed file token: expected ]', 239);
        return $is_list_item? $this->phrase ($block): '<p>' . $this->phrase ($block) . "</p>\n";
    endif;

    $token = substr ($block, 1, $token_end - 1);
    $after = substr ($block, $token_end + 1, 2);

    // embedded file
    if ($after === "\n|"):
        $lines = explode ("\n", substr ($block, $token_end + 2));
        foreach ($lines as $n => &$line):
            if ($line[0] === '|'):
                $line = substr ($line, 1);
            else:
                return $this->embedded_file (implode ("\n", array_slice ($lines, 0, $n)), $token, implode ("\n", array_slice ($lines, $n)));
            endif;
        endforeach;
        return $this->embedded_file (implode ("\n", $lines), $token);

    // linked file
    elseif ($after === '->'):
        return $this->linked_file (substr ($block, $token_end + 1), $token);

    // x-modules with zero links
    elseif (str_starts_with ($token, 'x-') || str_starts_with ($token, 'X-') and $after === '' || str_contains (self::space, $after[0])):
        return $this->linked_file (substr ($block, $token_end + 1), $token);

    // still not a file, just a bracketed string at the paragraph start
    else:
        return $is_list_item? $this->phrase ($block): '<p>' . $this->phrase ($block) . "</p>\n";

    endif;
}


/* Loads a linked Aneamal file located at $uri, processes it and returns its
 * content. This is useful for sharing common text between documents or to join
 * multiple parts of a huge document. The file $kind can be self::header or
 * self::linked for example. $tpl specifies an optional Aneamal template name
 * that shall be used with the Aneamal file. Templates can contain metadata,
 * style information in particular.
 */
private function file_aneamal (
    string $uri,
    string $kind,
    string|null $tpl = NULL
):    string
{
    // prevent an infinite recursion
    static $countdown = self::max_inclusions;
    if (--$countdown < 0):
        $this->error ('Too many Aneamal files included', 66);
        return '';
    endif;

    // only allow local files; trouble with data URIs would be that relative
    // addresses inside it could not be resolved
    if (get_uri_type ($uri) !== URI_LOCAL):
        $this->error ('Forbidden URI type for linked Aneamal file: use a local filename instead', 196);
        return '';
    endif;

    // split path from the query, which can be used to chose only certain lines
    [$path, $query] = split_uri_tail ($uri);

    // resolve the file path so that it is relative to the Aneamal root
    if (is_null ($canonical = $this->normalize_path ($path))):
        return '';
    endif;

    // read the file content
    if (is_null ($text = file_get_lines ($this->root . $canonical, $query))):
        $this->error ('Aneamal file not readable: ' . $path, 135);
        return '';
    endif;

    // cap the text at the textcap
    $textcap = $this->textcap ?? self::default_textcap;
    if (strlen ($text) > $textcap):
        $this->error ("Aneamal file exceeds textcap of $textcap: " . $canonical, 203);
        $text = substr ($text, 0, $textcap);
    endif;

    // process the file content as Aneamal document
    return $this->process_file_aneamal ($text, dirname ($canonical), basename ($canonical), $kind, $tpl);
}


/* Includes the user defined x-module of the given $subtype and passes
 * preprocessed versions of the given $paths to it and optionally a $clue.
 * Returns what the module returns, which must be UTF-8 encoded. For example, a
 * module x-audio.php could handle [x-audio]->url markup to integrate an audio
 * file into the document.
 * The optional $caption is informational and passed through for the form API.
 */
private function file_extension (
    array $paths,
    string|null $subtype,
    string|null $clue,
    string|null $caption
):    string
{
    if ($subtype === NULL or $subtype === ''):
        $this->error ('x-module subtype missing', 144);
        return '';
    endif;

    // $links stores the paths as given by the author of the Aneamal file,
    // $files stores the corresponding filenames in the local file system,
    // $hrefs stores them prepared for use in HTML
    $links = $files = $hrefs = [];
    foreach ($paths as $path):
        if (get_uri_type ($path) === URI_LOCAL and !is_null ($canonical = $this->normalize_path ($path))):
            $files[] = $this->root . $canonical;
            $hrefs[] = $this->home . strip_nml_suffix ($canonical);
        else:
            $files[] = NULL;
            $hrefs[] = $path;
        endif;
        $links[] = $path;
    endforeach;

    // prepare data passed to the module
    $data = [
        // numeric indices for backwards compatibility with old modules
        0 => [$links[0] ?? NULL, $files[0] ?? NULL, $hrefs[0] ?? NULL, $this->root, $this->home],
        1 => $clue ?? '',
        2 => $this->home . '/aneamal/x-' . $subtype,
        3 => $this->home . $this->dir,
        // x-module specific stuff
        'files'    => $files,
        'hrefs'    => $hrefs,
        'links'    => $links,
    ];

    // let the module do its work and return result
    return $this->use_module ('x-' . $subtype, $data, $clue, $caption) . "\n";
}


/* Returns the content of text file located at $path which is supposed to
 * contain UTF-8 encoded HTML code.
 */
private function file_html (
    string $path
):    string
{
    $file = $this->file_raw ($path);
    return isset ($file)? $file . "\n": '';
}


/* Returns an HTML <img> element to include the image located at $paths[0] with
 * an optional textual $clue for visually impaired readers, bots etc. and an
 * optional textual $hint that is typically displayed as tooltip on mouseover
 * events and comes already prepared for use as a HTML attribute. The image is
 * turned into a HTML link to $paths[1], if provided.
 * See https://www.w3.org/TR/html5/semantics-embedded-content.html#alt-text for
 * guidelines on composing the $clue.
 */
private function file_image (
    array $paths,
    string|null $clue = NULL,
    string|null $hint = NULL
):    string
{
    // resolve the file path
    [$source, $filename] = $this->media_link ($paths[0]);
    if (!$source):
        return '';
    endif;

    // Get width and height for local image files. The file is supposed to
    // exist, if there is $filename, so to optimize speed we do not check for
    // existence first, instead surpressing errors in filemtime () and
    // confirming existence en passent through its success.
    if ($filename and $time = @filemtime ($filename)):
        $size = query_memory (md5 ("$filename,$time"), function () use ($filename) {
            if ($s = @getimagesize ($filename) and $s[0] > 0 and $s[1] > 0):
                if ($exif = @exif_read_data ($filename, 'IFD0') and isset ($exif['Orientation'])):
                    // Swap width and height, if the EXIF orientation implies
                    // a 90° turn to the right or left.
                    if (in_array ($exif['Orientation'], [5, 6, 7, 8])):
                        [$s[1], $s[0]] = [$s[0], $s[1]];
                    endif;
                endif;
                return " width='$s[0]' height='$s[1]'";
            endif;
            return '';
        });
    else:
        $size = '';
    endif;

    // handle the optional textual alternative
    $alt = isset ($clue)? " alt='" . prepare_html_attribute ($clue) . "'": '';

    // handle optional hint that is already prepared for use as HTML attribute
    $title = isset ($hint)? " title='$hint'": '';

    // the loading attribute determines whether the image shall load lazily
    $loading = get_loading_attribute ($this->lazy ?? -1);

    // compose HTML img element
    $img = "<img src='{$source}'{$size}{$alt}{$title}{$loading}>";

    // return the image element, optionally with a link
    return isset ($paths[1])? $this->hyperlink ($paths[1], $img) . "\n": "$img\n";
}


/* Returns an HTML <video> element to include the video or audio file optionally
 * with a $hint that is typically displayed as tooltip on mouseover events and
 * comes already prepared for use as an HTML attribute. The address of the media
 * file and optionally of a still image and WebVTT file with closed captions for
 * the hard of hearing (or subtitles that simply translate the spoken text, but
 * will still be identified as closed captions in HTML here) in the $path array:
 * a single path links to a media file; in case of more paths the second one
 * links to a media file while the first links to a still image and an optional
 * third path links to the webvtt file. It is intentional to use HTML's <video>
 * element for both audio and video files: HTML's <audio> element does not work
 * with captions or a still image. While Aneamal also accepts a short clue in
 * the same way as for images, [v:this happens]->, that text is not used in the
 * HTML output yet, as there is nothing like the <img> element's alt-attribute
 * for the <video> element in HTML yet.
 */
function file_media (
    array $paths,
    string|null $hint = NULL,
    bool $do_loop = false
):    string
{
    // Resolve the file path of the media file:
    [$videosrc, $videofile] = $this->media_link ($paths[1] ?? $paths[0]);
    if (!$videosrc):
        return '';
    endif;

    // Resolve the file path of the still image. If a still image is available,
    // browsers are told not to preload media data, since the still image is
    // good enough as a placeholder and this reduces initial page load time.
    if (isset ($paths[1])):
        [$stillsrc, $stillfile] = $this->media_link ($paths[0]);
        if ($stillsrc):
            $poster = " poster='$stillsrc'";
            $preload = " preload='none'";
        endif;
    endif;

    // Resolve the file path of the closed captioning text track:
    if (isset ($paths[2])):
        [$tracksrc] = $this->media_link ($paths[2]);
        if ($tracksrc):
            $track = "<track src='$tracksrc' kind='captions' label='CC'>";
        endif;
    endif;

    // If both the still image and the video file are locally available, further
    // processing takes place: width and height of the video are determined and
    // a preview image is generated from the still that matches the video size.
    if ($videofile and $stillfile ?? false and $vtime = @filemtime ($videofile) and $stime = @filemtime ($stillfile)):

        $hash = md5 ("$videofile,$vtime,$stillfile,$stime");
        $hash[2] = '/';
        $previewsrc = encode_special_chars ($this->home . self::pixdir . "/$hash.jpg");
        $previewfile = $this->root . self::pixdir . "/$hash.jpg";

        // If the preview file exists already, its width and height are read to
        // be used on the video element and the preview is used as poster:
        if (file_exists ($previewfile)):

            $size = query_memory ($hash, function () use ($previewfile) {
                $s = getimagesize ($previewfile);
                return $s && $s[0] > 0 && $s[1] > 0? " width='$s[0]' height='$s[1]'": '';
            });
            $poster = " poster='$previewsrc'";

        // If the preview does not exist, width and height are read from the
        // video. If that succeeds, a matching preview is generated from the
        // still image:
        else:

            $size = query_memory ($hash, function () use ($videofile) {
                $s = get_video_dimensions ($videofile);
                return isset ($s)? " width='$s[0]' height='$s[1]'": '';
            });
            if ($size):
                $s = explode ("'", $size);
                if (!empty ($this->create_preview ((int) $s[1], (int) $s[3], $stillfile, $previewfile))):
                    $poster = " poster='$previewsrc'";
                endif;
            endif;

        endif;
    endif;

    // handle the optional hint and looping option
    $title = isset ($hint)? " title='$hint'": '';
    $loop = $do_loop? ' loop': '';

    // default values for some of the options only set conditionally above
    $poster ??= '';
    $preload ??= " preload='metadata'";
    $size ??= '';
    $track ??= '';

    return "<video src='{$videosrc}'{$poster}{$size}{$title}{$loop}{$preload} controls>{$track}<a href='{$videosrc}'>" . basename ($videosrc) . "</a></video>\n";
}


/* Loads the image located at $paths[0] and creates and saves a usually smaller
 * preview image of it. Returns an HTML <img> element to display the preview
 * with an optional textual $clue for visually impaired readers, bots etc. and
 * an optional textual $hint that is typically displayed as tooltip on mouseover
 * events and comes already prepared for use as a HTML attribute. The image is
 * turned into a HTML link to $paths[1], if provided, or else to the original
 * image at $paths[0].
 * See https://www.w3.org/TR/html5/semantics-embedded-content.html#alt-text for
 * guidelines on composing the $clue.
 */
private function file_preview (
    array $paths,
    string|null $clue = NULL,
    string|null $hint = NULL
):    string
{
    // check whether to create a HTML picture element with JXL + JPG previews
    $pictured = $this->fixes & self::recognized_fixes['previews'];

    // fetch only local files for security and legal reasons
    if (get_uri_type ($paths[0]) !== URI_LOCAL):
        $this->error ('Forbidden URI type for linked image file: use a local filename instead', 201);
        return '';
    endif;

    // resolve the file path to be relative to the Aneamal root directory
    if (is_null ($canonical = $this->normalize_path ($paths[0]))):
        return '';
    endif;

    // compose the filename of the original image and check its readability
    $original = $this->root . $canonical;
    if (!is_readable_file ($original)):
        $this->error ('Not a readable file: ' . $paths[0], 127);
        return '';
    endif;

    // get preview image dimension settings
    [$width, $height] = explode_integer_pair (',', $this->pixels ?? self::pixsize);

    // compose a filename for the preview image
    $hash = md5 ("$canonical,$width,$height" . strval (filemtime ($original)));
    $hash[2] = '/';
    $base = encode_special_chars ($this->home . self::pixdir . "/$hash");
    $jpg = $this->root . self::pixdir . "/$hash.jpg";
    $jxl = $pictured? $this->root . self::pixdir . "/$hash.jxl" : '';

    // create the preview image, if it does not exist yet
    if (!file_exists ($jpg)):
        if (!$this->create_preview ($width, $height, $original, $jpg, $jxl)):
            return '';
        endif;
    endif;

    // get the image dimensions as HTML attributes
    if ($width > 0 and $height > 0):
        $size = " width='$width' height='$height'";
    else:
        $size = query_memory ($hash, function () use ($jpg) {
            $s = getimagesize ($jpg);
            return $s && $s[0] > 0 && $s[1] > 0? " width='$s[0]' height='$s[1]'": '';
        });
    endif;

    // handle the optional textual alternative
    $alt = isset ($clue)? " alt='" . prepare_html_attribute ($clue) . "'": '';

    // handle optional hint that is already prepared for use as HTML attribute
    $title = isset ($hint)? " title='$hint'": '';

    // the loading attribute determines whether the image shall load lazily
    $loading = get_loading_attribute ($this->lazy ?? -1);

    // compose HTML img element
    $img = "<img src='{$base}.jpg'{$size}{$alt}{$title}{$loading}>";

    // wrap optional HTML picture element around
    if ($pictured):
        $source = file_exists ($jxl)? "<source srcset='{$base}.jxl' type='image/jxl'>" : '';
        $img = "<picture>{$source}{$img}</picture>";
    endif;

    // return the HTML code for displaying the preview image
    return $this->hyperlink ($paths[1] ?? $paths[0], $img) . "\n";
}


/* Returns the content of a text file located at $uri, where $uri refers to a
 * local file or a data URI. Local file paths can be followed by a query to
 * choose only certain lines from the file. If $do_cap is true, the loaded text
 * will be capped if it exceeds a given threshold in order to minimize negative
 * effects when a huge file gets accidentally loaded (e.g. loading an image
 * instead of a text file). If $do_normalize is true, it is checked whether the
 * file is UTF-8 encoded as it should be and otherwise made to be compatible
 * with UTF-8 by stripping invalid characters. It is important to return NULL
 * for missing/unreadable files instead of '' to distinguish this case from
 * empty files. The latter may trigger a certain action in a [t]-module, whereas
 * a [t]-module will not be called in case of a missing/unreadable file.
 */
private function file_raw (
    string $uri,
    bool $do_normalize = true,
    bool $do_cap = true,
    int|null $errline = NULL
):    string|null
{
    if ($uri === ''):
        $this->error ('Address missing', 232, $errline);
        return NULL;
    endif;

    $uri_type = get_uri_type ($uri);

    // split path from the query, which can be used to chose certain lines
    [$path, $query, $fragment] = split_uri_tail ($uri);

    // extend paths or handle data URIs
    if ($uri_type === URI_LOCAL):
        if (is_null ($canonical = $this->normalize_path ($path))):
            return NULL;
        elseif (is_null ($text = file_get_lines ($this->root . $canonical, $query))):
            $this->error ('Not a readable file: ' . $path, 192, $errline);
            return NULL;
        endif;
    elseif ($uri_type === URI_DATA):
        // data URIs have neither query nor fragment
        if (is_null ($text = $this->data_uri ($uri))):
            return NULL;
        endif;
    else: // prevent remote file inclusion
        $this->error ('Forbidden URI type for file: use local filename or data URI', 200, $errline);
        return NULL;
    endif;

    // cap the text at the textcap
    if ($do_cap):
        $textcap = $this->textcap ?? self::default_textcap;
        if (strlen ($text) > $textcap):
            $this->error ("File exceeds textcap of $textcap: " . $path, 193, $errline);
            $text = substr ($text, 0, $textcap);
        endif;
    endif;

    return $do_normalize? normalize_text ($text): $text;
}


/* Loads, handles and returns a text file located at $uri and announced with [t]
 * or [t-…]. In the latter case the processing is done by a T-module. Which one
 * is specified by $subtype. The optional $clue is passed to the module. The
 * module could be used to do syntax highlighting or to eval code for example.
 * Its output is expected to be UTF-8 encoded and returned.
 * The optional $caption is informational and passed through for the form API.
 */
private function file_text (
    string $uri,
    string|null $subtype,
    string|null $clue,
    string|null $caption
):    string
{
    // read the file
    $text = $this->file_raw ($uri, false);

    // process the file content as text
    return is_null ($text)? '': $this->process_file_text ($text, $subtype, $clue, $caption);
}


/* Loads a tab-seperated values (TSV) file, which is supposed to be UTF-8
 * encoded and located at $uri. Returns the file contents processed as a HTML
 * <table>. If $is_aneamal is true, Aneamal phrase markup is interpreted in
 * the field names and values. If $transpose is true, the table is transposed,
 * i.e. rows become columns and columns become rows.
 */
private function file_tsv (
    string $uri,
    bool $is_aneamal = false,
    bool $transpose = false
):    string
{
    // read the file and check its encoding
    $data = $this->file_raw ($uri);

    // process the file content as tab-separated values
    return is_null ($data)? '': $this->process_file_tsv ($data, $is_aneamal, $transpose);
}


/* Returns an HTML form elment with hidden data to be included in a submission:
 * _form ID to distinguish different forms on the same webpage; _time stamp of
 * when the form HTML was generated; a timestamp authentication code _taco. The
 * _time stamp is for checking whether a form submission is outdated. It could
 * also be used by modules to confirm that a user reloaded the page before
 * making another submission. The _taco is to confirm the authenticy of _time
 * so that spammers can not make a form appear *newer* than it is.
 * HTML encourages browsers to submit forms with a single textbox implicitly
 * (i.e. without activation of a submit button) when a user hits the "enter"
 * key. This behavior is not wanted and useless for forms without submit button
 * in Aneamal, so a hidden disabled submit button prevents it. See
 * https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission
 */
private function form_element (
):    string
{
    // Microsecond precision makes _time stamp collisions of webpages unlikely.
    [$fraction, $seconds] = explode ("\x20", microtime ());
    $time = $seconds . '.' . substr ($fraction, 2, 6);
    // SHA3 is supposedly suitable for a message authentication code when
    // applied to a fixed-length key followed by the message.
    $salsa = query_memory ('salsa', fn () => base64_encode (random_bytes (33)));
    $taco = hash ('sha3-256', "$salsa $this->form $time");
    // Enctype multipart/form-data is often more verbose than the default, but
    // permits file uploads that PHP accepts, which modules may do.
    return <<<HTML
        <form id='$this->form' method='post' enctype='multipart/form-data' hidden>
        <input name='_form' type='hidden' value='$this->form'>
        <input name='_time' type='hidden' value='$time'>
        <input name='_taco' type='hidden' value='$taco'>
        <input type='submit' disabled>
        </form>\n
        HTML;
}


/* Checks whether an active Aneamal form in this file (i.e. one that has been
 * submitted) includes a valid _time stamp and aborts the submission, if not.
 * The return value is positive, if the valid _time stamp is younger than the
 * modification time of the corresponding *.nml file (i.e. the form has not
 * changed in the meanwhile), and negative for an out-dated _time stamp.
 * Otherwise 0 is returned.
 */
private function form_time_check (
):    int
{
    // Simply return 0, if this Aneamal file does not have an active form.
    if (!isset ($_POST['_form']) or $_POST['_form'] !== $this->form):
        return 0;
    endif;

    // Abort the submission, if it lacks the hidden fields provided by Aneamal.
    if (!isset ($_POST['_taco'], $_POST['_time']) or !is_numeric ($_POST['_time'])):
        $_POST = $this->post = [];
        return 0;
    endif;

    // The return value of this function is cached for valid _time stamps, so it
    // is returned here without re-computation in case of repeated use.
    static $cache = [];
    if (isset ($cache[$key = "$_POST[_form] $_POST[_time] $_POST[_taco]"])):
        return $cache[$key];
    endif;

    // Get the key for validating the _time. $salsa will be NULL, if no key has
    // been set yet or the memory has just been erased. In that case, the _time
    // will be accepted without validation.
    $salsa = query_memory ('salsa');

    // Abort the submission in case of a failed validation.
    // NOTE: The validation can also fail, if the Aneamal Translator's memory
    // was erased by the admin after the submitted form had been generated, but
    // the memory has been filled anew due to a request of the page since then.
    if (isset ($salsa) and $_POST['_taco'] !== hash ('sha3-256', "$salsa $_POST[_form] $_POST[_time]")):
        $_POST = $this->post = [];
        return 0;
    endif;

    // Cache and return the result of comparing the validated age of the
    // submitted form with the edit time of this corresponding *.nml file.
    return $cache[$key] = (int) $_POST['_time'] <=> filemtime ($this->root . $this->dir . '/' . $this->filename);
}


/* Returns an ID to be used for the Aneamal form in this Aneamal file. It
 * depends on the kind of this file, i.e. its function in the webpage layout,
 * so that the ID does not easily change due to additions to the webpage. Forms
 * in embedded/linked files and quotation blocks get an enumerated ID which can
 * change unfortunately, if another form is embedded/linked/quoted earlier.
 */
private function form_id (
):    string
{
    return match ($this->filekind) {
        self::main => '_fm',
        self::header => '_fh',
        self::footer => '_ff',
        self::aside => '_fa',
        default => get_unique ('<form>', '_f'),
    };
}


/* Encodes a $string for use as a HTML id/URL fragment. The returned value only
 * contains small letters, numbers and the hyphen. Returns an empty string on
 * error. Except for the empty string, the same value can only be returned once
 * with $unique being set. Use this to guarantee unique HTML id attributes.
 */
private function fragment_identifier (
    string $string,
    bool $unique = false
):    string
{
    static $uniques = [];

    // create the ID by x_encode'ing the lowercase'd string
    $id = prepare_html_id ($string);
    if ($id === ''):
        $this->error ('Content missing in target: ' . $string, 19);
        return '';
    endif;

    // check the ID's uniqueness if required
    if ($unique):
        if (in_array ($id, $uniques, true)):
            $this->error ('Target already set: ' . $id, 20);
            return '';
        else:
            $uniques[] = $id;
        endif;
    endif;

    return $id;
}


/* Returns a list of errors that occurrend since this method was last called
 * while processing the current document (or since its start). The reported
 * errors are cleared from the errors array. $message is a heading for the error
 * messages, $from and $to provide a line number range in which the errors
 * occurred (inclusively, starting at 0). An empty string is returned if no
 * errors occurred.
 */
private function get_errors (
    string $message,
    int|null $from = NULL,
    int|null $to = NULL
):    string
{
    // return empty string if no errors occurred
    if (empty ($this->errors)):
        return '';
    endif;

    $uri = encode_special_chars ($this->errormore ?? self::error_base_url);

    $errors = '';
    $codes = [];
    foreach ($this->errors as [$msg, $code, $line]):
        $line = isset ($line)? 'line ' . strval ($line + 1) . ', ': '';
        if ($code):
            $errors .= "<span class='_$code'><br>$code: " . encode_special_chars ($msg) . " ($line<a href='{$uri}{$code}'>more info</a>).</span>";
            $codes[] = $code;
        else:
            $errors .= "<span><br>" . encode_special_chars ($msg) . "</span>";
        endif;
    endforeach;
    $codes = array_unique ($codes, SORT_NUMERIC);
    sort ($codes, SORT_NUMERIC);
    $classes = "_error _" . implode (' _', $codes);

    // compile information about the file/block where the errors occurred
    $info = encode_special_chars ($this->dir . '/' . $this->filename . match ($this->filekind) {
        self::embedded => ': embedded file',
        self::quoted => ': quotation',
        default => '',
    });
    if (isset ($from, $to) and $from !== $to):
        $info .= ', lines ' . strval ($from + 1) . ' to ' . strval ($to + 1);
    elseif (isset ($from) or isset ($to)):
        $info .= ', line ' . strval (($from ?? $to) + 1);
    endif;

    // clear error array
    $this->errors = [];

    // return the error messages
    return "<div class='$classes' style='" . self::error_style . "' data-nosnippet><b>$message</b> ($info): $errors</div>\n";
}


/* Finds the end of a word or bare string `...` or URI inside $string whose
 * start is initially given by &$index. Then &$index is set to the position of
 * the last character in the word or bare string or URI. The word, bare string
 * stripped of its enclosing backticks or URI is returned. If $is_uri is true,
 * less characters are interpreted as markup which would mark the start of a new
 * group and hence end a word, e.g. ~ is a valid character in an URI but not
 * inside a word, as it usually marks a gently emphasized string. Note that
 * &$index will be one less at the end than at the beginning, if no word or bare
 * group or URI is marked by &$index at the beginning. For example consider the
 * string "#. " between the quotation marks: at &$index 1, the dot, no word or
 * bare string or URI is found, so &$index would be set to 0 and an empty string
 * returned.
 */
private function group (
    string $string,
    int &$index,
    bool $is_uri = false
):    string
{
    // handle bare strings separately
    if ($string[$index] === '`'):
        return $this->enclosed ($string, $index, $is_uri);
    endif;

    for ($i = $index, $last = strlen ($string) - 1; $i <= $last; ++$i):
        switch ($string[$i]):
            // a backslash protects the next character
            case '\\':
                $i < $last and ++$i;
                break 1;
            // unprotected whitespace, hint, reference ... end a group
            case "\t":
            case "\n":
            case ' ':
            case '{':
            case '^':
            case '`':
                break 2;
            // punctuation before whitespace, reference or the end ends a group
            case '!':
            case ',':
            case '.':
            case ':':
            case ';':
            case '?':
                if ($i === $last or str_contains (self::space . '^', $string[$i + 1])):
                    break 2;
                else:
                    break 1;
                endif;
            // unprotected pairwise markup ends a non-URI group and is not
            // allowed (unprotected) in an URI that is not enclosed by `...`
            case '|':
            case '$':
                if ($is_uri):
                    $this->error ('Unprotected special character in URI: ' . $string[$i], 155);
                    break 1;
                else:
                    break 2;
                endif;
            // unprotected emphasis marks etc. end a non-URI group
            case '*':
            case '_':
            case '~':
            case '"':
            case '&':
            case '#':
            case '@':
                if ($is_uri):
                    break 1;
                else:
                    break 2;
                endif;
            // left brackets end a non-URI nesting group
            case '(':
            case '[':
                $j = $i;
                if ($is_uri or is_null ($this->bracketed ($string, $j))):
                    break 1;
                else:
                    break 2;
                endif;
            // a plus which is part of crossed-out markup and an equals sign as
            // part of inline note markup end a non-uri group
            case '+':
            case '=':
                if (!$is_uri and $i < $last and $string[$i + 1] === '-'):
                    break 2;
                else:
                    break 1;
                endif;
            // a hyphen which is part of a link arrow ends a group
            case '-':
                if ($i < $last and $string[$i + 1] === '>'):
                    break 2;
                else:
                    break 1;
                endif;
        endswitch;
    endfor;

    // copy the group from the string and set the index to the group's last
    // character
    $group = substr ($string, $index, $i - $index);
    $index = $i - 1;

    // remove slashed linebreaks and return the result (all linebreaks are
    // slashed linebreaks here)
    return str_replace ("\\\n", '', $group);
}


/* Returns the HTML <head> for this Aneamal document using mainly information
 * from metadata declarations.
 */
private function head (
):    string
{
    // output is always UTF-8 encoded
    $head = ["<meta charset='UTF-8'>"];

    // the title comes from @title metadata or the main heading
    $head[] = '<title>' . encode_special_chars ($this->title . $this->titletail) . "</title>";

    // add general <meta> data, including a meta tag 'generator'
    ksort ($this->metas);
    foreach ($this->metas as $name => $val):
        $head[] = sprintf (self::recognized_html_metas[$name], encode_special_chars ($val));
    endforeach;
    $head[] = "<meta name='generator' content='Aneamal'>";

    // add <link>s to alternate language files
    ksort ($this->altlangs);
    foreach ($this->altlangs as $lang => $val):
        $head[] = "<link rel='alternate' hreflang='$lang' href='" . encode_special_chars (strip_nml_suffix ($val)) . "'>";
    endforeach;

    // add further meta <link>s
    ksort ($this->links);
    foreach ($this->links as $name => $val):
        $head[] = sprintf (self::recognized_html_links[$name], encode_special_chars (strip_nml_suffix ($val)));
    endforeach;

    // add html meta content
    array_push ($head, ...$this->metahtmls);

    // add stylesheets
    foreach (array_reverse (array_unique (array_reverse ($this->stylesheets))) as $url):
        $head[] = "<link rel='stylesheet' href='" . encode_special_chars ($url) . "'>";
    endforeach;
    foreach (array_reverse (array_unique (array_reverse ($this->metastyles))) as $val):
        $head[] = "<style>\n{$val}\n</style>";
    endforeach;

    return "<head>\n" . implode ("\n", $head) . "\n</head>\n";
}


/* Identifies the end of a hook in a $string, translates it into an HTML <span>
 * element with class attribute and returns it. The position of the first
 * character & of the hook must be given in &$index at the beginning and is
 * set to the position of the last character at the end. If the & is directly
 * followed by a hint {…}, the hook will be invisible in the HTML output, but
 * it will still be possible to hook into it. If the & is directly followed by
 * metadata @, the metadata value will be displayed in the HTML output, but the
 * metadata name must be used to hook into it.
 */
private function hook (
    string $string,
    int &$index
):    string
{
    // a single ampersand at the end of the string is a valid hook
    if (!isset ($string[++$index])):
        return "<span class='_hook'></span>";
    endif;

    // extract and process the hook name
    $text = '';
    if ($string[$index] === '{'):
        $class = prepare_html_id ($this->encurled ($string, $index));
    else:
        $class = prepare_html_id ($this->targeted ($string, $index, $text));
    endif;

    // prepare and return the HTML output
    $class === '' or $class = " $class";
    return "<span class='_hook{$class}'>$text</span>";
}


/* Returns a HTML link <a> to the given $url. $text is used as link text. If no
 * $text is given, i.e. an empty string, the slightly edited $url is used as
 * link text too. If $unendorsed is true, the link will communicate to search
 * engines that it shall not be seen as a positive sign for the linked location
 * and to browsers that they shall not send a referrer to the linked location.
 */
private function hyperlink (
    string $url,
    string $text = '',
    bool $unendorsed = false
):    string
{
    // return a placeholder for empty $urls, used where a link would usually
    // have been expected, e.g. in a menu for the link to the current webpage
    if ($url === ''):
        if ($text === ''):
            $this->error ('Link text or address required', 227);
        endif;
        return "<a>$text</a>";
    endif;

    // prepare URLs depending on their type
    $uri_type = get_uri_type ($url);
    if ($uri_type === URI_LOCAL):
        if (is_null ($canonical = $this->normalize_path (strip_nml_suffix ($url)))):
            $href = '';
        else:
            $href = $this->home . $canonical;
        endif;
    else:
        $href = $url;
    endif;
    $href = encode_special_chars ($href);

    // generate a description if no description is given
    if ($text === ''):
        if ($uri_type === URI_DATA):
            $this->error ('Data-URI link without description', 118);
            return '';
        elseif ($uri_type === URI_REMOTE):
            $text = $href;
        else:
            $text = encode_special_chars (strip_nml_suffix ($url));
        endif;
    endif;

    // handle optional unendorsement
    $rel = $unendorsed? " rel='nofollow noreferrer'": '';

    // return the URI
    return "<a href='$href'$rel>$text</a>";
}


/* Analyzes the $initial part of a line from a numbered list, which may be an
 * item number like A.2.1. for a new list item. In that case the method returns
 * an array of arrays [0 => numeral type, 1 => decimal value as string] which
 * represent the parts of the item number. Otherwise an empty array is returned.
 * The numeral type is '1', 'A', 'a' or '?' and the decimal value is handled as
 * string so that it works for arbitrarily large values. The parameter
 * $previous must be such an array which represents the previous item's number.
 */
private function item_number (
    string $initial,
    array $previous
):    array
{
    // if the initial part does not end with a dot, it's not an item number
    if ($initial[-1] !== '.'):
        return [];
    endif;

    // split the item number at the dots, stripping an optional leading hash
    $parts = explode ('.', substr ($initial, $initial[0] === '#'? 1: 0, -1));
    $current = [];

    $maxlevel = count ($parts) - 1;
    $prelevel = count ($previous) - 1;

    // the hierarchy level can only increase by one from item to item
    if ($maxlevel > $prelevel + 1):
        return [];
    endif;

    foreach ($parts as $level => $part):

        // the lack of a numeral in a part means this can not be an item number
        if ($part === '' or $part === '-'):
            return [];
        endif;

        // determine the numerals used for the item number part and its value
        if (is_made_of ($part, '0123456789')):
            $type = '1';
            $value = ltrim ($part, '0') ?: '0';
        elseif (is_made_of ($part, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')):
            $type = 'A';
            $value = convert_alpha_number ($part);
        elseif (is_made_of ($part, 'abcdefghijklmnopqrstuvwxyz')):
            $type = 'a';
            $value = convert_alpha_number ($part, true);
        elseif (is_made_of ($part, '-', '0123456789')):
            $type = '1';
            $value = '-' . ltrim (substr ($part, 1), '0');
            $value === '-' and $value = '0';
        elseif ($part === '?'): // automatic numbering
            if ($level > $prelevel): // implied: $level === $maxlevel
                $type = '?';
                $value = '1';
            elseif ($level === $maxlevel):
                $type = $previous[$level][0];
                $value = bcadd ($previous[$level][1], '1');
            else:
                $type = $previous[$level][0];
                $value = $previous[$level][1];
            endif;
        else:
            return [];
        endif;

        // the type must be the same as the previous item's on the same level
        if ($level <= $prelevel and $type !== $previous[$level][0]):
            return [];
        endif;

        // the value may not change from the previous item except on maxlevel
        if ($level < $maxlevel and $value !== $previous[$level][1]):
            return [];
        endif;

        $current[] = [$type, $value];

    endforeach;
    return $current;
}


/* Processes the $content of a list item, which can be either a file, a math
 * block or simply text, possibly with phrase markup, and returns the
 * corresponding HTML code.
 */
private function item (
    string $content
):    string
{
    // leading whitespace is insignificant
    $content = ltrim ($content);

    // file candidate, i.e. file or text with bracketed string at its start
    if (str_match ($content, ['[', self::alphanumeric, '-:]'])):
        return "\n" . $this->file ($content, true);
    endif;

    // math block
    if (str_starts_with ($content, '$$')):
        return "\n" . $this->math_block ($content);
    endif;

    return $this->phrase ($content);
}


/* Identifies the end of a link in $string and returns an HTML <a> element for
 * it. In Aneamal, a link is marked with an arrow -> and &$index must initially
 * provide the location of the greater-than sign in that arrow. The arrow can
 * be either followed by an URI for a regular link or by one of a few special
 * marks, e.g. @ in which case an URI already declared in metadata will be used.
 * At the end &$index will be set to the position of the last character of the
 * link in $string. The optional $text is used as link text. If the $text is
 * empty, a link text will be generated from what the arrow points at.
 */
private function link (
    string $string,
    int &$index,
    string $text = ''
):    string
{
    // unendorsed links do not give a positive ranking signal to search engines
    $unendorsed = false;

    // handle links without address at the end of the string
    if (!isset ($string[++$index])):

        $address = '';

    // distinguish between links to a target in this document ...
    elseif ($string[$index] === '#'):

        if (isset ($string[++$index])):
            $group = $this->targeted ($string, $index, $text);
            $address = $group === ''? '#': '#' . $this->fragment_identifier ($group);
        else:
            $address = '#'; // an empty target refers to the top of the page
        endif;

        // fallback, if neither $text was given nor set by $this->targeted
        $text === '' and $text = '#';

    // ... and links declared in metadata ...
    elseif ($string[$index] === '@'):

        // make sure there is another character
        if (!isset ($string[++$index])):
            $this->error ('Metadata name expected after ->@', 92);
            return '';
        endif;

        // retrieve the metadata name
        $name = stripslashes ($this->group ($string, $index));

        // find the address declared for the given metadata name
        if (isset ($this->metavars[$name][self::link])):
            $address = $this->metavars[$name][self::link];
            $text !== '' or $text = encode_special_chars ($name);
        elseif (isset ($this->metavars[$name])):
            $this->error ('Metadata name not declared with link value: @' . $name, 246);
            return '';
        else:
            $this->error ('Metadata name after ->@ not declared: ' . $name, 93);
            return '';
        endif;

    // ... and shortened data URIs ...
    elseif ($string[$index] === ','):

        $this->error ('Obsolete shorthand for data URI hyperlinks', 255);
        if (isset ($string[++$index])):
            $address = 'data:;charset=UTF-8;base64,' . stripslashes ($this->group ($string, $index, true));
        else:
            $address = 'data:;charset=UTF-8;base64,';
        endif;

    // ... and normal link
    else:

        // handle unendorsed links
        if ($unendorsed = $string[$index] === '!'):
            if (!isset ($string[++$index])):
                $this->error ('Filename/URI missing: address expected after ->!', 94);
                return '';
            endif;
        endif;
        $address = stripslashes ($this->group ($string, $index, true));

    endif;

    // create and return the HTML for the link
    return $this->hyperlink ($address, $text, $unendorsed);
}


/* Prepares one or more linked file(s), whose type is encoded in the file
 * $token. See function parse_file_token in func.php for a description of the
 * $token. $extra contains the path(s) of the linked file(s) and possibly a
 * caption which must be preceded by white space. For image and media types
 * i, j, v, w it can also contain hints before the white space. If a caption is
 * provided or the file is of type i, j, v, w, the processed file and caption
 * are returned as an HTML <figure>, otherwise the result is returned without
 * <figure> element.
 */
private function linked_file (
    string $extra,
    string $token
):    string
{
    [$type, $subtype, $clue] = parse_file_token ($token);

    if (!isset (self::max_links_for_type[$type])):
        $this->error ("Unrecognized file type: $type", 8);
        return '';
    endif;

    // find optional link(s), hint, ..., caption and id
    $links = [];
    $link_counter = 0;
    $caption = $hint = NULL;
    $id = '';
    while ($extra !== ''):

        // get the caption and detach the optional id from its start
        if (str_contains (self::space, $extra[0])):
            $caption = ltrim ($extra);
            $id = $this->detach_caption_target ($caption);
            break;

        // handle links
        elseif (str_starts_with ($extra, '->')):
            if (++$link_counter > self::max_links_for_type[$type]):
                $this->error ("Too many links for linked file of type: $type", 216);
                return '';
            elseif (!isset ($extra[2])):
                $this->error ('Filename/URI missing: filename expected after ->', 58);
                return '';
            elseif ($i = 1 and !is_null ($links[] = $this->address ($extra, $i))):
                $extra = substr ($extra, $i + 1);
            else:
                return '';
            endif;

        // handle hints
        elseif ($extra[0] === '{'):
            if (!in_array ($type, ['i', 'j', 'v', 'w'], true) or $hint !== NULL):
                $this->error ("Too many hints for linked file of type: $type", 217);
                return '';
            else:
                $i = 0;
                $hint = prepare_html_attribute ($this->encurled ($extra, $i));
                $extra = substr ($extra, $i + 1);
            endif;

        // handle unexpected stuff
        else:
            $this->error ("Missing whitespace before caption: $extra", 241);
            return '';
        endif;
    endwhile;

    // call the right method to process the given file type
    $file = match ($type) {
        'a' => $this->file_aneamal ($links[0], self::linked, $subtype),
        'b' => $this->file_tsv ($links[0]),
        'd' => $this->file_tsv ($links[0], true),
        'h' => $this->file_html ($links[0]),
        'i' => $this->file_image ($links, $clue, $hint),
        'j' => $this->file_preview ($links, $clue, $hint),
        'p' => $this->file_tsv ($links[0], false, true),
        'q' => $this->file_tsv ($links[0], true, true),
        't' => $this->file_text ($links[0], $subtype, $clue, $caption),
        'v' => $this->file_media ($links, $hint),
        'w' => $this->file_media ($links, $hint, true),
        'x' => $this->file_extension ($links, $subtype, $clue, $caption),
    };

    // return the optionally captioned result
    if ($caption !== NULL):
        return "<figure{$id}>\n{$file}<figcaption>" . $this->phrase ($caption) . "</figcaption>\n</figure>\n";
    elseif ($id !== ''):
        return "<figure{$id}>\n{$file}</figure>\n";
    elseif (in_array ($type, ['i', 'j', 'v', 'w'], true)):
        return "<figure>\n{$file}</figure>\n";
    else:
        return $file;
    endif;
}


/* Loads metadata from another Aneamal file, typically an automatically found
 * @meta.nml in the same or an ancestor directory. If an $uri is provided,
 * metadata will be loaded from the given file instead. Returns true, if
 * metadata has been loaded, and false otherwise.
 */
private function load_settings (
    string|null $uri = NULL
):    bool
{
    $meta = NULL;

    if (isset ($uri)):
        if (get_uri_type ($uri) !== URI_LOCAL):
            $this->error ('Forbidden URI type for @meta: use a local filename instead', 187);
            return false;
        endif;

        // split path from the query, which can be used to chose certain lines
        [$path, $query] = split_uri_tail ($uri);

        // resolve the file path so that it is relative to the Aneamal root
        if (is_null ($canonical = $this->normalize_path ($path))):
            return false;
        endif;

        // read the file content
        if (is_null ($text = file_get_lines ($this->root . $canonical, $query))):
            $this->error ('Not a readable file: ' . $path, 202);
            return false;
        endif;

        $meta = new self ($text, dirname ($canonical), $this->home, basename ($canonical), self::settings);
    else:
        foreach (get_directories ($this->dir) as $dir):
            $filename = $dir . '/@meta.nml';
            if (is_readable ($this->root . $filename)):
                $meta = new self (file_get_contents ($this->root . $filename), $dir, $this->home, '@meta.nml', self::settings);
                break;
            endif;
        endforeach;
    endif;

    // end here, if no metadata has been loaded
    if ($meta === NULL):
        return false;
    endif;

    // copy various properties set via metadata declarations
    foreach ([
        'classes',     // @class
        'errormore',
        'fixes',       // @fix
        'metahtmls',   // @htmlhead
        'javascripts', // @javascript
        'lang',        // @lang or @language
        'layout',
        'lazy',        // @load
        'modules',     // @math and @t-... and @x-...
        'pixels',
        'metascripts', // @script
        'metastyles',  // @style
        'stylesheets', // @stylesheet
        'textcap',
        'titletail',
    ] as $property):
        $this->$property = $meta->$property;
    endforeach;

    // copy metadata used as <link rel='$index'> in the HTML head
    foreach (['atom', 'icon', 'license', 'me', 'rss', 'up'] as $index):
        if (isset ($meta->links[$index])):
            $this->links[$index] = $meta->links[$index];
        endif;
    endforeach;

    // copy metadata used as <meta name='$index'> in the HTML head
    foreach (['author', 'publisher', 'robots', 'translator', 'viewport'] as $index):
        if (isset ($meta->metas[$index])):
             $this->metas[$index] = $meta->metas[$index];
        endif;
    endforeach;

    // copy metadata that can be used in the body
    $this->metavars = $meta->metavars;
    $this->markvars = $meta->markvars;

    $this->metaerror = $meta->get_errors ('Errors in meta file');
    return true;
}


/* Applies a function from a math module to a LaTeX math $expression to turn
 * the code into a nice looking formula and returns the result. $is_block says
 * whether the $expression is from a math block, displaystyle in LaTeX
 * terminology, or math phrase markup, textstyle in LaTeX terminology. The math
 * module should be located at aneamal/math/index.php and usually makes use of
 * an external program such as KaTeX, MathJax or mimeTex. The optional $label,
 * only available for blocks, can contain an equation number for example and a
 * target at the start of the $label can be used to link to the math expression.
 */
private function math (
    string $expression,
    bool $is_block = false,
    string|null $label = NULL
):    string
{
    // prepare the HTML wrapper
    if (!$is_block):
        $start = "<span role='math'>";
        $end = '</span>';
    elseif ($label === NULL):
        $start = "<div role='math'>\n";
        $end = "\n</div>\n";
    else:
        $id = $this->detach_caption_target ($label);
        $start = "<div{$id} role='math'>\n";
        $end = "\n<span class='_label'>" . $this->phrase ($label) . "</span>\n</div>\n";
    endif;

    // prepare data to be passed to the module
    $data = [
        // numeric indices for backwards compatibility with old modules
        0 => $expression,
        1 => $is_block? 'display': 'text',
        2 => $this->home . '/aneamal/math',
        3 => $this->home . $this->dir,
        // math module specific stuff
        'kind' => $is_block? 'block': 'string',
        'math' => $expression,
    ];

    $result = $this->use_module ('math', $data);

    if ($result === ''):
        return encode_special_chars ($expression);
    else:
        return $start . $result . $end;
    endif;
}


/* Prepares an Aneamal $block that contains a mathematical LaTeX formula for
 * output. $block must start with two dollar signs and contain two more dollar
 * signs to mark the end of the mathematical expression. It can be followed by
 * a math label.
 */
private function math_block (
    string $block
):    string
{
    if ($pos = strpos_unslashed ($block, '$$', 2)):
        if (!isset ($block[$pos + 2])):
            return $this->math (substr ($block, 2, $pos - 2), true);
        elseif (!str_contains (self::space, $block[$pos + 2])):
            $this->error ('Missing whitespace between $$ and math label', 121);
        endif;
        return $this->math (substr ($block, 2, $pos - 2), true, ltrim (substr ($block, $pos + 2)));
    else:
        $this->error ('Math block not closed: expected $$', 6);
        return $this->math (substr ($block, 2), true);
    endif;
}


/* Returns an array whose first item contains a given $uri of a media file
 * (image, audio, video, captions) prepared for use as a href or src attribute
 * in HTML. The second item is the $uri prepared for use in the local file
 * system, if it points to a local file. In case of an error, an item is false.
 */
function media_link (
    string $uri
):    array
{
    $uri_type = get_uri_type ($uri);

    if ($uri_type === URI_LOCAL):
        if (is_null ($canonical = $this->normalize_path ($uri))):
            return [false, false];
        endif;
        $uri = $this->home . $canonical;
        $filename = $this->root . substr ($canonical, 0, strcspn ($canonical, '?#'));
    elseif ($uri_type === URI_PAGE):
        $this->error ('Link expected to point to a file: ' . $uri, 204);
        return [false, false];
    endif;

    return [encode_special_chars ($uri), $filename ?? false];
}


/* Handles the metadata declaration of a metadata $name that is recognized by or
 * reserved for special use in Aneamal and whose value $val is plain text (in
 * contrast to a link). Some metadata declarations change the behaviour of the
 * Aneamal-HTML translator (e.g. 'pixels' sets the size for preview images).
 * Other metadata declarations are published in the HTML output as <meta>
 * elements inside the <head>. The output is done by other methods, but this one
 * prepares for it.
 */
private function meta_content (
    string $name,
    string $val,
    int $errline
):    void
{
    switch ($name):
        case 'aside':
            if ($val === 'off'):
                $this->aside = '';
            else:
                $this->error ('Invalid @aside value: ' . $val, 182, $errline);
            endif;
            return;
        case 'charset':
            if ($this->filekind === self::embedded or $this->filekind === self::quoted):
                $this->error ('@charset declared in embedded file or quotation block', 46, $errline);
            elseif (!in_array (strtoupper ($val), ['UTF-8', 'UTF8'])):
                $this->error ('Obsolete character encoding declared, use UTF-8 instead', 238, $errline);
            endif;
            return;
        case 'class':
        case 'classes':
            foreach (explode_comma_separated ($val) as $string):
                array_push ($this->classes, ...explode (' ', $string));
            endforeach;
            return;
        case 'dir':
            $direction = strtolower ($val);
            if ($direction === 'ltr' or $direction === 'rtl'):
                $this->direction = $direction;
            else:
                $this->error ('Invalid writing direction declared: ' . $val, 110, $errline);
            endif;
            return;
        case 'fix':
            $inherited = $this->fixes;
            $this->fixes = 0;
            foreach (explode_comma_separated ($val) as $fix):
                if (isset (self::recognized_fixes[$fix])):
                    $this->fixes |= self::recognized_fixes[$fix];
                elseif ($fix === 'inherit'):
                    $this->fixes |= $inherited;
                elseif ($fix !== ''):
                    $this->error ('Invalid @fix value: ' . $fix, 223, $errline);
                endif;
            endforeach;
            return;
        case 'footer':
            if ($val === 'off'):
                $this->footer = '';
            else:
                $this->error ('Invalid @footer value: ' . $val, 173, $errline);
            endif;
            return;
        case 'header':
            if ($val === 'off'):
                $this->header = '';
            else:
                $this->error ('Invalid @header value: ' . $val, 171, $errline);
            endif;
            return;
        case 'lang':
        case 'language':
            if (is_made_of ($val, self::alphanumeric . '-')):
                $this->lang = $val;
            else:
                $this->error ('Invalid characters in language code', 69, $errline);
            endif;
            return;
        case 'layout':
            if ($val === 'manual' or $val === 'blank'):
                $this->layout = $val;
            elseif ($val === 'auto'):
                $this->layout = NULL;
            else:
                $this->error ('Invalid @layout value: ' . $val, 169, $errline);
            endif;
            return;
        case 'load':
            if ($val === 'eager'):
                $this->lazy = 0;
            elseif ($val === 'lazy'):
                $this->lazy = 1;
            elseif ($val === 'auto'):
                $this->lazy = -1;
            else:
                $this->error ('Invalid @load value: ' . $val, 231, $errline);
            endif;
            return;
        case 'look':
            if ($val === 'off'):
                $this->look = '';
            else:
                $this->error ('Invalid @look value: ' . $val, 175, $errline);
            endif;
            return;
        case 'math':
            $this->modules['math'] = $val;
            return;
        case 'meta':
            if ($val === 'off'):
                $this->meta = '';
            else:
                $this->error ('Invalid @meta value: ' . $val, 195, $errline);
            endif;
            return;
        case 'pixels':
            $this->pixels = $val;
            return;
        case 'role':
            $this->role = $val;
            return;
        case 'textcap':
            $this->textcap = (int) $val;
            return;
        case 'title':
            $this->title = $val;
            return;
        case 'titletail':
            $this->titletail = $val === ''? '': " $val";
            return;
    endswitch;

    if (isset (self::recognized_html_metas[$name])):
        $this->metas[$name] = $val;
    elseif (str_starts_with ($name, 't-') or str_starts_with ($name, 'x-')):
        $this->modules[$name] = $val;
    else:
        $this->error ('Reserved metadata name: ' . $name, 190, $errline);
    endif;
}


/* Checks and registers a custom mark declaration. The custom marks $name must
 * start with an ampersand. Its $value should be either a path to a file or
 * the content of an embedded file in which case either $is_link or $is_embedded
 * should be true. If $is_fallback is true, then the custom mark is only
 * registered, if it has not been registered by a parent file before.
 */
private function meta_custom (
    string $name,
    string $value,
    bool $is_fallback,
    bool $is_link,
    bool $is_embedded,
    int $errline
):    void
{
    // Custom marks consist of an ampersand followed by a letter or digit.
    if (strlen ($name) !== 2 or !str_contains (self::alphanumeric, $name[1])):
        $this->error ('Malformed custom-mark declaration', 95, $errline);
        return;
    endif;

    // Each custom mark may only be defined once per Aneamal file.
    if (isset ($this->metadecs[$name])):
        $this->error ('Custom mark already declared: ' . $name, 96, $errline);
        return;
    endif;
    $this->metadecs[$name] = true;

    // Stop if this custom mark declaration is just a fallback and the mark has
    // been declared in a parent file such as @meta.nml or a template.
    if ($is_fallback and isset ($this->markvars[$name])):
        return;
    endif;

    // Register the custom mark:
    if ($is_embedded):
        $this->markvars[$name] = $value;
    elseif ($is_link):
        $this->markvars[$name] = $this->file_raw ($value, true, false, $errline) ?? '';
    else:
        $this->error ('Custom mark not declared as link or embedded file', 120, $errline);
    endif;
}


/* Handles the metadata declaration of a metadata $name that is recognized by or
 * reserved for special use in Aneamal and whose value $val is an embedded file.
 * The output is done by other methods, but this one prepares for it.
 */
private function meta_embedded (
    string $name,
    string $val,
    int $errline
):    void
{
    switch ($name):
        case 'htmlhead':
            $this->metahtmls[] = $val;
            return;
        case 'math':
            $this->modules['math'] = $val;
            return;
        case 'script':
            $this->metascripts[] = $val;
            return;
        case 'style':
            $this->metastyles[] = $val;
            return;
    endswitch;

    if (str_starts_with ($name, 't-') or str_starts_with ($name, 'x-')):
        $this->modules[$name] = $val;
    else:
        $this->error ('Embedded file not supported for this metadata name @' . $name, 154, $errline);
    endif;
}


/* Handles the metadata declaration of a metadata $name that is recognized by or
 * reserved for special use in Aneamal and whose value $val is an URI (in
 * contrast to plain text). Some metadata declarations change the behaviour of
 * the Aneamal-HTML translator (e.g. 'errormore' sets the base URL for more
 * information in case of errors). Other unescaped variables will later be
 * published in the HTML output as <link> elements inside the <head>. The output
 * is done by other methods, but this one prepares for it.
 */
private function meta_link (
    string $name,
    string $val,  // URI as given in the meta declaration
    string $uri,  // URI already checked and prepared for use in links
    int $errline
):    void
{
    switch ($name):
        case 'aside':
            $this->aside = $val;
            return;
        case 'errormore':
            $this->errormore = $uri;
            return;
        case 'footer':
            $this->footer = $val;
            return;
        case 'header':
            $this->header = $val;
            return;
        case 'htmlhead':
            $this->metahtmls[] = $this->file_raw ($val, true, false, $errline) ?? '';
            return;
        case 'javascript':
            $this->javascripts[] = $uri;
            return;
        case 'look':
            $this->look = $uri;
            return;
        case 'math':
            $this->modules['math'] = $this->file_raw ($val, true, false, $errline);
            return;
        case 'meta':
            $this->meta = $val;
            return;
        case 'script':
            $this->metascripts[] = $this->file_raw ($val, true, false, $errline) ?? '';
            return;
        case 'style':
            $this->metastyles[] = $this->file_raw ($val, true, false, $errline) ?? '';
            return;
        case 'stylesheet':
            $this->stylesheets[] = $uri;
            return;
    endswitch;

    if (isset (self::recognized_html_links[$name])):
        $this->links[$name] = $uri;
    elseif (str_starts_with ($name, 'lang-')):
        $lang = substr ($name, 5);
        if ($lang === ''):
            $this->error ('Alternative-language code missing', 85, $errline);
        else:
            $this->altlangs[$lang] = $uri;
        endif;
    elseif (str_starts_with ($name, 't-') or str_starts_with ($name, 'x-')):
        $this->modules[$name] = $this->file_raw ($val, true, false, $errline);
    else:
        $this->error ('Reserved metadata name: ' . $name, 191, $errline);
    endif;
}


/* Handles a metadata declaration - that is a metadata value $val assigned to a
 * $name - of various types, which occurred or started in line $errline. Values
 * can be plain text, link URIs or the content of an embedded file. In the
 * latter case, $is_embedded must be true. Metadata can be accessed from within
 * the main body of the document.
 */
private function metadata (
    string $name,
    string $val,
    int $errline,
    bool $is_embedded = false
):    void
{
    if ($name === '' or $name === '\\' or $name === '?'):
        $this->error ('Metadata name missing', 74, $errline);
        return;
    endif;

    // A question mark at the end of the name means that the value will only be
    // used, if it has not been inherited from a parent file.
    $is_fallback = $name[-1] === '?' && !is_slashed ($name, strlen ($name) - 1);
    if ($is_fallback):
        $name = rtrim (substr ($name, 0, -1));
    endif;

    // Determine the metadata type before simplifying the metadata name:
    $type = get_meta_type ($name);
    $name = self::metadata_aliases[$name] ?? stripslashes ($name);

    // Identify whether the value is an Aneamal link and turn it into just the
    // URI in that case.
    $is_link = false;
    if (!$is_embedded and $is_link = str_starts_with ($val, '->')):
        $length = strlen ($val);

        // retrieve the address
        $pos = 1;
        $val = $this->address ($val, $pos, false);
        if ($val === NULL):
            return;
        endif;

        // check whether the URL is followed by anything, which is forbidden
        if ($length > $pos + 1):
            $this->error ('Characters after link in metadata declaration for @' . $name, 60, $errline);
            return;
        endif;
    endif;

    // Handle custom mark declarations:
    if ($type === META_CUSTOM):
        $this->meta_custom ($name, $val, $is_fallback, $is_link, $is_embedded, $errline);
        return;
    endif;

    // ASCII characters except alphanumerics and hyphen are not allowed in
    // metadata names.
    if (strlen ($name) !== strcspn ($name, "\t !\"#$%&'()*+,./:;<=>?@[\\]^_`{|}~")):
        $this->error ('Unexpected character in metadata name: ' . $name, 189, $errline);
        return;
    endif;

    // Each metadata name except for a select few may only be declared locally
    // once, so we keep track of these.
    if (isset ($this->metadecs[$name]) and !in_array ($name, self::metadata_multiples)):
        $this->error ('Metadata name already declared: ' . $name, 194, $errline);
        return;
    endif;
    $this->metadecs[$name] = true;

    // Stop if this metadata declaration is just a fallback and the name has
    // been declared in a parent file such as @meta.nml or a template.
    if ($is_fallback and isset ($this->metavars[$name])):
        return;
    endif;

    // Process the metadata declaration depending on the form of its content:
    if ($is_embedded):

        // Register the embedded file, but do not store its content in metavars
        // since files embedded in metadata cannot be accessed in the body.
        $this->metavars[$name] = [self::embd => true];

        // process embedded files further
        if ($type !== META_SPECIAL):
            $this->error ('Embedded file used for unrecognized metadata name: ' . $name, 244, $errline);
        else:
            $this->meta_embedded ($name, $val, $errline);
        endif;

    elseif ($is_link):

        // check and normalize URIs
        $uri = $val;
        if (get_uri_type ($uri) === URI_LOCAL):
            if (is_null ($val = $this->normalize_path ($val))):
                return;
            endif;
            $uri = $this->home . $val;
        endif;

        // Store the metadata link for later use in the body.
        $this->metavars[$name] = [self::link => $uri];

        // process special meta links further
        if ($type === META_SPECIAL and $val !== ''):
            $this->meta_link ($name, $val, $uri, $errline);
        endif;

    else: // textual value

        $val = stripslashes ($val);

        // Store the metadata for later use in the body.
        $this->metavars[$name] = [self::text => $val];

        // process special plain text meta variables further
        if ($type === META_SPECIAL):
            $this->meta_content ($name, $val, $errline);
        endif;

    endif;
}


/* Turns a $path like //foo/./bar/baz/../file into /foo/bar/file by treating a
 * single dot path component as same directory and a double dot component as
 * parent directory. Multiple slashes like // are turned into single ones.
 * Pathes that do not start with a slash like bar/file, i.e. pathes that are
 * relative to the directory of the file that is currently worked on, are
 * changed into pathes with a leading slash that are relative to the Aneamal
 * root directory. The input $path must not be empty and not start with ? or #.
 * Returns null on error, that is when a parent of the Aneamal root directory
 * is referenced.
 */
function normalize_path (
    string $path
):    string|null
{
    $parts = [];

    // remove and remember the query string and URL fragment from the path
    $pathlen = strcspn ($path, '?#');
    if ($pathlen < strlen ($path)):
        $tail = substr ($path, $pathlen);
        $path = substr ($path, 0, $pathlen);
    else:
        $tail = '';
    endif;

    // make path relative to the Aneamal root directory if it is not yet
    if ($path[0] !== '/'):
        $path = $this->dir . '/' . $path;
    endif;

    // split the path into its components (folders and file)
    foreach (explode ('/', substr ($path, 1)) as $part):
        // don't add . to the normalized path
        if ($part === '.'):
            continue;
        // drop the current path when encoutering ..
        elseif ($part === '..'):
            if (array_pop ($parts) === NULL):
                $this->error ('Linked parent of Aneamal root directory', 51);
                return NULL;
            endif;
        // add non-empty component to the normalized path
        elseif ($part !== ''):
            $parts[] = $part;
        endif;
    endforeach;

    // return the resolved path with leading and possibly trailing slash
    if (empty ($parts)):
        return '/' . $tail;
    elseif ($part === '.' or $part === '..' or $path[-1] === '/'):
        return '/' . implode ('/', $parts) . '/' . $tail;
    else:
        return '/' . implode ('/', $parts) . $tail;
    endif;
}


/* Parses $lines that together form a numbered list and returns it as HTML <ol>
 * list. Numbered lists may be hierarchical and every sublist may use its own
 * enumeration type. However items on the same level of the same sublist must
 * use the same enumeration type (e.g. decimal numbers).
 */
private function numbered_list (
    array $lines
):    string
{
    $previous = [];
    $list = $item = '';
    $prelevel = $maxlevel = -1;
    $fixbignum = $this->fixes & self::recognized_fixes['list-numbers'];

    foreach ($lines as $line):
        $initial_length = strcspn ($line, "\t\x20");
        $initial = substr ($line, 0, $initial_length);
        $current = $this->item_number ($initial, $previous);

        // if the current line belongs to the latest item
        if ($current === []):
            $item .= "\n" . $line;

        // if the current line constitutes a new item
        else:
            $list .= $this->item ($item);
            $maxlevel = count ($current) - 1;
            $type = $current[$maxlevel][0];
            $value = $current[$maxlevel][1];

            // CSS to display numbers too big for browsers' 32-bit calculation
            if ($fixbignum and bccomp (ltrim ($value, '-'), '2147483647') === 1 and !is_null ($style = convert_number_string ($value, $type))):
                $style = " style='list-style-type:\"$style. \"'";
            else:
                $style = '';
            endif;

            // the item identifier can be a target for links; handle it
            $name = $initial[0] === '#'? $this->fragment_identifier (substr ($initial, 1), true): '';
            $id = $name === ''? '': " id='$name'";

            // if the new item is deeper in the hieararchy than the previous
            if ($maxlevel > $prelevel):
                $list .= $type === '?'? "\n<ol>\n<li{$id}>": "\n<ol type='$type'>\n<li{$id}{$style} value='$value'>";

            else:
                // if the new item is higher in the hierarchy than the previous
                if ($maxlevel !== $prelevel):
                    $list .= str_repeat ("</li>\n</ol>\n", $prelevel - $maxlevel);
                endif;
                $list .= $type === '?'? "</li>\n<li{$id}>": "</li>\n<li{$id}{$style} value='$value'>";
            endif;

            $item = substr ($line, $initial_length);
            $previous = $current;
            $prelevel = $maxlevel;
        endif;
    endforeach;

    return substr ($list, 1) . $this->item ($item) . str_repeat ("</li>\n</ol>\n", $maxlevel + 1);
}


/* Parses $lines that make up the options in an options block. Each option
 * consists of a key enclosed in curly brackets at the beginning of a line and
 * an answer associated with that key. There can be a $question associated with
 * the options. Returns a HTML <fieldset> with the $question as <legend> and for
 * each option a checkbox or radio button for the key and <label> for the
 * answer. Feeds the form API.
 */
private function options (
    array $lines,
    string|null $question = NULL
):    string
{
    // This block is active, if it belongs to a form that has been posted.
    $this->form ??= $this->form_id ();
    $active = isset ($_POST['_form']) && $_POST['_form'] === $this->form;

    // Join the lines of the block into distinct options and Preprocess each
    // option, extracting [key, modifier, answer]. We also count here how many
    // mutually exclusive, preselected and regular options occur.
    $prep = [];
    $exclusive = $selected = $regular = 0; // counters
    foreach (joint ($lines, fn ($x) => $x[0] === '{') as $k => $option):

        // Extract the key, its end being marked by a right curly bracket:
        $pos = strpos_unslashed ($option, '}', 1);
        if ($pos === NULL):
            $this->error ('Malformed option: expected }', 112, $k);
            continue;
        endif;
        $key = trim (strip_slashed_breaks_and_slashes (substr ($option, 1, $pos - 1)));

        // Add keys without modifier and answer to the preprocessed array:
        if (!isset ($option[++$pos])):
            $prep[] = [$key, NULL, NULL];
            ++$regular;
            continue;
        endif;

        // Distinguish between mutually exclusive, preselected and regular
        // options based on the modifier, a single (non-whitespace) byte right
        // after the right curly bracket:
        if ($option[$pos] === "'"):
            $modifier = $option[$pos++];
            ++$exclusive;
        elseif ($option[$pos] === "-"):
            $modifier = $option[$pos++];
            ++$selected;
        else:
            $modifier = str_contains ("!01", $option[$pos])? $option[$pos++]: NULL;
            ++$regular;
        endif;

        // Add options with modifier or answer to the preprocessed array:
        if (!isset ($option[$pos])):
            $prep[] = [$key, $modifier, NULL];
        elseif (str_contains (self::space, $option[$pos])):
            $prep[] = [$key, $modifier, ltrim (substr ($option, $pos + 1))];
        else:
            $prep[] = [$key, $modifier, substr ($option, $pos)];
            $this->error ('Missing whitespace between key and answer in an option', 205, $k);
        endif;

    endforeach;

    // Determine the HTML <input type>: radio button for valid mutually
    // exclusive options, i.e. when all options are marked as exclusive except
    // for at most a single preselected option. Otherwise checkboxes are used.
    if ($regular === 0 and $exclusive > 0 and $selected <= 1):
        $type = 'radio';
        $name = get_unique ($this->form);
    else:
        $type = 'checkbox';
        if ($exclusive > 0):
            $this->error ('Mix of mutually exclusive and non-exclusive options', 249);
        endif;
    endif;

    // Generate the HTML for each option and in an active form the respective
    // data for the form API.
    $html = $data = [];
    $mismatch = NULL;
    foreach ($prep as [$key, $modifier, $answer]):

        // Generate a name that is unique within the form for checkboxes. Radio
        // buttons share a common name that has been generated earlier.
        if ($type === 'checkbox'):
            $name = get_unique ($this->form);
        endif;

        // Attributes for the HTML <input>, including a globally unique ID.
        $attr = [
            'id'    => $id = get_unique (),
            'name'  => $name,
            'form'  => $this->form,
            'type'  => $type,
            'value' => $key,
        ];

        // Differentiate finer between different kinds of options:
        // option that is selected by default
        if ($modifier === '-'):
            $attr['checked'] = NULL;
        // option that MUST be selected to submit
        elseif ($modifier === '!'):
            $attr['required'] = NULL;
            // abort the submission, if this required option is not selected
            if ($active and !isset ($_POST[$name])):
                $active = false;
                $_POST = $this->post = [];
            endif;
        // option that SHOULD be selected (to pass a test)
        elseif ($modifier === '1'):
            if ($active and !$mismatch):
                $mismatch = !isset ($_POST[$name]);
            endif;
        // option that SHOULD not be selected (to pass a test)
        elseif ($modifier === '0'):
            if ($active and !$mismatch):
                $mismatch = isset ($_POST[$name]);
            endif;
        endif;

        // Compose the HTML for the option:
        $attributes = implode_html_attributes ($attr);
        $html[] = "<input{$attributes}> <label for='$id'>" . $this->phrase ($answer ?? '') . "</label>";

        // Compose data about this option for the form API:
        if ($active and isset ($_POST[$name]) and $_POST[$name] === $key):
            $data[] = isset ($answer)? ['input' => $key, 'label' => $answer]: ['input' => $key];
        endif;

    endforeach;

    // Push data about this options block to the form API, even if no option has
    // been selected (so that $data is empty).
    if ($active):
        $this->post[] = isset ($question)? ['topic' => $question, 'block' => $data]: ['block' => $data];
        if (isset ($mismatch)):
            $this->post[array_key_last ($this->post)]['match'] = !$mismatch;
        endif;
    endif;

    // Return the HTML for this options block:
    $legend = isset ($question)? '<legend>' . $this->phrase ($question) . "</legend>\n": '';
    return "<fieldset>\n" . $legend . implode ("<br>\n", $html) . "\n</fieldset>\n";
}


/* Parses phrase markup in a $text and returns the text prepared for output as
 * HTML. This method is applied to text inside blocks: to list items, image
 * captions, paragraphs, heading content etc.
 */
private function phrase (
    string $text
):    string
{
    $output = $word = '';

    // $groupend becomes true without pushing the group into the output, if a
    // group has just ended. This is important so that a following link or hint
    // can be associated with the group or groups before it. If no link or hint
    // follow immediately, the group is pushed to the output before continuing.
    $groupend = false;

    // $linked and $hinted become true when a link or hint are not yet pushed to
    // the output. They can be used to prevent a chain of multiple links or
    // hints which don't make sense.
    $linked = $hinted = false;

    // go through the text, byte by byte
    for ($i = 0, $length = strlen ($text); $i < $length; ++$i):

        $char = $text[$i];

        // link, excluding references to a note
        if ($char === '-' and $length > $i + 1 and $text[$i + 1] === '>'):
            ++$i;
            $link = $this->link ($text, $i, $word);
            if ($linked):
                $this->error ('Multiple consecutive links', 88);
            elseif ($link !== ''):
                $word = $link;
            endif;
            $groupend = $linked = true;
            continue;
        // hint
        elseif ($char === '{'):
            $hint = prepare_html_attribute ($this->encurled ($text, $i));
            if ($word === ''):
                $this->error ('Annotated text missing: text expected before {', 56);
            elseif ($hinted):
                $this->error ('Multiple consecutive hints', 89);
            else:
                $word = "<span title='$hint'>$word</span>";
            endif;
            $groupend = $hinted = true;
            continue;
        elseif ($groupend === true):
            $output .= $word;
            $word = '';
            $groupend = $linked = $hinted = false;
        endif;

        switch ($char):
            case '\\': // marks next byte as literal character
                if ($length > ++$i and $text[$i] !== "\n"):
                    $word .= encode_printable_ascii ($text[$i]);
                endif;
                break;
            case '&': // hook or deprecated custom mark
                if ($length <= ++$i):
                    $output .= $word;
                    $word = "<span class='_hook'></span>";
                    $groupend = true;
                elseif (isset ($this->markvars['&' . $text[$i]])):
                    $word .= $this->markvars['&' . $text[$i]];
                elseif (isset (self::default_markvars[$text[$i]])):
                    $word .= self::default_markvars[$text[$i]];
                else:
                    $output .= $word;
                    --$i;
                    $word = $this->hook ($text, $i);
                    $groupend = true;
                endif;
                break;
            case '#': // target
                $output .= $word;
                $word = $this->target ($text, $i);
                $groupend = true;
                break;
            case '@': // metadata
                if ($length <= ++$i):
                    $this->error ('Metadata name expected after @', 133);
                    $word .= '&#64;';
                elseif (isset ($this->metavars[$name = stripslashes ($this->group ($text, $i))][self::text])):
                    $output .= $word;
                    $word = encode_special_chars ($this->metavars[$name][self::text]);
                    $groupend = true;
                elseif (isset (self::default_textvars[$name])):
                    $output .= $word;
                    $word = encode_special_chars (self::default_textvars[$name]);
                    $groupend = true;
                elseif (isset ($this->metavars[$name])):
                    $this->error ('Metadata name not declared with text value: @' . $name, 245);
                    $word .= '&#64;' . encode_special_chars ($name);
                else:
                    $this->error ('Metadata name after @ not declared: ' . $name, 134);
                    $word .= '&#64;' . encode_special_chars ($name);
                endif;
                break;
            case '`': // bare string, e.g. to define a link text
                $output .= $word;
                $word = $this->phrase ($this->enclosed ($text, $i));
                $groupend = true;
                break;
            case '^': // reference to a note
                $output .= $word;
                $word = $this->reference ($text, $i);
                $groupend = $linked = true;
                break;
            case '$': // math
                $output .= $word;
                $word = $this->math ($this->enclosed ($text, $i, true));
                $groupend = true;
                break;
            case '|': // code
                $output .= $word;
                $word = $this->code ($this->enclosed ($text, $i, true));
                $groupend = true;
                break;
            case '}':
                $this->error ('Unmatched right curly bracket: }', 57);
                $word .= '}';
                break;
            case '_': // supplementary emphasis
            case '~': // gentle emphasis
            case '*': // heavy emphasis
            case '"': // quoted string
                $output .= $word;
                $word = $this->emphasis ($this->enclosed ($text, $i), $char);
                $groupend = true;
                break;
            case "\n": // line break
                $output .= $word . '<br>';
                $word = '';
                break;
            case "\t":
            case "\x20": // end of a word
                $output .= $word . "\x20";
                $word = '';
                break;
            case '(':
            case '[': // brackets
                if (is_null ($group = $this->bracketed ($text, $i))):
                    $word .= $char;
                else:
                    $output .= $word;
                    $word = $char . $this->phrase ($group) . self::brackets[$char];
                    $groupend = true;
                endif;
                break;
            case '+':
            case '=':
                if ($length > $i + 1 and $text[$i + 1] === '-'):
                    $output .= $word;
                    $word = $this->emphasis ($this->enclosed2 ($text, $i), "$char-");
                    $groupend = true;
                else:
                    $word .= $char;
                endif;
                break;
            case '-':
                if ($length > $i + 1 and in_array ($text[$i + 1], ['+', '='], true)):
                    $mark = '-' . $text[++$i];
                    $this->error ('Unmatched right cross or unmatched right fork: ' . $mark, 207);
                    $word .= $mark;
                else:
                    $word .= '-';
                endif;
                break;
            case '<':
                $word .= '&#60;';
                break;
            case '>':
                $word .= '&#62;';
                break;
            default:
                $word .= $char;
        endswitch;

    endfor;

    return $output . $word;
}


/* Removes comments (i.e. lines starting with %) and interprets and removes
 * metadata declarations (i.e. lines starting with @). Despite removing lines,
 * the array keys of $this->lines are preserved.
 * There are two kinds of metadata, regular and embedded file metadata. The
 * initial line of regular metadata must contain an unslashed colon separating
 * the name from the value. The value may span multiple lines where all lines
 * start with @ and all but the last line end with \ and there are no other
 * lines in between except for comments. The initial line of embedded file
 * metadata does not contain an unslashed colon. Its name is the whole initial
 * line (without @ and trimmed). Its value is the embedded file content defined
 * by the following lines that start with @ followed by |.
 */
private function preprocess_comments_meta (
):    void
{
    $state = 0; // 0 (none), 1 (regular) or 2 (embedded)
    $val = NULL;
    foreach ($this->lines as $n => $line):

        // get the leftmost byte
        $left = $line[0] ?? NULL;

        // check for reserved semicolon
        if ($left === ';'):
            $this->error ('Reserved first character in line: semicolon ;', 210, $n);
        endif;

        // if in a comment line ...
        if ($left === '%'):

            // ... remove the line
            unset ($this->lines[$n]);

        // if in a metadata line ...
        elseif ($left === '@'):

            // get the line without the initial @
            $line = ltrim (substr ($line, 1));

            // if in an embedded file already
            if ($state === 2):
                if (str_starts_with ($line, '|')):
                    if ($val === NULL):
                        $val = substr ($line, 1);
                    else:
                        $val .= "\n" . substr ($line, 1);
                    endif;
                elseif ($val === NULL):
                    $this->error ('Value missing in metadata declaration for @' . $name, 240, $start); // multiple uses
                    $state = 0;
                else:
                    $this->metadata ($name, $val, $start, true);
                    $state = 0;
                endif;
            endif;

            // if not in multiline metadata yet
            if ($state === 0):
                $start = $n; // remembers where the metadata declaration started
                $statement = explode_unslashed (':', $line, 2);
                if (isset ($statement[1])):
                    $name = rtrim ($statement[0]);
                    $val = ltrim ($statement[1]);
                    $state = 1;
                elseif ($statement[0] !== ''):
                    $name = $statement[0];
                    $val = NULL;
                    $state = 2;
                endif;
            // if in multiline metadata already
            elseif ($state === 1):
                $val = substr ($val, 0, -1) . $line;
            endif;

            // if regular metadata doesn't continue as multiline metadata
            if ($state === 1 and ($val === '' or $val[-1] !== '\\' or is_slashed ($val, strlen ($val) - 1))):
                $this->metadata ($name, $val, $start);
                $state = 0;
            endif;

            unset ($this->lines[$n]);

        // if not in a metadata line, but an embedded file had been open
        elseif ($state === 2):

            if ($val === NULL):
                $this->error ('Value missing in metadata declaration for @' . $name, 240, $start); // multiple uses
            else:
                $this->metadata ($name, $val, $start, true);
            endif;
            $state = 0;

        // if not in a metadata line, but a metadata line was expected
        elseif ($state === 1):

            $this->error ('Malformed multiline metadata declaration for @' . $name, 100);
            $this->metadata ($name, substr ($val, 0, -1), $start);
            $state = 0;

        endif;

    endforeach;

    // if embedded file/multiline metadata is still open at the document end
    if ($state === 2):
        if ($val === NULL):
            $this->error ('Value missing in metadata declaration for @' . $name, 240, $start); // multiple uses
        else:
            $this->metadata ($name, $val, $start, true);
        endif;
    elseif ($state === 1):
        $this->metadata ($name, substr ($val, 0, -1), $start);
    endif;
}


/* Removes white space from the beginning and end of each line in the file and
 * converts sandwich markup to line style markup. Sandwich style markup begins
 * with a line like "/prefix/until" and ends with a line like "until" (quotes
 * not included). Converting it to line style markup means removing the initial
 * and final lines and prepending "prefix" to every line in between. Despite
 * removing lines, the array keys of $this->lines are preserved.
 */
private function preprocess_lines (
):    void
{
    $until = NULL;
    foreach ($this->lines as $n => $line):
        $trimmed = trim ($line);

        // if not inside a sandwich yet
        if ($until === NULL):
            $this->lines[$n] = $trimmed;

            // if in the initial line of sandwich markup
            if (str_starts_with ($trimmed, '/')):
                $close = strpos ($trimmed, '/', 1);
                if ($close):
                    $prefix = ltrim (substr ($trimmed, 1, $close - 1));
                    if ($prefix === ''):
                        $this->error ('Prefix missing in sandwich markup', 143, $n);
                    else:
                        $until = ltrim (substr ($trimmed, $close + 1));
                        unset ($this->lines[$n]);
                    endif;
                else:
                    $this->error ('Invalid sandwich markup: expected second /', 141, $n);
                endif;
            endif;

        // if in the final line of sandwich markup
        elseif ($until === $trimmed):
            $until = NULL;
            unset ($this->lines[$n]);

        // if inside a sandwich
        else:
            $this->lines[$n] = trim ($prefix . $this->lines[$n]);
        endif;

    endforeach;

    // if sandwich markup is still open at the document end
    if ($until !== NULL and $until !== ''):
        $this->error ('Bottom sandwich delimiter missing: ' . $until, 142);
    endif;
}


/* Parses an Aneamal text that has been included as linked or embedded file or
 * as a block quotation. A template which adds standard styles and scripts may
 * be used. These and the file's stylesheets and scripts are added to this
 * file's corresponding array. The file's meta title and general metadata are
 * not used.
 *
 * $text:       Aneamal text
 * $dir:        directory in which the parsed file is located relative to the
 *              Aneamal root, starting with a slash; needed to locate other
 *              files referenced from this
 * $filename:   basename of the the file the $text is written in
 * $kind:       an integer identifying the kind of Aneamal document
 * $tpl:        name of a metadata template file
 * $citation:   optionally a string that references the source of the text, this
 *              makes sense for quotation blocks
 */
private function process_file_aneamal (
    string $text,
    string $dir,
    string $filename,
    string $kind,
    string|null $tpl = NULL,
    string $citation = ''
):    string
{
    // cache for templates: they only need to be loaded once for multiple uses
    static $templates = [];

    $lazy = $pixels = NULL;
    $tplerrors = $role = '';
    $attr = $classes = $modules = [];
    $metavars = $this->metavars;
    $markvars = $this->markvars;

    // handle the optional template
    if (isset ($tpl)):
        if (!isset ($templates[$tpl])):
            if (
                is_readable ($this->root . ($tplfile = '/aneamal/a-' . $tpl . '/index.nml'))
                or is_readable ($this->root . ($tplfile = '/aneamal/a-' . $tpl . '.nml'))
            ):
                $templates[$tpl] = new self (
                    file_get_contents ($this->root . $tplfile),
                    dirname ($tplfile),
                    $this->home,
                    basename ($tplfile),
                    self::template,
                    $metavars,
                    $markvars
                );
                $this->javascripts = array_merge ($this->javascripts, $templates[$tpl]->javascripts);
                $this->metascripts = array_merge ($this->metascripts, $templates[$tpl]->metascripts);
                $this->stylesheets = array_merge ($this->stylesheets, $templates[$tpl]->stylesheets);
                $this->metastyles = array_merge ($this->metastyles, $templates[$tpl]->metastyles);
                if (!empty ($templates[$tpl]->lines) and implode ($templates[$tpl]->lines) !== ''):
                    $templates[$tpl]->error ('Content in template', 151, array_key_first (array_filter ($templates[$tpl]->lines, fn ($l) => $l !== '')));
                endif;
                $tplerrors = $templates[$tpl]->get_errors ('Errors in template');
            else:
                $this->error ('Template not readable: a-' . $tpl, 152);
            endif;
        endif;
        if (isset ($templates[$tpl])):
            $classes = array_merge (["a-$tpl"], $templates[$tpl]->classes);
            $role = $templates[$tpl]->role;
            $lazy = $templates[$tpl]->lazy;
            $pixels = $templates[$tpl]->pixels;
            $modules = $templates[$tpl]->modules;
            $metavars = $templates[$tpl]->metavars;
            $markvars = $templates[$tpl]->markvars;
        endif;
    endif;

    $doc = new self ($text, $dir, $this->home, $filename, $kind, $metavars, $markvars);

    // pass preview image size settings from template or main file to the $doc
    $doc->lazy ??= $lazy ?? $this->lazy;

    // pass preview image size settings from template or main file to the $doc
    $doc->pixels ??= $pixels ?? $this->pixels;

    // merge module configurations whereby settings in the doc take precedence
    $doc->modules = array_merge ($this->modules, $modules, $doc->modules);

    // pass base URI for detailed error explanations to the $doc
    $doc->errormore ??= $this->errormore;

    // pass size limit for text file inclusions to the $doc
    $doc->textcap ??= $this->textcap;

    // pass fixes to the $doc
    $doc->fixes ??= $this->fixes;

    // Pass language data to the $doc iff it lacks it. The writing direction is
    // only passed for embedded files and quotation blocks, since other files
    // set it to ltr in the constructor by default.
    $doc->lang ??= $this->lang;
    $doc->direction ??= $this->direction;

    // generate the document body of the file
    $body = $doc->body ();
    if ($body === '' and $citation === ''):
        return '';
    endif;

    // inherit styles and scripts
    $this->javascripts = array_merge ($this->javascripts, $doc->javascripts);
    $this->metascripts = array_merge ($this->metascripts, $doc->metascripts);
    $this->stylesheets = array_merge ($this->stylesheets, $doc->stylesheets);
    $this->metastyles = array_merge ($this->metastyles, $doc->metastyles);

    // prepare $doc's language data iff different from this document's data
    if ($this->lang !== $doc->lang):
        $attr['lang'] = $doc->lang;
    endif;
    if ($this->direction !== $doc->direction):
        $attr['dir'] = $doc->direction;
    endif;

    // prepare role and class, combined from the template and the $doc
    $role = trim ($role . ' ' . $doc->role);
    if ($role !== ''):
        $attr['role'] = $role;
    endif;
    if ($classes or $doc->classes):
        $attr['class'] = array_merge ($classes, $doc->classes);
    endif;

    // handle the optional citation and id
    $id = '';
    if ($citation !== ''):
        $id = $this->detach_caption_target ($citation);
        $citation = "<cite>" . $this->phrase ($citation) . "</cite>\n";
    endif;

    // choose the HTML element to wrap the HTML in
    $tag = match ($kind) {
        self::aside  => 'aside',
        self::footer => 'footer',
        self::header => 'header',
        self::quoted => 'blockquote',
        default => 'div',
    };

    $attributes = implode_html_attributes ($attr);
    return "<{$tag}{$id}{$attributes}>\n{$body}{$citation}</$tag>\n{$tplerrors}";
}


/* Lets a t-module specified by $subtype process a give $text. The t-module is
 * provided with an optional $clue and its returned result is expected to be
 * UTF-8 encoded. If no $subtype is provided, the $text is handled as
 * preformatted plain text. The result is returned.
 * The optional $caption is informational and passed through for the form API.
 */
function process_file_text (
    string $text,
    string|null $subtype,
    string|null $clue,
    string|null $caption
):    string
{
    // handle text which is not processed by a module
    if ($subtype === NULL):
        return "<pre class='_plain'>" . encode_special_chars (normalize_text ($text)) . "</pre>\n";
    elseif ($subtype === ''):
        $this->error ('t-module subtype missing', 248);
        return '';
    endif;

    // prepare the data to be passed to the module
    $data = [
        // numeric indices for backwards compatibility with old modules
        0 => $text,
        1 => $clue ?? '',
        2 => $this->home . '/aneamal/t-' . $subtype,
        3 => $this->home . $this->dir,
        // t-module specific stuff
        'text' => $text,
    ];

    // let the module do its work
    return $this->use_module ('t-' . $subtype, $data, $clue, $caption) . "\n";
}


/* Translates tab-seperated values ($tsv) into an HTML table and returns it.
 * Parameters allow to $parse phrase_markup within values and to $transpose the
 * whole table.
 */
private function process_file_tsv (
    string $tsv,
    bool $parse = false,
    bool $transpose = false
):    string
{
    // remove optional line break from the end
    if ($tsv !== '' and $tsv[-1] === "\n"):
        $tsv = substr ($tsv, 0, -1);
    endif;

    // split the file into lines and fields
    $data = array_map (fn ($line) => explode ("\t", $line), explode ("\n", $tsv));

    // parse nameline
    $records = count ($data) - 1;
    $fields = count ($data[0]);
    foreach ($data[0] as $col => $field):
        $data[0][$col] = "<th scope='" . ($transpose? 'row': 'col') . "'>" . ($parse? $this->phrase ($field): encode_special_chars ($field)) . "</th>\n";
    endforeach;

    // check the number of fields per record and parse values
    foreach ($data as $row => $record):
        if ($row === 0):
            continue;
        endif;
        foreach ($record as $col => $field):
            $data[$row][$col] = "<td>" . ($parse? $this->phrase ($field): encode_special_chars ($field)) . "</td>\n";
        endforeach;
        $count = count ($record);
        if ($count < $fields):
            $data[$row] = array_pad ($data[$row], $fields, "<td></td>\n");
        elseif ($count > $fields):
            $this->error ('Too many fields in TSV line ' . strval ($row + 1), 183);
            return '';
        endif;
    endforeach;

    // optionally transpose data
    if ($transpose):
        $data = transpose_matrix ($data);
    endif;

    // finish and return the table
    foreach ($data as $row => $record):
        $data[$row] = "<tr>\n" . implode ($record) . "</tr>\n";
    endforeach;

    // return result
    if ($transpose):
        $colgroups = "<colgroup>\n" . ($records? "<colgroup span='$records'>\n": '');
        return "<table>\n$colgroups<tbody>\n" . implode ($data) . "</tbody>\n</table>\n";
    else:
        return "<table>\n<thead>\n" . $data[0] . "</thead>\n<tbody>\n" . implode (array_slice ($data, 1)) . "</tbody>\n</table>\n";
    endif;
}


/* Identifies the end of a reference to a note in $string and translates the
 * reference to HTML, i.e. returns a <sup>erscript HTML <a> element. Initially
 * &$index must give the position of a character ^ in $string which marks the
 * reference and is followed by the name of a target on the same page, usually
 * in a note. At the end of the method, &$index will be set to the position of
 * the last character of the reference.
 */
private function reference (
    string $string,
    int &$index
):    string
{
    // make sure there is a next character
    if (!isset ($string[++$index])):
        $this->error ('Target text missing in reference: target expected after ^', 71);
        return '';
    endif;

    // extract and process target name
    $text = '';
    $name = $this->fragment_identifier ($this->targeted ($string, $index, $text));

    // return the HTML output
    if ($name === ''):
        return "<sup>$text</sup>";
    else:
        return "<sup><a href='#$name'>$text</a></sup>";
    endif;
}


/* Sets non-static properties of this nml2html object to their initial value.
 */
private function reset (
):    void
{
    foreach (get_class_vars (__CLASS__) as $property => $initial):
        $this->$property = $initial;
    endforeach;
}


/* Runs a module, providing it with parameters in the $data array. Regular
 * modules return an anonymous function when included and that function is
 * cached, called and its return value returned. Legacy modules return a string
 * directly and that string is passed along by this function.
 * The $caption of the Aneamal block that the module deals with is used in the
 * form API, iff this is a regular module using that API.
 */
private function run_module (
    string $f, // filename
    array $data,
    string|null $caption
):    string
{
    // cache for anonymous functions (= Closures) that are returned by modules
    static $closure = [];
    // cache for settings a module makes via its default parameter values
    static $setting = [];

    // X-modules can accept a variable number of links. The cardinality says how
    // many were actually provided; the default for *any* module being 1.
    $cardinality = isset ($data['links'])? count ($data['links']): 1;

    // Run cached modules:
    if (isset ($closure[$f])):
        // A cardinality outside the range expected by the module is an error.
        if ($cardinality < $setting[$f]['min']):
            throw new CardinalityException ("$cardinality instead of minimum " . $setting[$f]['min']);
        elseif ($cardinality > $setting[$f]['max']):
            throw new CardinalityException ("$cardinality exceeds maximum of " . $setting[$f]['max']);
        endif;
        // Form API. The order in which $data items are set is important here,
        // since form_time_check () reads $this->form and aborts a submission by
        // emptying $_POST in case of an invalid timestamp.
        if (isset ($setting[$f]['post'])):
            $data['form'] = $this->form ??= $this->form_id ();
            $data['cron'] = $this->form_time_check ();
            $data['post'] = $this->post;
            // If the form was posted and the module has a post handler, it is
            // called to get an array of inputs in the module's responsibility.
            if ($setting[$f]['post'] !== '' and isset ($_POST['_form']) and $_POST['_form'] === $this->form):
                $block = $setting[$f]['post'] ($data);
                if (!is_array ($block)):
                    $this->error ('Post handler of module ' . $data['type'] . ' returned unexpected type: ' . get_debug_type ($block), 243);
                elseif (!array_is_list ($block)):
                    $this->error ('Post handler of module ' . $data['type'] . ' did not return a PHP list', 251);
                else:
                    $data['post'][] = $this->post[] = isset ($caption)? [
                        'addon' => $data['type'],
                        'block' => $block,
                        'topic' => $caption,
                    ]:[
                        'addon' => $data['type'],
                        'block' => $block,
                    ];
                endif;
            endif;
        endif;
        return (string) ($closure[$f] ($data));
    endif;

    // Otherwise report an error, if the module file cannot be read; that state
    // is not cached by this script, because PHP itself caches is_readable.
    if (!is_readable ($f)):
        $this->error ('Module not found: ' . $data['type'], 186);
        return '';
    endif;

    // Otherwise include the module file; parse errors are reported through an
    // anonymous function so that they will be cached and reported whenever an
    // erroneous module is used without PHP trying to parse it again and again.
    // This function "accepts" any cardinality, so that no cardinality errors
    // will be reported instead of the parse error. (We do not know which
    // cardinality the erroneous module would accept if it was bug-free.)
    try {
        $response = include_module ($f, $data);
    } catch (\ParseError $e) {
        $response = function ($_, $min = 0, $max = PHP_INT_MAX) use ($e) {
            throw $e;
            return '';
        };
    }

    // If the module returned an anonymous function, cache it and the settings
    // communicated via its function parameter default values, and execute it.
    if ($response instanceof \Closure):
        $closure[$f] = $response;
        foreach ((new \ReflectionFunction ($response))->getParameters () as $i => $parameter):
            // The 0th parameter does not communicate settings, instead
            // accepting the $data provided to the module.
            if ($i > 0 and $parameter->isDefaultValueAvailable ()):
                $value = $parameter->getDefaultValue ();
                switch ($parameter->name):
                    case 'max': // maximum number of links for an x-module
                    case 'min': // minimum number of links for an x-module
                        $setting[$f][$parameter->name] = (int) $value;
                        break;
                    case 'post':
                        // The post handler must be callable, if it exists, or
                        // it will be reported as error instead of running the
                        // module at all. Module developers must fix this. Being
                        // lenient here would just complicate trouble-shooting.
                        if ($value === '' or is_callable ($value)):
                            $setting[$f]['post'] = $value;
                        else:
                            $closure[$f] = function ($_) use ($data, $value) {
                                $this->error ('Post handler of module ' . $data['type'] . " not callable: $value", 242);
                                return '';
                            };
                        endif;
                        break;
                endswitch;
            endif;
        endforeach;
        $setting[$f]['max'] ??= 1;
        $setting[$f]['min'] ??= 1;
        return $this->run_module ($f, $data, $caption);
    endif;

    // Only modules that return an anonymous function work with a cardinality
    // other than 1, but this part of the function handles modules that do not
    // return an anonymous function, hence a different cardinality is an error.
    if ($cardinality !== 1):
        throw new CardinalityException ("$cardinality instead of expected 1");
    endif;

    // Otherwise return the response of the module; this is mainly for backwards
    // compatibility as modules were not required to return a cacheable
    // anonymous function in the past.
    return (string) $response;
}


/* Processes a $block that represents a regular section break or heading with
 * optional sublines and returns their HTML equivalent. $attributes contains
 * HTML attributes such as classes that apply to the section's heading and are
 * set on the <hgroup> element, if available, or a <div>.
 */
private function sectioner (
    string $block,
    array $attributes = []
):    string
{
    $start = substr ($block, 0, 3);
    $rank = self::sectioners[$start];

    // prepare wrapper for attributes
    if ($attributes):
        $attr = implode_html_attributes ($attributes);
        $starttag = "<div{$attr}>\n";
        $endtag = "</div>\n";
    else:
        $attr = $starttag = $endtag = '';
    endif;

    // end previous expandable sections
    $sectiontags = $this->end_sections ($rank);

    // handle section breaks
    if ($start === $block):
        if ($rank === 1):
            $this->error ('Main heading incomplete', 188);
            return $sectiontags;
        else:
            return $sectiontags . $starttag . "<hr class='_h{$rank}'>\n" . $endtag;
        endif;
    endif;

    // find the end of the heading and extract its content
    $endpos = strrpos_unmasked ($block, $start, self::masks, 3);
    if ($endpos === NULL):
        $this->error ('Heading not closed: expected ' . $start, 4);
        return $sectiontags;
    endif;
    $content = trim (strip_slashed_breaks (substr ($block, 3, $endpos - 3)));
    if ($content === ''):
        $this->error ('Heading text missing', 176);
    endif;

    // translate the heading to HTML
    $return = "<h$rank>";
    foreach (explode ("\n", $content) as $i => $line):
        if ($i > 0):
            $return .= '<br><span>' . $this->phrase ($line) . '</span>';
        else:
            $class = prepare_html_id ($line);
            $return .= $this->phrase ($line);
        endif;
    endforeach;
    $return .= "</h$rank>\n";

    // use the main heading as <title> if no @title was declared as metadata
    if ($rank === 1 and $this->title === ''):
        $this->title = str_replace ("\n", "\x20", stripslashes (ltrim ($content)));
    endif;

    // add tags for this section
    if ($rank > 1):
        $sectiontags .= "<section class='$class'>\n";
        $this->sections[$rank] = '</section>';
    endif;

    // return the heading with the optional sublines
    if (strlen ($block) === $endpos + 3):
        return $sectiontags . $starttag . $return . $endtag;
    elseif ($block[$endpos + 3] !== "\n"):
        $this->error ('Line feed missing after heading', 91);
        return $sectiontags . $starttag . $return . $endtag;
    else:
        $sublines = $this->phrase (ltrim (substr ($block, $endpos + 3)));
        return $sectiontags . "<hgroup{$attr}>\n$return<p>$sublines</p>\n</hgroup>\n";
    endif;
}


/* Parses $lines which represent a single- or multi-tagged list. Returns a HTML
 * description list (<dl>) for a single-tagged list or a HTML <table> for
 * multi-tagged lists. In single-tagged lists the same tag may occur for
 * multiple items, whereas the same tag-combination may only occur once in a
 * multi-tagged list.
 * Admittedly, this function is a monster.
 */
private function tagged_list (
    array $lines
):    string
{
    $items = [];
    $dimension = 0;
    $origin = NULL;

    // this foreach preprocesses each item by extracting the tags and content
    foreach (joint ($lines, fn ($x) => $x[0] === '<') as $k => $item):

        $close = -1;
        $start = 1;
        $tags = [];
        $count = 0;
        $empty = 0;

        // save the item's tags, identified by pointy brackets < >
        while ($close = strpos_unmasked ($item, '>', self::masks, $close + 2)):
            // continue searching if > is preceded by an unslashed -, which
            // means it defines a link, not the end of the tag
            if ($item[$close - 1] === '-' and !is_slashed ($item, $close - 1)):
                continue;
            endif;
            // save the tag, count empty and non-empty tags
            $tags[$count] = trim (substr ($item, $start, $close - $start));
            $tags[$count] === ''? ++$empty: ++$count;
            // check whether > is followed by <, which means another tag follows
            // for the same item, otherwise stop searching
            if (isset ($item[$close + 1]) and $item[$close + 1] === '<'):
                $start = $close + 2;
            else:
                break;
            endif;
        endwhile;

        // the item with the biggest number of tags determines the dimension
        if ($dimension < $count):
            $dimension = $count;
        endif;

        // save the item if a closing > was found, the number of tags is
        // consistent with the list's dimension and there are no empty tags or
        // all tags are empty in a list with dimension greater than one
        if ($close === NULL):
            $this->error ('Malformed tagged-list item: expected >', 7, $k);
        elseif ($empty === 2 and $count === 0):
            if ($origin === NULL):
                $origin = substr ($item, $close + 1);
            else:
                $this->error ('Origin already set', 105, $k);
            endif;
        elseif ($empty !== 0):
            $this->error ('Empty tag in tagged list', 106, $k);
        else:
            $tags[] = substr ($item, $close + 1);
            $items[] = $tags;
        endif;

    endforeach;

    // if dimension is 1, render the list as HTML description list …
    if ($dimension === 1):

        if ($origin !== NULL):
            $this->error ('Origin set for single-tagged list', 219);
        endif;
        $list = '';
        foreach ($items as [$tag, $content]):
            $list .= '<dt>' . $this->phrase ($tag) . "</dt>\n<dd>" . $this->item ($content) . "</dd>\n";
        endforeach;
        return "<dl>\n$list</dl>\n";

    // send an error for tagged lists without tag
    elseif ($dimension < 1):

        $this->error ("Tagged list lacks tags", 225);
        return '';

    // send an error for tagged lists with dimension greater than four
    elseif ($dimension > 4):

        $this->error ("Too many tags: $dimension", 49);
        return '';

    endif;

    // In the following foreach loop the different tags are mapped to $numbers,
    // which are then used to refer to the tags. $headings is filled with the
    // tags processed for HTML output. $contents is filled with the contents
    // of the items processed for HTML output. $x_axis and $y_axis mirror the
    // the structure of column-/row headings; the $branch array is used to
    // remember which leading tag combinations are used with further tags to
    // make sure that there is no content directly assigned to these.
    // EXAMPLE:
    // Consider the item <~a~><b><c> ~d~; the following assignments will be made:
    //  $numbers['~a~'] = 0
    //  $numbers['b'] = 1
    //  $numbers['c'] = 2
    //  $headings[0] = '<i>a</i>'
    //  $headings[1] = 'b'
    //  $headings[2] = 'c'
    //  $contents['0:1:2'] = '<i>d</i>';
    //  $y_axis[0][2] = 0;
    //  $x_axis[1] = [];
    //  $branch[0] = true;
    //  $branch['0:1'] = true;
    $numbers = $headings = $contents = $y_axis = $x_axis = $branch = [];
    foreach ($items as $item):

        $content = array_pop ($item);
        $tags = [];
        $last = count ($item) - 1;
        foreach ($item as $i => $tag):
            if (isset ($numbers[$tag])):
                $t = $numbers[$tag];
            else:
                $t = $numbers[$tag] = count ($numbers);
                $headings[$t] = $this->phrase ($tag);
            endif;
            $tags[] = $t;
            $tagstring = implode (':', $tags);
            if (isset ($contents[$tagstring])):
                $error = '<' . implode ('><', array_slice ($item, 0, $i + 1)) . '>';
                $this->error ("Tag combination already used to tag an item: $error", 220);
                continue 2;
            elseif (!isset ($branch[$tagstring]) and $i !== $last):
                $branch[$tagstring] = true;
            endif;
        endforeach;

        if (isset ($branch[$tagstring])):
            $error = '<' . implode ('><', $item) . '>';
            $this->error ("Tag combination already used in another combination: $error", 221);
            continue 1;
        else:
            $contents[$tagstring] = $this->item ($content);
        endif;

        switch (count ($tags)):
            case 1:
                $y_axis[$tags[0]] = [];
                break;
            case 2:
                isset ($y_axis[$tags[0]]) or $y_axis[$tags[0]] = [];
                isset ($x_axis[$tags[1]]) or $x_axis[$tags[1]] = [];
                break;
            case 3:
                $y_axis[$tags[0]][$tags[2]] = 0;
                isset ($x_axis[$tags[1]]) or $x_axis[$tags[1]] = [];
                break;
            case 4:
                $y_axis[$tags[0]][$tags[2]] = 0;
                $x_axis[$tags[1]][$tags[3]] = 0;
        endswitch;

    endforeach;
    unset ($branch, $items, $numbers);

    // process the origin for HTML output
    $origin = isset ($origin)? $this->item ($origin): '';

    if ($dimension === 2):

        // build the first row of the table
        $colcount = count ($x_axis);
        $table = "<colgroup>\n<colgroup span='$colcount'>\n";
        $table .= "<thead>\n<tr>\n<td>$origin</td>\n";
        foreach (array_keys ($x_axis) as $x):
            $table .= "<th scope='col'>" . $headings[$x] . "</th>\n";
        endforeach;
        $table .= "</tr>\n</thead>\n";

        // build the body of the table
        $table .= "<tbody>\n";
        foreach (array_keys ($y_axis) as $y):
            $table .= "<tr>\n<th scope='row'>" . $headings[$y] . "</th>\n";
            if (isset ($contents[$y])):
                $table .= "<td colspan='$colcount'>" . $contents[$y] . "</td>\n";
            else:
                foreach (array_keys ($x_axis) as $x):
                    $table .= '<td>' . ($contents["$y:$x"] ?? '') . "</td>\n";
                endforeach;
            endif;
            $table .= "</tr>\n";
        endforeach;

        return "<table>\n$table</tbody>\n</table>\n";

    elseif ($dimension === 3):

        // build the first row of the table
        $colcount = count ($x_axis);
        $table = "<colgroup span='2'>\n<colgroup span='$colcount'>\n";
        $table .= "<thead>\n<tr>\n<td colspan='2'>$origin</td>\n";
        foreach (array_keys ($x_axis) as $x):
            $table .= "<th scope='col'>" . $headings[$x] . "</th>\n";
        endforeach;
        $table .= "</tr>\n</thead>\n";

        // build the body of the table
        foreach ($y_axis as $y => $rowgroup): // rowgroups
            $table .= "<tbody>\n<tr>\n";
            if (empty ($rowgroup)): // single row that is a rowgroup
                $table .= "<th colspan='2' scope='rowgroup'>" . $headings[$y] . "</th>\n";
                if (isset ($contents[$y])): // single cell spanning all cols
                    $table .= "<td colspan='$colcount'>" . $contents[$y] . "</td>\n";
                else: // row with named cells
                    foreach (array_keys ($x_axis) as $x): // cells
                        $table .= '<td>' . ($contents["$y:$x"] ?? '') . "</td>\n";
                    endforeach;
                endif;
            else: // rowgroup with named rows
                $rowspan = " rowspan='" . count ($rowgroup) . "'";
                $table .= "<th{$rowspan} scope='rowgroup'>" . $headings[$y] . "</th>\n";
                $tbody = [];
                foreach (array_keys ($rowgroup) as $r => $z): // rows
                    $tr = "<th scope='row'>" . $headings[$z] . "</th>\n";
                    foreach (array_keys ($x_axis) as $x): // cells
                        if (isset ($contents["$y:$x"])): // cell spans rows
                            if ($r === 0):
                                $tr .= "<td{$rowspan}>" . $contents["$y:$x"] . "</td>\n";
                            endif;
                        else: // simple cell
                            $tr .= '<td>' . ($contents["$y:$x:$z"] ?? '') . "</td>\n";
                        endif;
                    endforeach;
                    $tbody[] = $tr;
                endforeach;
                $table .= implode ("</tr>\n<tr>\n", $tbody);
            endif;
            $table .= "</tr>\n</tbody>\n";
        endforeach;

        return "<table>\n$table</table>\n";

    else: // $dimension === 4

        // build colgroups and table head
        $table = "<colgroup span='2'>\n";
        $toprow = "<td colspan='2' rowspan='2'>$origin</td>\n";
        $subrow = '';
        $groups = [];
        $colcount = 0;
        foreach ($x_axis as $x => $colgroup):
            $groups[$x] = $count = count ($colgroup);
            if ($count === 0):
                ++$colcount;
                $table .= "<colgroup>\n";
                $toprow .= "<th rowspan='2' scope='colgroup'>" . $headings[$x] . "</th>\n";
            else:
                $colcount += $count;
                $table .= "<colgroup span='$count'>\n";
                $toprow .= "<th colspan='$count' scope='colgroup'>" . $headings[$x] . "</th>\n";
                foreach (array_keys ($colgroup) as $q):
                    $subrow .= "<th scope='col'>" . $headings[$q] . "</th>\n";
                endforeach;
            endif;
        endforeach;
        $table .= "<thead>\n<tr>\n$toprow</tr>\n<tr>\n$subrow</tr>\n</thead>\n";

        // build the body of the table
        foreach ($y_axis as $y => $rowgroup): // rowgroups
            $table .= "<tbody>\n<tr>\n";
            if (empty ($rowgroup)): // single row that is a rowgroup
                $table .= "<th colspan='2' scope='rowgroup'>" . $headings[$y] . "</th>\n";
                if (isset ($contents[$y])):
                    $table .= "<td colspan='$colcount'>" . $contents[$y] . "</td>\n";
                else:
                    foreach (array_keys ($x_axis) as $x):
                        $colspan = $groups[$x] === 0? '': " colspan='" . $groups[$x] . "'";
                        $table .= "<td{$colspan}>" . ($contents["$y:$x"] ?? '') . "</td>\n";
                    endforeach;
                endif;
            else: // rowgroup with named rows
                $t = count ($rowgroup);
                $table .= "<th rowspan='$t' scope='rowgroup'>" . $headings[$y] . "</th>\n";
                $tbody = [];
                foreach (array_keys ($rowgroup) as $r => $z): // rows
                    $tr = "<th scope='row'>" . $headings[$z] . "</th>\n";
                    foreach ($x_axis as $x => $colgroup): // cellgroups
                        if (empty ($colgroup)): // single cell that is a cellgroup
                            if (isset ($contents["$y:$x"])):
                                if ($r === 0):
                                    $tr .= "<td rowspan='$t'>" . $contents["$y:$x"] . "</td>\n";
                                endif;
                            else:
                                $tr .= '<td>' . ($contents["$y:$x:$z"] ?? '') . "</td>\n";
                            endif;
                        elseif (isset ($contents["$y:$x"])):
                            if ($r === 0):
                                $tr .= "<td colspan='" . $groups[$x] . "' rowspan='$t'>" . $contents["$y:$x"] . "</td>\n";
                            endif;
                        else: // cellgroup with col-named cells
                            foreach (array_keys ($colgroup) as $c => $q):
                                if (isset ($contents["$y:$x:$z"])):
                                    if ($c === 0):
                                        $tr .= "<td colspan='" . $groups[$x] . "'>" . $contents["$y:$x:$z"] . "</td>\n";
                                    endif;
                                else:
                                    $tr .= '<td>' . ($contents["$y:$x:$z:$q"] ?? '') . "</td>\n";
                                endif;
                            endforeach;
                        endif;
                    endforeach;
                    $tbody[] = $tr;
                endforeach;
                $table .= implode ("</tr>\n<tr>\n", $tbody);
            endif;
            $table .= "</tr>\n</tbody>\n";
        endforeach;

        return "<table>\n$table</table>\n";

    endif;
}


/* Identifies the end of a target in a $string, translates it into an HTML
 * <span> element with id attribute and returns it. The position of the first
 * character # of the target must be given in &$index at the beginning and is
 * set to the position of the last character at the end. If the # is directly
 * followed by a hint {…}, the target will be invisible in the HTML output, but
 * it will still be possible to link to it. If the # is directly followed by
 * metadata @, the metadata value will be displayed in the HTML output, but the
 * metadata name must be used to link to it.
 */
private function target (
    string $string,
    int &$index
):    string
{
    // make sure there is a next character
    if (!isset ($string[++$index])):
        $this->error ('Target text missing: text expected after #', 42);
        return '';
    endif;

    // extract and process the target name
    $text = '';
    if ($string[$index] === '{'):
        $name = $this->fragment_identifier ($this->encurled ($string, $index), true);
    else:
        $name = $this->fragment_identifier ($this->targeted ($string, $index, $text), true);
    endif;

    // prepare and return the HTML output
    $name === '' or $name = " id='$name'";
    return "<span{$name}>$text</span>";
}


/* Identifies the end of a hook or target name in a $string, either where the
 * target is set (except invisible targets #{...}), or where it is referenced/
 * linked to. &$index must give the position of the first character of the
 * target name initially, excluding marks &, #, ^, ->, and is set to its last
 * character's position at the end. If an empty string is passed as &$text
 * parameter, that variable will be replaced with parsed text generated from the
 * target name for display. Returns the target name.
 */
private function targeted (
    string $string,
    int &$index,
    string|null &$text = NULL
):    string
{

    $char = $string[$index];

    if (in_array ($char, ['*', '~', '_', '"'], true)):
        $group = $this->enclosed ($string, $index);
        if ($text === ''):
            $text = $this->emphasis ($group, $char);
        endif;
    elseif ($char === '|'):
        $group = $this->enclosed ($string, $index, true);
        if ($text === ''):
            $text = $this->code ($group);
        endif;
    elseif ($char === '$'):
        $group = $this->enclosed ($string, $index, true);
        if ($text === ''):
            $text = $this->math ($group);
        endif;
    elseif (isset (self::brackets[$char]) and !is_null ($group = $this->bracketed ($string, $index))):
        if ($text === ''):
            $text = $char . $this->phrase ($group) . self::brackets[$char];
        endif;
    elseif (str_match ($string, [$index => '+=', '-'])):
        $group = $this->enclosed2 ($string, $index);
        if ($text === ''):
            $text = $this->emphasis ($group, "$char-");
        endif;
    elseif ($char === '#' and isset ($string[$index + 1])):
        $start = $index++;
        $group = $this->targeted ($string, $index);
        if ($text === ''):
            $text = $this->target ($string, $start);
        endif;
    elseif ($char === '&'):
        $start = $index++;
        $group = isset ($string[$index])? $this->targeted ($string, $index): '';
        if ($text === ''):
            $text = $this->hook ($string, $start);
        endif;
    elseif ($char === '@' and isset ($string[$index + 1])):
        $start = $index++;
        $group = $this->group ($string, $index);
        if ($text === ''):
            $text = $this->phrase (substr ($string, $start, $index - $start + 1));
        endif;
    else:
        $group = $this->group ($string, $index);
        if ($text === ''):
            $text = $this->phrase ($group);
        endif;
    endif;

    return $group;
}


/* Parses $lines that make up single- and multi-line text boxes. Each box is
 * marked by a token like [_] or [=] at the beginning of a line, optionally with
 * a linked file to supply default text and a label. There can be a $question
 * associated with the boxes. Returns a HTML <fieldset> with the optional
 * $question as <legend>, <input>s and/or <textarea>s and optionally <label>s.
 * Feeds the form API.
 */
private function textboxes (
    array $lines,
    string|null $question = NULL
):    string
{
    $html = $data = [];

    // this block is active, if it belongs to a form that has been posted
    $this->form ??= $this->form_id ();
    $active = isset ($_POST['_form']) && $_POST['_form'] === $this->form;

    // split the block into parts where each part corresponds to a textbox and
    // optionally modifiers and label and process each part
    foreach (joint ($lines, fn ($x) => str_match ($x, ['[', '_=-', ':]'])) as $k => $part):

        // differentiate between textbox types
        $type = $part[1];
        if ($type === '-'):
            $this->error ("Reserved textbox type: [-]", 236, $k);
            continue;
        endif;

        // find the end of the textbox token and extract the optional clue
        if (is_null ($pos = strpos_unslashed ($part, ']', 2))):
            $this->error ('Malformed textbox: expected ]', 233, $k);
            continue;
        elseif ($pos > 2):
            $clue = strip_slashed_breaks_and_slashes (substr ($part, 3, $pos - 3));
        else:
            $clue = NULL;
        endif;

        // generate a globally unique ID and a name that is unique for the form
        $attr = [
            'id'   => $id = get_unique (),
            'name' => $name = get_unique ($this->form),
            'form' => $this->form,
        ];

        // single-line textboxes get a 'text' type attribute (multi-line
        // textboxes get an own HTML element instead) that is the HTML default
        // anyway, but is useful for styling with CSS
        if ($type === '_'):
            $attr['type'] = 'text';
        endif;

        // optional placeholder
        if (isset ($clue)):
            $attr['placeholder'] = $clue;
        endif;

        // see whether text input is required
        if (isset ($part[++$pos]) and $part[$pos] === "!"):
            $attr['required'] = NULL;
            ++$pos;
        endif;

        // load the text from an optional linked file
        $content = $datalist = '';
        if (substr ($part, $pos, 2) === '->'):
            ++$pos;
            if (!is_null ($uri = $this->address ($part, $pos))):
                if (!is_null ($file = $this->file_raw ($uri, true, true, $k))):
                    // The text is used as default content for multi-line boxes.
                    if ($type === '='):
                        $content = encode_special_chars ($file);
                    // Multi-line text for a single-line textbox is interpreted
                    // as a single-column TSV file offering input suggestions.
                    elseif (str_contains ($file, "\n")):
                        $attr['list'] = $listid = get_unique ();
                        $options = [];
                        foreach (explode ("\n", $file) as $n => $line):
                            // A single-column TSV may not contain tabs.
                            if (str_contains ($line, "\t")):
                                $this->error ('Unexpected tab character in suggestions file', 235);
                                $line = substr ($line, 0, strpos ($line, "\t"));
                            endif;
                            // The 0th line is the TSV nameline, not an option.
                            $n and $options[] = "<option value='" . encode_special_chars ($line) . "'>\n";
                        endforeach;
                        $datalist = "\n<datalist id='$listid'>\n" . implode ($options) . "</datalist>";
                    // Single-line text serves as default for single-line boxes.
                    else:
                        $attr['value'] = $file;
                    endif;
                endif;
            endif;
            ++$pos;
        endif;

        // prepare the optional HTML $label and $about info for the form API
        if (isset ($part[$pos])):
            $about = ltrim (substr ($part, $pos));
            $label = " <label for='$id'>" . $this->phrase ($about) . "</label>";
            if (!str_contains (self::space, $part[$pos])):
                $this->error ('Missing whitespace after textbox', 234, $k);
            endif;
        else:
            $about = $clue;
            $label = '';
        endif;

        // add textbox to HTML output
        $attributes = implode_html_attributes ($attr);
        $html[] = match ($type) {
            '_' => "<span><input{$attributes}>{$label}</span>{$datalist}",
            '=' => "<span><textarea{$attributes}>$content</textarea>$label</span>",
        };

        // prepare data about this textbox for the form API, if the current
        // form has been submitted
        if ($active):

            // sanitation for single-line textboxes: remove newlines
            if ($type === '_' and isset ($_POST[$name])):
                $_POST[$name] = str_replace (["\r", "\n"], '', $_POST[$name]);
            endif;

            // abort submission, if required content is missing
            if (array_key_exists ('required', $attr)):
                if (!isset ($_POST[$name]) or $_POST[$name] === ''):
                    $active = false;
                    $_POST = $this->post = [];
                endif;
            endif;

            // add sanitized posted data to the form API
            if (isset ($_POST[$name]) and $_POST[$name] !== ''):
                $value = normalize_text ($_POST[$name]);
                $data[] = isset ($about)? ['input' => $value, 'label' => $about]: ['input' => $value];
            endif;

        endif;

    endforeach;

    // add data about this textbox block to the form API, if the current form
    // has been submitted (even if $data is empty)
    if ($active):
        $this->post[] = isset ($question)? ['topic' => $question, 'block' => $data]: ['block' => $data];
    endif;

    // return the HTML
    $legend = isset ($question)? '<legend>' . $this->phrase ($question) . "</legend>\n": '';
    return "<fieldset>\n" . $legend . implode ("<br>\n", $html) . "\n</fieldset>\n";
}


/* Executes a module $type, located in the /aneamal/$type folder. This can be a
 * t-module [t-...], x-module [x-...] or math module which parses $$...$$ and
 * $...$. It supplies the module with $arguments specific to the kind of module
 * and additional data send to all modules such as an optional $clue.
 * The optional $caption is informational and passed through for the form API.
 */
private function use_module (
    string $type,
    array $arguments,
    string|null $clue = NULL,
    string|null $caption = NULL
):    string
{
    // prevent stuff like [x-foo/../html]->bar, which would include THIS file
    if (str_contains ($type, '.') or substr_count ($type, '/') > 1):
        $this->error ("Unexpected character in module name: $type", 218);
        return '';
    endif;

    // add data that is supplied to all modules
    $name = str_contains ($type, '/')? substr ($type, 0, strcspn ($type, '/')): $type;
    $prfx = '_' . $name[0] . $this->filekind;
    $data = array_merge ($arguments, [
        'base' => $this->home . $this->dir,
        'clue' => $clue,
        'here' => $this->home . '/aneamal/' . $type,
        'home' => $this->home,
        'lang' => $this->lang,
        'meta' => $this->modules[$name] ?? '',
        'name' => $name,
        'pixl' => $this->pixels ?? self::pixsize,
        'root' => $this->root,
        'type' => $type,
        'uniq' => get_unique ($prfx, $prfx),
    ]);

    try {
        $path = __DIR__ . '/' . $type;
        $result = $this->run_module ($path . '/index.php', $data, $caption);
    } catch (\ParseError $e) {
        $this->error ("ParseError in module $type: \"" . $e->getMessage () . '" in ' . strip_root ($e->getFile ()) . ', line ' . $e->getLine (), 198);
        return '';
    } catch (\TypeError $e) {
        $this->error ("TypeError in module $type: " . strip_root ($e->getFile ()) . ', line ' . $e->getLine (), 199);
        return '';
    } catch (CardinalityException $e) {
        $this->error ("Wrong number of links for module $type: " . $e->getMessage (), 215);
        return '';
    } catch (ModuleMessage $e) {
        $this->error ("Message from module $type: \"" . $e->getMessage () . '"', 222);
        return '';
    } catch (\Throwable $e) {
        $this->error ("Error in module $type: \"" . $e->getMessage () . '" in ' . strip_root ($e->getFile ()) . ', line ' . $e->getLine (), 197);
        return '';
    }

    // check text encoding and return the result
    return normalize_text ($result);
}


} // class nml2html

// The End