builtin-programs/draw/text.folk

When the image library is /imageLib/ {

set cc [C]
$cc extend $imageLib
$cc include <math.h>

$cc struct GlyphInfo {
    float advance;
    float planeBounds[4];
    float atlasBounds[4];
}
$cc struct Font {
    Image atlasImage;
    int gpuAtlasImage;
    // TODO: This only handles ASCII, obviously.
    GlyphInfo glyphInfos[128];
}

$cc struct vec2f { float x; float y; }
$cc proc vec2f_add {vec2f a vec2f b} vec2f {
    return (vec2f) { a.x + b.x, a.y + b.y };
}
$cc proc vec2f_rotate {vec2f a float radians} vec2f {
    return (vec2f) {
        a.x*cosf(radians) + a.y*sinf(radians),
        -a.x*sinf(radians) + a.y*cosf(radians)
    };
}
$cc proc vec2f_toObj {vec2f a} Jim_Obj* {
    Jim_Obj *v[] = { Jim_NewDoubleObj(interp, a.x), Jim_NewDoubleObj(interp, a.y) };
    return Jim_NewListObj(interp, v, 2);
}
$cc proc charOrFallback {Font* font int ch} int {
    if (ch < ' ' || ch >= sizeof(font->glyphInfos)/sizeof(font->glyphInfos[0])) {
        return '?';
    }
    return ch;
}
$cc proc textExtent {Font* font char* text float scale} vec2f {
    float em = scale;
    float x = 0; float y = 0;
    float width = 0;
    for (int i = 0; text[i] != 0; i++) {
        int ch = text[i];
        if (ch == '\n') {
            y = y + em; x = 0; continue;
        }
        ch = charOrFallback(font, ch);

        x = x + font->glyphInfos[ch].advance * em;
        if (x > width) { width = x; }
    }
    return (vec2f) { width, y };
}
$cc proc textShape {Jim_Obj* viewport Jim_Obj* surfaceToClip
                    Font* font char* text
                    float x0 float y0 float scale
                    float blockAnchorX float blockAnchorY float lineAnchorX float lineAnchorY
                    float radians Jim_Obj* color} Jim_Obj* {
    vec2f extent = textExtent(font, text, scale);
    float em = scale;

    // The anchor origin goes from top-left (0.0) to bottom-right (1.0).
    float blockOffsetX = -(blockAnchorX * extent.x);
    // `lineAnchorY - 1` because the font is relative to the bottom, while we're relative to the top.
    float blockOffsetY = -(blockAnchorY * extent.y + (lineAnchorY - 1) * em);
    // Offset is rotated before adding (x0, y0), so that text is relative
    // to (x0, y0).
    vec2f rotatedBlockOffset = vec2f_rotate((vec2f) {blockOffsetX, blockOffsetY}, radians);
    vec2f blockStart = (vec2f) { x0 + rotatedBlockOffset.x, y0 + rotatedBlockOffset.y };

    // Need to get the initial line width so the line is relative to lineAnchorX. We
    // later recalculate this whenever we hit '\n', but obviously that doesn't work at
    // the start.
    float lineWidth = 0.0;
    for (int i = 0; text[i] != '\n' && text[i] != '\0'; i++) {
        lineWidth += font->glyphInfos[charOrFallback(font, text[i])].advance * em;
    }

    // Get the string representations of the shared per-label args once,
    // before the loop, so we don't call Jim_GetString (which may trigger
    // UpdateStringOfDouble for every float) N times per glyph.
    const char* sc_str    = Jim_GetString(surfaceToClip, NULL);
    const char* color_str = Jim_GetString(color, NULL);
    const char* vp_str    = Jim_GetString(viewport, NULL);
    float atlas_w = (float)font->atlasImage.width;
    float atlas_h = (float)font->atlasImage.height;

    // Build the instances list as a pre-formatted Tcl list string.
    // Each element is a brace-enclosed instance: {{sc} {atlas} {color} {vp} {a} {b} {c} {d} n}
    // This avoids Jim ever calling UpdateStringOfList / ListElementQuotingType on instances.
    int textLen = strlen(text);
    int bufSize = (textLen + 1) * 320;
    char* buf = (char*)malloc(bufSize);
    int pos = 0;
    int needSpace = 0;

    // Relative character position (relative to (0, 0), not rotated).
    float relCharX = 0;
    float relCharY = 0;
    for (int i = 0; text[i] != 0; i++) {
        int ch = text[i];
        if (ch == '\n') {
            relCharX = 0;
            relCharY += em;
            lineWidth = 0.0;
            for (int j = i + 1; text[j] != '\n' && text[j] != '\0'; j++) {
                lineWidth += font->glyphInfos[charOrFallback(font, text[j])].advance * em;
            }
            continue;
        }

        ch = charOrFallback(font, ch);
        GlyphInfo* glyphInfo = &font->glyphInfos[ch];
        if (ch != ' ') {
            // Calculate the absolute glyph position.
            float lineOffsetX = -(lineAnchorX * lineWidth) - blockOffsetX;
            // `lineOffsetY` doesn't exist, since it's already included in the `blockOffsetY` calculation.
            vec2f rotatedLineOffset = vec2f_rotate((vec2f) { lineOffsetX, 0 }, radians);
            vec2f combinedOffset = vec2f_add(blockStart, rotatedLineOffset);
            vec2f charPos = vec2f_add(combinedOffset, vec2f_rotate((vec2f) { relCharX, relCharY }, radians));

            float left = glyphInfo->planeBounds[0] * em;
            float bottom = glyphInfo->planeBounds[1] * em;
            float right = glyphInfo->planeBounds[2] * em;
            float top = glyphInfo->planeBounds[3] * em;

            vec2f topLeft     = vec2f_add(charPos, vec2f_rotate((vec2f) {left,  -top},    radians));
            vec2f topRight    = vec2f_add(charPos, vec2f_rotate((vec2f) {right, -top},    radians));
            vec2f bottomRight = vec2f_add(charPos, vec2f_rotate((vec2f) {right, -bottom}, radians));
            vec2f bottomLeft  = vec2f_add(charPos, vec2f_rotate((vec2f) {left,  -bottom}, radians));

            if (needSpace) buf[pos++] = ' ';
            needSpace = 1;

            pos += snprintf(buf + pos, bufSize - pos,
                "{{%s} {%g %g %g %g} {%s} {%s} {%g %g} {%g %g} {%g %g} {%g %g} %d}",
                sc_str,
                glyphInfo->atlasBounds[0] / atlas_w,
                glyphInfo->atlasBounds[1] / atlas_h,
                glyphInfo->atlasBounds[2] / atlas_w,
                glyphInfo->atlasBounds[3] / atlas_h,
                color_str,
                vp_str,
                topLeft.x,     topLeft.y,
                topRight.x,    topRight.y,
                bottomRight.x, bottomRight.y,
                bottomLeft.x,  bottomLeft.y,
                font->gpuAtlasImage);
        }

        // Advance to next character position.
        relCharX += glyphInfo->advance * em;
    }

    Jim_Obj* result = Jim_NewStringObj(interp, buf, pos);
    free(buf);
    return result;
}
$cc proc fontNew {Image atlasImage int gpuAtlasImage {GlyphInfo[128]} glyphInfos} Font* {
    Font* font = (Font*) malloc(sizeof(Font));
    font->atlasImage = atlasImage;
    font->gpuAtlasImage = gpuAtlasImage;
    memcpy(font->glyphInfos, glyphInfos, sizeof(font->glyphInfos));
    return font;
}
$cc proc fontFree {Font* font} void {
    free(font);
}
set fontLib [$cc compile]

When the image loader is /loadImage/ {
    fn loadImage
    fn loadFont {name} {
        set csvFd [open "vendor/fonts/$name.csv" r]; set csv [read $csvFd]; close $csvFd
        set fields [list ]
        # HACK: Create list of null glyphs to initialize.
        set glyphInfos [list]
        for {set i 0} {$i < 128} {incr i} {
            lappend glyphInfos {}
        }

        foreach line [split $csv "\n"] {
            set values [lassign [split $line ,] glyphIdx]
            if {![string is integer -strict $glyphIdx]} { continue }

            lassign $values advance \
                planeLeft planeBottom planeRight planeTop \
                atlasLeft atlasBottom atlasRight atlasTop
            lset glyphInfos $glyphIdx \
                [dict create advance $advance \
                     planeBounds [list $planeLeft $planeBottom $planeRight $planeTop] \
                     atlasBounds [list $atlasLeft $atlasBottom $atlasRight $atlasTop]]
        }

        set defaultGlyphInfo [lindex $glyphInfos 63] ;# '?'
        for {set i 0} {$i < [llength $glyphInfos]} {incr i} {
            if {[lindex $glyphInfos $i] eq {}} {
                lset glyphInfos $i $defaultGlyphInfo
            }
        }

        set im [{*}$loadImage "[pwd]/vendor/fonts/$name.png"]
        Wish the GPU loads image $im as texture
        When the GPU has loaded image $im as texture /gim/ {
            puts "text: Loaded $name as GPU texture $gim"
            set font [$fontLib fontNew $im $gim $glyphInfos]
            Claim the GPU has font $name with data $font \
                -destructor [list $fontLib fontFree $font]
        }
    }

    foreach fontPath [list {*}[glob vendor/fonts/*.png]] {
        set fontName ""
        regexp {vendor/fonts/(.*).png} $fontPath -> fontName
        if {!($fontName eq "")} {
            loadFont $fontName
        }
    }
}

Wish the GPU compiles function "glyphMsd" {
    {sampler2D atlas vec4 atlasGlyphBounds vec2 glyphUv} vec4 {
        vec2 atlasUv = mix(atlasGlyphBounds.xw, atlasGlyphBounds.zy, glyphUv);
        return texture(atlas, vec2(atlasUv.x, 1.0-atlasUv.y));
    }
}
Wish the GPU compiles function "median" {
    {float r float g float b} float {
        return max(min(r, g), min(max(r, g), b));
    }
}

# HACK: (?) the push constant args are ordered to minimize padding so
# that it fits into 128 bytes.
Wish the GPU compiles pipeline "glyph" {
    {mat3 surfaceToClip
     vec4 atlasGlyphBounds
     vec4 color
     vec2 viewport
     vec2 a vec2 b vec2 c vec2 d
     sampler2D atlas
     fn rotate} {

     vec2 vertices[6] = vec2[6](a, b, c, a, c, d);
     vec3 v = surfaceToClip * vec3(vertices[gl_VertexIndex], 1.0);
     return vec4(v.xy/v.z, 0.0, 1.0);

} {fn rotate fn invBilinear fn glyphMsd fn median} {
    vec2 clipXy = (gl_FragCoord.xy / viewport) * 2.0 - 1.0;
    vec3 surfaceXy = inverse(surfaceToClip) * vec3(clipXy, 1.0);
    surfaceXy /= surfaceXy.z;

    vec2 glyphUv = invBilinear(surfaceXy.xy, a, b, c, d);
    if( max( abs(glyphUv.x-0.5), abs(glyphUv.y-0.5))>=0.5 ) {
        return vec4(0.0);
    }
    vec3 msd = glyphMsd(atlas, atlasGlyphBounds, glyphUv).rgb;
    // https://blog.mapbox.com/drawing-text-with-signed-distance-fields-in-mapbox-gl-b0933af6f817
    float sd = median(msd.r, msd.g, msd.b);
    float uBuffer = 0.2;
    float uGamma = 0.2;
    float opacity = smoothstep(uBuffer - uGamma, uBuffer + uGamma, sd);

    return (opacity < 0.01) ? vec4(0.0) : vec4(color.rgb, opacity * color.a);
}}

When the color map is /colorMap/ &\
     /someone/ wishes to draw text onto /p/ with /...options/ {
    if {[dict exists $options position]} {
        lassign [dict get $options position] x0 y0
    } else {
        set x0 [dict get $options x]
        set y0 [dict get $options y]
    }
    set scale [dict getdef $options scale 0.01] ;# 1cm default scale
    set font [dict getdef $options font "PTSans-Regular"]
    set text [dict get $options text]
    set anchor [dict getdef $options anchor "center"]
    set radians [dict getdef $options radians 0]
    set color [dict getdef $options color white]
    set color [dict getdef $colorMap $color $color]
    set layer [dict getdef $options layer 0]

    if {$anchor == "topleft"} {
        set anchor [list 0 0 0 0]
    } elseif {$anchor == "top"} {
        set anchor [list 0.5 0 0.5 0]
    } elseif {$anchor == "topright"} {
        set anchor [list 1.0 0 1 0]
    } elseif {$anchor == "left"} {
        set anchor [list 0 0.5 0 0.5]
    } elseif {$anchor == "center"} {
        set anchor [list 0.5 0.5 0.5 0.5]
    } elseif {$anchor == "right"} {
        set anchor [list 1.0 0.5 1 0.5]
    } elseif {$anchor == "bottomleft"} {
        set anchor [list 0 1 0 1]
    } elseif {$anchor == "bottom"} {
        set anchor [list 0.5 1 0.5 1]
    } elseif {$anchor == "bottomright"} {
        set anchor [list 1 1 1 1]
    }

    When $p has canvas /id/ with /...wiOptions/ &\
         $p has canvas projection /surfaceToClip/ &\
         the GPU has font $font with data /fontData/ {

        set wiResolution [list [dict get $wiOptions width] [dict get $wiOptions height]]
        set instances [$fontLib textShape \
                           $wiResolution $surfaceToClip \
                           $fontData \
                           $text $x0 $y0 $scale {*}$anchor $radians $color]

        # We need to batch into one wish so we don't deal with n^2
        # checks for existing statements for n glyphs.
        Wish the GPU draws pipeline "glyph" onto canvas $id \
            with instances $instances layer $layer
    }
}

}