Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ All notable changes to this project will be documented in this file. Take a look
* `Recipes/` contains self-contained and explained code you can reuse in your own application.
* `App/` folder contains the scaffolding (file management, navigation, error handling) needed to run the Playground.

#### Shared

* Added support for SVG covers in `ResourceCoverService`. SVG images can now be used as publication covers and are rendered to bitmaps (contributed by [@grighakobian](https://github.com/readium/swift-toolkit/pull/751)).

### Removed

* Carthage is no longer a supported distribution method. Please migrate to Swift Package Manager or CocoaPods.
Expand Down
10 changes: 4 additions & 6 deletions Sources/Shared/Publication/Services/Cover/CoverService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,12 @@ public typealias CoverServiceFactory = (PublicationServiceContext) -> CoverServi
public protocol CoverService: PublicationService {
/// Returns the publication cover as a bitmap at its maximum size.
///
/// If the cover is not a bitmap format (e.g. SVG), it will be scaled down to fit the screen
/// using `UIScreen.main.bounds.size`.
/// If the cover is not a bitmap format (e.g. SVG), it will be rendered at its intrinsic size,
/// or scaled down to a reasonable maximum to avoid excessive memory usage.
func cover() async -> ReadResult<UIImage?>

/// Returns the publication cover as a bitmap, scaled down to fit the given `maxSize`.
///
/// If the cover is not in a bitmap format (e.g. SVG), it is exported as a bitmap filling
/// `maxSize`. The cover might be cached in memory for next calls.
/// Returns the publication cover as a bitmap, scaled down to fit within `maxSize` while
/// preserving the aspect ratio. The cover might be cached in memory for next calls.
func coverFitting(maxSize: CGSize) async -> ReadResult<UIImage?>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,50 +11,69 @@ import UIKit
///
/// It will look for:
/// 1. Links with explicit `cover` relation in the resources.
/// 2. First `readingOrder` resource if it's a bitmap, or if it has a bitmap
/// `alternates`.
/// 2. First `readingOrder` resource if it's a bitmap or SVG, or if it has a
/// bitmap/SVG `alternates`.
public final class ResourceCoverService: CoverService {
/// Default maximum size in points for SVG cover rendering.
private static let defaultCoverMaxSize = CGSize(width: 400, height: 600)

private let context: PublicationServiceContext

public init(context: PublicationServiceContext) {
self.context = context
}

public func cover() async -> ReadResult<UIImage?> {
await loadCover(maxSize: nil)
}

public func coverFitting(maxSize: CGSize) async -> ReadResult<UIImage?> {
await loadCover(maxSize: maxSize)
}

private func loadCover(maxSize: CGSize?) async -> ReadResult<UIImage?> {
// Try resources with explicit `cover` relation
for link in context.manifest.linksWithRel(.cover) {
if let image = await loadImage(from: link) {
if let image = await loadImage(from: link, maxSize: maxSize) {
return .success(image)
}
}

// Fallback: first reading order bitmap or alternate
// Fallback: first reading order bitmap/SVG or alternate
if let firstLink = context.manifest.readingOrder.first {
if firstLink.mediaType?.isBitmap == true {
if let image = await loadImage(from: firstLink) {
return .success(image)
}
if let image = await loadImage(from: firstLink, maxSize: maxSize) {
return .success(image)
}

for alternate in firstLink.alternates {
if alternate.mediaType?.isBitmap == true {
if let image = await loadImage(from: alternate) {
return .success(image)
}
if let image = await loadImage(from: alternate, maxSize: maxSize) {
return .success(image)
}
}
}

return .success(nil)
}

private func loadImage(from link: Link) async -> UIImage? {
private func loadImage(from link: Link, maxSize: CGSize?) async -> UIImage? {
guard
let mediaType = link.mediaType,
mediaType.isBitmap || mediaType.matches(.svg),
let resource = context.container[link.url()],
let data = try? await resource.read().get()
else {
return nil
}
return UIImage(data: data)

if mediaType.matches(.svg) {
return UIImage.fromSVG(data, maxSize: maxSize ?? Self.defaultCoverMaxSize)
}

let image = UIImage(data: data)
if let maxSize = maxSize {
return image?.scaleToFit(maxSize: maxSize)
}
return image
}

public static func makeFactory() -> (PublicationServiceContext) -> ResourceCoverService {
Expand Down
68 changes: 68 additions & 0 deletions Sources/Shared/Toolkit/Extensions/UIImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,78 @@
//

import func AVFoundation.AVMakeRect
import CoreGraphics
import Foundation
import UIKit

private enum CoreSVG {
typealias CreateFromData = @convention(c) (CFData, CFDictionary?) -> Unmanaged<CFTypeRef>?
typealias GetCanvasSize = @convention(c) (CFTypeRef) -> CGSize
typealias DrawInContext = @convention(c) (CGContext, CFTypeRef) -> Void
typealias ReleaseDocument = @convention(c) (CFTypeRef) -> Void

static let createFromData: CreateFromData? = load("CGSVGDocumentCreateFromData")
static let getCanvasSize: GetCanvasSize? = load("CGSVGDocumentGetCanvasSize")
static let drawInContext: DrawInContext? = load("CGContextDrawSVGDocument")
static let releaseDocument: ReleaseDocument? = load("CGSVGDocumentRelease")

private static func load<T>(_ name: String) -> T? {
guard let sym = dlsym(dlopen(nil, RTLD_LAZY), name) else { return nil }
return unsafeBitCast(sym, to: T.self)
}
}

extension UIImage {
/// Creates a `UIImage` by rendering an SVG document from the given data,
/// scaled down to fit `maxSize` while preserving the aspect ratio.
///
/// If the SVG canvas is smaller than `maxSize`, it is rendered at its
/// native size to avoid upscaling embedded bitmaps.
///
/// Returns `nil` if the data is not a valid SVG or if SVG rendering is
/// unavailable on the current platform.
static func fromSVG(_ data: Data, maxSize: CGSize) -> UIImage? {
guard
let createFromData = CoreSVG.createFromData,
let getCanvasSize = CoreSVG.getCanvasSize,
let drawInContext = CoreSVG.drawInContext,
let releaseDocument = CoreSVG.releaseDocument,
let document = createFromData(data as CFData, nil)
else {
return nil
}
let svgDocument = document.takeUnretainedValue()
defer { releaseDocument(svgDocument) }

let canvasSize = getCanvasSize(svgDocument)
guard canvasSize.width > 0, canvasSize.height > 0 else {
return nil
}

// Render at the smaller of the canvas size and the requested max
// size, preserving the SVG aspect ratio.
let renderSize: CGSize
if canvasSize.width <= maxSize.width, canvasSize.height <= maxSize.height {
renderSize = canvasSize
} else {
let targetRect = AVMakeRect(
aspectRatio: canvasSize,
insideRect: CGRect(origin: .zero, size: maxSize)
)
renderSize = targetRect.size
}

let renderer = UIGraphicsImageRenderer(size: renderSize)
return renderer.image { ctx in
let cgContext = ctx.cgContext
let scaleX = renderSize.width / canvasSize.width
let scaleY = renderSize.height / canvasSize.height
cgContext.translateBy(x: 0, y: renderSize.height)
cgContext.scaleBy(x: scaleX, y: -scaleY)
drawInContext(cgContext, svgDocument)
}
}

func scaleToFit(maxSize: CGSize) -> UIImage {
if size.width <= maxSize.width, size.height <= maxSize.height {
return self
Expand Down
4 changes: 4 additions & 0 deletions Tests/SharedTests/Fixtures/Publication/Services/cover-svg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions Tests/SharedTests/Fixtures/Toolkit/Extensions/cover-svg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading