Investigation: Replacing Satori with Yoga + opentype.js
Date: 2025-12-17
Current State
Satori provides:
- Flexbox layout (via Yoga internally)
- Text-to-SVG-path conversion (via
@shuding/opentype.js) - CSS subset implementation
- 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
| Component | Library | Purpose |
|---|---|---|
| Layout | yoga-layout (~324KB) | Flexbox positions |
| Text measurement | opentype.js (~200KB) | Width/height for layout |
| SVG output | Custom (~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
- Smaller bundle (~200KB savings estimated)
- Simpler code, easier to debug
- Editable text in exported SVG (for Figma, etc.)
- Less dependency on Satori’s CSS subset
Potential Risks
- Need to implement Yoga node tree building
- Need to handle text wrapping manually
- Less battle-tested than Satori
- 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
Layout: Yoga has Swift bindings (SwiftYogaKit, Yoga-SwiftUI). But our report layout is simple enough that manual layout may suffice.
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 - 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
| Aspect | Current (Satori) | Option A | Option B | Option C |
|---|---|---|---|---|
| JS Bundle | 522KB | 0 | 0 | 0 |
| Dependencies | satori, yoga-wasm, opentype.js | SwiftYogaKit | None | SwiftYogaKit |
| Text measurement | opentype.js | Core Text | Core Text | Core Text |
| Layout engine | Yoga (JS) | Yoga (Swift) | Manual | Yoga (Swift) |
| Output format | SVG -> PDF/PNG | SVG -> PDF/PNG | PDF/PNG direct | PDF/PNG direct |
| Complexity | Medium | Medium | Low | Medium |
| Flexibility | High (flexbox) | High | Low | High |
Recommendation
Option A (Swift + Yoga + SVG) is the best fit:
- SVG output required for flexibility and editability
- Yoga provides flexbox layout in Swift (same as current JS approach)
- Core Text for text measurement (native, handles all Unicode)
- Generate SVG string in Swift
- Render via WebKit (existing approach for SVG -> PDF/PNG)
- Eliminates JS entirely
- Better error handling (Swift vs JS exceptions)
- 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
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
StringExtensions (
Shared/Sources/TimeTrackerShared/Utilities/StringExtensions.swift)xmlEscapedproperty for proper XML entity escaping
WebKitReportService (updated)
- Now uses
SVGReportRenderer.render(reportData)instead of JS evaluation - Same WebKit-based PDF/PNG conversion (SVG rendered in hidden WebView)
- Now uses
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:
testSVGRendererProducesValidSVGtestSVGRendererIncludesTitletestSVGRendererIncludesDateRangetestSVGRendererIncludesTotalHourstestSVGRendererIncludesChartDatatestSVGRendererIncludesRecordstestSVGRendererIncludesFootertestSVGRendererXMLEscapesSpecialCharacterstestSVGRendererMoreRecordsIndicatortestSVGRendererUntaggedRecordShowsDashtestSVGRendererEmptyReport
Architecture Decision
Chose simplified manual layout over Yoga for these reasons:
- Report layout is simple (header, chart, table, footer)
- No flexbox complexity needed
- Fewer dependencies
- Direct control over positioning
Conclusion
- Implemented: Pure Swift SVG renderer (simpler than Option A)
- Eliminated JS entirely while keeping SVG output
- Core Text for text measurement, manual layout, string building for SVG
References
- https://github.com/vercel/satori
- https://www.yogalayout.dev/
- https://github.com/opentypejs/opentype.js
- https://github.com/cntrump/SwiftYogaKit
- https://github.com/tiepvuvan/Yoga-SwiftUI
- https://developer.apple.com/documentation/foundation/nsattributedstring/1529154-boundingrect
- https://developer.apple.com/documentation/coregraphics