Add text-to-speech functionality

- Add TTSManager and SpeechQueue utilities
- Create AddTextToSpeechQueueUseCase and ReadBookmarkUseCase
- Add SpeechPlayer UI components (GlobalPlayerContainerView, SpeechPlayerView, SpeechPlayerViewModel)
- Update BookmarkDetailView and BookmarkDetailViewModel for TTS integration
- Add audio background mode to Info.plist
- Update PhoneTabView for TTS controls
- Add StringExtensions for text processing
- Add StringExtensionsTests for testing
- Update Localizable.xcstrings with new strings
- Add VS Code settings
This commit is contained in:
Ilyas Hallak 2025-07-09 22:31:17 +02:00
parent 3e6db364b5
commit 09f1ddea58
15 changed files with 800 additions and 31 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.fontSize": 14
}

View File

@ -3,12 +3,31 @@
"strings" : {
"" : {
},
"%@ (%lld)" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ (%2$lld)"
}
}
}
},
"%lld" : {
},
"%lld Artikel in Queue" : {
},
"%lld min" : {
},
"%lld Minuten" : {
},
"%lld." : {
},
"12 min • Today • example.com" : {
@ -44,6 +63,9 @@
},
"Artikel automatisch als gelesen markieren" : {
},
"Artikel vorlesen" : {
},
"Automatischer Sync" : {
@ -107,6 +129,9 @@
},
"Fehler" : {
},
"Fehler: %@" : {
},
"Fertig" : {
@ -137,6 +162,9 @@
},
"Item at %@" : {
},
"Keine Artikel in der Queue" : {
},
"Keine Bookmarks" : {
@ -266,6 +294,9 @@
},
"Version %@" : {
},
"Vorlese-Queue" : {
},
"Vorschau" : {

View File

@ -0,0 +1,19 @@
import Foundation
class AddTextToSpeechQueueUseCase {
private let speechQueue: SpeechQueue
init(speechQueue: SpeechQueue = .shared) {
self.speechQueue = speechQueue
}
func execute(bookmarkDetail: BookmarkDetail) {
var text = bookmarkDetail.title + "\n"
if let content = bookmarkDetail.content {
text += content.stripHTML
} else {
text += bookmarkDetail.description.stripHTML
}
speechQueue.enqueue(text)
}
}

View File

@ -0,0 +1,13 @@
import Foundation
class ReadBookmarkUseCase {
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {
self.addToSpeechQueue = addToSpeechQueue
}
func execute(bookmarkDetail: BookmarkDetail) {
addToSpeechQueue.execute(bookmarkDetail: bookmarkDetail)
}
}

View File

@ -16,9 +16,13 @@
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>green</string>
<string>green2</string>
<key>UIImageName</key>
<string>readeck</string>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
</dict>
</plist>

View File

@ -225,6 +225,16 @@ struct BookmarkDetailView: View {
.foregroundColor(.secondary)
}
}
metaRow(icon: "speaker.wave.2") {
Button(action: {
viewModel.addBookmarkToSpeechQueue()
}) {
Text("Artikel vorlesen")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}

View File

@ -6,6 +6,7 @@ class BookmarkDetailViewModel {
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -22,6 +23,7 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
}
@MainActor
@ -78,4 +80,9 @@ class BookmarkDetailViewModel {
func refreshBookmarkDetail(id: String) async {
await loadBookmarkDetail(id: id)
}
func addBookmarkToSpeechQueue() {
bookmarkDetail.content = articleContent
addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
}
}

View File

@ -15,42 +15,49 @@ struct PhoneTabView: View {
@State private var selectedTabIndex: Int = 0
var body: some View {
TabView(selection: $selectedTabIndex) {
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
NavigationStack {
tabView(for: tab)
}
.tabItem {
Label(tab.label, systemImage: tab.systemImage)
}
.tag(idx)
}
NavigationStack {
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
NavigationLink(tag: tab, selection: $selectedMoreTab) {
GlobalPlayerContainerView {
TabView(selection: $selectedTabIndex) {
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
NavigationStack {
tabView(for: tab)
.navigationTitle(tab.label)
} label: {
}
.tabItem {
Label(tab.label, systemImage: tab.systemImage)
}
.listRowBackground(Color(R.color.bookmark_list_bg))
.tag(idx)
}
.navigationTitle("Mehr")
.scrollContentBackground(.hidden)
.background(Color(R.color.bookmark_list_bg))
}
.tabItem {
Label("Mehr", systemImage: "ellipsis")
}
.tag(mainTabs.count)
.onAppear {
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
selectedMoreTab = nil
NavigationStack {
if let selectedTab = selectedMoreTab {
tabView(for: selectedTab)
.navigationTitle(selectedTab.label)
} else {
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
NavigationLink {
tabView(for: tab)
.navigationTitle(tab.label)
} label: {
Label(tab.label, systemImage: tab.systemImage)
}
.listRowBackground(Color(R.color.bookmark_list_bg))
}
.navigationTitle("Mehr")
.scrollContentBackground(.hidden)
.background(Color(R.color.bookmark_list_bg))
}
}
.tabItem {
Label("Mehr", systemImage: "ellipsis")
}
.tag(mainTabs.count)
.onAppear {
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
selectedMoreTab = nil
}
}
}
.accentColor(.accentColor)
}
.accentColor(.accentColor)
}
@ViewBuilder
@ -75,7 +82,7 @@ struct PhoneTabView: View {
case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
case .tags:
Text("Tags")
LabelsView()
}
}
}

View File

@ -0,0 +1,39 @@
import SwiftUI
struct GlobalPlayerContainerView<Content: View>: View {
let content: Content
@State private var speechQueue = SpeechQueue.shared
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
ZStack(alignment: .bottom) {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
if speechQueue.hasItems {
VStack(spacing: 0) {
SpeechPlayerView()
.padding(.horizontal, 16)
.padding(.vertical, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
Rectangle()
.fill(.clear)
.frame(height: 49)
}
}
}
.animation(.spring(), value: speechQueue.hasItems)
}
}
#Preview {
GlobalPlayerContainerView {
Text("Main Content")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
}

View File

@ -0,0 +1,186 @@
import SwiftUI
struct SpeechPlayerView: View {
@State var viewModel = SpeechPlayerViewModel()
@State private var isExpanded = false
@State private var dragOffset: CGFloat = 0
@State private var speechQueue = SpeechQueue.shared
@State private var ttsManager = TTSManager.shared
private let minHeight: CGFloat = 60
private let maxHeight: CGFloat = 300
var body: some View {
VStack(spacing: 0) {
if isExpanded {
expandedView
} else {
collapsedView
}
}
.frame(height: isExpanded ? maxHeight : minHeight)
.background(.ultraThinMaterial)
.cornerRadius(12)
.shadow(radius: 8, x: 0, y: -2)
.offset(y: dragOffset)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation.height
}
.onEnded { value in
withAnimation(.spring()) {
if value.translation.height < -50 && !isExpanded {
isExpanded = true
} else if value.translation.height > 50 && isExpanded {
isExpanded = false
}
dragOffset = 0
}
}
)
}
private var collapsedView: some View {
HStack(spacing: 16) {
// Play/Pause Button
Button(action: {
if ttsManager.isCurrentlySpeaking() {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
// Current Text
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
if speechQueue.queueCount > 0 {
Text("\(speechQueue.queueCount) Artikel in Queue")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
// Stop Button
Button(action: {
viewModel.stop()
}) {
Image(systemName: "stop.fill")
.font(.title3)
.foregroundColor(.secondary)
}
// Expand Button
Button(action: {
withAnimation(.spring()) {
isExpanded.toggle()
}
}) {
Image(systemName: "chevron.up")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var expandedView: some View {
VStack(spacing: 16) {
// Header
HStack {
Text("Vorlese-Queue")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Button(action: {
withAnimation(.spring()) {
isExpanded = false
}
}) {
Image(systemName: "chevron.down")
.font(.title3)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
// Controls
HStack(spacing: 24) {
Button(action: {
if ttsManager.isCurrentlySpeaking() {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
.font(.title)
.foregroundColor(.accentColor)
}
Button(action: {
viewModel.stop()
}) {
Image(systemName: "stop.fill")
.font(.title2)
.foregroundColor(.secondary)
}
}
// Queue List
if speechQueue.queueCount == 0 {
Text("Keine Artikel in der Queue")
.font(.subheadline)
.foregroundColor(.secondary)
.padding()
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(Array(speechQueue.queueItems.enumerated()), id: \.offset) { index, item in
HStack {
Text("\(index + 1).")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 20, alignment: .leading)
Text(item)
.font(.subheadline)
.lineLimit(2)
.truncationMode(.tail)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
.padding(.horizontal, 16)
}
}
Spacer()
}
}
}
#Preview {
SpeechPlayerView()
}

View File

@ -0,0 +1,24 @@
import Foundation
@Observable
class SpeechPlayerViewModel {
var isSpeaking: Bool {
return TTSManager.shared.isCurrentlySpeaking()
}
var currentText: String {
return SpeechQueue.shared.currentText
}
func pause() {
TTSManager.shared.pause()
}
func resume() {
TTSManager.shared.resume()
}
func stop() {
SpeechQueue.shared.clear()
}
}

View File

@ -0,0 +1,74 @@
import Foundation
@Observable
class SpeechQueue {
private var queue: [String] = []
private var isProcessing = false
private let ttsManager: TTSManager
private let language: String
static let shared = SpeechQueue()
var hasItems: Bool {
return !queue.isEmpty || ttsManager.isCurrentlySpeaking()
}
var queueCount: Int {
return queue.count
}
var currentItem: String? {
return queue.first
}
var queueItems: [String] {
return queue
}
var currentText: String {
return queue.first ?? ""
}
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
self.ttsManager = ttsManager
self.language = language
}
func enqueue(_ text: String) {
queue.append(text)
processQueue()
}
func enqueue(contentsOf texts: [String]) {
queue.append(contentsOf: texts)
processQueue()
}
func clear() {
queue.removeAll()
ttsManager.stop()
isProcessing = false
}
private func processQueue() {
guard !isProcessing, !queue.isEmpty else { return }
isProcessing = true
let next = queue.removeFirst()
ttsManager.speak(text: next, language: language)
// Delegate/Notification für didFinish kann hier angebunden werden
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.waitForSpeechToFinish()
}
}
private func waitForSpeechToFinish() {
if ttsManager.isCurrentlySpeaking() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.waitForSpeechToFinish()
}
} else {
self.isProcessing = false
self.processQueue()
}
}
}

View File

@ -0,0 +1,29 @@
import Foundation
extension String {
var stripHTML: String {
// Entfernt HTML-Tags und decodiert HTML-Entities
let attributedString = try? NSAttributedString(
data: Data(utf8),
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
return attributedString?.string ?? self
}
var stripHTMLSimple: String {
// Einfache Regex-basierte HTML-Entfernung
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", with: "'")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -0,0 +1,165 @@
import Foundation
import UIKit
import AVFoundation
class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
static let shared = TTSManager()
private let synthesizer = AVSpeechSynthesizer()
private var isSpeaking = false
override private init() {
super.init()
synthesizer.delegate = self
configureAudioSession()
}
private func configureAudioSession() {
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
try audioSession.setActive(true)
// Background-Audio aktivieren
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
// Notification für App-Lifecycle
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
} catch {
print("Fehler beim Konfigurieren der Audio-Session: \(error)")
}
}
func speak(text: String, language: String = "de-DE") {
guard !text.isEmpty else { return }
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
let utterance = AVSpeechUtterance(string: text)
// Versuche eine hochwertige Stimme zu finden
if let enhancedVoice = findEnhancedVoice(for: language) {
utterance.voice = enhancedVoice
} else {
utterance.voice = AVSpeechSynthesisVoice(language: language)
}
utterance.rate = 0.5
utterance.pitchMultiplier = 1.0
utterance.volume = 1.0
synthesizer.speak(utterance)
isSpeaking = true
}
private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice? {
let availableVoices = AVSpeechSynthesisVoice.speechVoices()
// Bevorzugte Stimmen für alle Sprachen
let preferredVoiceNames = [
"Anna", // Deutsche Premium-Stimme
"Helena", // Deutsche Premium-Stimme
"Siri", // Siri-Stimme (falls verfügbar)
"Enhanced", // Enhanced-Stimmen
"Karen", // Englische Premium-Stimme
"Daniel", // Englische Premium-Stimme
"Marie", // Französische Premium-Stimme
"Paolo", // Italienische Premium-Stimme
"Carmen", // Spanische Premium-Stimme
"Yuki" // Japanische Premium-Stimme
]
// Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen
for voiceName in preferredVoiceNames {
if let voice = availableVoices.first(where: {
$0.language == language &&
$0.name.contains(voiceName)
}) {
return voice
}
}
// Fallback: Erste verfügbare Stimme für die Sprache
return availableVoices.first(where: { $0.language == language })
}
func pause() {
synthesizer.pauseSpeaking(at: .word)
}
func resume() {
synthesizer.continueSpeaking()
}
func stop() {
synthesizer.stopSpeaking(at: .immediate)
isSpeaking = false
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
isSpeaking = false
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
isSpeaking = false
}
func isCurrentlySpeaking() -> Bool {
return synthesizer.isSpeaking
}
@objc private func handleAppDidEnterBackground() {
// App geht in Hintergrund - Audio-Session beibehalten
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Fehler beim Aktivieren der Audio-Session im Hintergrund: \(error)")
}
}
@objc private func handleAppWillEnterForeground() {
// App kommt in Vordergrund - Audio-Session erneuern
do {
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Fehler beim Aktivieren der Audio-Session im Vordergrund: \(error)")
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// Debug-Methode: Zeigt alle verfügbaren Stimmen für eine Sprache
func printAvailableVoices(for language: String = "de-DE") {
let voices = AVSpeechSynthesisVoice.speechVoices()
let filteredVoices = voices.filter { $0.language.starts(with: language.prefix(2)) }
print("Verfügbare Stimmen für \(language):")
for voice in filteredVoices {
print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)")
}
}
// Debug-Methode: Zeigt alle verfügbaren Sprachen
func printAllAvailableLanguages() {
let voices = AVSpeechSynthesisVoice.speechVoices()
let languages = Set(voices.map { $0.language })
print("Verfügbare Sprachen:")
for language in languages.sorted() {
print("- \(language)")
}
}
}

View File

@ -0,0 +1,158 @@
import XCTest
@testable import readeck
final class StringExtensionsTests: XCTestCase {
// MARK: - stripHTML Tests
func testStripHTML_SimpleTags() {
let html = "<p>Dies ist ein <strong>wichtiger</strong> Artikel.</p>"
let expected = "Dies ist ein wichtiger Artikel.\n"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_ComplexNestedTags() {
let html = "<div><h1>Titel</h1><p>Text mit <em>kursiv</em> und <strong>fett</strong>.</p></div>"
let expected = "Titel\nText mit kursiv und fett."
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_WithAttributes() {
let html = "<p class=\"important\" id=\"main\">Text mit Attributen</p>"
let expected = "Text mit Attributen\n"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_EmptyTags() {
let html = "<p></p><div>Inhalt</div><span></span>"
let expected = "\nInhalt\n"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_SelfClosingTags() {
let html = "<p>Text mit <br>Zeilenumbruch und <img src=\"test.jpg\"> Bild.</p>"
let expected = "Text mit \nZeilenumbruch und Bild.\n"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_NoTags() {
let plainText = "Dies ist normaler Text ohne HTML."
XCTAssertEqual(plainText.stripHTML, plainText)
}
func testStripHTML_EmptyString() {
let emptyString = ""
XCTAssertEqual(emptyString.stripHTML, emptyString)
}
func testStripHTML_OnlyTags() {
let onlyTags = "<p><div><span></span></div></p>"
let expected = "\n"
XCTAssertEqual(onlyTags.stripHTML, expected)
}
// MARK: - stripHTMLSimple Tests
func testStripHTMLSimple_BasicTags() {
let html = "<p>Text mit <strong>fett</strong>.</p>"
let expected = "Text mit fett."
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_HTMLEntities() {
let html = "<p>Text mit &nbsp;Leerzeichen, &amp; Zeichen und &quot;Anführungszeichen&quot;.</p>"
let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_MoreEntities() {
let html = "<p>&lt;Tag&gt; und &#39;Apostroph&#39;</p>"
let expected = "<Tag> und 'Apostroph'"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_ComplexHTML() {
let html = "<div class=\"container\"><h1>Überschrift</h1><p>Absatz mit <em>kursiv</em> und <strong>fett</strong>.</p><ul><li>Liste 1</li><li>Liste 2</li></ul></div>"
let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
func testStripHTMLSimple_NoTags() {
let plainText = "Normaler Text ohne HTML."
XCTAssertEqual(plainText.stripHTMLSimple, plainText)
}
func testStripHTMLSimple_EmptyString() {
let emptyString = ""
XCTAssertEqual(emptyString.stripHTMLSimple, emptyString)
}
func testStripHTMLSimple_WhitespaceHandling() {
let html = " <p> Text mit Whitespace </p> "
let expected = "Text mit Whitespace"
XCTAssertEqual(html.stripHTMLSimple, expected)
}
// MARK: - Performance Tests
func testStripHTML_Performance() {
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
measure {
_ = largeHTML.stripHTML
}
}
func testStripHTMLSimple_Performance() {
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
measure {
_ = largeHTML.stripHTMLSimple
}
}
// MARK: - Edge Cases
func testStripHTML_MalformedHTML() {
let malformed = "<p>Unvollständiger <strong>Tag"
let expected = "Unvollständiger Tag"
XCTAssertEqual(malformed.stripHTML, expected)
}
func testStripHTML_UnicodeCharacters() {
let html = "<p>Text mit Umlauten: äöüß und Emojis: 🚀📱</p>"
let expected = "Text mit Umlauten: äöüß und Emojis: 🚀📱"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_Newlines() {
let html = "<p>Erste Zeile<br>Zweite Zeile</p>"
let expected = "Erste Zeile\nZweite Zeile"
XCTAssertEqual(html.stripHTML, expected)
}
func testStripHTML_ListItems() {
let html = "<ul><li>Erster Punkt</li><li>Zweiter Punkt</li><li>Dritter Punkt</li></ul>"
let expected = "Erster Punkt\nZweiter Punkt\nDritter Punkt"
XCTAssertEqual(html.stripHTML, expected)
}
}