From 31d1f1d20fde683867391ea99446044b362cf3da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:36:01 +0100 Subject: [PATCH] task 5: add AttributedStringBuilder for platform-aware font attributes Co-Authored-By: Claude Sonnet 4.6 --- .../BookParser/AttributedStringBuilder.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 VorleserKit/Sources/BookParser/AttributedStringBuilder.swift diff --git a/VorleserKit/Sources/BookParser/AttributedStringBuilder.swift b/VorleserKit/Sources/BookParser/AttributedStringBuilder.swift new file mode 100644 index 0000000..39448d2 --- /dev/null +++ b/VorleserKit/Sources/BookParser/AttributedStringBuilder.swift @@ -0,0 +1,121 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Builds an NSAttributedString from plain text with formatting ranges. +/// The resulting .string is character-for-character identical to the input text. +enum AttributedStringBuilder { + struct FormattingRange { + enum Style { + case body + case bold + case italic + case heading(level: Int) + case paragraphStart + } + let range: NSRange + let style: Style + } + + /// Build an attributed string from plain text and formatting ranges. + /// The plain text is used as-is — no characters are added or removed. + static func build(text: String, ranges: [FormattingRange]) -> NSAttributedString { + let result = NSMutableAttributedString( + string: text, + attributes: [ + .font: bodyFont(), + .foregroundColor: textColor(), + ] + ) + + for range in ranges { + switch range.style { + case .body: + break + case .bold: + result.addAttribute(.font, value: boldFont(), range: range.range) + case .italic: + result.addAttribute(.font, value: italicFont(), range: range.range) + case .heading(let level): + result.addAttribute(.font, value: headingFont(level: level), range: range.range) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 16 + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: range.range) + case .paragraphStart: + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = 8 + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: range.range) + } + } + + return NSAttributedString(attributedString: result) + } + + /// Build a plain attributed string with body font (no formatting ranges). + static func buildPlain(text: String) -> NSAttributedString { + NSAttributedString( + string: text, + attributes: [ + .font: bodyFont(), + .foregroundColor: textColor(), + ] + ) + } + + // MARK: - Platform fonts + + #if canImport(UIKit) + private static func bodyFont() -> UIFont { + .preferredFont(forTextStyle: .body) + } + + private static func boldFont() -> UIFont { + let descriptor = UIFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func italicFont() -> UIFont { + let descriptor = UIFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.traitItalic)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func headingFont(level: Int) -> UIFont { + let style: UIFont.TextStyle = level <= 2 ? .title1 : .title3 + let descriptor = UIFont.preferredFont(forTextStyle: style).fontDescriptor.withSymbolicTraits(.traitBold)! + return UIFont(descriptor: descriptor, size: 0) + } + + private static func textColor() -> UIColor { + .label + } + + #elseif canImport(AppKit) + private static func bodyFont() -> NSFont { + .preferredFont(forTextStyle: .body) + } + + private static func boldFont() -> NSFont { + let body = NSFont.preferredFont(forTextStyle: .body) + return NSFontManager.shared.convert(body, toHaveTrait: .boldFontMask) + } + + private static func italicFont() -> NSFont { + let body = NSFont.preferredFont(forTextStyle: .body) + return NSFontManager.shared.convert(body, toHaveTrait: .italicFontMask) + } + + private static func headingFont(level: Int) -> NSFont { + let style: NSFont.TextStyle = level <= 2 ? .title1 : .title3 + let heading = NSFont.preferredFont(forTextStyle: style) + return NSFontManager.shared.convert(heading, toHaveTrait: .boldFontMask) + } + + private static func textColor() -> NSColor { + .textColor + } + #endif +}