@@ -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}
0 commit comments