Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift
T
Huang Xin 9321c2cd39 fix(widget): round iOS cover thumbnail size to whole pixels (#4950)
The iOS reading widget downsampled covers to a fractional target size.
UIGraphicsImageRenderer allocates a whole-pixel buffer while draw(in:)
fills only the exact fractional rect, so for portrait covers whose scaled
width rounds up the rightmost pixel column was left partially covered and
semi-transparent. Encoded to JPEG that column flattened into a visible
bright hairline along the right edge (intermittent, portrait covers only).

Round both target dimensions to whole pixels so the draw rect matches the
pixel buffer and every edge pixel is fully covered. Android is unaffected
because it scales to a fixed 240x360 and center-crops.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:24:40 +02:00

68 lines
2.5 KiB
Swift

import Foundation
import UIKit
import WidgetKit
enum ReadingWidgetWriter {
static let suiteName = "group.com.bilingify.readest"
static let snapshotKey = "readingWidgetSnapshot"
static let thumbMaxPixels: CGFloat = 240
struct SnapshotBook: Codable {
let hash: String
let title: String
let author: String
let percent: Int
}
struct Snapshot: Codable {
let books: [SnapshotBook]
let sectionTitle: String
let emptyTitle: String
}
static var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suiteName)
}
static func coversDir() -> URL? {
guard let base = containerURL?.appendingPathComponent("widget/covers", isDirectory: true)
else { return nil }
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base
}
/// Downsample a cover file to <= thumbMaxPixels on the long edge and write
/// it as JPEG into the App Group container. Returns silently on failure.
static func writeThumbnail(hash: String, sourcePath: String) {
guard let dir = coversDir() else { return }
let dst = dir.appendingPathComponent("\(hash).jpg")
guard let image = UIImage(contentsOfFile: sourcePath) else {
try? FileManager.default.removeItem(at: dst)
return
}
let longEdge = max(image.size.width, image.size.height)
let scale = longEdge > thumbMaxPixels ? thumbMaxPixels / longEdge : 1
// Round to whole pixels: the renderer allocates a whole-pixel buffer, so a
// fractional target would leave the trailing pixel column/row only partially
// covered (semi-transparent). Flattened into JPEG that shows up as a bright
// hairline along the right/bottom edge. Whole-pixel target == full coverage.
let target = CGSize(
width: (image.size.width * scale).rounded(),
height: (image.size.height * scale).rounded())
let format = UIGraphicsImageRendererFormat()
format.scale = 1.0
let renderer = UIGraphicsImageRenderer(size: target, format: format)
let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) }
if let data = resized.jpegData(compressionQuality: 0.8) {
try? data.write(to: dst)
}
}
static func write(snapshot: Snapshot) {
guard let data = try? JSONEncoder().encode(snapshot) else { return }
UserDefaults(suiteName: suiteName)?.set(data, forKey: snapshotKey)
DispatchQueue.main.async {
WidgetCenter.shared.reloadAllTimelines()
}
}
}