Investigation: Replacing Satori with Yoga + opentype.js

Date: 2025-12-17

Current State

Satori provides:

  1. Flexbox layout (via Yoga internally)
  2. Text-to-SVG-path conversion (via @shuding/opentype.js)
  3. CSS subset implementation
  4. Complete SVG generation

Bundle: 522KB (dist/report.js), node_modules: ~7MB total

Key Insight

Satori converts text to <path> elements in SVG. But we render SVG in WebKit which has full font support. We can use <text> elements instead, which means:

  • No text-to-path conversion needed
  • Only need text measurement for layout calculations
  • Much simpler architecture

Proposed Architecture

ComponentLibraryPurpose
Layoutyoga-layout (~324KB)Flexbox positions
Text measurementopentype.js (~200KB)Width/height for layout
SVG outputCustom (~100 lines)Generate <text> and <rect> elements

Text Measurement with opentype.js

opentype.js can measure text without rendering paths:

const font = opentype.loadSync('font.ttf');

// Measure text width
const text = "Hello";
let width = 0;
for (let i = 0; i < text.length; i++) {
    const glyph = font.charToGlyph(text[i]);
    width += glyph.advanceWidth;
    if (i < text.length - 1) {
        width += font.getKerningValue(glyph, font.charToGlyph(text[i + 1]));
    }
}
// Scale to font size
width = width * (fontSize / font.unitsPerEm);

Available metrics:

  • glyph.advanceWidth - width of each character
  • Kerning pairs - spacing adjustments between character pairs
  • font.ascender / font.descender - vertical metrics

SVG Output

Instead of Satori’s complex path-based text:

<path d="M10 20 L30 20..." />  <!-- text as paths -->

Use simple text elements:

<text x="10" y="20" font-family="Geneva" font-size="14">Hello</text>

WebKit handles font rendering natively.

Potential Benefits

  1. Smaller bundle (~200KB savings estimated)
  2. Simpler code, easier to debug
  3. Editable text in exported SVG (for Figma, etc.)
  4. Less dependency on Satori’s CSS subset

Potential Risks

  1. Need to implement Yoga node tree building
  2. Need to handle text wrapping manually
  3. Less battle-tested than Satori
  4. May have edge cases with special characters

Alternative: Pure Swift Rendering (Recommended)

The JS layer is unnecessary. Everything Satori does can be done natively in Swift.

Current Flow (with JS)

Swift (ReportData) -> JSON -> JS (Satori/Yoga) -> SVG -> Swift (WebKit) -> PDF/PNG

Pure Swift Flow

Swift (ReportData) -> Swift (layout + Core Text) -> Core Graphics -> PDF/PNG

Why This Works

  1. Layout: Yoga has Swift bindings (SwiftYogaKit, Yoga-SwiftUI). But our report layout is simple enough that manual layout may suffice.

  2. Text Measurement: Core Text is native and excellent:

let attributedString = NSAttributedString(
    string: text,
    attributes: [.font: font]
)
let size = attributedString.boundingRect(
    with: CGSize(width: .greatestFiniteMagnitude, height: .greatestFiniteMagnitude),
    options: [.usesLineFragmentOrigin],
    context: nil
).size
  1. Rendering: Core Graphics draws directly to PDF or bitmap:
// Create PDF context
UIGraphicsBeginPDFContextToData(data, pageRect, nil)
UIGraphicsBeginPDFPage()
let context = UIGraphicsGetCurrentContext()!

// Draw text
let paragraphStyle = NSMutableParagraphStyle()
text.draw(at: CGPoint(x: 40, y: 40), withAttributes: [.font: font])

// Draw rectangles (chart bars)
context.setFillColor(tagColor.cgColor)
context.fill(CGRect(x: 120, y: y, width: barWidth, height: 18))

UIGraphicsEndPDFContext()

Architecture Options

Option A: Swift + Yoga + SVG

  • Use SwiftYogaKit for flexbox layout
  • Generate SVG string in Swift
  • Render via WebKit (current approach for SVG -> PDF/PNG)
  • Benefit: SVG still exportable, editable in Figma

Option B: Swift + Core Graphics (no SVG)

  • Manual layout (our report is simple: header, chart, table, footer)
  • Core Text for text measurement and rendering
  • Core Graphics draws directly to PDF context
  • Benefit: Simplest, no dependencies, no WebKit for rendering

Option C: Swift + Yoga + Core Graphics

  • SwiftYogaKit for flexible layouts
  • Core Graphics for rendering
  • Benefit: Flexbox flexibility without JS

Comparison

AspectCurrent (Satori)Option AOption BOption C
JS Bundle522KB000
Dependenciessatori, yoga-wasm, opentype.jsSwiftYogaKitNoneSwiftYogaKit
Text measurementopentype.jsCore TextCore TextCore Text
Layout engineYoga (JS)Yoga (Swift)ManualYoga (Swift)
Output formatSVG -> PDF/PNGSVG -> PDF/PNGPDF/PNG directPDF/PNG direct
ComplexityMediumMediumLowMedium
FlexibilityHigh (flexbox)HighLowHigh

Recommendation

Option A (Swift + Yoga + SVG) is the best fit:

  1. SVG output required for flexibility and editability
  2. Yoga provides flexbox layout in Swift (same as current JS approach)
  3. Core Text for text measurement (native, handles all Unicode)
  4. Generate SVG string in Swift
  5. Render via WebKit (existing approach for SVG -> PDF/PNG)
  6. Eliminates JS entirely
  7. Better error handling (Swift vs JS exceptions)
  8. Easier debugging (single language)

Implementation Sketch for Option A

import YogaKit

class SVGReportRenderer {
    func render(data: ReportData) -> String {
        let pageSize = CGSize(width: 842, height: 595) // A4 landscape
        let margin: CGFloat = 40

        // Build Yoga layout tree
        let root = YGNodeNew()
        YGNodeStyleSetWidth(root, Float(pageSize.width))
        YGNodeStyleSetHeight(root, Float(pageSize.height))
        YGNodeStyleSetPadding(root, .all, Float(margin))
        YGNodeStyleSetFlexDirection(root, .column)

        // Add header, chart, records nodes...
        let header = buildHeaderNode(data: data)
        YGNodeInsertChild(root, header, 0)

        // Calculate layout
        YGNodeCalculateLayout(root, Float(pageSize.width), Float(pageSize.height), .LTR)

        // Generate SVG from computed layout
        return generateSVG(root: root, pageSize: pageSize)
    }

    private func measureText(_ text: String, font: UIFont) -> CGSize {
        let attributedString = NSAttributedString(
            string: text,
            attributes: [.font: font]
        )
        return attributedString.boundingRect(
            with: CGSize(width: .greatestFiniteMagnitude, height: .greatestFiniteMagnitude),
            options: [.usesLineFragmentOrigin],
            context: nil
        ).size
    }

    private func generateSVG(root: YGNodeRef, pageSize: CGSize) -> String {
        var svg = """
        <svg xmlns="http://www.w3.org/2000/svg" width="\(pageSize.width)" height="\(pageSize.height)">
        <rect width="100%" height="100%" fill="white"/>
        """

        // Traverse Yoga tree, emit SVG elements at computed positions
        // YGNodeLayoutGetLeft(node), YGNodeLayoutGetTop(node), etc.

        svg += "</svg>"
        return svg
    }
}

SVG Generation

Generate simple SVG elements from Yoga layout:

// Text element
func svgText(x: CGFloat, y: CGFloat, text: String, fontSize: CGFloat, color: String) -> String {
    let escaped = text.xmlEscaped()
    return """
    <text x="\(x)" y="\(y)" font-family="Geneva" font-size="\(fontSize)" fill="\(color)">\(escaped)</text>
    """
}

// Rectangle (for chart bars, row backgrounds)
func svgRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat, fill: String, rx: CGFloat = 0) -> String {
    return """
    <rect x="\(x)" y="\(y)" width="\(width)" height="\(height)" fill="\(fill)" rx="\(rx)"/>
    """
}

// Line (for table borders)
func svgLine(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat, stroke: String) -> String {
    return """
    <line x1="\(x1)" y1="\(y1)" x2="\(x2)" y2="\(y2)" stroke="\(stroke)"/>
    """
}

Implementation Status

COMPLETED: 2025-12-17

The pure Swift SVG renderer has been implemented, eliminating the JS bundle entirely:

What Was Built

  1. SVGReportRenderer (Shared/Sources/TimeTrackerShared/Services/SVGReportRenderer.swift)

    • Pure Swift implementation using Core Text for text measurement
    • Renders ReportData to SVG string with proper layout
    • A4 landscape: 842x595 points, 40px margin
    • Features: header with title/date range/total, chart bars, records table, footer
    • Text truncation with ellipsis for long content
    • XML escaping for special characters
  2. StringExtensions (Shared/Sources/TimeTrackerShared/Utilities/StringExtensions.swift)

    • xmlEscaped property for proper XML entity escaping
  3. WebKitReportService (updated)

    • Now uses SVGReportRenderer.render(reportData) instead of JS evaluation
    • Same WebKit-based PDF/PNG conversion (SVG rendered in hidden WebView)

Benefits Achieved

  • Eliminated 522KB JS bundle (report.js removed)
  • No node_modules needed (7MB saved in development)
  • Faster builds (no pre-build script to run npm)
  • Better debugging (single language, Swift stack traces)
  • Native text measurement (Core Text, handles all Unicode)

Test Coverage

11 SVG-specific tests in ReportServiceTests:

  • testSVGRendererProducesValidSVG
  • testSVGRendererIncludesTitle
  • testSVGRendererIncludesDateRange
  • testSVGRendererIncludesTotalHours
  • testSVGRendererIncludesChartData
  • testSVGRendererIncludesRecords
  • testSVGRendererIncludesFooter
  • testSVGRendererXMLEscapesSpecialCharacters
  • testSVGRendererMoreRecordsIndicator
  • testSVGRendererUntaggedRecordShowsDash
  • testSVGRendererEmptyReport

Architecture Decision

Chose simplified manual layout over Yoga for these reasons:

  1. Report layout is simple (header, chart, table, footer)
  2. No flexbox complexity needed
  3. Fewer dependencies
  4. Direct control over positioning

Conclusion

  1. Implemented: Pure Swift SVG renderer (simpler than Option A)
  2. Eliminated JS entirely while keeping SVG output
  3. Core Text for text measurement, manual layout, string building for SVG

References