creaturesurvive/htmltext
Fast, thread-safe HTML → AttributedString conversion for SwiftUI, UIKit and AppKit. It uses no
Why
Media servers, forums, feeds and CMS APIs return HTML fragments: Jellyfin and Plex overviews, RSS descriptions, comments. Apple's only built-in converter is NSAttributedString(data:options:[.documentType: .html]). It:
- runs a hidden WebKit instance, and is slow (milliseconds per call) and memory-hungry
- must run on the main thread and can deadlock or crash when it doesn't
- spins the run loop re-entrantly, which breaks SwiftUI updates and scrolling
- produces Times New Roman text with web styling you then have to strip out
- returns
NSAttributedString, not theAttributedStringSwiftUI wants
HTMLText parses HTML itself. It runs on any thread or actor and returns a semantic AttributedString that SwiftUI's Text renders in your app's fonts.
Features
- Lenient parser that repairs malformed markup the way browsers do: implied closes (
<p>,
<li>, table cells), mis-nested tags, stray end tags, unquoted attributes, bare < and &. Scripts, styles, comments and <head> are dropped.
- Complete entity decoding: the full WHATWG table (2,125 named references), numeric references
with Windows-1252 remapping, and legacy semicolon-less forms, all per spec.
- Correct text layout: HTML whitespace collapsing, paragraph and line breaks,
<pre>, nested
ordered/unordered lists with aligned markers (start, reversed, value), blockquotes, definition lists, tables, <hr>, <q>, and image alt text.
- Semantic output: bold, italic, code and strikethrough map to
inlinePresentationIntent,
which SwiftUI Text renders natively. Links become tappable link attributes, resolved against a base URL, with schemes filtered by an allow list so javascript: never becomes a link.
- Styling hooks: headings, underline, superscript/subscript,
<mark>and blockquotes get SwiftUI
attributes. Provide your own HTMLStyleSheet to change any of it.
- UIKit/AppKit:
NSAttributedString(html:baseFont:)derives bold, italic, monospaced and heading
fonts from your base font.
- Plain text:
HTML.plainText(from:)uses the same layout rules, for previews, search indexes
and accessibility labels.
- Safe on hostile input: rendering is iterative with a nesting cap, so deeply nested or garbage
markup can't overflow the stack.
Installation
Add HTMLText to your Package.swift:
dependencies: [
.package(url: "https://github.com/CreatureSurvive/HTMLText.git", from: "1.0.0"),
],
targets: [
.target(name: "MyApp", dependencies: ["HTMLText"]),
]Or in Xcode, choose File › Add Package Dependencies… and enter https://github.com/CreatureSurvive/HTMLText.
Requirements
| Platform | Minimum | | --- | --- | | iOS | 16.0 | | macOS | 13.0 | | tvOS | 16.0 | | watchOS | 9.0 | | visionOS | 1.0 |
Swift 6.1 (Xcode 16.4) or later, in Swift 6 language mode. No third-party dependencies.
Usage
SwiftUI
Text(AttributedString(html: "<p>A <b>bold</b> and <i>italic</i> <a href=\"/about\">link</a>.</p>",
options: HTMLRenderOptions(baseURL: serverURL)))Convert off the main thread for large content:
.task(id: html) {
attributed = await Task.detached { AttributedString(html: html) }.value
}Plain text
let preview = HTML.plainText(from: overview) // "Line one.\n\nLine two."
if HTML.containsMarkup(text) { ... }UIKit / AppKit
label.attributedText = NSAttributedString(html: html, baseFont: .preferredFont(forTextStyle: .body), textColor: .label)Options
var options = HTMLRenderOptions(baseURL: URL(string: "https://example.com"))
options.paragraphSpacing = 1 // no blank line between paragraphs
options.bulletMarkers = ["–"]
options.images = .omit
options.allowedLinkSchemes.insert("myapp")Custom styling
let styleSheet = HTMLStyleSheet { style in
var container = HTMLStyleSheet.semantic.attributes(style)
if style.headingLevel != nil { container.swiftUI.foregroundColor = .accentColor }
if style.code { container.swiftUI.font = .system(.body, design: .monospaced) }
return container
}
Text(AttributedString(html: html, styleSheet: styleSheet))Lower-level access
let tree = HTML.parse(html) // HTMLElement tree
let runs = HTMLLayout().runs(for: tree) // [HTMLRun] — text + semantic style
HTMLEntityDecoder.decode("Tom & Jerry") // "Tom & Jerry"
htmlEscape("<b>") // "<b>"Performance
A typical 1–5 KB overview converts in well under a millisecond. A 1.2 MB document with 60,000 styled runs converts in about 0.9 s in a release build. Conversion is thread-safe, so batches can be parallelized.
Scope
HTMLText targets content HTML, not web pages. CSS is ignored apart from semantic tags, and there is no layout engine or JavaScript. Use WKWebView for full pages.
Changelog
See CHANGELOG.md. Releases follow Semantic Versioning.
Contributing
Issues and pull requests are welcome. Please run swift test before opening a pull request, and add tests for new behavior.
License
Available under the MIT license. See LICENSE for details.
The entity table is generated from the WHATWG HTML Standard (CC BY 4.0).
Package Metadata
Repository: creaturesurvive/htmltext
Default branch: main
README: README.md