Portable, purely typographic text formatting in TADS3?

Is there a straightforward way to do customizable typographic formatting of T3 output that’s portable across interpreters? For the web-based ones you can use markup and twiddle the page’s CSS. But that doesn’t work in, for example, FrobTADs.

I can sorta munge together something using T3’s output filters, but it feels very kludgy. E.g., something like:

#charset "us-ascii"
#include <adv3.h>
#include <en_us.h>

pebbleOutputFilter: OutputFilter
        tagPattern = static new RexPattern(
                '<nocase><langle>(/pebble|pebble)<rangle>')

        isActive = true
        activate() { isActive = true; }
        deactivate() { isActive = nil; }

        _filterTagOpen = nil
        _filterResultsVector = nil

        filterText(ostr, val) {
                local idx, str;

                // Only filter text if we're currently active.
                if(isActive == nil)
                        return(inherited(ostr, val));

                // Get the first occurance of our markup tag.
                idx = rexSearch(tagPattern, val);

                // Loop until we're out of input or tags.
                while(idx != nil) {
                        // Is this an open or a close?  If the tag starts
                        // with a slash then we're a close, otherwise we're
                        // an open.
                        _filterTagOpen = !rexGroup(1)[3].startsWith('/');

                        // Get the stuff after the tag.
                        str = val.substr(idx[1] + idx[2]);

                        // Create a results vector.
                        if(_filterResultsVector == nil)
                                _filterResultsVector = new Vector();

                        // Append the stuff before the tag to the results
                        // vector.  We store things as a two-element array,
                        // the first element being the matching text and
                        // the second being a boolean flag indicating
                        // if it was found inside our markup tags (boolean
                        // true) or not (nil).
                        _filterResultsVector.append([
                                val.substr(1, idx[1] - 1),
                                !_filterTagOpen
                        ]);

                        // Update our value to only look at what's after
                        // the tag we just looked at.
                        val = str;

                        // Find the next tag, if any.
                        idx = rexSearch(tagPattern, str);
                }

                // If we found matching text, we'll have a results
                // vector, which we now check.
                if(_filterResultsVector != nil) {
                        // If str is non-nil, that means we had a little
                        // bit left over after our last tag match, so we
                        // add it to the results vector.
                        if(str != nil)
                                _filterResultsVector.append([ str, nil ]);

                        // Now we make a string buffer to dump our results
                        // vector into.
                        val = new StringBuffer();

                        // Go through the vector, checking if each bit was
                        // found inside or outside our markup tags and
                        // adding it to the string buffer with appropriate
                        // formatting.
                        _filterResultsVector.forEach(function(o) {
                                // Each element of the vector is a two-element
                                // array.  The first element is the text,
                                // and the second is a flag indicating if
                                // it was found inside the markup tags or
                                // not (boolean true if it was, nil otherwise).
                                if(o[2] == true) {
                                        val.append(_filterFormat(o[1]));
                                } else {
                                        val.append(o[1]);
                                }
                        });

                        // Convert the buffer into a string.
                        val = toString(val);

                        // Reset the results vector.
                        _filterResultsVector = nil;
                }

                return(val);
        }

        _filterFormat(str) {
                local ar, buf, r;

                // Split the string at whitespace.
                ar = str.split(R'<space>+');

                // If we don't have any spaces, we don't have anything to do.
                if(ar.length < 2)
                        return(str);

                // buf will hold our line buffer and r will hold our return
                // buffer.
                buf = new StringBuffer();
                r = new StringBuffer();

                // Start out with an indentation.
                buf.append('\t\t');

                // Go through every word(-ish thing) in the string.
                ar.forEach(function(o) {
                        // If we just have a newline by itself, insert a
                        // line break and reset the line buffer.
                        if(rexMatch('^<space>*<newline>+<space>*$', o) != nil) {
                                r.append(toString(buf));
                                r.append('<.p>\n\t\t');
                                buf.deleteChars(1);
                                buf.append('\t\t');
                                return;
                        }

                        // Append the word to the line buffer and add a space.
                        buf.append(o);
                        buf.append(' ');

                        // If we've reached the end of a line, flush the
                        // line buffer to the return buffer and reset the
                        // line buffer.
                        if(buf.length() > 40) {
                                r.append('\n\t\t');
                                r.append(toString(buf));
                                r.append('\n');
                                buf.deleteChars(1);
                                buf.append('\t\t');
                        }
                });
                // Add anything left over in the line buffer to the return
                // buffer.
                if(buf.length > 0) {
                        r.append('\n\t\t');
                        r.append(toString(buf));
                        r.append('\n');
                }
                
                // Return the return buffer as a string.
                return(toString(r));
        }
;

startRoom:      Room 'Void'
        "This is a featureless void. "
;
+me: Person;
+pebble: Thing 'small round pebble' 'pebble'
        "This is a bit of random pre-markup text.
        <.p>
        <PEBBLE>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
        eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
        ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
        aliquip ex ea commodo consequat. Duis aute irure dolor in
        reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
        pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
        culpa qui officia deserunt mollit anim id est laborum.
        \n
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
        eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
        ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
        aliquip ex ea commodo consequat. Duis aute irure dolor in
        reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
        pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
        culpa qui officia deserunt mollit anim id est laborum.
        </PEBBLE>
        <.p>
        This is a bit of concluding post-markup text. "
;

versionInfo: GameID;
gameMain: GameMainDef
        initialPlayerChar = me
        newGame() {
                mainOutputStream.addOutputFilter(pebbleOutputFilter);
                runGame(true);
        }
;

This defines a new OutputFilter called pebbleOutputFilter. It looks for the <PEBBLE></PEBBLE> markup tags, and will format anything inside those tags as double tab indented text.

So given text formatted like the pebble’s description:

+pebble: Thing 'small round pebble' 'pebble'
        "This is a bit of random pre-markup text.
        <.p>
        <PEBBLE>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
        eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
        ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
        aliquip ex ea commodo consequat. Duis aute irure dolor in
        reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
        pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
        culpa qui officia deserunt mollit anim id est laborum.
        \n
        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
        eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
        ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
        aliquip ex ea commodo consequat. Duis aute irure dolor in
        reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
        pariatur. Excepteur sint occaecat cupidatat non proident, sunt in
        culpa qui officia deserunt mollit anim id est laborum.
        </PEBBLE>
        <.p>
        This is a bit of concluding post-markup text. "
;

This will output:

Void
This is a featureless void.

You see a pebble here.

>x pebble
This is a bit of random pre-markup text.

               Lorem ipsum dolor sit amet, consectetur
               adipiscing elit, sed do eiusmod tempor
               incididunt ut labore et dolore magna aliqua.
               Ut enim ad minim veniam, quis nostrud exercitation
               ullamco laboris nisi ut aliquip ex ea commodo
               consequat.  Duis aute irure dolor in reprehenderit
               in voluptate velit esse cillum dolore eu
               fugiat nulla pariatur.  Excepteur sint occaecat
               cupidatat non proident, sunt in culpa qui
               officia deserunt mollit anim id est laborum.

               Lorem ipsum dolor sit amet, consectetur
               adipiscing elit, sed do eiusmod tempor
               incididunt ut labore et dolore magna aliqua.
               Ut enim ad minim veniam, quis nostrud exercitation
               ullamco laboris nisi ut aliquip ex ea commodo
               consequat.  Duis aute irure dolor in reprehenderit
               in voluptate velit esse cillum dolore eu
               fugiat nulla pariatur.  Excepteur sint occaecat
               cupidatat non proident, sunt in culpa qui
               officia deserunt mollit anim id est laborum.

This is a bit of concluding post-markup text.

>

…which does everything I expect it to, and appears to work cross-interpreter.

The exact typographic formatting it’s doing here isn’t particularly interesting.
And the formatting method is ugly and unoptimized. And could probably be better handled with native HTML tags that are handled by all modern interpreters.

But I’m talking more about the overall filtering mechanism: regex matching tags, using a bespoke line buffer to manage captured text, and so on. Is there a quicker/cleaner/better supported approach to this sort of thing?

What I’m thinking of in broad terms is things like implementing stylistic tags that give different typographic effects for different kinds of in-game text. E.g. formatting text the player reads in books as block quotes, overheard coversations are in italics or something, or even more elaborate stuff like, I don’t know, everything ghosts say is right-justified or something like that.

Clearly everything could just be hard-coded with whatever HTML/other markup is needed to produce the effect. But I want to have a single toggle to turn any typographic effects on and off, for several reasons: testing, to give the player the option to customize the appearance, to have a master toggle (for e.g. screen readers) to turn off all typographic effects, and so on.

This seems like it would be very much the sort of thing that a game engine for implementing all-text games would have a lot of stuff for, but most of the T3 filtering stuff actually seems more about straightforward string substitutions, rather than typographic formatting in general.

2 Likes

Since this doesn’t appear to be something with a straightforward solution in T3 itself, I’ve put together yet another little T3 module to do this sort of thing. The git repo is available here.

It implements two utility subclasses of OutputFilter: BufferedOutputFilter and LineBufferedOutputFilter. The first is for formatting tagged text as a block. For example, given the filter:

myFilter:  BufferedOutputFilter
        bofTag = 'foozle'

        bofFormat(str) {
                return('<q>' + str + '</q>');
        }
;

Then if you have an object declared as:

+pebble: Thing 'small round pebble' 'pebble'
        "The pebble says:
        <.p>
        <FOOZLE>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
        eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
        ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
        aliquip ex ea commodo consequat.</FOOZLE> "
;

…this would produce…

>x pebble
The pebble says:

"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.  Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."

This is way overkill for what’s done in this example (just putting quotes around a block of text), but the example is just to demonstrate what’s happening: you mark a block of text with tags defined in the filter’s bofTag property, and then the text in the tags gets passed to the filter’s bofFormat() method.

The LineBufferedOutputFilter class works similarly, except the formatting is line-by-line instead of on the entire block of tagged text at once. The filter has a lineBufferWidth property that defines the line length in characters, and the tagged text will be wrapped at whitespace boundaries whenever adding an addition word would cause the line length to be greater than lineBufferWidth.

With a LineBufferedOutputFilter instance you can either just define a lineBufferPrefix and/or a lineBufferSuffix if all you want is to put something at the start and/or end of every line, or you can supply a bofFormat() function if you want to do more elaborate per-line formatting.

As an example, the complete declaration of the included quoteOutputFilter is:

quoteOutputFilter: LineBufferedOutputFilter
        bofTag = 'quote'
        lineBufferPrefix = '\t\t'
        lineBufferWidth = 40
;

…which produces output line the example I used in the first post (only in this case the tags used are <QUOTE></QUOTE>, not <PEBBLE></PEBBLE>.

Note that these classes are not reactive. That is, they don’t “know” the correct line width of the window the game is writing to, so if (for example) the player resizes the game window while playing, the text won’t automagically flow to accommodate the new window size or anything like that. It also isn’t clever about computing line widths, and doesn’t try to figure out the rendered size of a given string (so ‘\t’ counts as two characters, regardless of how many typographic spaces a tab is rendered as being).

This is mostly just something I threw together to handle a couple special cases in a WIP, but posting it in case it’s useful to other folks.

1 Like