Skip to content

Commit e49966e

Browse files
authored
Add PDF fit preference (#680)
1 parent fae59aa commit e49966e

File tree

8 files changed

+291
-15
lines changed

8 files changed

+291
-15
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. Take a look
1313
* This callback is called before executing any navigation action.
1414
* Useful for hiding UI elements when the user navigates, or implementing analytics.
1515
* Added swipe gesture support for navigating in PDF paginated spread mode.
16+
* Added `fit` preference for PDF documents to control how pages are scaled within the viewport.
17+
* Only effective in scroll mode. Paginated mode always uses page fit due to PDFKit limitations.
1618

1719
### Deprecated
1820

@@ -26,6 +28,15 @@ All notable changes to this project will be documented in this file. Take a look
2628

2729
* Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)).
2830

31+
#### Navigator
32+
33+
* The `Fit` enum has been redesigned to fit the PDF implementation.
34+
* **Breaking change:** Update any code using the old `Fit` enum values.
35+
* The PDF navigator's content inset behavior has changed:
36+
* iPhone: Continues to apply window safe area insets (to account for notch/Dynamic Island).
37+
* iPad/macOS: Now displays edge-to-edge with no automatic safe area insets.
38+
* You can customize this behavior with `VisualNavigatorDelegate.navigatorContentInset(_:)`.
39+
2940
### Fixed
3041

3142
#### Navigator

Sources/Navigator/PDF/PDFDocumentView.swift

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,31 @@ public final class PDFDocumentView: PDFView {
5050
}
5151

5252
private func updateContentInset() {
53-
let insets = documentViewDelegate?.pdfDocumentViewContentInset(self) ?? window?.safeAreaInsets ?? .zero
53+
let insets = contentInset
5454
firstScrollView?.contentInset.top = insets.top
5555
firstScrollView?.contentInset.bottom = insets.bottom
5656
}
5757

58+
private var contentInset: UIEdgeInsets {
59+
if let contentInset = documentViewDelegate?.pdfDocumentViewContentInset(self) {
60+
return contentInset
61+
}
62+
63+
// We apply the window's safe area insets (representing the system
64+
// status bar, but ignoring app bars) on iPhones only because in most
65+
// cases we prefer to display the content edge-to-edge.
66+
// iPhones are a special case because they are the only devices with a
67+
// physical notch (or Dynamic Island) which is included in the window's
68+
// safe area insets. Therefore, we must always take it into account to
69+
// avoid hiding the content.
70+
if UIDevice.current.userInterfaceIdiom == .phone {
71+
return window?.safeAreaInsets ?? .zero
72+
} else {
73+
// Edge-to-edge on macOS and iPadOS.
74+
return .zero
75+
}
76+
}
77+
5878
override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
5979
super.canPerformAction(action, withSender: sender) && editingActions.canPerformAction(action)
6080
}
@@ -70,4 +90,209 @@ public final class PDFDocumentView: PDFView {
7090
editingActions.buildMenu(with: builder)
7191
super.buildMenu(with: builder)
7292
}
93+
94+
var isPaginated: Bool {
95+
isUsingPageViewController || displayMode == .twoUp || displayMode == .singlePage
96+
}
97+
98+
var isSpreadEnabled: Bool {
99+
displayMode == .twoUp || displayMode == .twoUpContinuous
100+
}
101+
102+
/// Returns whether the document is currently zoomed to match the given
103+
/// `fit`.
104+
func isAtScaleFactor(for fit: Fit) -> Bool {
105+
let scaleFactorToFit = scaleFactor(for: fit)
106+
// 1% tolerance for floating point comparison
107+
let tolerance: CGFloat = 0.01
108+
return abs(scaleFactor - scaleFactorToFit) < tolerance
109+
}
110+
111+
/// Calculates the appropriate scale factor based on the fit preference.
112+
///
113+
/// Only used in scroll mode, as the paginated mode doesn't support custom
114+
/// scale factors without visual hiccups when swiping pages.
115+
func scaleFactor(for fit: Fit) -> CGFloat {
116+
// While a `width` fit works in scroll mode, the pagination mode has
117+
// critical limitations when zooming larger than the page fit, so it
118+
// does not support a `width` fit.
119+
//
120+
// - Visual snap: There is no API to pre-set the zoom scale for the next
121+
// page. PDFView resets the scale per page, causing a visible snap
122+
// when swiping. We don’t see the issue with edge taps.
123+
// - Incorrect anchoring: When zooming larger than the page fit, the
124+
// viewport centers vertically instead of showing the top. The API to
125+
// fix this works in scroll mode but is ignored in paginated mode.
126+
//
127+
// So we only support a `page` fit in paginated mode.
128+
if isPaginated {
129+
return scaleFactorForSizeToFitVisiblePages
130+
}
131+
132+
switch fit {
133+
case .auto, .width:
134+
// Use PDFKit's default auto-fit behavior
135+
return scaleFactorForSizeToFit
136+
case .page:
137+
return scaleFactorForLargestPage
138+
}
139+
}
140+
141+
/// Calculates the scale factor to fit the visible pages (by area) to the
142+
/// viewport.
143+
private var scaleFactorForSizeToFitVisiblePages: CGFloat {
144+
// The native `scaleFactorForSizeToFit` is incorrect when displaying
145+
// paginated spreads, so we need to use a custom implementation.
146+
if !isPaginated || !isSpreadEnabled {
147+
scaleFactorForSizeToFit
148+
} else {
149+
calculateScale(
150+
for: spreadSize(for: visiblePages),
151+
viewSize: bounds.size,
152+
insets: contentInset
153+
)
154+
}
155+
}
156+
157+
/// Calculates the scale factor to fit the largest page or spread (by area)
158+
/// to the viewport.
159+
private var scaleFactorForLargestPage: CGFloat {
160+
guard let document = document else {
161+
return 1.0
162+
}
163+
164+
// Check cache before expensive calculation
165+
let viewSize = bounds.size
166+
let insets = contentInset
167+
if
168+
let cached = cachedScaleFactorForLargestPage,
169+
cached.document == ObjectIdentifier(document),
170+
cached.viewSize == viewSize,
171+
cached.contentInset == insets,
172+
cached.spread == isSpreadEnabled,
173+
cached.displaysAsBook == displaysAsBook
174+
{
175+
return cached.scaleFactor
176+
}
177+
178+
var maxSize: CGSize = .zero
179+
var maxArea: CGFloat = 0
180+
181+
if !isSpreadEnabled {
182+
// No spreads: find largest individual page
183+
for pageIndex in 0 ..< document.pageCount {
184+
guard let page = document.page(at: pageIndex) else { continue }
185+
let pageSize = page.bounds(for: displayBox).size
186+
let area = pageSize.width * pageSize.height
187+
188+
if area > maxArea {
189+
maxArea = area
190+
maxSize = pageSize
191+
}
192+
}
193+
} else {
194+
// Spreads enabled: find largest spread
195+
let pageCount = document.pageCount
196+
197+
if displaysAsBook, pageCount > 0 {
198+
// First page displayed alone - check its size
199+
if let firstPage = document.page(at: 0) {
200+
let firstSize = firstPage.bounds(for: displayBox).size
201+
let firstArea = firstSize.width * firstSize.height
202+
if firstArea > maxArea {
203+
maxArea = firstArea
204+
maxSize = firstSize
205+
}
206+
}
207+
}
208+
209+
// Check spreads (pairs of pages)
210+
let startIndex = displaysAsBook ? 1 : 0
211+
for pageIndex in stride(from: startIndex, to: pageCount, by: 2) {
212+
let leftIndex = pageIndex
213+
let rightIndex = pageIndex + 1
214+
215+
guard let leftPage = document.page(at: leftIndex) else { continue }
216+
217+
if rightIndex < pageCount, let rightPage = document.page(at: rightIndex) {
218+
// Two-page spread
219+
let currentSpreadSize = spreadSize(for: [leftPage, rightPage])
220+
let spreadArea = currentSpreadSize.width * currentSpreadSize.height
221+
222+
if spreadArea > maxArea {
223+
maxArea = spreadArea
224+
maxSize = currentSpreadSize
225+
}
226+
} else {
227+
// Last page alone (odd page count)
228+
let leftSize = leftPage.bounds(for: displayBox).size
229+
let singleArea = leftSize.width * leftSize.height
230+
if singleArea > maxArea {
231+
maxArea = singleArea
232+
maxSize = leftSize
233+
}
234+
}
235+
}
236+
}
237+
238+
let scale = calculateScale(
239+
for: maxSize,
240+
viewSize: viewSize,
241+
insets: insets
242+
)
243+
244+
cachedScaleFactorForLargestPage = (
245+
document: ObjectIdentifier(document),
246+
scaleFactor: scale,
247+
viewSize: viewSize,
248+
contentInset: insets,
249+
spread: isSpreadEnabled,
250+
displaysAsBook: displaysAsBook
251+
)
252+
return scale
253+
}
254+
255+
/// Cache for expensive largest page scale calculation.
256+
private var cachedScaleFactorForLargestPage: (
257+
document: ObjectIdentifier,
258+
scaleFactor: CGFloat,
259+
viewSize: CGSize,
260+
contentInset: UIEdgeInsets,
261+
spread: Bool,
262+
displaysAsBook: Bool
263+
)?
264+
265+
/// Calculates the combined size of pages laid out side-by-side horizontally.
266+
private func spreadSize(for pages: [PDFPage]) -> CGSize {
267+
var size = CGSize.zero
268+
for page in pages {
269+
let pageBounds = page.bounds(for: displayBox)
270+
size.height = max(size.height, pageBounds.height)
271+
size.width += pageBounds.width
272+
}
273+
return size
274+
}
275+
276+
/// Calculates the scale factor needed to fit the given content size within
277+
/// the available viewport, accounting for content insets.
278+
private func calculateScale(
279+
for contentSize: CGSize,
280+
viewSize: CGSize,
281+
insets: UIEdgeInsets
282+
) -> CGFloat {
283+
guard contentSize.width > 0, contentSize.height > 0 else {
284+
return 1.0
285+
}
286+
287+
let availableSize = CGSize(
288+
width: viewSize.width - insets.left - insets.right,
289+
height: viewSize.height - insets.top - insets.bottom
290+
)
291+
292+
let widthScale = availableSize.width / contentSize.width
293+
let heightScale = availableSize.height / contentSize.height
294+
295+
// Use the smaller scale to ensure both dimensions fit
296+
return min(widthScale, heightScale)
297+
}
73298
}

Sources/Navigator/PDF/PDFNavigatorViewController.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,12 @@ open class PDFNavigatorViewController:
199199
super.viewWillTransition(to: size, with: coordinator)
200200

201201
if let pdfView = pdfView {
202-
// Makes sure that the PDF is always properly scaled down when
203-
// rotating the screen, if the user didn't zoom in.
204-
let isAtMinScaleFactor = (pdfView.scaleFactor == pdfView.minScaleFactor)
202+
// Makes sure that the PDF is always properly scaled when rotating
203+
// the screen, if the user didn't set a custom zoom.
204+
let isAtScaleFactor = pdfView.isAtScaleFactor(for: settings.fit)
205+
205206
coordinator.animate(alongsideTransition: { _ in
206-
self.updateScaleFactors(zoomToFit: isAtMinScaleFactor)
207+
self.updateScaleFactors(zoomToFit: isAtScaleFactor)
207208

208209
// Reset the PDF view to update the spread if needed.
209210
if self.settings.spread == .auto {
@@ -403,7 +404,8 @@ open class PDFNavigatorViewController:
403404

404405
@objc private func visiblePagesDidChange() {
405406
// In paginated mode, we want to refresh the scale factors to properly
406-
// fit the newly visible pages.
407+
// fit the newly visible pages. This is especially important for
408+
// paginated spreads.
407409
if !settings.scroll {
408410
updateScaleFactors(zoomToFit: true)
409411
}
@@ -489,11 +491,20 @@ open class PDFNavigatorViewController:
489491
guard let pdfView = pdfView else {
490492
return
491493
}
492-
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit
494+
495+
let scaleFactorToFit = pdfView.scaleFactor(for: settings.fit)
496+
497+
if settings.scroll {
498+
// Allow zooming out to 25% in scroll mode.
499+
pdfView.minScaleFactor = 0.25
500+
} else {
501+
pdfView.minScaleFactor = scaleFactorToFit
502+
}
503+
493504
pdfView.maxScaleFactor = 4.0
494505

495506
if zoomToFit {
496-
pdfView.scaleFactor = pdfView.minScaleFactor
507+
pdfView.scaleFactor = scaleFactorToFit
497508
}
498509
}
499510

Sources/Navigator/PDF/Preferences/PDFPreferences.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public struct PDFPreferences: ConfigurablePreferences {
1414
/// Background color behind the document pages.
1515
public var backgroundColor: Color?
1616

17+
/// Method for fitting the pages within the viewport.
18+
public var fit: Fit?
19+
1720
/// Indicates if the first page should be displayed in its own spread.
1821
public var offsetFirstPage: Bool?
1922

@@ -41,6 +44,7 @@ public struct PDFPreferences: ConfigurablePreferences {
4144

4245
public init(
4346
backgroundColor: Color? = nil,
47+
fit: Fit? = nil,
4448
offsetFirstPage: Bool? = nil,
4549
pageSpacing: Double? = nil,
4650
readingProgression: ReadingProgression? = nil,
@@ -51,6 +55,7 @@ public struct PDFPreferences: ConfigurablePreferences {
5155
) {
5256
precondition(pageSpacing == nil || pageSpacing! >= 0)
5357
self.backgroundColor = backgroundColor
58+
self.fit = fit
5459
self.offsetFirstPage = offsetFirstPage
5560
self.pageSpacing = pageSpacing
5661
self.readingProgression = readingProgression
@@ -63,6 +68,7 @@ public struct PDFPreferences: ConfigurablePreferences {
6368
public func merging(_ other: PDFPreferences) -> PDFPreferences {
6469
PDFPreferences(
6570
backgroundColor: other.backgroundColor ?? backgroundColor,
71+
fit: other.fit ?? fit,
6672
offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage,
6773
pageSpacing: other.pageSpacing ?? pageSpacing,
6874
readingProgression: other.readingProgression ?? readingProgression,

Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public final class PDFPreferencesEditor: StatefulPreferencesEditor<PDFPreference
3232
isEffective: { $0.preferences.backgroundColor != nil }
3333
)
3434

35+
/// Method for fitting the pages within the viewport.
36+
///
37+
/// Only effective when `scroll` is on.
38+
public lazy var fit: AnyEnumPreference<Fit> =
39+
enumPreference(
40+
preference: \.fit,
41+
setting: \.fit,
42+
defaultEffectiveValue: defaults.fit ?? .auto,
43+
isEffective: { $0.settings.scroll },
44+
supportedValues: [.auto, .page, .width]
45+
)
46+
3547
/// Indicates if the first page should be displayed in its own spread.
3648
///
3749
/// Only effective when `spread` is not off.

0 commit comments

Comments
 (0)