fix: Replace local() with url() for WKWebView font loading

Fix custom fonts not displaying in WKWebView by replacing CSS `src: local()`
with `src: url()` using bundle file paths. WKWebView cannot resolve fonts via
local() even when registered in Info.plist UIAppFonts.

Changes:
- Add fontFileNames and cssFontFamily properties to FontFamily enum
- Implement generateFontFaceCSS() helper in WebView.swift and NativeWebView.swift
- Generate @font-face CSS with file:// URLs for all 12 font files
- Fonts are now dynamically loaded from FontFamily enum (single source of truth)

Affected fonts:
- Literata (Regular, Bold)
- Merriweather (Regular, Bold)
- Source Serif 4 (Regular, Bold)
- Lato (Regular, Bold)
- Montserrat (Regular, Bold)
- Source Sans 3 (Regular, Bold)
This commit is contained in:
Ilyas Hallak 2025-12-19 21:31:01 +01:00
parent e96d6e13ac
commit 997d740597
6 changed files with 117 additions and 315 deletions

View File

@ -69,6 +69,61 @@ enum FontFamily: String, CaseIterable {
return false return false
} }
} }
/// Returns the font file names (without extension) for this font family
/// These correspond to the files listed in Info.plist under UIAppFonts
var fontFileNames: [(fileName: String, weight: String)]? {
switch self {
case .literata:
return [
("Literata-Regular", "normal"),
("Literata-Bold", "bold")
]
case .merriweather:
return [
("Merriweather-Regular", "normal"),
("Merriweather-Bold", "bold")
]
case .sourceSerif:
return [
("SourceSerif4-Regular", "normal"),
("SourceSerif4-Bold", "bold")
]
case .lato:
return [
("Lato-Regular", "normal"),
("Lato-Bold", "bold")
]
case .montserrat:
return [
("Montserrat-Regular", "normal"),
("Montserrat-Bold", "bold")
]
case .sourceSans:
return [
("SourceSans3-Regular", "normal"),
("SourceSans3-Bold", "bold")
]
// System fonts don't need to be loaded via @font-face
case .system, .newYork, .avenirNext, .monospace, .serif, .sansSerif:
return nil
}
}
/// Returns the CSS font-family name used in @font-face declarations
var cssFontFamily: String? {
switch self {
case .literata: return "Literata"
case .merriweather: return "Merriweather"
case .sourceSerif: return "Source Serif 4"
case .lato: return "Lato"
case .montserrat: return "Montserrat"
case .sourceSans: return "Source Sans 3"
// System fonts don't need CSS font-family names
case .system, .newYork, .avenirNext, .monospace, .serif, .sansSerif:
return nil
}
}
} }
enum FontCategory { enum FontCategory {

View File

@ -190,66 +190,7 @@ struct NativeWebView: View {
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")"> <meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style> <style>
/* Load custom fonts from app bundle */ /* Load custom fonts from app bundle */
@font-face { \(generateFontFaceCSS())
font-family: 'Literata';
src: local('Literata-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Literata';
src: local('Literata-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Merriweather';
src: local('Merriweather-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Merriweather';
src: local('Merriweather-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Source Serif 4';
src: local('SourceSerif4-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: local('SourceSerif4-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Lato';
src: local('Lato-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Lato';
src: local('Lato-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Montserrat';
src: local('Montserrat-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Montserrat';
src: local('Montserrat-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Source Sans 3';
src: local('SourceSans3-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Source Sans 3';
src: local('SourceSans3-Bold');
font-weight: bold;
}
* { * {
max-width: 100%; max-width: 100%;
@ -445,6 +386,36 @@ struct NativeWebView: View {
} }
} }
private func generateFontFaceCSS() -> String {
var css = ""
// Iterate through all font families from the enum
for fontFamily in FontFamily.allCases {
// Only process fonts that need to be loaded (Google fonts)
guard let fileNames = fontFamily.fontFileNames,
let cssFamilyName = fontFamily.cssFontFamily else {
continue
}
// Generate @font-face rules for each weight variant
for (fileName, weight) in fileNames {
if let fontPath = Bundle.main.path(forResource: fileName, ofType: "ttf") {
let fileURL = URL(fileURLWithPath: fontPath).absoluteString
css += """
@font-face {
font-family: '\(cssFamilyName)';
src: url('\(fileURL)') format('truetype');
font-weight: \(weight);
}
"""
}
}
}
return css
}
private func getFontSize(from fontSize: FontSize) -> Int { private func getFontSize(from fontSize: FontSize) -> Int {
switch fontSize { switch fontSize {
case .small: return 14 case .small: return 14

View File

@ -75,66 +75,7 @@ struct WebView: UIViewRepresentable {
<meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")"> <meta name="color-scheme" content="\(isDarkMode ? "dark" : "light")">
<style> <style>
/* Load custom fonts from app bundle */ /* Load custom fonts from app bundle */
@font-face { \(generateFontFaceCSS())
font-family: 'Literata';
src: local('Literata-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Literata';
src: local('Literata-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Merriweather';
src: local('Merriweather-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Merriweather';
src: local('Merriweather-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Source Serif 4';
src: local('SourceSerif4-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Source Serif 4';
src: local('SourceSerif4-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Lato';
src: local('Lato-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Lato';
src: local('Lato-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Montserrat';
src: local('Montserrat-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Montserrat';
src: local('Montserrat-Bold');
font-weight: bold;
}
@font-face {
font-family: 'Source Sans 3';
src: local('SourceSans3-Regular');
font-weight: normal;
}
@font-face {
font-family: 'Source Sans 3';
src: local('SourceSans3-Bold');
font-weight: bold;
}
:root { :root {
--background-color: \(isDarkMode ? "#000000" : "#ffffff"); --background-color: \(isDarkMode ? "#000000" : "#ffffff");
@ -407,6 +348,36 @@ struct WebView: UIViewRepresentable {
WebViewCoordinator() WebViewCoordinator()
} }
private func generateFontFaceCSS() -> String {
var css = ""
// Iterate through all font families from the enum
for fontFamily in FontFamily.allCases {
// Only process fonts that need to be loaded (Google fonts)
guard let fileNames = fontFamily.fontFileNames,
let cssFamilyName = fontFamily.cssFontFamily else {
continue
}
// Generate @font-face rules for each weight variant
for (fileName, weight) in fileNames {
if let fontPath = Bundle.main.path(forResource: fileName, ofType: "ttf") {
let fileURL = URL(fileURLWithPath: fontPath).absoluteString
css += """
@font-face {
font-family: '\(cssFamilyName)';
src: url('\(fileURL)') format('truetype');
font-weight: \(weight);
}
"""
}
}
}
return css
}
private func getFontSize(from fontSize: FontSize) -> Int { private func getFontSize(from fontSize: FontSize) -> Int {
switch fontSize { switch fontSize {
case .small: return 14 case .small: return 14

View File

@ -1,145 +0,0 @@
//
// LogStore.swift
// readeck
//
// Created by Ilyas Hallak on 01.11.25.
//
import Foundation
// MARK: - Log Entry
struct LogEntry: Identifiable, Codable {
let id: UUID
let timestamp: Date
let level: LogLevel
let category: LogCategory
let message: String
let file: String
let function: String
let line: Int
var fileName: String {
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
}
var formattedTimestamp: String {
DateFormatter.logTimestamp.string(from: timestamp)
}
init(
id: UUID = UUID(),
timestamp: Date = Date(),
level: LogLevel,
category: LogCategory,
message: String,
file: String,
function: String,
line: Int
) {
self.id = id
self.timestamp = timestamp
self.level = level
self.category = category
self.message = message
self.file = file
self.function = function
self.line = line
}
}
// MARK: - Log Store
actor LogStore {
static let shared = LogStore()
private var entries: [LogEntry] = []
private let maxEntries: Int
private init(maxEntries: Int = 1000) {
self.maxEntries = maxEntries
}
func addEntry(_ entry: LogEntry) {
entries.append(entry)
// Keep only the most recent entries
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
func getEntries() -> [LogEntry] {
return entries
}
func getEntries(
level: LogLevel? = nil,
category: LogCategory? = nil,
searchText: String? = nil
) -> [LogEntry] {
var filtered = entries
if let level = level {
filtered = filtered.filter { $0.level == level }
}
if let category = category {
filtered = filtered.filter { $0.category == category }
}
if let searchText = searchText, !searchText.isEmpty {
filtered = filtered.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
$0.function.localizedCaseInsensitiveContains(searchText)
}
}
return filtered
}
func clear() {
entries.removeAll()
}
func exportAsText() -> String {
var text = "Readeck Debug Logs\n"
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
text += "Total Entries: \(entries.count)\n"
text += String(repeating: "=", count: 80) + "\n\n"
for entry in entries {
text += "[\(entry.formattedTimestamp)] "
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
text += "[\(entry.category.rawValue)] "
text += "\(entry.fileName):\(entry.line) "
text += "\(entry.function)\n"
text += " \(entry.message)\n\n"
}
return text
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "DEBUG"
case 1: return "INFO"
case 2: return "NOTICE"
case 3: return "WARNING"
case 4: return "ERROR"
case 5: return "CRITICAL"
default: return "UNKNOWN"
}
}
}
// MARK: - DateFormatter Extension
extension DateFormatter {
static let exportTimestamp: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}

View File

@ -1,38 +0,0 @@
import Foundation
class VersionManager {
static let shared = VersionManager()
private let lastSeenVersionKey = "lastSeenAppVersion"
private let userDefaults = UserDefaults.standard
var currentVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
}
var currentBuild: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
}
var fullVersion: String {
"\(currentVersion) (\(currentBuild))"
}
var lastSeenVersion: String? {
userDefaults.string(forKey: lastSeenVersionKey)
}
var isNewVersion: Bool {
guard let lastSeen = lastSeenVersion else {
// First launch
return true
}
return lastSeen != currentVersion
}
func markVersionAsSeen() {
userDefaults.set(currentVersion, forKey: lastSeenVersionKey)
}
private init() {}
}

View File

@ -302,15 +302,3 @@ extension Dictionary {
} }
} }
// MARK: - Debug Build Detection
extension Bundle {
var isDebugBuild: Bool {
#if DEBUG
return true
#else
return false
#endif
}
}