From 940c4baa368fa6202d5f1489e548f56c9f32dbb1 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 15 Aug 2026 18:58:07 +0200 Subject: [PATCH 1/2] Fit a document to the screen, as Android does OpenDocument.droid gives odrcore the same five answers we do, and gets the same HTML out: a text document or a presentation carries `width=device-width` and no scale of its own, a spreadsheet or a csv names `initial-scale=1.0`. Only `embedShippedResources` was left to odrcore's default here, so it is spelled out now next to the rest. What differed is the reader. A web view in Android's overview mode zooms a page out until its full width is on screen, which is what odrcore means by leaving the scale out; WKWebView has no such setting, so a letter-wide page was laid out at 100% and ran off the right edge of every phone. A script at document end gives the viewport meta the width the page actually needs, which is the same thing by the only means iOS has. A page that names its own scale means it, and is left alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q1T4UTUxDqjCGUn3Uiyhkj --- CHANGELOG.md | 2 ++ OpenDocumentReader/CoreWrapper.swift | 12 +++++++-- .../DocumentViewController.swift | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1324c..fffbbb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ once the version tag exists. ### Changed +- A document wider than the screen opens fitted to it rather than running off + the edge, which is what it has always done on Android. - The way out of a document is a back chevron, not the words "Back to documents". - Documents that can be edited offer a pencil next to the search button. diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift index 6aeed67..331c06f 100644 --- a/OpenDocumentReader/CoreWrapper.swift +++ b/OpenDocumentReader/CoreWrapper.swift @@ -128,15 +128,23 @@ private func isCsv(_ file: DecodedFile) -> Bool { file.fileType == .commaSeparat throw coreWrapperError(.unsupportedFileType, "not a document file") } + // the same answers OpenDocument.droid gives odrcore, so a document is + // the same document on both — the viewport meta each page carries is + // decided from these let config = HtmlConfig() config.editable = editable // resource paths are resolved relative to an output directory, and in // server mode there is none — odrcore rejects the combination config.relativeResourcePaths = false - // the side margins of a printed page, which is what it was written to look like + // the side margins of a printed page, which is what it was written to + // look like, and what makes odrcore call a text document paged: its + // pages are then fitted to the screen rather than shown at full size config.textDocumentMargin = true - // served with the pages rather than inlined as base64, as in OpenDocument.droid + // served with the pages rather than inlined as base64 config.embedImages = false + // odrcore's own css and js go into the page: there is no output + // directory to put them beside + config.embedShippedResources = true let documentType: DocumentType let openedDocument: OdrCoreObjC.Document? diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 0e47961..1bafc53 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -59,6 +59,29 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel private lazy var toolBarItems: [UIBarButtonItem] = toolBar.items ?? [] private lazy var toolBarItemsWithoutEdit: [UIBarButtonItem] = toolBarItems.filter { $0 !== editButton } + /// What OpenDocument.droid gets from `loadWithOverviewMode`, which iOS has + /// no setting for: a page wider than the screen is zoomed out until it fits + /// instead of running off the edge. + /// + /// odrcore asks for that by leaving the initial scale out of the viewport + /// meta - `width=device-width` alone, which every browser but a web view in + /// overview mode reads as "lay out at screen width and let the rest + /// overflow". A page that names its scale (a spreadsheet, a csv) means it, + /// and is left alone. + private static let fitToWidthScript = """ + (function () { + var meta = document.querySelector('meta[name="viewport"]'); + if (!meta || (meta.content || '').indexOf('initial-scale') !== -1) { + return; + } + + var width = document.documentElement.scrollWidth; + if (width > window.innerWidth) { + meta.setAttribute('content', 'width=' + width + ',user-scalable=yes'); + } + })(); + """ + /// Fills the banner slot when no ad does. Sits on top of `bannerSlot` rather than in the /// layout chain, so the slot keeps its height and nothing below it moves. private let houseAdView = HouseAdView() @@ -91,6 +114,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // would fight the first pageTabBar.addTarget(self, action: #selector(pageSelected(sender:)), for: .valueChanged) webview.navigationDelegate = self + webview.configuration.userContentController.addUserScript( + WKUserScript(source: Self.fitToWidthScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) searchBar.delegate = self searchBar.showsCancelButton = true From c5408f1ea4e0f5340002ed94ca9f62968bfc708a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 15 Aug 2026 19:08:09 +0200 Subject: [PATCH 2/2] Fit only what odrcore served The script was registered once for the web view, which shows more than odrcore's pages: the formats odrcore does not handle are loaded from a file, and a link in a document is followed in the same view. Their viewport is their author's to write - a fixed-width page would have been shrunk, and `user-scalable=no`, a maximum scale or a `viewport-fit` thrown away with the rest of what it said. It is installed per load now, only for a URL `isServedURL` recognises, and the script checks the origin it was installed for before it touches anything - the fallback to a file happens after the page it replaces was already asked for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q1T4UTUxDqjCGUn3Uiyhkj --- .../DocumentViewController.swift | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 1bafc53..8921c36 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -68,8 +68,19 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel /// overview mode reads as "lay out at screen width and let the rest /// overflow". A page that names its scale (a spreadsheet, a csv) means it, /// and is left alone. - private static let fitToWidthScript = """ + /// + /// Only for what odrcore served, which is why `origin` is checked here as + /// well as before the script is installed: the same web view shows the + /// formats odrcore does not handle, and follows links out of a document. + /// Their viewport is their author's to write, and rewriting it would throw + /// away what it says - `user-scalable=no`, a maximum scale, a `viewport-fit`. + private static func fitToWidthScript(servedFrom origin: String) -> String { + """ (function () { + if (location.origin !== '\(origin)') { + return; + } + var meta = document.querySelector('meta[name="viewport"]'); if (!meta || (meta.content || '').indexOf('initial-scale') !== -1) { return; @@ -81,6 +92,27 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel } })(); """ + } + + /// Arms ``fitToWidthScript(servedFrom:)`` for a page that came off our own + /// server, and disarms it for anything else. At document end rather than on + /// `didFinish`, so the page is fitted before it is first drawn instead of + /// jumping once the images are in. + private func installFitToWidth(for url: URL) { + let scripts = webview.configuration.userContentController + scripts.removeAllUserScripts() + + guard CoreWrapper.isServedURL(url), + let scheme = url.scheme, let host = url.host, let port = url.port + else { + return + } + + scripts.addUserScript( + WKUserScript( + source: Self.fitToWidthScript(servedFrom: "\(scheme)://\(host):\(port)"), + injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + } /// Fills the banner slot when no ad does. Sits on top of `bannerSlot` rather than in the /// layout chain, so the slot keeps its height and nothing below it moves. @@ -114,8 +146,6 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // would fight the first pageTabBar.addTarget(self, action: #selector(pageSelected(sender:)), for: .valueChanged) webview.navigationDelegate = self - webview.configuration.userContentController.addUserScript( - WKUserScript(source: Self.fitToWidthScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) searchBar.delegate = self searchBar.showsCancelButton = true @@ -597,6 +627,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return } + installFitToWidth(for: url) + // pages come off the loopback server; a file URL needs read access // granted along with it if url.isFileURL {