Platform-independent text layout engines

I am preparing to take some new steps on a hobby project that relies
heavily on TextKit and macOS. I have some doubts about the direction
that TextKit and macOS are taking and I would like to look at
alternatives.

What platform-independent engines for text layout with Swift are people
using?

Is anybody writing their own engine?

Thanks,
David

1 Like

The only real alternative is HTML+CSS in a WebView. These days that's the most powerful layout engine.

1 Like

What are your requirements? Full Unicode support, or ASCII only? Proportional fonts, or fixed-pitch fonts only? Latin only, or CJK?

For my app, I wrote a bespoke ASCII-only, fixed-pitch font only cross-platform text editor engine from scratch, because I wanted a unified editing experience across Windows and macOS (previously using TextKit 1 on macOS, and I wasn't happy with it either). That wasn't too much effort, but my use case is extremely constrained.

1 Like

Could you elaborate a bit on that? Which parts of the layout engine did you write yourself? Are you using any glyph transform engine? Glyph rendering? How do you handle platform integration, like text selection, or even advanced interaction with stuff like text to speech or vice versa?

As I said, from scratch. So, everything, including the underlying text storage (an ASCII piece table, because it is very simple and suits my use case well).

Layout and colouring / syntax highlighting is based on glyph runs. Font and glyph metrics are retrieved using CoreText on macOS and CanvasFontFace (Win2D) on Windows.

Rendering of the layed out glyphs uses CTFontDrawGlyphs on macOS, and Win2D's DrawGlyphRun on Windows.

Here's the entire macOS renderer:

import CoreGraphics
import CoreText

import CompositorCore

func render(run: CodeEditor.GlyphRun, into context: CGContext, fontManager: MacFontManager, metrics: GlyphMetrics, documentBounds: Rect) {
    guard let ctFont = fontManager.coreTextFont(fontHandle: run.fontHandle) else {
        return
    }
    if let backgroundColor = run.backgroundColor {
        let runOrigin = Point(run.origins[0])
        let runRect = Rect(x: runOrigin.x,
                           y: runOrigin.y - metrics.ascent,
                           width: Float(run.length) * metrics.advance,
                           height: metrics.lineHeight)
            .flippedVertically(in: documentBounds)
        context.setFillColor(backgroundColor.cgColor)
        context.fill(runRect.platformRect)
    }
    let origins = (0..<run.length).map { index in
        CGPoint(Point(run.origins[index]).flippedVertically(in: documentBounds))
    }
    context.setFillColor(run.color.cgColor)
    origins.withUnsafeBufferPointer { buffer in
        guard let baseAddress = buffer.baseAddress else {
            return
        }
        CTFontDrawGlyphs(ctFont, run.glyphs, baseAddress, run.length, context)
    }
}

and this is the entire renderer on Windows:

import UWP
import Win2D
import WindowsFoundation

import CompositorCore
import WinSupport

func render(run: CodeEditor.GlyphRun, into drawingSession: CanvasDrawingSession, font: CodeEditor.Font, fontManager: WindowsFontManager) {
    guard let windowsFont = fontManager.font(fontHandle: run.fontHandle) else {
        return
    }
    if let backgroundColor = run.backgroundColor {
        let backgroundRect = WindowsFoundation.Rect(x: run.origins[0].x,
                                                    y: run.origins[0].y - font.metrics.ascent,
                                                    width: Float(run.length) * font.metrics.advance,
                                                    height: font.metrics.lineHeight)
        try? drawingSession.fillRectangle(backgroundRect, backgroundColor.uwpColor)
    }
    let origin = Vector2(x: run.origins[0].x, y: run.origins[0].y)
    let glyphs: [CanvasGlyph] = (0..<run.length).map { index in 
        let glyphIndexInFont = run.glyphs[index]
        let advance = index < run.length - 1 ? run.origins[index + 1].x - run.origins[index].x : 0.1
        let ascenderOffset: Float = 0.0 // run.origins[index].y - run.origins[0].y //: 0
        return CanvasGlyph(index: glyphIndexInFont, advance: advance, advanceOffset: 0, ascenderOffset: ascenderOffset)
    }
    let brush = CanvasSolidColorBrush(drawingSession.device, run.color.uwpColor)
    do {
        try drawingSession.drawGlyphRun(
            origin, 
            windowsFont.fontFace,
            font.size,
            glyphs,
            false,
            0,
            brush)
    } catch let error { 
        logDebug("\(error)")
    }
}

Text selection is also entirely custom and based on querying the layout engine for glyph hits.

4 Likes

For context, here's how the macOS editor view drives the renderer:

final class CodeEditorView: NSView {

    ...
    override func draw(_ dirtyRect: NSRect) {
        guard let graphicsContext = NSGraphicsContext.current,
              let fontManager = editor.fontManager as? MacFontManager else {
            return
        }
        let context = graphicsContext.cgContext
        context.saveGState()        
        if let (lines, lineNumbers) = editor.linesInViewport() {
            for line in lines {
                // <selection / search result background drawing>
                for glyphRun in line.glyphRuns {
                    render(run: glyphRun,
                           into: context,
                           fontManager: fontManager,
                           metrics: font.metrics,
                           documentBounds: documentBounds)
                }
            }
            // <gutter / line number drawing>
        }
        if let caretBounds = editor.caretBounds {
            context.setFillColor(NSColor.black.cgColor)
            context.fill(caretBounds.flippedVertically(in: documentBounds).platformRect)
        }
        context.restoreGState()
    }
}

My respect. How does the performance compare to TextKit?

Objectively, I can't say, I didn't compare workloads between the two solutions. Subjectively, it's plenty fast for my use case (on both macOS and Windows).

But keeping things ASCII-only (no Unicode grapheme handling, no text shaping, etc) makes this a much simpler problem to solve than what TextKit offers.

I am afraid I need full Unicode support, and proportional fonts. Also:
graphic elements embedded in the text, "floats," and fine-grained
control of text layout.

David