Inside geoWrite – 3: Font Management

In the series about the internals of the geoWrite WYSIWYG text editor for the C64, this article discusses the font manager’s system of caches for pixel fonts.

Article Series

  1. The Overlay System
  2. Screen Recovery
  3. Font Management ← this article
  4. Zero Page
  5. Copy Protection
  6. Localization
  7. File Format and Pagination
  8. Copy & Paste
  9. Keyboard Handling

GEOS Fonts Overview

The GEOS operating system contains a rendering library for pixel fonts of up to 63 pt, using its own font file format.

Like most GEOS files, fonts are VLIR bundles that contain one “sub-file” for every point size. This is “California”, which is available at 10, 12, 14 and 18 pt:

California         size  
\--- File Header    256  
\--- 10             892  
\--- 12            1114  
\--- 14            1322  
\--- 18            2110

To use a font, an application has to explicitly load it into its own memory buffer and activate it using the LoadCharSet API:

    LoadW   r0, otherFnBuffer  
    jsr     OpenRecordFile          ; open font file  
    lda     #12  
    jsr     PointRecord             ; select 12 pt font  
    LoadW   r7, FONT_BUFFER  
    LoadW   r2, FONT_BUFFER_SIZE  
    jsr     ReadRecord              ; read pixel font into memory  
    jsr     CloseRecordFile         ; close font file  
    LoadW   r0, FONT_BUFFER  
    jsr     LoadCharSet             ; activate font

The 9 pt system font (called “BSW”) is always in memory and can be activated using UseSystemFont:

jsr     UseSystemFont

As soon as a font is activated, it can be used for drawing text:

  • Putchar – draw a character
  • PutString – draw a zero-terminated string of characters

Font metrics are accessible through:

  • curHeight (global variable) – font height (in pixels)
  • baselineOffset (global variable) – baseline offset (in pixels from top)
  • GetRealSize – query width and height of a given character code

All this API is very basic. The GEOS KERNAL does not help the application with:

  • enumerating available fonts and sizes: the application has to find font files on disk and decode their metadata.
  • dynamically caching several fonts in memory: GEOS only knows about a single font at a time.
  • getting font metrics without loading the font data: the GEOS API for getting the metrics requires the font to be loaded and active.

geoWrite implements all this on the application side.

Enumerating Fonts

geoWrite’s “font” menu shows all available fonts and their point sizes:

To get this information, applications have to find font files on disk and extract it from their metadata.

A font file has a file type of FONT, and its file name is also the font’s name, so that’s what the application will show in the UI.

The API FindFTypes returns an array of file names matching a filename or type, so this is how geoWrite gets the (file) names of the fonts on disk:

    LoadW   r6, fontNames  
    LoadB   r7L, FONT               ; file type  
    LoadB   r7H, MAX_FONT_FILES  
    LoadW   r10, 0                  ; no name filter  
    jsr     FindFTypes              ; get font files  
    lda     #8  
    sub     r7H                     ; number of files found  
    sta     numFontFiles

(All code has been edited for readability.)

geoWrite also needs to get the available point sizes for each font, as well as the start track and sector of the data on disk and its size. This way, it can later load the data for a particular point size without having to read any extra metadata again.

In C notation, the data structure that it builds looks like this:

struct {  
    uint16_t font_id;  
    uint16_t record_size;  
    uint16_t start_ts;  
} disk_fonts[16][8];

For each of the (up to) 8 font files, there are (up to) 16 point sizes, for each of which geoWrite collects the font ID, the record size and the start track and sector.

A font ID is a GEOS concept that allows applications to use numbers instead of font name strings. It is a 16 bit value that uniquely identifies the font and point size:

  • The upper 10 bits are a unique ID assigned by Berkeley Softworks, e.g. 3 is a synonym “California”.
  • The lower 6 bits are the point size (0-63).

This is the main function to extract the metadata of a font:

;---------------------------------------------------------------  
; extractFontMetadata  
;
; Function:  Read point sizes, track/sector pointers and data  
;            sizes for a font file from its file header  
;
; Pass:      a   font index (0-7)  
;            r0  font filename  
;---------------------------------------------------------------  
extractFontMetadata:  
        pha  
        MoveW   r0, r6  
        jsr     FindFile                ; get file  
        LoadW   r9, dirEntryBuf  
        jsr     GetFHdrInfo             ; read file header  
        MoveW   dirEntryBuf+OFF_DE_TR_SC, r1  
        jsr     ldR4DiskBlkBuf  
        jsr     GetBlock                ; read index block  
        pla                             ; font index (0-7)  
        asl     a  
        asl     a  
        asl     a  
        asl     a                       ; * 16  
        tay  
        ldx     #0  
@loop:  jsr     extractFontIdTrackSector  
        jsr     extractFontRecordSize  
        iny  
        iny  
        inx  
        inx  
        cpx     #FONTS_PER_FONTFILE  
        bne     @loop  
        rts

It opens each font file and reads its file header and index block. For every point size, it calls extractFontIdTrackSector and extractFontRecordSize.

Here is extractFontIdTrackSector:

;---------------------------------------------------------------  
; extractFontIdTrackSector  
;
; Function:  Copy a point size ID and its track/sector pointer  
;            from the font file header and the index block  
;            into the app's data structures.  
;
; Pass:      x   font index within font file (0-15)  
;            y   fontfile * 16 + fontindex * 2  
;---------------------------------------------------------------  
extractFontIdTrackSector:  
        lda     fileHeader+OFF_GHPOINT_SIZES,x  
        sta     diskFontIds,y  
        and     #FONT_SIZE_MASK  
        sta     r6L                     ; point size  
        lda     fileHeader+OFF_GHPOINT_SIZES+1,x  
        sta     diskFontIds+1,y  
        ora     diskFontIds,y  
        beq     @rts                    ; skip empty records  
        txa  
        pha  
        lda     r6L                     ; point size  
        asl     a  
        tax  
        lda     diskBlkBuf+2,x          ; track  
        sta     diskFontRecordTrackSector,y  
        lda     diskBlkBuf+3,x          ; sector  
        sta     diskFontRecordTrackSector+1,y  
        pla  
        tax  
@rts:   rts

A font’s file header contains 16 words at offset OFF_GHPOINT_SIZES that contain font IDs of the different point sizes, which this code copies into its internal data structure. It takes the start track and sectors for each point size from the VLIR index sector.

And this is extractFontRecordSize:

;---------------------------------------------------------------  
; extractFontRecordSize  
;
; Function:  Copy a font's data size from the font file header  
;            into the app's data structures.  
;
; Pass:      x   font index within file (0-15)  
;            y   fontfile * 16 + fontindex * 2  
;---------------------------------------------------------------  
extractFontRecordSize:  
        lda     fileHeader+OFF_GHSET_LENGTHS,x  
        sta     diskFontRecordSize,y  
        sta     r2L  
        lda     fileHeader+OFF_GHSET_LENGTHS+1,x  
        sta     diskFontRecordSize+1,y  
        sta     r2H

        CmpWI   r2, MEM_SIZE_FONTS      ; data size too big?  
        bcc     @rts  
        beq     @rts  
        lda     #0  
        sta     diskFontRecordTrackSector,y ; then pretend it doesn't exist

@rts:   rts

Similarly, it extracts the data size for each point size.

Caching Font Data

geoWrite can keep up to 8 fonts in memory at the same time and dynamically allocates space for fonts in a 7000 byte buffer.

Fonts are managed with an LRU strategy, meaning that if a new font is supposed to be loaded that wouldn’t fit, the least recently used font will be removed from memory.

The font buffer contains one font immediately after the other: If a font is removed, fonts at higher addresses are moved down to fill the hole. This way, the free space is always at the end and there is no fragmentation.

These are the data structures in C notation:

uint8_t buffer[7000];  
struct {  
    uint16_t font_id;  
    uint16_t data_ptr;  
    uint16_t data_size;  
    uint16_t lru;  
} loaded_fonts[8];

For every loaded font, geoWrite keeps track of its font ID, the pointer to the data in the buffer, the size in the buffer, and its LRU ID.

The main API of the font library is the call setFontFromFile, which allows the application to ask the library to activate a font given its ID. If it’s not already in memory, it will be loaded into the buffer, and if necessary, one or more previously used fonts will be removed from memory.

This is the first part of the function:

;---------------------------------------------------------------  
; setFontFromFile  
;
; Function:  Set font. If necessary, load from disk and cache it.  
;
; Pass:      r1  font ID  
;
; Return:    c   =0: success  
;                =1: fail, system font was loaded instead  
;---------------------------------------------------------------  
setFontFromFile:  
        CmpW    r1, curFont  
        bne     @find  
        clc  
        rts

@find:  jsr     findLoadedFont          ; is it already loaded?  
        bcs     @load                   ; not found  
        jsr     updateloadedFontLruId   ; mark it as the latest one that was used  
        lda     loadedFontPtrsHi,x  
        sta     r0H  
        lda     loadedFontPtrsLo,x  
        sta     r0L  
        jsr     LoadCharSet             ; switch to it  
        MoveW   r1, curFont  
        clc  
        rts

If the requested font is the currently active font, the function does nothing. Otherwise, it checks whether the font is already loaded into memory, and if yes, it just activates it and returns.

This is the implementation of findLoadedFont:

;---------------------------------------------------------------  
; findLoadedFont  
;
; Function:  Search for font in font buffer.  
;
; Pass:      r1  font ID  
;
; Return:    c   =0: found  
;                    x   index  
;---------------------------------------------------------------  
findLoadedFont:  
        ldx     #0  
@loop:  cpx     loadedFontsCount  
        beq     @notfound  
        lda     loadedFontIdsLo,x  
        cmp     r1L  
        bne     @1  
        lda     loadedFontIdsHi,x  
        cmp     r1H  
        beq     @found  
@1:     inx  
        bra     @loop

@notfound:  
        sec                             ; failure  
        rts  
@found:  
        clc                             ; success  
        rts

It scans the array of loaded font IDs. If the ID is found, the index to be used with the data structures is returned in X.

If the font ID is not currently loaded into memory, setFontFromFile will load it:

;---------------------------------------------------------------  
; setFontFromFile  
; (continued)  
;---------------------------------------------------------------  
@load:  jsr     findFontIdOnDisk        ; does the font exist on disk?  
        bcs     useSystemFont           ; no, quietly use system font instead  
        lda     diskFontRecordTrackSector,x ; does point size exist?  
        beq     useSystemFont           ; no, quietly use system font instead  
        txa  
        pha  
        lda     diskFontRecordSize,x    ; r3 = size of font data  
        sta     r3L  
        lda     diskFontRecordSize+1,x  
        sta     r3H  
        jsr     allocateFontBufferSpace ; kick out least recently used font(s) if needed  
        jsr     updateloadedFontLruId   ; mark it as the latest one that was used  
        lda     r1L  
        sta     loadedFontIdsLo,x       ; save the ID in the table so the font  
        lda     r1H                     ; can be found in RAM again  
        sta     loadedFontIdsHi,x  
        lda     loadedFontPtrsHi,x      ; r7 = allocated location in RAM  
        sta     r7H  
        lda     loadedFontPtrsLo,x  
        sta     r7L  
        pla  
        tax  
        PushW   r1                      ; save ID  
        PushW   r7                      ; save RAM location  
        lda     diskFontRecordTrackSector,x; location on disk  
        sta     r1L  
        lda     diskFontRecordTrackSector+1,x  
        sta     r1H  
        LoadW   r2, MEM_SIZE_FONTS      ; maximum file size  
        jsr     setDevice  
        jsr     ReadFile                ; load font data into font buffer  
        PopW    r0                      ; read RAM location into r0  
        PopW    curFont                 ; read ID into curFont  
        cpx     #0  
        bne     useSystemFont           ; read error  
        jsr     LoadCharSet  
        clc                             ; success  
        rts

useSystemFont:  
        jsr     UseSystemFont  
        LoadW   curFont, SYSTEM_FONT_ID  
        sec                             ; fail: it's not the font we wanted  
        rts

It calls findFontIdOnDisk (not shown) to check whether the information about the available fonts and point sizes on disk contains the requested font ID.

If the ID is found, setFontFromFile calls allocateFontBufferSpace with the required data size to make space for the font in the buffer, and loads it using ReadFile and the track and sector pointer. If anything goes wrong, the system font is activated instead.

This is allocateFontBufferSpace:

;---------------------------------------------------------------  
; allocateFontBufferSpace  
;
; Function:  Allocate buffer space for a new font.  
;
; Pass:      r3  size of font data  
;
; Note:      This function cannot fail: It will remove fonts  
;            using an LRU strategy until there is space.  
;---------------------------------------------------------------  
allocateFontBufferSpace:  
        ldx     loadedFontsCount        ; no fonts loaded?  
        beq     @first                  ; then load it to start of buffer

        cpx     #MAX_FONTS_LOADED  
        beq     @remove                 ; too many fonts loaded, remove one

        lda     loadedFontPtrsLo-1,x    ; check for r3 bytes of spaces in font buffer  
        clc                             ; (last font pointer + last font size + required size)  
        adc     loadedfontDataSizeLo-1,x  
        tay  
        lda     loadedFontPtrsHi-1,x  
        adc     loadedfontDataSizeHi-1,x  
        tax  
        tya  
        add     r3L  
        tay  
        txa  
        adc     r3H  
        cmp     #>MEM_SCRRECV  
        bne     :+  
        cpy     #<MEM_SCRRECV  
:       bcc     @add                    ; it fits  
        beq     @add

@remove:  
        PushW   r1                      ; does not fit  
        jsr     unloadLruFont           ; remove one  
        PopW    r1  
        bra     allocateFontBufferSpace ; try again

@first: lda     #>MEM_FONT              ; load first font to start  
        ldy     #<MEM_FONT              ; of font buffer  
        bra     @set                    

@add:   ldx     loadedFontsCount        ; new ptr = last ptr + size  
        lda     loadedFontPtrsLo-1,x  
        clc  
        adc     loadedfontDataSizeLo-1,x  
        tay  
        lda     loadedFontPtrsHi-1,x  
        adc     loadedfontDataSizeHi-1,x  
@set:   ldx     loadedFontsCount  
        sta     loadedFontPtrsHi,x      ; store new ptr  
        tya  
        sta     loadedFontPtrsLo,x  
        lda     r3L  
        sta     loadedfontDataSizeLo,x  ; new size  
        lda     r3H  
        sta     loadedfontDataSizeHi,x  
        inc     loadedFontsCount        ; one font more  
        rts

If the new font does not fit into the empty space at the end of the buffer, this function keeps calling unloadLruFont until there is enough space. It then fills the data pointer and size fields for the new font and increments the number of currently loaded fonts.

unloadLruFont is used to make space:

;---------------------------------------------------------------  
; unloadLruFont  
;
; Function:  Unload the least recently used font and compress  
;            the font buffer.  
;---------------------------------------------------------------  
unloadLruFont:  
                                        ; find lowest LRU ID  
        ldy     #0                      ; candidate for lowest  
        ldx     #1  
@loop1: cpx     loadedFontsCount  
        beq     @end1                   ; done iterating  
        lda     loadedFontLruIdHi,x  
        cmp     loadedFontLruIdHi,y  
        bne     @1  
        lda     loadedFontLruIdLo,x  
        cmp     loadedFontLruIdLo,y  
@1:     bcs     @2  
        txa                             ; current one is lower  
        tay                             ; -> update candidate  
@2:     inx  
        bra     @loop1

@end1:  tya  
        tax                             ; lowest index to X

@loop2: inx  
        cpx     loadedFontsCount        ; is it the last one?  
        beq     @end2                   ; then we're done

        dex  
        lda     loadedfontDataSizeHi+1,x; count: size of the one after  
        sta     r2H  
        lda     loadedfontDataSizeLo+1,x  
        sta     r2L  
        lda     loadedFontPtrsHi+1,x    ; source: address of the one after  
        sta     r0H  
        lda     loadedFontPtrsLo+1,x  
        sta     r0L  
        lda     loadedFontPtrsHi,x      ; target: address of the current one  
        sta     r1H  
        lda     loadedFontPtrsLo,x  
        sta     r1L  
        txa  
        pha  
        jsr     MoveData                ; move the next font down  
        pla  
        tax  
        lda     loadedFontIdsLo+1,x     ; move loadedFontIds  
        sta     loadedFontIdsLo,x  
        lda     loadedFontIdsHi+1,x  
        sta     loadedFontIdsHi,x  
        lda     loadedFontLruIdLo+1,x   ; move FontLru  
        sta     loadedFontLruIdLo,x  
        lda     loadedFontLruIdHi+1,x  
        sta     loadedFontLruIdHi,x  
        lda     loadedfontDataSizeLo+1,x  
        sta     loadedfontDataSizeLo,x  ; move loadedfontDataSize  
        clc  
        adc     loadedFontPtrsLo,x      ; update fontPtrs  
        sta     loadedFontPtrsLo+1,x  
        lda     loadedfontDataSizeHi+1,x  
        sta     loadedfontDataSizeHi,x  
        adc     loadedFontPtrsHi,x  
        sta     loadedFontPtrsHi+1,x  
        inx  
        bra     @loop2                  ; repeat for all fonts above removed one

@end2:  dec     loadedFontsCount  
        rts

It finds the lowest LRU ID, i.e. the least recently used font, moves all fonts at higher addresses (and their pointers) down, and decrements the number of loaded fonts.

To keep track of which font is least recently used, updateLoadedFontLruId is called on load and on every activation of a font:

;---------------------------------------------------------------  
; updateLoadedFontLruId  
;
; Function:  Mark a given font as most recently used.  
;
; Pass:      x   font index  
;---------------------------------------------------------------  
updateLoadedFontLruId:  
        lda     fontLruCounter  
        sta     loadedFontLruIdLo,x  
        lda     fontLruCounter+1  
        sta     loadedFontLruIdHi,x  
        IncW    fontLruCounter  
        bne     @rts

        ; 16 bit overflow: clear LRU ID for all fonts  
        ldy     #0  
        tya  
@loop:  sta     loadedFontLruIdLo,y  
        sta     loadedFontLruIdHi,y  
        iny  
        cpy     loadedFontsCount  
        bne     @loop

@rts:   rts

It keeps assigning the next number of a sequence to the given font, guaranteeing that it will always be the highest number and therefore the last one to be removed.

Caching Metrics

When a word processor is dealing with fonts, it does not always need the actual image data for it. Sometimes the fonts metrics are enough, i.e. the height of the font, the baseline offset and the width of the different characters. This is true when selecting text, for example, to know where the character boundaries are, or when reflowing a document.

Caching font data is expensive; the 7000 bytes of geoWrite can hold five 12pt fonts, but only two 24pt fonts. Caching metrics is cheap: The character widths take up only 96 bytes per point size (for the printable ASCII character codes $20-$7F).

geoWrite therefore has an independent cache for font metrics that holds information about the last 8 loaded fonts.

The data structure looks like this:

struct {  
    uint16_t font_id;  
    uint8_t height;  
    uint8_t baseline_offset;  
    uint8_t widths[96];  
} metrics[8];

So if the application wants to draw characters, it has to call setFontFromFile, which will make sure the pixel data is in memory and activated, but if it only needs the font for measuring, it should call lookupFontMetrics instead:

;---------------------------------------------------------------  
; lookupFontMetrics  
;
; Function:  Prepare cached font metrics for use.  
;
; Pass:      a3  font ID  
;---------------------------------------------------------------  
lookupFontMetrics:  
        ldx     #0                      ; find font id in metricsIds  
@loop:  lda     metricsIds,x  
        tay  
        ora     metricsIds+8,x  
        beq     @nfound  
        cpy     a3L  
        bne     @no  
        lda     metricsIds+8,x  
        cmp     a3H  
        beq     @found  
@no:    inx  
        cpx     #MAX_FONTS_LOADED  
        bne     @loop

        jsr     getMod8Index

        ; not found in metrics cache  
@nfound:  
        PushB   r1H  
        jsr     moveA3R1                ; r1 = font id  
        txa  
        pha  
        jsr     setFontFromFile         ; set font  
        pla  
        tax                             ; mod8 index  
        PopB    r1H  
        lda     a3L                     ; store font id in metricsIds  
        sta     metricsIds,x  
        lda     a3H  
        sta     metricsIds+8,x  
        lda     curHeight               ; store height in table  
        sta     metricsHeights,x  
        lda     baselineOffset  
        sta     metricsBaselineOffsets,x

        jsr     getCachedFontMetrics  
        jsr     calcCharWidths  
        rts

@found: jmp     getCachedFontMetrics

; ----------------------------------------------------------------------------  
getCachedFontMetrics:  
        [...]  
        sta     metricsWidths  
        [...]  
        sta     metricsWidths+1  
        lda     metricsHeights,x  
        sta     curFontHeight  
        lda     metricsBaselineOffsets,x  
        sta     curBaselineOffset  
        rts

If the metrics for the requested font and point size are in the cache, they will be copied into curFontHeight, curBaselineOffset and the array metricsWidths. Otherwise, the font’s pixel data is loaded and the metrics are added to the cache using calcCharWidths (not shown).

To get the width of a character after metrics have been looked up, the app can now call getCharWidth:

;---------------------------------------------------------------  
; getCharWidth  
;
; Function:  Get the width of a specified char of the currently  
;            active metrics set (-> lookupFontMetrics).  
;
; Pass:      a   character  
;            x   currentMode  
;
; Return:    a   width  
;---------------------------------------------------------------  
getCharWidth:  
        sub     #$20                    ; ASCII -> table index  
        pha  
        MoveW   metricsWidths, r14  
        pla  
        tay  
        lda     (r14),y                 ; width  
        sta     metricsTmp  
        txa                             ; mode  
        and     #SET_BOLD  
        beq     :+  
        inc     metricsTmp              ; add one  
:       txa  
        and     #SET_OUTLINE  
        beq     :+  
        inc     metricsTmp              ; add 3  
        inc     metricsTmp  
:       lda     metricsTmp  
        rts

This is basically a reimplementation of the GEOS KERNAL’s GetRealSize API: If the current font style is bold or outline, the width is increased by one or three pixels, respectively.

Conclusion

One goal of a modern operating system and even of many kinds of libraries is to abstract what is going on underneath it. GEOS is a very constrained operating system with only 64 KB of total RAM at its disposal, so it tries to provide as many useful functions as possible (graphics, text rendering, disk access, mouse, printer, …) that fit into 20 KB of code, but in many parts of the system, it barely abstracts the underlying hardware.

GEOS applications are seen as as the natural extension of the operating system, and many features that did not fit into the operating system were implemented in the applications, with full awareness of the details of the filesystem or the file formats of system files.

The GEOS font manager can be regarded as a low-level system library, which deals with the internals of the BAM/VLIR filesystem and the font file format.

References

Leave a Comment