diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml
index d7a327f0..de20f83c 100644
--- a/.github/workflows/build_test.yml
+++ b/.github/workflows/build_test.yml
@@ -45,9 +45,7 @@ jobs:
- name: run tests
run: bundle exec fastlane tests
- # taking the pictures is a quarter hour of simulators and belongs to the
- # release, but a screenshot target that no longer compiles should not wait
- # for one to say so
+ # Compile screenshot tests here; capture them during release.
- name: build the screenshot tests
run: >
xcodebuild build-for-testing
@@ -62,9 +60,7 @@ jobs:
path: |
/Users/runner/Library/Developer/Xcode/DerivedData/OpenDocumentReader-*/
- # the test job only builds the simulator slice of one target, so release-only
- # and device-only breakage used to surface at deploy time. Both schemes, since
- # the flavors link different sources and different sdks
+ # Build both device targets to check Release code and each SDK configuration.
build:
runs-on: macos-26
strategy:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 45f885ec..5c180586 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -185,13 +185,7 @@ jobs:
build/*.app.dSYM.zip
if-no-files-found: warn
- # Beside the build rather than behind it: it signs nothing and uploads nothing,
- # it just drives simulators, and it takes about as long. On a dry run too -
- # the artifact is the only way to look at the pictures before the store does.
- # A runner per device rather than one doing both. Two simulators on the seven
- # gigabytes a runner has spent their time swapping, and the runs took five
- # times as long as they do alone; a device to a machine is that, structurally.
- # It halves the wall clock as well, since the two halves photograph at once.
+ # Capture screenshots alongside the build, one runner per device to limit memory use.
screenshots:
if: ${{ inputs.screenshots_from_run == '' }}
runs-on: macos-26
@@ -221,10 +215,7 @@ jobs:
python-version: "3.13"
- run: python3 -m pip install --quiet Pillow
- # The lane builds once into build/screenshots and photographs eighteen
- # times without building again. Keeping it means a rerun starts at the
- # first simulator instead of at the compiler. Keyed on everything the
- # build reads, so a stale one is never used - only ever missed.
+ # Cache the screenshot build using all source and resource inputs.
- name: reuse the last build
uses: actions/cache@v6
with:
@@ -268,9 +259,7 @@ jobs:
path: ~/Library/Logs/snapshot
if-no-files-found: warn
- # The two halves put back together, and only now checked: a set is both
- # devices in every locale, and neither runner above can see the other's.
- # Republished under the names the release reads.
+ # Combine both device artifacts and validate the complete screenshot set.
screenshot-set:
needs: screenshots
runs-on: ubuntu-24.04
@@ -351,9 +340,7 @@ jobs:
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
run: bundle exec fastlane ios ${{ matrix.lane }}
- # its own job because the listing stays editable until the version is submitted,
- # while a build cannot be uploaded twice. No macOS runner: this touches the
- # listing, not the app
+ # Upload editable listing metadata independently of the binary.
listing:
needs: [upload, screenshot-set]
# !cancelled() rather than success(), or naming a run to repair - which
@@ -389,9 +376,7 @@ jobs:
given: ${{ inputs.version }}
run: scripts/resolve-version.py --input "$given" --dry-run "$dry_run"
- # where the lane looks for them, and the same set both apps are given.
- # From the run being repaired when one was named, so the pictures are the
- # ones already taken rather than a quarter hour of simulators again.
+ # Use the same screenshots for both apps, from the specified run when repairing a listing.
- name: fetch the screenshots
uses: actions/download-artifact@v8
with:
@@ -412,11 +397,7 @@ jobs:
set -o pipefail
bundle exec fastlane ios ${{ matrix.lane }} 2>&1 | tee listing.log
- # The Fastfile stops deliver from sending a picture twice, so neither of
- # these should ever print. They stay because both are silent in the store
- # and in this log: deliver ends "Successfully uploaded all screenshots"
- # and the job goes green either way. If a fastlane upgrade moves what the
- # Fastfile patches, this is what still notices.
+ # Fail if deliver reports duplicate uploads or a truncated screenshot set.
- name: the screenshots went up once each
run: |
set -euo pipefail
diff --git a/Ads/AdSlot.swift b/Ads/AdSlot.swift
index 2a1bf074..6b5c8aa6 100644
--- a/Ads/AdSlot.swift
+++ b/Ads/AdSlot.swift
@@ -108,9 +108,7 @@ final class AdSlot: NSObject, BannerViewDelegate {
bannerView.load(Request())
}
- /// Asks again for a hidden banner, which the unit will not refresh. Reschedules itself before
- /// it asks: a request that never comes back must not end the chain. A suspended app cannot
- /// fire the work item, so backgrounding needs no guard.
+ /// Retry unfilled banners, scheduling the next attempt before sending this request.
private func scheduleRetry() {
retry?.cancel()
diff --git a/Ads/ConsentManager.swift b/Ads/ConsentManager.swift
index 6dc1cf2c..19cadb74 100644
--- a/Ads/ConsentManager.swift
+++ b/Ads/ConsentManager.swift
@@ -8,25 +8,15 @@ Gathers advertising consent through Google's User Messaging Platform.
import UIKit
import UserMessagingPlatform
-/// The consent form in front of the banner in the Lite configuration.
-///
-/// Google's EU user consent policy requires a TCF-integrated CMP for the EEA, the UK and
-/// Switzerland; UMP is Google's own, and what the Android app uses. Who is asked is decided by the
-/// geo-targeting of the messages in AdMob, not here.
+/// Gathers Lite ad consent through UMP using AdMob region settings.
final class ConsentManager {
static let manager = ConsentManager()
private init() {}
- /// Brings consent up to date, presenting the form where the region requires one.
- ///
- /// Reports `canRequestAds`: whether an answer is on file, not what it was - "do not consent"
- /// leaves it true. What the answer allows is `adsMayUseAdvertisingIdentifier`. Errors do not
- /// enter into it; the decision is cached, so a failed form or an offline update still leaves
- /// an earlier consent standing.
- ///
- /// Completes on the main queue.
+ /// Refresh consent and present a form when required.
+ /// Reports canRequestAds on main; cached consent survives refresh failures.
func gatherConsent(from viewController: UIViewController, completion: @escaping (Bool) -> Void) {
requestUpdate { updated in
guard updated else {
@@ -44,13 +34,7 @@ final class ConsentManager {
}
}
- /// Brings consent up to date without ever presenting a form.
- ///
- /// Needed on every launch: `privacyOptionsRequirementStatus` answers from a cache only an
- /// update in the *current* session fills, so skipping it hides the privacy entry point on the
- /// second launch.
- ///
- /// Completes on the main queue.
+ /// Refresh the session consent cache without presenting a form. Completes on main.
func refresh(completion: @escaping () -> Void) {
requestUpdate { _ in
DispatchQueue.main.async {
@@ -59,13 +43,8 @@ final class ConsentManager {
}
}
- /// Whether an ad shown to this user may carry an advertising identifier, which is what ATT
- /// governs.
- ///
- /// The first flag of the TCF signals UMP writes to `UserDefaults` is purpose 1, device storage.
- /// Without it Google falls back to limited ads, which carry no identifier; refusing only
- /// personalisation leaves one in play, for frequency capping and cross-app reporting. Absent
- /// keys mean no TCF region.
+ /// Whether TCF purpose 1 (device storage) allows the advertising identifier.
+ /// True when no TCF consent is stored.
var adsMayUseAdvertisingIdentifier: Bool {
guard let purposeConsents = UserDefaults.standard.string(forKey: "IABTCF_PurposeConsents") else {
return true
@@ -80,10 +59,7 @@ final class ConsentManager {
ConsentInformation.shared.privacyOptionsRequirementStatus == .required
}
- /// Reopens the consent form so a decision can be changed or withdrawn, as GDPR Art. 7(3) and
- /// TCF require. Only in response to the user asking.
- ///
- /// Completes on the main queue.
+ /// Present privacy choices on user request. Completes on main.
func presentPrivacyOptions(from viewController: UIViewController, completion: @escaping () -> Void) {
ConsentForm.presentPrivacyOptionsForm(from: viewController) { formError in
if let formError = formError {
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d062cbcd..84560900 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ Rules:
### Changed
+- Shortened code comments and corrected editing and screenshot documentation.
- The engine is odrcore 7.4.0, up from 7.3.2.
- A document no longer loads images from the internet.
diff --git a/OpenDocumentReader/AppDelegate.swift b/OpenDocumentReader/AppDelegate.swift
index 8e1cb261..be41c5f4 100644
--- a/OpenDocumentReader/AppDelegate.swift
+++ b/OpenDocumentReader/AppDelegate.swift
@@ -1,10 +1,4 @@
-//
-// AppDelegate.swift
-// OpenDocument Reader
-//
-// Created by Thomas Taschauer on 06.02.19.
// Copyright © 2019 Thomas Taschauer. All rights reserved.
-//
import UIKit
diff --git a/OpenDocumentReader/Constants.swift b/OpenDocumentReader/Constants.swift
index 76abbfa1..db618344 100644
--- a/OpenDocumentReader/Constants.swift
+++ b/OpenDocumentReader/Constants.swift
@@ -1,10 +1,4 @@
-//
-// Constants.swift
-// OpenDocumentReader
-//
-// Created by Artsem Lemiasheuski on 19.12.19.
// Copyright © 2019 Thomas Taschauer. All rights reserved.
-//
import Foundation
diff --git a/OpenDocumentReader/CoreWrapper.swift b/OpenDocumentReader/CoreWrapper.swift
index 85d35828..fd3f422a 100644
--- a/OpenDocumentReader/CoreWrapper.swift
+++ b/OpenDocumentReader/CoreWrapper.swift
@@ -31,12 +31,7 @@ private final class PageServer {
var port: UInt32 { lock.withLock { handle?.port ?? 0 } }
- /// Connects `service` and returns the base URL its views are served under.
- /// Nil if the socket could not be opened.
- ///
- /// A fresh prefix every time, because the web view caches by URL:
- /// re-translating after a password or an edit has to end up at an address it
- /// has not seen.
+ /// Serve the translation under a fresh URL to bypass WebKit caches; nil on failure.
func connect(_ service: HtmlService) -> URL? {
lock.lock()
defer { lock.unlock() }
@@ -65,9 +60,7 @@ private final class PageServer {
/// slide of a presentation, every page of a PDF, the entire text document.
private func isCombinedView(_ view: HtmlView) -> Bool { view.name == "document" }
-/// The views to show as pages, the same way OpenDocument.droid picks them: a tab
-/// per sheet for spreadsheets, and for everything else the combined view alone,
-/// which already holds every slide or page.
+/// Use one tab per spreadsheet sheet; prefer the combined view for other documents.
private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [HtmlView] {
let isSpreadsheet = documentType == .spreadsheet
let hasCombinedView = views.contains(where: isCombinedView)
@@ -152,9 +145,7 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [
throw coreWrapperError(.unsupportedFileType, "odrcore does not render this file type")
}
- // 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
+ // Keep rendering settings consistent with OpenDocument.droid.
let config = HtmlConfig()
config.editable = editable
// how far an edit may reach: inside one paragraph, or across the
@@ -163,13 +154,9 @@ private func selectViews(_ views: [HtmlView], _ documentType: DocumentType) -> [
// 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, and what makes odrcore call a text document paged: its
- // pages are then fitted to the screen rather than shown at full size
+ // Keep page margins and enable paged layout.
config.textDocumentMargin = true
- // the reader's own appearance: odrcore writes a dark sheet behind a
- // `prefers-color-scheme: dark`, which the web view answers from the
- // system setting. A pdf has no dark view and stays light.
+ // Follow system appearance; PDFs remain light.
config.colorScheme = .system
// served with the pages rather than inlined as base64
config.embedImages = false
diff --git a/OpenDocumentReader/Document.swift b/OpenDocumentReader/Document.swift
index fd9edefc..dd8ed670 100644
--- a/OpenDocumentReader/Document.swift
+++ b/OpenDocumentReader/Document.swift
@@ -148,9 +148,7 @@ class Document: UIDocument {
notify { $0.documentLoadingCompleted(self) }
}
- /// Clamped to what the document has: switching to page five of a
- /// spreadsheet and then editing it into four sheets should not walk off the
- /// end.
+ /// Clamp the selected page after a reload changes the page count.
private func showPage() {
guard let pageURLs, !pageURLs.isEmpty else { return }
@@ -165,10 +163,7 @@ class Document: UIDocument {
notify { $0.documentUpdateContent(self) }
}
- /// UIDocument reads on a background queue, so `load(fromContents:)` — and
- /// with it everything `parse` reports — arrives off the main thread. Runs
- /// inline when already there, so setting `page` or `edit` still updates the
- /// view before returning.
+ /// Deliver UI callbacks on main, synchronously when already there.
private func notify(_ body: @escaping (DocumentDelegate) -> Void) {
guard !Thread.isMainThread else {
if let delegate {
diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift
index 45ddf6a2..4e022500 100644
--- a/OpenDocumentReader/DocumentBrowserViewController.swift
+++ b/OpenDocumentReader/DocumentBrowserViewController.swift
@@ -33,18 +33,14 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument
refreshPrivacyButton()
}
- /// Whether the screen `-ODRScreenshot` asked for is already up.
- /// `viewDidAppear` runs again every time something over the browser goes
- /// away, and a second document presented over the first would be the picture.
+ /// Stage each screenshot once, even if the browser reappears.
private var isShowingScreenshot = false
private func showForScreenshot(_ screen: ScreenshotMode.Screen) {
guard !isShowingScreenshot else { return }
isShowingScreenshot = true
- // The browser is the picture. It opens on Recents, which is empty when
- // the samples were laid out rather than opened, so each is revealed in
- // turn to put it there.
+ // Reveal the samples to populate Recents before capturing the browser.
guard screen != .browser else {
reveal(ScreenshotMode.browserDocuments) { [weak self] in
guard let self else { return }
@@ -78,10 +74,7 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument
// MARK: - Privacy
- /// Brings consent up to date and then offers the way back to it.
- ///
- /// The app has no settings screen, so the browser's chrome carries this - the only route back
- /// to either choice, both of which are asked once.
+ /// Refresh consent and expose privacy options in the browser toolbar.
private func refreshPrivacyButton() {
guard Features.withAds else { return }
diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift
index 8f877037..cbb7427e 100644
--- a/OpenDocumentReader/DocumentViewController.swift
+++ b/OpenDocumentReader/DocumentViewController.swift
@@ -186,9 +186,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
override func viewDidLoad() {
super.viewDidLoad()
- // once, not on every appearance: a second target would parse the
- // document twice for a single tap, and a second set of constraints
- // would fight the first
+ // Install targets and constraints once.
pageTabBar.addTarget(self, action: #selector(pageSelected(sender:)), for: .valueChanged)
webview.navigationDelegate = self
webview.uiDelegate = self
@@ -237,9 +235,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
])
}
- /// Only a link that leaves the page carries `target="_blank"`, and this app
- /// has no second window: the web goes to the browser, and what odrcore
- /// serves — should any of it arrive here — to the web view that asked.
+ /// Open served links in this web view and external links in their system app.
func webView(
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
@@ -255,10 +251,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
return nil
}
- /// odrcore renders a page only once this web view asks for it, so a document
- /// that falls over halfway through translating falls over here rather than
- /// in `translate`. Only the main frame counts: a link in the document
- /// answering 404 is not this document failing to render.
+ /// Rendering is lazy, so main-frame HTTP failures count as document failures.
func webView(
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
@@ -347,9 +340,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
}
}
- // the document is drawn, which is what a screenshot of it waits for -
- // and only the document: the "loading" page finishes first, and a
- // picture of it is a picture of the word "loading"
+ // Signal screenshot readiness only after the document navigation finishes.
if let documentNavigation, navigation === documentNavigation {
stageScreenshot()
}
@@ -359,9 +350,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
/// says it is ready. A picture of a search is a picture of its hits.
private func stageScreenshot() {
switch ScreenshotMode.screen {
- // The search is shown on the ODF document rather than on the pdf: as of
- // odrcore 6.7.0 a hit in a pdf is drawn beside the word it found, not on
- // it. Move this back to `.pdf` once a core lands that places it right.
+ // Use the text document for the search screenshot.
case .text:
let query = ScreenshotMode.query
showSearchBar()
@@ -369,9 +358,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
// the hits are the picture, not a keyboard sitting over them
searchBar.resignFirstResponder()
- // Ready once the hits are drawn, not once they are asked for: the
- // call is asynchronous, and a picture taken in between is a picture
- // of the page unsearched.
+ // Wait for search results before marking the screenshot ready.
callSearch("odr.search", with: query) { [weak self] in
guard let self else { return }
@@ -450,9 +437,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
}
}
- /// From iOS 26 the bar's buttons are glass capsules filling its whole
- /// height, which whatever is pinned to either edge would cut off. Older
- /// bars have a background of their own and want no such gap.
+ /// Leave room around iOS 26 glass toolbar buttons.
private static var toolBarMargin: CGFloat {
if #available(iOS 26.0, *) {
return 8
@@ -493,9 +478,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
searchBar.topAnchor.constraint(equalTo: barStack.bottomAnchor, constant: Self.toolBarMargin).isActive =
true
- // under the search bar, which is why the tab bar is not in the tool
- // bar's stack: an arranged subview is placed by the stack, and these
- // would be a second answer to the same question
+ // Place tabs below search, outside the toolbar stack.
pageTabBar.topAnchor.constraint(equalTo: searchBar.bottomAnchor).isActive = true
pageTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
pageTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
@@ -513,11 +496,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
bannerSlotHeight.constant = 0.0
}
- /// No ad to show, so the slot promotes the paid app instead of collapsing.
- ///
- /// This is our own view - nothing is fetched and no identifier is read - so it is as valid on
- /// the path where the user refused consent as on the one where an ad request merely came back
- /// empty.
+ /// Show the local Pro promotion when no ad is available.
private func showHouseAd() {
houseAdView.rotate()
@@ -630,11 +609,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
findAll(searchText: searchText)
}
- /// The pen: turns the mode on, and off again. Leaving with changes the
- /// page alone holds asks first.
- ///
- /// It stands on the core's answer, and every edition opens what it names.
- /// What Lite does not sell is the tool - see ``editToolTapped(_:)``.
+ /// Toggle editing, confirming unsaved changes before leaving.
@IBAction func toggleEdit(_ sender: UIBarButtonItem) {
if isEditingDocument {
leaveEdit()
@@ -841,8 +816,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
}
}
- /// Whether the page has said what it is. Not the strip itself: a sheet
- /// answers with no strip at all.
+ /// Whether the current page's editing tools are ready.
private(set) var isEditSessionReady = false
/// The tools are up, which is what a screenshot of an edit waits for.
@@ -1059,9 +1033,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
layOutDocumentTitle()
}
- /// The name takes what the buttons leave between the back button and the
- /// rest. The bar does not say where it put a button, so the room is
- /// counted from how many there are.
+ /// Fit the title between the back button and trailing actions.
private func layOutDocumentTitle() {
let buttons = (toolBar.items ?? []).filter { $0.image != nil }
let start = Self.toolBarButtonWidth + Self.toolBarTitleGap
@@ -1084,9 +1056,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
/// Kept clear either side of the name, so it never sits against a button.
private static let toolBarTitleGap: CGFloat = 16
- /// While an edit is on the bar is the edit's: undo, redo and save join it,
- /// and the magnifier and the name stand down - six buttons and a name do
- /// not fit a phone's bar. A pdf mark is never put back, so redo stays out.
+ /// Show edit actions while editing; hide search and title. PDFs have no redo.
private func updateToolBar() {
documentTitleLabel.isHidden = isEditingDocument
view.setNeedsLayout()
@@ -1127,18 +1097,14 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
return document?.result != nil && components?.url == document?.result
}
- /// The pencil, for a document and a pdf alike: the two never stand in the
- /// bar together, so the label separates them. Selected while the mode is
- /// on.
+ /// Use the pencil for editing and PDF markup, with a distinct accessibility label.
private func updateEditButtonRole() {
editButton.image = UIImage(systemName: "pencil")
editButton.accessibilityLabel = NSLocalizedString(canMark ? "mark_pdf" : "menu_edit", comment: "")
editButton.isSelected = isEditingDocument
}
- /// Asked of the page rather than guessed from the format: odrcore writes the
- /// `odr` object into what it renders as a document or as text, and into
- /// nothing else — a pdf picks the button up on its own once it does.
+ /// Ask the translated page whether it supports search.
private func updateSearchButton() {
guard let url = webview.url, isDocumentPage(url) else {
canSearch = false
@@ -1223,7 +1189,8 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
}
}
- /// Drops the document, so viewDidDisappear does not close it a second time.
+ /// Close and release the document before dismissing the reader, so
+ /// viewDidDisappear does not close it a second time.
func closeCurrentDocument(then finish: (() -> Void)? = nil) {
document?.close()
document = nil
@@ -1347,9 +1314,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
printController.present(animated: true, completionHandler: nil)
}
- /// A page of ours rather than a document: the word "loading", or the error.
- /// The colour scheme is named because a web view paints a page that claims
- /// none white, whatever the reader has the device set to.
+ /// Loading and error pages follow system appearance.
private func loadMessage(_ body: String) {
webview.loadHTMLString(
"
\(body)",
@@ -1379,10 +1344,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel
documentNavigation = self.webview.load(URLRequest(url: url))
}
- /// Three the system draws better, all falling back to `corePageInReserve`:
- /// html, which odrcore has no type for and reads as its own source, iWork,
- /// whose styles and pictures it does not read, and a container it knows as a
- /// document (`.epub` is composite content, `.zip` is not).
+ /// Prefer WebKit for HTML, iWork and composite archives, with odrcore as fallback.
private func systemDrawsItBetter(_ doc: Document) -> Bool {
let ext = doc.fileURL.pathExtension.lowercased()
diff --git a/OpenDocumentReader/DocumentWebView.swift b/OpenDocumentReader/DocumentWebView.swift
index 36ddf192..7adc7c59 100644
--- a/OpenDocumentReader/DocumentWebView.swift
+++ b/OpenDocumentReader/DocumentWebView.swift
@@ -1,8 +1,6 @@
import WebKit
-/// Built here rather than decoded: a nib turns every data detector on, and a
-/// configuration cannot be changed once the view has it. Detectors draw links
-/// over the invisible text a pdf carries for selection and search.
+/// Disable data detectors before creating the web view; their overlays obscure PDF text.
final class DocumentWebView: WKWebView {
required init?(coder: NSCoder) {
diff --git a/OpenDocumentReader/EditToolBar.swift b/OpenDocumentReader/EditToolBar.swift
index 303d421f..6db827ae 100644
--- a/OpenDocumentReader/EditToolBar.swift
+++ b/OpenDocumentReader/EditToolBar.swift
@@ -1,11 +1,7 @@
import UIKit
-/// The strip of tools under the bar: what changes the text. Undo, redo and
-/// save are the bar's.
-///
-/// A tap does the tool's one job, and a long press opens the colours it
-/// applies. Without ``advancedEditing`` every tool but the highlighter is
-/// dimmed and offers Pro instead.
+/// Formatting and PDF tools. Tap to apply; long-press for colors.
+/// Locked tools offer Pro, while highlighting stays available.
final class EditToolBar: UIView {
/// One button of the strip.
@@ -618,9 +614,7 @@ final class EditToolBar: UIView {
return UIMenu(title: tool.label, children: children)
}
- /// A letter over a wave, shaped like the `underline` and `strikethrough`
- /// beside it. The system set has no wavy underline. The letter is the
- /// English one and does not follow the language, which a system glyph does.
+ /// Custom wavy-underline icon; SF Symbols has no equivalent.
private static let squigglyImage: UIImage = {
let size = CGSize(width: iconSize, height: iconSize)
diff --git a/OpenDocumentReader/HouseAdView.swift b/OpenDocumentReader/HouseAdView.swift
index 18dd8e2b..13bac95e 100644
--- a/OpenDocumentReader/HouseAdView.swift
+++ b/OpenDocumentReader/HouseAdView.swift
@@ -1,14 +1,8 @@
import StoreKit
import UIKit
-/// The promotion for the paid app that takes the banner's place when no ad arrives.
-///
-/// This is our own content, not an ad: nothing is fetched, no identifier is read and no storage is
-/// touched. That is what makes it usable on every no-fill path, including the one where the user
-/// refused consent outright and Google is serving limited ads or nothing at all.
-///
-/// One layout, sized to whatever the slot gives it - the banner height is fixed by the ad slot and
-/// cannot grow, so parts drop out instead of wrapping.
+/// Local Pro promotion shown when no ad fills the banner.
+/// Hide content as space shrinks to preserve the fixed banner height.
final class HouseAdView: UIView {
/// One rotation of the promotion. `shortHeadline` is what a phone-width slot can actually fit.
diff --git a/OpenDocumentReader/NonFree/AnalyticsManager.swift b/OpenDocumentReader/NonFree/AnalyticsManager.swift
index b99f72a4..9becfc01 100644
--- a/OpenDocumentReader/NonFree/AnalyticsManager.swift
+++ b/OpenDocumentReader/NonFree/AnalyticsManager.swift
@@ -1,8 +1,7 @@
import Foundation
import os
-// Event and parameter names kept from the Firebase days so the reports stay
-// comparable with OpenDocument.droid, which uses the same identifiers.
+// Event and parameter names shared with OpenDocument.droid.
enum AnalyticsConstants {
static let paramContentType = "content_type"
static let paramContent = "content"
@@ -14,9 +13,7 @@ enum AnalyticsConstants {
static let eventAddToCart = "add_to_cart"
}
-/// Analytics used to go to Firebase. OpenDocument.droid dropped its Firebase
-/// dependency, so this mirrors the shell its `nonfree` package keeps: the call
-/// sites stay, but nothing leaves the device, so nothing switches it off.
+/// Logs analytics events locally without uploading them.
final class AnalyticsManager {
static let shared = AnalyticsManager()
diff --git a/OpenDocumentReader/NonFree/CrashManager.swift b/OpenDocumentReader/NonFree/CrashManager.swift
index ec531a09..96dd3c7e 100644
--- a/OpenDocumentReader/NonFree/CrashManager.swift
+++ b/OpenDocumentReader/NonFree/CrashManager.swift
@@ -1,10 +1,7 @@
import Foundation
import os
-/// Crash reporting used to go to Crashlytics. OpenDocument.droid dropped its
-/// Firebase dependency, so this mirrors the shell its `nonfree` package keeps:
-/// errors are logged locally instead of being uploaded, so nothing switches it
-/// off.
+/// Logs errors locally without uploading them.
final class CrashManager {
static let shared = CrashManager()
diff --git a/OpenDocumentReader/PageTabBar.swift b/OpenDocumentReader/PageTabBar.swift
index 433d127d..6a856ead 100644
--- a/OpenDocumentReader/PageTabBar.swift
+++ b/OpenDocumentReader/PageTabBar.swift
@@ -1,11 +1,6 @@
import UIKit
-/// A horizontally scrollable row of text tabs, one per document page.
-///
-/// Replaces the vendored ScrollableSegmentedControl, whose upstream was archived
-/// in 2022, and covers only what the document view asked of it: text tabs that
-/// share the available width while they fit, scroll once they do not, and
-/// underline the selected one.
+/// Scrollable page tabs with an underline for the selected page.
final class PageTabBar: UIControl {
static let titlePadding: CGFloat = 8
static let underlineHeight: CGFloat = 4
@@ -114,10 +109,7 @@ final class PageTabBar: UIControl {
resizeTabs()
}
- /// Every tab gets an even share of the row while that is wide enough for the
- /// longest title. Failing that they keep their own widths and share out
- /// whatever is left over, so a single long sheet name is neither truncated
- /// nor allowed to dictate the width of the short ones.
+ /// Use equal widths when titles fit; otherwise preserve title widths and distribute spare space.
private func resizeTabs() {
guard let widestTitle = titleWidths.max() else {
tabWidths = []
diff --git a/OpenDocumentReader/ScreenshotMode.swift b/OpenDocumentReader/ScreenshotMode.swift
index 35f83a69..4c48330a 100644
--- a/OpenDocumentReader/ScreenshotMode.swift
+++ b/OpenDocumentReader/ScreenshotMode.swift
@@ -1,21 +1,10 @@
import UIKit
-/// The back door the App Store screenshots are taken through.
-///
-/// `-ODRScreenshot text` puts the app on one screen and leaves it there, so
-/// `fastlane snapshot` photographs the same thing in every language without
-/// having to drive the document browser - which is Apple's own UI, in eleven
-/// languages, and would be a different tap in each.
-///
-/// Off in every build nobody passes that argument to, and compiled to nothing
-/// outside Debug. The documents it opens are bundled the same way: written by
-/// `scripts/make-screenshot-documents.py`, and kept out of the archive that
-/// goes to the store by `EXCLUDED_SOURCE_FILE_NAMES`.
+/// Stages screenshots with -ODRScreenshot in Debug builds.
+/// Samples are generated by scripts/make-screenshot-documents.py and excluded from Release.
enum ScreenshotMode {
- /// What one launch shows. The screenshot test launches once per case and
- /// takes one picture, so this list and the one in
- /// `scripts/store-screenshots.py` are the same list.
+ /// One launch per screen; keep these cases aligned with scripts/store_screenshots.py.
enum Screen: String {
/// the document browser, with one of each format sitting in it
case browser
@@ -94,16 +83,10 @@ enum ScreenshotMode {
Sample(name: "manual", format: "pdf", from: "paper"),
]
- /// Set on the view the picture is of, once it has something to show. The
- /// test waits for it rather than for a fixed number of seconds, because
- /// translating a document takes as long as the simulator takes.
+ /// Signals that the screenshot content is ready.
static let readyIdentifier = "odr-screenshot-ready"
- /// `nil` unless this launch was asked for a screenshot.
- ///
- /// Read from `UserDefaults` rather than from the argument list, because
- /// `-ODRScreenshot text` on the command line is a default: that is how
- /// XCUITest's `launchArguments` arrive.
+ /// Reads the Debug-only -ODRScreenshot launch argument through UserDefaults.
static var screen: Screen? {
#if DEBUG
guard let name = UserDefaults.standard.string(forKey: "ODRScreenshot") else { return nil }
@@ -117,19 +100,12 @@ enum ScreenshotMode {
/// Where each sample ended up, by name, once it was laid out.
private(set) static var laidOut: [String: URL] = [:]
- /// The browser's own picture, in the order it should reveal them.
- ///
- /// Revealing a document puts it in Recents, which is the tab the browser
- /// opens on, and Recents shows the newest first - so this is back to front.
+ /// Reveal samples in reverse order because Recents lists the newest first.
static var browserDocuments: [URL] {
folder.reversed().compactMap { laidOut[$0.name] }
}
- /// Lays the whole folder out before anything lists it.
- ///
- /// All of it on every screen, not just the one being photographed: the
- /// browser is behind every other picture, and a folder holding one file
- /// looks like a folder nobody uses.
+ /// Prepare all sample files before the document browser loads.
static func prepare() {
guard screen != nil else { return }
@@ -144,9 +120,7 @@ enum ScreenshotMode {
}
}
- /// Clears the folder first. A simulator is kept between runs and between
- /// languages, so without this the picture is this run's documents plus
- /// every run before it, under whatever names those used.
+ /// Remove samples from previous screenshot runs and locales.
private static func empty() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first,
let existing = try? FileManager.default.contentsOfDirectory(
@@ -160,12 +134,7 @@ enum ScreenshotMode {
}
}
- /// What the folder is called and what the search screenshot looks for, in
- /// the app's language.
- ///
- /// Written beside the samples by `make-screenshot-documents.py`, because
- /// these are read by nobody but the screenshots and have no business in
- /// `Localizable.strings`.
+ /// Localized sample names and search text from make-screenshot-documents.py.
private struct Details: Decodable {
let files: [String: String]
let search: String
@@ -189,16 +158,10 @@ enum ScreenshotMode {
return all["en"]
}
- /// The word the search screenshot types. Whatever the pdf it is looking at
- /// actually says, which is not always this language.
+ /// The word the search screenshot looks for in the text sample.
private(set) static var query = ""
- /// The sample document of this screen, in the app's language, copied out of
- /// the bundle into the documents directory - which is where the document
- /// browser looks, and the only place the app may write.
- ///
- /// English where the language has no sample of its own, which is what that
- /// storefront's screenshot would show anyway: the app has no UI in it either.
+ /// Copy the localized sample to Documents, falling back to English.
private static func layOut(_ sample: Sample, called title: String) -> URL? {
var source: URL?
for language in Bundle.main.preferredLocalizations + ["en"] {
diff --git a/OpenDocumentReader/StoreReviewHelper.swift b/OpenDocumentReader/StoreReviewHelper.swift
index c44d7c21..c8c067b3 100644
--- a/OpenDocumentReader/StoreReviewHelper.swift
+++ b/OpenDocumentReader/StoreReviewHelper.swift
@@ -1,10 +1,4 @@
-//
-// StoreReviewHelper.swift
-// Template1
-//
-// Created by Apple on 14/11/17.
// Copyright © 2017 Mobiotics. All rights reserved.
-//
import Foundation
import StoreKit
diff --git a/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift b/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift
index 31df9ba6..15d546fb 100644
--- a/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift
+++ b/OpenDocumentReaderTests/DeclaredDocumentTypesTests.swift
@@ -4,9 +4,6 @@ import XCTest
@testable import OpenDocumentReader
-/// What the app offers itself for, held against odrcore's format table the way
-/// OpenDocument.droid holds its manifest against the same table. The plist
-/// cannot read the table, so this is what keeps the two from drifting.
class DeclaredDocumentTypesTests: XCTestCase {
/// Every `LSItemContentTypes` entry of the built app, not of a copy kept
@@ -18,10 +15,6 @@ class DeclaredDocumentTypesTests: XCTestCase {
return entries.flatMap { $0["LSItemContentTypes"] as? [String] ?? [] }.compactMap { UTType($0) }
}()
- /// What the app offers itself for: odrcore's document formats, plus the two
- /// non-document ones worth opening a viewer for. Narrower than everything
- /// odrcore translates - it does images, media, fonts and archives too, and
- /// the app does not want to be the handler for an mp3.
private var offeredFileTypes: [FileType] {
Odr.allFileTypes.compactMap { FileType(rawValue: $0.intValue) }
.filter { type in
@@ -48,9 +41,6 @@ class DeclaredDocumentTypesTests: XCTestCase {
}
}
- /// A dynamic type is what iOS invents for an extension it does not know. It
- /// conforms to `public.data` and nothing else, so a file carrying one is
- /// greyed out in the browser however broad the claims are.
func testNoOfferedFormatResolvesToADynamicType() {
for type in offeredFileTypes {
for fileExtension in Odr.extensions(fileType: type) {
diff --git a/OpenDocumentReaderTests/DocumentTitleTests.swift b/OpenDocumentReaderTests/DocumentTitleTests.swift
index 26cd548d..876b80bf 100644
--- a/OpenDocumentReaderTests/DocumentTitleTests.swift
+++ b/OpenDocumentReaderTests/DocumentTitleTests.swift
@@ -58,9 +58,6 @@ class DocumentTitleTests: XCTestCase {
controller.view.layoutIfNeeded()
}
- /// Where a button is drawn, read through a private key: the bar has no
- /// public way to say. The iOS 26 bar does not draw a button put back into it
- /// while the window is a test's, so there the test cannot look.
private func frame(of item: UIBarButtonItem) throws -> CGRect {
guard let view = item.value(forKey: "view") as? UIView, view.window != nil, view.bounds.width > 0 else {
throw XCTSkip("the bar did not draw this button")
diff --git a/OpenDocumentReaderTests/EditWorkflowTests.swift b/OpenDocumentReaderTests/EditWorkflowTests.swift
index 53676624..c9f05451 100644
--- a/OpenDocumentReaderTests/EditWorkflowTests.swift
+++ b/OpenDocumentReaderTests/EditWorkflowTests.swift
@@ -3,9 +3,6 @@ import XCTest
@testable import OpenDocumentReader
-/// The whole round trip, driven through the real view controller rather than
-/// through ``CoreWrapper`` alone: the bar is half of what makes editing work,
-/// and it is the half that broke.
class EditWorkflowTests: XCTestCase {
private var documentURL: URL!
private var window: UIWindow!
@@ -210,9 +207,6 @@ class EditWorkflowTests: XCTestCase {
// MARK: - the page
- /// The one thing edit mode is for. odrcore makes the flow `contenteditable`,
- /// but a tap has to reach a run for the caret to be set and the keyboard to
- /// unfold.
func testTappingTheTextReachesTheEditableRun() throws {
openDocument()
@@ -668,9 +662,6 @@ class EditWorkflowTests: XCTestCase {
waitForPage(where: "document.querySelectorAll('[contenteditable]').length > 0")
}
- /// The controller is its own navigation delegate — taking that away is what
- /// tells the tool bar what the page can do — so the test waits on the page
- /// itself rather than on `didFinish`.
private func waitForPage(
where condition: String, file: StaticString = #filePath, line: UInt = #line
) {
diff --git a/OpenDocumentReaderTests/LockedDocumentTests.swift b/OpenDocumentReaderTests/LockedDocumentTests.swift
index eb4dd91d..86fbfded 100644
--- a/OpenDocumentReaderTests/LockedDocumentTests.swift
+++ b/OpenDocumentReaderTests/LockedDocumentTests.swift
@@ -3,10 +3,6 @@ import XCTest
@testable import OpenDocumentReader
-/// A legacy Word, Excel or PowerPoint file can say it is password protected,
-/// and no password opens it: odrcore reads the flag but cannot decrypt any of
-/// them. The reader has to say so rather than ask, and rather than hand the
-/// file to the web view, which makes nothing of it either.
class LockedDocumentTests: XCTestCase {
private let temporaryDirectory = NSTemporaryDirectory()
private var documentURL: URL!
diff --git a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift
index 24a34055..6ab81d5e 100644
--- a/OpenDocumentReaderTests/OpenDocumentReaderTests.swift
+++ b/OpenDocumentReaderTests/OpenDocumentReaderTests.swift
@@ -1,10 +1,4 @@
-//
-// OpenDocumentReaderTests.swift
-// OpenDocumentReaderTests
-//
-// Created by Thomas Taschauer on 08.11.20.
// Copyright © 2020 Thomas Taschauer. All rights reserved.
-//
import WebKit
import XCTest
@@ -227,9 +221,6 @@ class OpenDocumentReaderTests: XCTestCase {
XCTAssertTrue(html.contains("(prefers-color-scheme: dark)"), html)
}
- /// The page measures the view and fits itself to it. A web view does not fit
- /// a page to the screen the way a browser does, and the app used to carry a
- /// script of its own for this.
func testAPageFitsItselfToTheView() throws {
let wrapper = CoreWrapper()
@@ -257,9 +248,6 @@ class OpenDocumentReaderTests: XCTestCase {
XCTAssertNotEqual(before, wrapper.pageURLs)
}
- /// A missing `NSAllowsLocalNetworking` shows up here and nowhere else: App
- /// Transport Security applies to the web view, not to the `URLSession`
- /// above.
func testTheWebViewLoadsAServedPage() throws {
let wrapper = CoreWrapper()
diff --git a/OpenDocumentReaderTests/PageTabBarTests.swift b/OpenDocumentReaderTests/PageTabBarTests.swift
index 0224bead..f02134a9 100644
--- a/OpenDocumentReaderTests/PageTabBarTests.swift
+++ b/OpenDocumentReaderTests/PageTabBarTests.swift
@@ -105,9 +105,6 @@ class PageTabBarTests: XCTestCase {
XCTAssertLessThan(widths[1], widths[2] / 2)
}
- /// An even share is only fair while it is wide enough for the longest
- /// title: all three of these fit the bar, but an even third would truncate
- /// the long one for no reason.
func testALongTitleKeepsItsWidthWhileTheyAllStillFit() throws {
tabBar.titles = ["A", "B", "Q4 Revenue Forecast"]
diff --git a/OpenDocumentReaderTests/PrintAppearanceTests.swift b/OpenDocumentReaderTests/PrintAppearanceTests.swift
index 76e9cce6..e03cc904 100644
--- a/OpenDocumentReaderTests/PrintAppearanceTests.swift
+++ b/OpenDocumentReaderTests/PrintAppearanceTests.swift
@@ -4,9 +4,6 @@ import XCTest
@testable import OpenDocumentReader
-/// Print takes the page the web view is showing, and since odrcore renders a
-/// document in the reader's own appearance that page can be dark. Paper is not:
-/// a dark page printed as it stands is pale ink on white, which is nothing.
class PrintAppearanceTests: XCTestCase {
/// The web view prints in light whatever the device is set to, so the menu
diff --git a/OpenDocumentReaderUITests/ScreenshotTests.swift b/OpenDocumentReaderUITests/ScreenshotTests.swift
index d0d636cc..b215bab7 100644
--- a/OpenDocumentReaderUITests/ScreenshotTests.swift
+++ b/OpenDocumentReaderUITests/ScreenshotTests.swift
@@ -1,16 +1,7 @@
import XCTest
-/// The pictures the App Store shows.
-///
-/// One launch per screen, each with `-ODRScreenshot `, which is the
-/// app's own back door: it puts itself on that screen and stays there. Nothing
-/// here taps anything, because everything there is to tap on the way - the
-/// document browser - is Apple's UI in eleven languages.
-///
-/// `fastlane snapshot` runs this once per language per device and collects what
-/// it writes. `scripts/store-screenshots.py` then checks the set and stages it
-/// for the release; the names below are the names it expects, and their order is
-/// the order the store shows them in.
+/// Capture each store screen in a separate launch with -ODRScreenshot .
+/// Fastlane repeats the test for each device and locale.
final class ScreenshotTests: XCTestCase {
/// Each picture's name, and the screen it is of. The number is the place
@@ -45,10 +36,7 @@ final class ScreenshotTests: XCTestCase {
// the language and the region this run is for, once
Snapshots.prepare(app)
- // The keyboard offers to teach swipe typing the first time it comes up
- // on a fresh simulator, over the keyboard the edit screenshot is of.
- // Told here that it has already been shown, so nothing has to be tapped
- // away in a language this test cannot read.
+ // Suppress the first-run swipe keyboard tutorial.
app.launchArguments += ["-DidShowContinuousPathIntroduction", "1"]
let arguments = app.launchArguments
@@ -78,11 +66,7 @@ final class ScreenshotTests: XCTestCase {
}
}
- /// Waits for the browser to have listed the documents.
- ///
- /// Recents is filled by the system a few at a time and the app cannot say
- /// when it is done, so this waits for the count to stop growing. The first
- /// cell is not the end of it.
+ /// Wait until the Recents file count stabilizes.
@MainActor
private func waitForTheFolderToFill(in app: XCUIApplication) {
let cells = app.collectionViews.cells
@@ -106,15 +90,7 @@ final class ScreenshotTests: XCTestCase {
}
}
- /// Taps into the text of the document being edited.
- ///
- /// A real tap, not a focus call from the app: WebKit only raises the
- /// keyboard for a gesture it saw, so an edit staged entirely in code sets a
- /// caret and nothing else.
- ///
- /// Where the text is depends on the page, and a tap into its margin reaches
- /// nothing, so this works down the page until the keyboard answers rather
- /// than betting the run on one offset.
+ /// Tap document text to raise the keyboard; WebKit requires a user gesture.
@MainActor
private func raiseTheKeyboard(in app: XCUIApplication) {
let page = app.webViews.firstMatch
@@ -135,16 +111,12 @@ final class ScreenshotTests: XCTestCase {
XCTFail("no tap down the page set a caret, so the keyboard never came up")
}
- /// Belt and braces for the swipe typing panel, in case the launch argument
- /// stops being honoured. Only reaches an English keyboard, which is why it
- /// is the second line of defence and not the first.
+ /// Dismiss the English keyboard tutorial if the launch flag did not suppress it.
@MainActor
private func dismissTheKeyboardTutorial(in app: XCUIApplication) {
let continueButton = app.buttons["Continue"]
- // Short, because this waits its whole timeout on every run that does not
- // need it - which is all of them while the launch argument holds. The
- // panel comes up with the keyboard, and the keyboard is already up here.
+ // Keep this fallback wait short on runs without a tutorial.
if continueButton.waitForExistence(timeout: 0.5) {
continueButton.tap()
}
diff --git a/OpenDocumentReaderUITests/Snapshots.swift b/OpenDocumentReaderUITests/Snapshots.swift
index 0a18b0cd..31776ad3 100644
--- a/OpenDocumentReaderUITests/Snapshots.swift
+++ b/OpenDocumentReaderUITests/Snapshots.swift
@@ -1,26 +1,8 @@
import Foundation
import XCTest
-/// The hand-off to `fastlane snapshot`, which is a directory and a file name.
-///
-/// Nothing is passed in process. `capture_ios_screenshots` writes what the run
-/// is for into a cache directory on the host, launches this bundle once per
-/// language, and afterwards collects whatever PNGs it finds there. That is the
-/// whole protocol: two files read, one directory written.
-///
-/// fastlane ships a `SnapshotHelper.swift` to do this, and it used to sit here
-/// verbatim - three hundred lines carrying macOS, tvOS and watchOS branches, a
-/// landscape correction, and a wait on the network activity indicator that iOS
-/// has not drawn since 13. None of it applied to this app, and none of it could
-/// be read or formatted like the rest of these sources, because a copied file
-/// has to stay byte for byte theirs to be replaceable. This is the part we use,
-/// written as ours.
-///
-/// One thing theirs does that this does not: it passes on the `launch_arguments`
-/// from the lane, through a third file. The lane sets none, and the app takes
-/// the only argument it cares about from ``ScreenshotTests`` directly.
-///
-/// https://docs.fastlane.tools/actions/snapshot/
+/// Read fastlane language and locale files and write simulator screenshots.
+/// Launch arguments are set by ScreenshotTests. See https://docs.fastlane.tools/actions/snapshot/.
enum Snapshots {
/// Where fastlane leaves what it wants, and looks for what it gets back.
@@ -37,11 +19,7 @@ enum Snapshots {
handOff?.appendingPathComponent("screenshots", isDirectory: true)
}
- /// Puts the language and region this run is for on the app's arguments.
- ///
- /// One launch of this bundle is one language. The app cannot work out which
- /// on its own - the simulator is the same one every time - so fastlane names
- /// it in a file and it is handed over here.
+ /// Pass fastlane language and locale settings to the app.
@MainActor
static func prepare(_ app: XCUIApplication) {
guard let handOff else {
@@ -55,9 +33,7 @@ enum Snapshots {
app.launchArguments += ["-AppleLanguages", "(\(language))"]
}
- // The region decides how a date and a number are written, which is half
- // of what a spreadsheet screenshot shows. It follows the language when
- // the lane does not name one of its own.
+ // Use the language as the locale fallback for dates and numbers.
let region =
read(handOff.appendingPathComponent("locale.txt"))
?? language.map { Locale(identifier: $0).identifier }
@@ -66,10 +42,7 @@ enum Snapshots {
}
}
- /// Writes the screen under the name the release expects to find.
- ///
- /// `scripts/store-screenshots.py` reads these back by name, so the shape of
- /// it - the device, then the screen - is a promise to that script.
+ /// Write -.png for scripts/store_screenshots.py.
@MainActor
static func take(_ name: String) {
guard let pictures,
diff --git a/README.md b/README.md
index d8383385..29525321 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Security blocks plain HTTP.
- The pencil calls `odr.editing.enable()`. The page stays in place. The same
pencil marks up a PDF.
- The bar holds undo, redo and save. The strip (`EditToolBar`) holds the text
- formatting. A sheet or a plain text file shows no strip.
+ and cell formatting. Plain text files show no formatting strip.
- A tap uses a tool. A long press opens its colours. Do not add chevrons.
- Redo is hidden over a PDF. The magnifier is hidden during an edit.
- A PDF tool is armed by the page (`odr.annotation.press`), and the app never
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 0b8b6d86..67997b6e 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -6,24 +6,8 @@ require "tmpdir"
# deliver, whose upload_to_app_store this ends in
require "deliver"
-# deliver sends every picture, then reads the set back from App Store Connect
-# and sends again whatever it cannot find. The store lists a picture some
-# seconds after it takes it, so what deliver cannot find is only the locales it
-# sent last - sv and tr, at the end of the alphabet. The second copy joins the
-# first, fills the locale to the ten the store allows, and the pictures that no
-# longer fit are then dropped with a line nobody reads. The lane still ends
-# "Successfully uploaded all screenshots".
-#
-# Not one upload has failed here; only the reading back has. So wait and read
-# again, and if a picture really is missing, stop and say so - never send a
-# second copy.
-#
-# class_eval on the class rather than `module Deliver ... end`: fastlane evals
-# this file against a binding scoped to Fastlane::FastFile, so reopening it by
-# name defines Fastlane::FastFile::Deliver and leaves the real deliver running.
-# Reading a constant still finds the real one, so nothing says a word - which
-# is how the first go at this shipped, patched nothing, and doubled 1.41's
-# screenshots a third time.
+# Wait for App Store Connect to list uploaded screenshots without resending them.
+# Patch via class_eval because Fastlane evaluates this file in its own namespace.
LOOKS = 7
LOOK_AGAIN_AFTER = 30
WAITS_AT_MOST = (LOOKS - 1) * LOOK_AGAIN_AFTER
@@ -67,17 +51,13 @@ end
default_platform(:ios)
-# name also picks the app's half of the listing: fastlane/metadata is what both
-# say, and fastlane/metadata-pro and -lite are where they differ. An app's name
-# has to be unique in the store, so that much always differs.
+# The app name selects its metadata override directory.
APPS = {
pro: { name: "pro", scheme: "ODR Full", app_identifier: "at.tomtasche.reader" },
lite: { name: "lite", scheme: "ODR Lite", app_identifier: "at.tomtasche.reader.lite1" },
}.freeze
-# Absolute, because lane bodies run in fastlane/ while actions run one directory
-# up: a bare "build" would be fastlane/build/ to File.exist? and build/ to gym.
-# Resolved at parse time, which fastlane also does from fastlane/.
+# Resolve paths before lane and action working directories diverge.
IPA_DIR = File.expand_path("../build").freeze
# Absolute for the same reason. They resolve the repository from their own path,
@@ -92,15 +72,10 @@ MAKE_DOCUMENTS = File.expand_path("../scripts/make-screenshot-documents.py").fre
SCREENSHOT_DIR = File.expand_path("screenshots").freeze
FRAMED_DIR = File.expand_path("framed").freeze
-# Named, where snapshot would pick a fresh temp directory each run. The app is
-# built once into it and then photographed eighteen times without building
-# again, and CI keeps it between runs.
+# Reuse one build across screenshot devices and locales.
SCREENSHOT_BUILD_DIR = File.expand_path("../build/screenshots").freeze
-# The two sizes App Store Connect asks an app that runs on both for, named by
-# whatever the runner's Xcode calls them. Newest first, and the first one the
-# runner actually has wins - a simulator's name changes with every Xcode, the
-# number of pixels it has does not, and store_screenshots.py checks that.
+# Choose the newest installed iPhone and iPad models from these candidates.
SCREENSHOT_DEVICES = {
"iphone" => ["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"],
"ipad" => ["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"],
@@ -142,9 +117,7 @@ def screenshot_ios
newest
end
-# The locales to photograph. Every one the app is translated into, unless
-# ODR_SCREENSHOT_LANGUAGES names fewer - which is how to look at one language's
-# pictures without waiting out the other eight.
+# Capture all supported locales unless ODR_SCREENSHOT_LANGUAGES narrows the set.
def screenshot_languages
given = ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip
return given.split(",").map(&:strip) unless given.empty?
@@ -152,9 +125,7 @@ def screenshot_languages
sh(STORE_SCREENSHOTS, "--languages", log: false).split("\n").map(&:strip).reject(&:empty?)
end
-# One simulator per device the store wants, out of the ones that run the iOS
-# being photographed, or a failure naming what is there instead - which beats
-# xcodebuild spending twenty minutes not finding it.
+# Choose one installed simulator per requested device type.
def screenshot_devices(ios)
given = ENV["ODR_SCREENSHOT_DEVICES"].to_s.strip
return given.split(",").map(&:strip) unless given.empty?
@@ -224,16 +195,10 @@ platform :ios do
UI.message("photographing #{devices.join(' and ')} on iOS #{ios} in #{languages.join(', ')}")
- # The documents in the pictures, written rather than committed: they are
- # build output, and the only thing that opens them is the run below. Ahead
- # of the build, because the target picks its resources up off the disk as
- # it starts - a file written later lands in the next build, not this one.
+ # Generate sample resources before Xcode discovers build inputs.
sh(MAKE_DOCUMENTS)
- # Built once, here. snapshot asks xcodebuild to build and then test on every
- # run, and there are eighteen of them - two devices in nine languages. The
- # build is the same every time, and checking it is still up to date costs
- # about two minutes on a runner.
+ # Build once for all screenshot runs.
scan(
project: "OpenDocumentReader.xcodeproj",
scheme: "ODR Screenshots",
@@ -244,11 +209,8 @@ platform :ios do
skip_detect_devices: true
)
- # The Pro scheme, so no ad sdk is linked and no consent form can come up in
- # front of a picture. The two apps differ only in the edit, where Lite dims
- # the Pro tools: the run takes that screen a second time as Lite shows it,
- # and each listing gets its own edit. The banner Lite carries is in no
- # picture either way.
+ # Use Pro for both listings so ad and consent dialogs cannot obscure screenshots.
+ # The run captures the edit screen a second time as Lite shows it.
capture_ios_screenshots(
project: "OpenDocumentReader.xcodeproj",
scheme: "ODR Screenshots",
@@ -258,47 +220,31 @@ platform :ios do
ios_version: ios,
languages: languages,
output_directory: SCREENSHOT_DIR,
- # 9:41 and a full battery, as Apple's own screenshots have. Spelled out
- # because the default asks for "charged", which iOS 26 draws as a green
- # charging battery - Apple's own pictures show a plain full one.
+ # Use 9:41 and a full, non-charging battery.
override_status_bar: true,
override_status_bar_arguments:
"--time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 " \
"--cellularMode active --cellularBars 4 --batteryState discharging --batteryLevel 100",
# a rerun replaces the set rather than adding to it
clear_previous_screenshots: true,
- # snapshot refuses to start without a file named SnapshotHelper.swift,
- # and checks its version marker. We do not carry theirs - the hand-off is
- # OpenDocumentReaderUITests/Snapshots.swift, which is ours - so there is
- # nothing for it to find and nothing for it to check.
+ # Snapshots.swift implements the handoff; no vendored SnapshotHelper.swift to check.
skip_helper_version_check: true,
# the runner has no browser to open the summary in, and no one watching it
skip_open_summary: true,
stop_after_first_error: true,
- # one device per xcodebuild rather than both at once: two simulators
- # rendering on a three core runner took ten minutes over one, and ran the
- # test out of the time XCTest allows it
+ # Run one simulator at a time; two at once overran the XCTest time limit.
concurrent_simulators: false,
# the build above, photographed as it stands
derived_data_path: SCREENSHOT_BUILD_DIR,
test_without_building: true,
- # the language the app is given is not the language the status bar is
- # drawn in, and an iPad's status bar carries the date. Without this it
- # reads Mon Aug 17 over a Russian document.
+ # Localize the simulator status bar as well as the app.
localize_simulator: true
)
- # A raw capture is not what the store shows. The framing is separate from the
- # photography so that changing a headline costs a second of Pillow rather
- # than a quarter hour of simulators - rerun this script alone after an edit
- # to fastlane/frames/frames.json.
+ # Frame captures separately so design changes do not require new screenshots.
sh(FRAME_SCREENSHOTS)
- # what came out is what the store would be given, so it is checked here
- # rather than at upload time on the other side of the run. A run narrowed to
- # some of the languages or to one of the devices has nothing to check
- # against: half a set is what it was asked for, and the release checks the
- # two halves together once both runners have handed theirs in.
+ # Validate complete sets; the release workflow checks combined device jobs.
narrowed = %w[ODR_SCREENSHOT_LANGUAGES ODR_SCREENSHOT_DEVICE ODR_SCREENSHOT_DEVICES]
.any? { |name| !ENV[name].to_s.strip.empty? }
@@ -342,9 +288,7 @@ platform :ios do
)
end
- # ASC_KEY_CONTENT is the base64 of the .p8. It is written to a private
- # temporary file because that is the shape app_store_connect_api_key takes it
- # in; every caller removes it again.
+ # Decode the API key into a private temporary file; callers remove it.
private_lane :api_key_file do
path = File.join(Dir.mktmpdir("asc-api-key"), "AuthKey_#{ENV.fetch('ASC_KEY_ID')}.p8")
File.write(path, Base64.decode64(ENV.fetch("ASC_KEY_CONTENT")))
@@ -374,16 +318,11 @@ platform :ios do
}.max + 1
end
- # ODR_VERSION and ODR_BUILD_NUMBER come from the environment rather than lane
- # options, which fastlane passes through as strings: `dry_run:false` would
- # arrive as "false" and read as true. An unset ODR_BUILD_NUMBER asks App Store
- # Connect itself, as a hand run does.
+ # Read version and build from the environment; resolve an unset build number from the store.
private_lane :build_ipa do |options|
version = ENV["ODR_VERSION"].to_s.strip
- # resolve-version.py refuses this in CI, but these lanes are meant to be
- # runnable by hand, where nothing else stops an unversioned build before the
- # upload rejects 0.0.0 twenty minutes in
+ # Require a version for local uploads as well as CI.
if version.empty? && !dry_run?
UI.user_error!(
"no version to build: set ODR_VERSION (e.g. ODR_VERSION=1.36), " \
@@ -402,11 +341,7 @@ platform :ios do
UI.message("building #{options[:scheme]} #{version.empty? ? '(unversioned)' : version} as build #{build_number}")
- # Manual, because automatic signing has xcodebuild mint distribution assets
- # of its own - cloud signing, which only an Admin key may do, and which
- # left the imported certificate unused. sigh wants an Admin key only when
- # the account has no profile yet and it has to create one; that fails here,
- # before the build, rather than after it.
+ # Use manual signing and fetch the provisioning profile before building.
profile_dir = Dir.mktmpdir("provisioning-profile")
get_provisioning_profile(
api_key: key,
@@ -461,9 +396,7 @@ platform :ios do
ipa: ipa,
skip_screenshots: true,
skip_metadata: true,
- # uploading is not the same as shipping: promoting a build stays a
- # deliberate step in App Store Connect. deliver's option is
- # submit_for_review; skip_submission is pilot's and is rejected here.
+ # Upload without submitting for review.
submit_for_review: false,
# deliver runs precheck even when it is not submitting, and precheck
# refuses to run at all against in-app purchases with an API key
@@ -474,18 +407,7 @@ platform :ios do
end
end
- # What the store says about the app: the "What's New" of ODR_VERSION in every
- # locale, and the rest of the listing text with it.
- #
- # deliver uploads every metadata file it finds under metadata_path, so it is
- # given a staged directory rather than fastlane/metadata itself, and
- # store_listing.py names what goes into it. What is staged is what this
- # repository says the store should say; anything else is left as App Store
- # Connect has it.
- #
- # Separate from upload_ipa so a rejected word can be rewritten and pushed again
- # without touching the binary, which App Store Connect refuses a second time
- # anyway.
+ # Stage and upload version notes and app-specific listing metadata separately from the binary.
private_lane :upload_listing do |options|
version = ENV["ODR_VERSION"].to_s.strip
UI.user_error!("no version to write notes for: set ODR_VERSION (e.g. ODR_VERSION=1.41)") if version.empty?
@@ -499,10 +421,7 @@ platform :ios do
# ever written to disk
sh(STORE_LISTING, "--version", version, "--stage", notes_dir, "--app", options[:name])
- # A run with nothing captured writes the text alone, so fixing a word in a
- # description does not cost a quarter hour of simulators. A run with
- # something captured has to have all of it: half a set is worse in the
- # store than the set already up there.
+ # Upload text alone when no screenshots exist; otherwise require a complete set.
captured = !Dir.glob(File.join(FRAMED_DIR, "*", "*.png")).empty?
sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR, "--stage", shots_dir, "--app", options[:name]) if captured
UI.important("no screenshots under #{FRAMED_DIR} - writing the text only") unless captured
diff --git a/fastlane/screenshots/README.md b/fastlane/screenshots/README.md
index b9f59bf2..c5a6f29b 100644
--- a/fastlane/screenshots/README.md
+++ b/fastlane/screenshots/README.md
@@ -29,11 +29,11 @@ Six screens per device, in store order. The edit is taken twice:
| | |
| --- | --- |
| `01-browser` | the document browser, with one file of each format |
-| `02-text` | a text document |
+| `02-text` | a text document with search results |
| `03-sheet` | a spreadsheet, with its sheet tabs |
| `04-edit` | a document in edit mode, keyboard up |
| `04-edit-lite` | the same, as Lite shows it: the Pro tools dimmed |
-| `05-pdf` | a PDF, with a search under way |
+| `05-pdf` | a PDF |
| `06-office` | a Word file |
Two devices: a 6.9" iPhone and a 13" iPad. `Fastfile` lists the simulator
@@ -42,7 +42,7 @@ pixel sizes the store accepts.
## Locales
-The store has eleven locales and the app has nine. `hi` and `sv` get the
+Nine of the eleven store locales have app translations. `hi` and `sv` get the
English pictures, because the app has no Hindi or Swedish UI.
`scripts/store_screenshots.py --languages` prints the locales to capture.
diff --git a/scripts/changelog_section.py b/scripts/changelog_section.py
index a11c176c..0baaabd2 100755
--- a/scripts/changelog_section.py
+++ b/scripts/changelog_section.py
@@ -1,18 +1,11 @@
#!/usr/bin/env python3
-#
-# Prints the CHANGELOG.md section for one version, and fails when there is none.
-#
-# The release run reads it before building, so a version without release copy
-# fails in seconds rather than once both apps are uploaded, and again when it
-# drafts the github release, whose body the section becomes.
+# Print a version section from CHANGELOG.md; fail if missing or empty.
import argparse
import re
import sys
-# `## [1.37] - 2026-08-02`, `## [1.38]`, `## Unreleased`. Whatever follows the
-# version is not looked at, so an unusual date separator still closes the
-# section above it. Not `###`, which belongs to whichever section it sits in
+# Match version headings at level two, allowing optional brackets and dates.
HEADING = re.compile(r"^## +\[?([^\]\s]+)")
# `[1.37]: https://github.com/...compare/1.36...1.37` at the foot of the file:
diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py
index 415cf21c..b638512b 100755
--- a/scripts/frame-screenshots.py
+++ b/scripts/frame-screenshots.py
@@ -1,25 +1,6 @@
#!/usr/bin/env python3
-#
-# Puts the captured screenshots into the picture the store shows: the app on a
-# phone, on a coloured ground, under a line of copy in that locale's language.
-#
-# scripts/frame-screenshots.py frame the whole capture
-# scripts/frame-screenshots.py --locale en-US one locale, for a look
-#
-# `fastlane ios screenshots` takes the raw captures into fastlane/screenshots;
-# this reads them and writes the framed set to fastlane/framed, which is what
-# `scripts/store_screenshots.py` then checks and stages. The raw set is left
-# alone, so a framing change costs a rerun of this and not of the simulators.
-#
-# Nothing here is drawn from an image file. Every part of the design is a
-# rounded rectangle, a plain rectangle or a line of text, so it is all in
-# `fastlane/frames/frames.json` and in the numbers below - which is also what
-# lets one canvas size become another. The only asset is the font.
-#
-# Needs Pillow, which is the one thing in this repository's scripts that is not
-# in the standard library:
-#
-# python3 -m pip install Pillow
+# Frame App Store captures from fastlane/screenshots into fastlane/framed.
+# Requires Pillow. Use --locale to frame one locale.
import argparse
import bisect
@@ -42,21 +23,7 @@
CAPTURED = ROOT / "fastlane" / "screenshots"
FRAMED = ROOT / "fastlane" / "framed"
-# The design, as fractions of the canvas rather than pixels, because the canvas
-# is whatever the capture is - 1320x2868 today and something else after the next
-# device.
-#
-# How far down something sits is a fraction of the height; how big it is, and
-# how far across, is a fraction of the width. So a taller canvas gives
-# everything more room without stretching any of it.
-#
-# The proportions came off the 2020 artwork at 1242x2208, where the phone was
-# anchored by its top left corner and left to run off the bottom and the right.
-# It stands whole now, as the Play listing's does: a device with its corners cut
-# off reads as a picture that did not fit rather than as a phone. The canvas
-# cannot grow to make room - App Store Connect takes the capture's own size and
-# no other - so the device is fitted into it instead, which costs it some size
-# and leaves ground under it.
+# Layout dimensions are fractions of the canvas: y positions use height; sizes use width.
LAYOUT = {
"iphone": {
"headline_top": 0.068,
@@ -67,18 +34,13 @@
"screen_top": 0.265,
"screen_width": 0.650, # as wide as it may be; `foot` is the other limit
"foot": 0.045, # ground left under the device, of the height
- # An iPhone 17 Pro Max, from its published dimensions: a 440pt screen
- # inside a 78.0mm body, which leaves 2.54mm - 15.3pt - of black border
- # and aluminium on every side, and a 62pt display corner.
+ # iPhone 17 Pro Max bezel and corner proportions.
"bezel": 0.0272, # screen edge to the outside of the body
"rim": 0.57, # how much of that is the black border
"corner": 0.141, # screen corner, of the screen's width
"island": (0.2826, 0.0811), # the Dynamic Island, of the screen's width
"island_top": 0.0334,
- # The action button and the two volume keys: (how far down the body,
- # how long), both of the body's height. Measured off Apple's own bezel
- # artwork, which is also where the bezel, the island and the corner
- # come from - so all of it is the device rather than a guess at it.
+ # Left buttons as (top, height), relative to body height.
"buttons": [(0.189, 0.0423), (0.262, 0.0686), (0.349, 0.0686)],
# the side button, on the right edge
"buttons_right": [(0.286, 0.1082)],
@@ -89,34 +51,9 @@
"dash_stroke": 0.0056,
"dash_on": 0.0236,
"dash_off": 0.0098,
- # Each decoration is a line that comes in from off the canvas, turns a
- # corner and leaves again - one corner of a rounded rectangle far bigger
- # than the picture. Points are (x, y) in canvas fractions, and a point
- # past 1 is off the edge on purpose. A y of "chips" starts the line at
- # the top of the tabs, so it runs behind however many there are and
- # comes out underneath: anchored to a number instead, a screen with one
- # tab leaves the line starting in mid air below it.
- # The line crossing every screen. Each picture takes it in at the height
- # the one before let it out at, steps it to a new height somewhere along
- # the way, and hands it on - so no two screens carry the same line and
- # the gallery still reads as one. There is one height per seam, which is
- # one more than there are screens.
- #
- # Every height sits between the foot of the headline and the top of the
- # device, the only band that is neither written on nor covered up.
+ # Gallery seam heights in canvas fractions; adjacent screens share a seam.
"seams": [0.150, 0.200, 0.170, 0.215, 0.158, 0.195, 0.176],
- # What the line does between the two seams it has to join. "in" is the
- # height it arrived at and "out" the one it has to leave at; anything
- # else is a height of its own. Only the left quarter is free below the
- # band - the device covers the rest - so that is where a line can wander
- # before it has to come back up and go.
- # A step and, on some, a shallow dip into the left column before it. Kept
- # shallow on purpose: the line is there to tie the pictures together, and
- # one that wanders far down the page competes with what it is framing.
- # One step, at a different place on each. Two of them dip a little
- # further first, and only a little: the line is there to tie the
- # pictures together, and anything more reads as decoration for its own
- # sake across the top of every screen.
+ # Routes join the "in" and "out" seams; numeric heights are canvas fractions.
"routes": [
[(0.34, "in"), (0.34, "out")],
[(0.17, "in"), (0.17, "out")],
@@ -126,10 +63,7 @@
[(0.15, "in"), (0.15, 0.238), (0.33, 0.238), (0.33, "out")],
],
"radii": [0.078, 0.066, 0.086, 0.062, 0.072, 0.070],
- # The lower line. Some of these hang off the tabs, some come in from the
- # left edge instead - picking up where the picture before let its own
- # line disappear behind the device - and one leaves to the left again.
- # A screen with no tabs takes one of the ones that does not need them.
+ # Lower decorations attach to chips or enter from the canvas edge.
"decorations": [
[("chips", "chips"), ("chips", 0.930), (0.55, 0.930)],
[(-0.2, 0.700), (0.155, 0.700), (0.155, 0.930), (0.62, 0.930)],
@@ -146,10 +80,7 @@
"screen_top": 0.250,
"screen_width": 0.720,
"foot": 0.055,
- # An iPad Pro 13-inch, likewise: a 1032pt screen in a 215.5mm body is
- # 8.44mm - 43.8pt - of border, near three times the phone's, and the
- # display corner is 18pt where the phone's is 62. Nothing on the left
- # edge either: its buttons are all on the top one.
+ # iPad Pro 13-inch bezel and corner proportions.
"bezel": 0.0350,
"rim": 0.86,
"corner": 0.0174,
@@ -189,9 +120,7 @@
BUTTON = ("#d8d8d6", "#a9a9a7", "#c4c4c2")
GLASS = 96 # how brightly the screen's edge catches the light, of 255
-# The shadow the device casts. Black rather than a colour of its own, which was
-# mixed for the green ground and went muddy on the orange one, and offset down
-# and right instead of sitting square behind the body, where the body covers it.
+# Black shadow, offset down and right.
SHADOW = (0, 0, 0, 105)
SHADOW_OFFSET = (0.30, 0.65) # of the bezel, across and down
SHADOW_BLUR = 0.85 # of the bezel
@@ -251,12 +180,7 @@ def rounded_path(points, radius, per_corner=24):
def dashed(canvas, points, stroke, on, off, colour=(255, 255, 255, 255), phase=0.0):
- """Lays dashes along a path, so a dash carries on around a corner.
-
- Counted out from the start of the path rather than accumulated as it walks,
- because a step that rounds to nothing next to a distance already travelled
- is a step that never arrives.
- """
+ """Draw dashes continuously around a path, measuring from its start to avoid rounding drift."""
reached = [0.0]
for before, after in zip(points, points[1:]):
reached.append(reached[-1] + math.hypot(after[0] - before[0], after[1] - before[1]))
@@ -297,20 +221,12 @@ def at(distance):
def squircle(box, radius, exponent=5.0, per_corner=40):
- """A rounded rectangle whose corners are superellipse quadrants.
-
- Apple's corners are a continuous curve rather than a circular arc: the
- curvature eases into the straight edge instead of starting at full bend. A
- circle reads as an Android phone, or as a 2015 one. Exponent 5 is close
- enough that nobody looks twice.
- """
+ """Return a rounded rectangle with superellipse corners."""
x0, y0, x1, y1 = box
r = min(radius, (x1 - x0) / 2, (y1 - y0) / 2)
points = []
- # each corner as (centre, x sign, y sign), going clockwise from bottom right.
- # Two of the four are walked backwards, so that every quadrant leaves off
- # where the next one starts and the outline closes.
+ # Walk superellipse corners clockwise, reversing alternate quadrants.
for (cx, cy), sx, sy in (((x1 - r, y1 - r), 1, 1), ((x0 + r, y1 - r), -1, 1),
((x0 + r, y0 + r), -1, -1), ((x1 - r, y0 + r), 1, -1)):
for step in range(per_corner + 1):
@@ -325,13 +241,7 @@ def squircle(box, radius, exponent=5.0, per_corner=40):
def outset(points, distance):
- """The same outline, moved out by a fixed distance along its own normals.
-
- A squircle grown by raising its radius is not parallel to the one it grew
- from: the gap between the two opens up around the corner and closes down
- the sides. Drawn that way a bezel is visibly fatter at the corners than
- along the edges, which is the first thing anybody who owns the phone sees.
- """
+ """Offset an outline along its normals to preserve a constant border width."""
walked = list(zip(points, points[1:] + points[:1]))
facing = 1.0 if sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in walked) > 0 else -1.0
moved = []
@@ -355,13 +265,7 @@ def stencil(size, points, supersample=3):
def chamfer(share):
- """The metal's colour that far across the band, outside edge to black.
-
- Read off Apple's own bezel: the edge is turned, so it comes in at a middling
- grey, climbs to a highlight about two thirds of the way in and falls away
- again. Lit along its length like this it reads as a rolled edge; filled
- flat, as a grey stripe.
- """
+ """Return the metal color across the bezel, from outside edge to black."""
stops = ((0.00, 109), (0.19, 157), (0.40, 190), (0.64, 235), (0.79, 195), (1.00, 120))
place = bisect.bisect_right([at for at, _ in stops], share)
if place == 0:
@@ -412,26 +316,14 @@ def walked(points):
def phone(canvas, shot, layout):
- """The device: the capture behind glass, in a metal body.
-
- Drawn rather than pasted from a mockup, so it is the shape of whatever was
- captured. A frame downloaded for one device is the wrong shape for the next
- one, and the sets on offer stop at a generation the store no longer asks for.
-
- Built in its own image and composited once, so the parts can be masked
- against each other without the ground showing through the seams.
- """
+ """Composite a capture inside a device frame, with bezel, buttons and shadow."""
width, height = canvas.size
bezel = layout["bezel"] * width # screen edge to the outside of the body
rim = bezel * layout["rim"] # how much of that is metal
left, top = layout["screen_left"] * width, layout["screen_top"] * height
- # Two limits rather than one fraction: `screen_width` is as wide as it may
- # be, and `foot` is how much ground has to be left under it. Sized by the
- # fraction alone, a device a little taller than the one the number was picked
- # for runs its bottom rim off the canvas and a shorter one leaves a stripe of
- # ground - neither of which is a decision anybody made.
+ # Fit the device within screen_width while preserving the foot margin.
standing = (height - layout["foot"] * height) - top - bezel
screen_width = min(layout["screen_width"] * width, standing * shot.width / shot.height)
screen_height = screen_width * shot.height / shot.width
@@ -463,15 +355,11 @@ def phone(canvas, shot, layout):
radius=stand * 0.55, fill=255)
device.paste(metal, (0, 0), buttons)
- # Every edge is the screen's own outline moved out, so the black border and
- # the metal around it are the same width the whole way round - which is what
- # they are on the device, and not what a bigger squircle would give.
+ # Offset the screen outline to keep the bezel width constant.
face = squircle(here(screen), corner)
outline = outset(face, bezel)
- # The metal, lit across the band's own width rather than the body's: the
- # band is filled as rings, each the colour ``chamfer`` gives for how far in
- # it sits, so the highlight follows the edge the whole way round.
+ # Shade concentric bezel rings with chamfer().
band = bezel - rim
lit = Image.new("RGB", size, chamfer(1.0))
rings = ImageDraw.Draw(lit)
@@ -492,10 +380,7 @@ def phone(canvas, shot, layout):
(round(inside[0]), round(inside[1]),
round(inside[0]) + fitted.width, round(inside[1]) + fitted.height)))
- # The hairline where the glass meets the surround, which a real device
- # catches the light along. Without it a screen that is dark at the top - the
- # intro is - runs into the black bezel, and the two read as one fat border
- # off a phone from 2016.
+ # Highlight the glass edge to separate dark screens from the bezel.
hair = max(1.0, screen_width * 0.0012)
halo = ImageChops.subtract(
stencil(size, face), stencil(size, outset(face, -hair))
@@ -521,12 +406,7 @@ def phone(canvas, shot, layout):
def headline(canvas, lines, layout):
- """Two lines, light over bold, centred and shrunk until they fit.
-
- Fitted rather than set at a fixed size because the same sentence is a third
- longer in German than in English, and a line that runs off the picture is
- worse than one set a little smaller.
- """
+ """Fit two centered headline lines to the available width."""
width, height = canvas.size
size = round(layout["headline_size"] * width)
allowed = layout["headline_width"] * width
@@ -553,14 +433,7 @@ def chips(canvas, names, palette, layout):
face = font(round(layout["chip_text"] * width), "Bold")
draw = ImageDraw.Draw(canvas)
- # As wide as the longest word in the whole design needs, and no narrower
- # than the design's own tab: "odt" is three letters and "docx" is four, and
- # a tab cut to fit the shorter one loses the end of the longer.
- #
- # Measured across every format rather than the two or three on this screen,
- # so the tabs are one length through the gallery. Cut to fit each screen
- # they step in and out as the reader swipes, which reads as the pictures
- # having been made at different times.
+ # Size all chips to the widest format name, keeping their width consistent across screens.
padding = layout["chip_text"] * width * 0.42
chip_width = max([least] + [draw.textlength(name, font=face) + 2 * padding for name in palette])
@@ -586,9 +459,7 @@ def frame(shot, screen, locale, spec, order=0):
stroke = layout["dash_stroke"] * width
radius = layout["radii"][order % len(layout["radii"])] * width
- # The crossing line, and the dash pattern picked up where the screens before
- # it left off, so the dashes carry on across the gallery rather than
- # restarting at every picture.
+ # Continue the dash phase from preceding screens.
before = sum(walked(crossing(layout, index, shot.size)) for index in range(order))
dashed(
canvas, rounded_path(crossing(layout, order, shot.size), radius), stroke, on, off, phase=before
diff --git a/scripts/make-screenshot-documents.py b/scripts/make-screenshot-documents.py
index 8ba16918..988e935a 100755
--- a/scripts/make-screenshot-documents.py
+++ b/scripts/make-screenshot-documents.py
@@ -1,23 +1,7 @@
#!/usr/bin/env python3
-"""Builds the documents the App Store screenshots are taken of.
+"""Generate reproducible localized samples for Debug screenshot builds.
-A reader's screenshots are mostly the document it is reading, so these are
-written rather than borrowed: a short report, a small spreadsheet and a three
-slide deck, in every language the app speaks and the store has a listing for.
-The screenshot of the German store shows a German document.
-
-Kept small on purpose - a few kilobytes each, no images, no third party
-material - because they are read once, on a simulator, to be photographed.
-They are bundled in Debug builds only; `EXCLUDED_SOURCE_FILE_NAMES` keeps
-`sample-*` out of the archive that goes to the store.
-
- python3 scripts/make-screenshot-documents.py
- python3 scripts/make-screenshot-documents.py --language en one of them
-
-What it writes is not committed - the screenshot lane runs this before it
-builds. The packages are byte for byte reproducible, so a rerun that changes
-no wording writes the same bytes.
-"""
+Usage: python3 scripts/make-screenshot-documents.py [--language en]"""
import argparse
import json
@@ -53,11 +37,7 @@
"""
-# A4 upright for the report and the sheet, 16:9 for the deck.
-#
-# A page is fitted to the width of the screen, so a page with little on it reads
-# as a smudge in the top third of an empty sheet. The answer is words rather
-# than smaller paper: these documents are written long enough to fill A4.
+# A4 portrait for documents; 16:9 for slides.
PAGE_LAYOUTS = {
"document": '',
@@ -69,9 +49,7 @@
ACCENT = "#1c6fd6"
RULE = "#d4d9e0"
-# Which section of the report the figures sit under - the costs one, second of the
-# five - and how many rows of them there are. The rows are what carry the report
-# past the foot of a phone screen, so this is the number to turn if it stops.
+# Place the cost table under the second report section.
COSTS_SECTION = 1
REPORT_ROWS = 26
@@ -113,15 +91,7 @@ def paragraph_style(name: str, *, size: str, weight: str = "normal", colour: str
def report(words: dict) -> str:
- """A title, a lead, headed sections with the costs figures under theirs, and a
- closing line.
-
- Long on purpose. A page fitted to the width of a phone is about two thirds of
- its height, so a document that ends after one is photographed with a third of
- the screen showing the backdrop behind it. Which is why there is a table in
- here at all: the figures are the only length the report can be given that is
- already written in all nine languages.
- """
+ """Build a report with headings and a cost table long enough to fill the screenshot."""
automatic = "\n".join(
[
paragraph_style("Title", size="26pt", weight="bold", space="0.8cm"),
@@ -170,15 +140,7 @@ def report(words: dict) -> str:
def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple:
- """The figures as rows of cells: a header, the items across as many periods
- as are asked for with their totals, and a totals row under them.
-
- Narrowed and shortened for the files that are not the budget, so the .xlsx
- and the invoice hold their own figures rather than the .ods twice.
-
- As wide as the header a language has words for, so one translated ahead of
- the others comes out short rather than out of step.
- """
+ """Return header, data and totals rows for the requested periods and row count."""
periods = words["periods"][:columns]
columns = len(periods)
taken = FIGURES[:rows] if rows else FIGURES
@@ -199,11 +161,7 @@ def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple
def odf_row(cells: list, style: str | None = None) -> str:
- """One row of an ODF table, as the report and the sheet both write it.
-
- A figure carries its value in the attribute as well as in the text, or the
- spreadsheet holds a column of text that happens to look like numbers.
- """
+ """Build an ODF row with numeric cells carrying both values and display text."""
marked = f' table:style-name="{style}"' if style else ""
out = [" "]
for cell in cells:
@@ -664,9 +622,7 @@ def deck(words: dict) -> str:
},
}
-# What the sheets add up: forty rows over six periods, so there is enough of it
-# to look like a spreadsheet. The first twenty keep the first four figures they
-# had, because the invoice and the .xlsx take slices off the front.
+# Forty rows across six periods; other formats reuse smaller slices.
FIGURES = [
[1200, 1450, 1310, 1600, 1380, 1520],
[480, 620, 510, 470, 690, 540],
@@ -710,9 +666,7 @@ def deck(words: dict) -> str:
[740, 770, 800, 790, 830, 860],
]
-# What the browser lists them as. Realistic rather than descriptive: the first
-# screenshot is meant to look like somebody's folder, and the extensions do the
-# talking about what the app opens.
+# Localized sample filenames.
FILE_NAMES = {
"en": {"text": "Quarterly report", "sheet": "Budget", "slides": "Project plan",
"word": "Contract", "cells": "Sales figures", "deck": "Team offsite",
@@ -743,9 +697,7 @@ def deck(words: dict) -> str:
"paper": "Fatura", "rows": "Kişiler", "notes": "Notlar"},
}
-# The rest of the folder, so it does not read as a set of nine samples. Each is
-# a copy of the sample named beside it, which only decides its icon: the browser
-# shows a name and an icon, and none of them is ever opened.
+# Extra browser entries copy existing samples and are never opened.
FILLERS = {
"meeting": "text",
"letter": "text",
@@ -874,12 +826,7 @@ def deck(words: dict) -> str:
}
-# The word the search screenshot looks for.
-#
-# Counted out of the document rather than written down, so it is always a word
-# that is really in there, and always one that is in there several times - a
-# search that highlights a single hit does not look like a search. Short words
-# are skipped because "the" and "and" say nothing about the document.
+# Choose a frequent long word for visible search matches.
def query(words: dict) -> str:
"""The most repeated long word of the report, which is what to search for."""
text = " ".join(
@@ -897,10 +844,7 @@ def query(words: dict) -> str:
return max(counted, key=lambda word: (counted[word], len(word)))
-# The folder is not nine copies of one report. What each file is called says
-# what it should hold, so the contract reads like a contract and the invoice
-# like an invoice - a folder where every document has the same title is the one
-# thing a picture of a folder must not be.
+# Distinct sample content for each document format.
OTHERS = {
"en": {
"contract": ["Service agreement",
@@ -1097,15 +1041,7 @@ def query(words: dict) -> str:
PEOPLE = ["A. Bauer", "M. Rossi", "J. Novak", "L. Dubois", "S. Meyer", "K. Larsen"]
-# --- the other formats ------------------------------------------------------
-#
-# The first screenshot is a folder, so the folder has to look like somebody's.
-# What it holds is the quiet half of the message: an .odt beside an .xlsx beside
-# a .pdf says what the app opens without a line of copy claiming it.
-#
-# These are written small and plain for the same reason the ODF ones are. They
-# are read by odrcore, not by Word, so they carry the least markup that is still
-# a valid package.
+# Minimal OOXML packages for screenshot samples.
OOXML_RELS = """
@@ -1115,11 +1051,7 @@ def query(words: dict) -> str:
WORD_MAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
-# How many lines the contract's appendix lists, and the half-points the whole of it
-# is set in. Between them they are what carries it past the foot of a phone screen,
-# the clauses above being fourteen short sentences - and they are the only length
-# there is to give it, the renderer honouring neither `w:spacing` on a paragraph nor
-# `w:trHeight` on a row.
+# Appendix row count and text size in half-points.
ANNEX_ROWS = 40
CONTRACT_TEXT = 28
@@ -1161,9 +1093,7 @@ def run(text: str, *, size: int, bold: bool = False, colour: str = "") -> str:
def para(runs: str, after: int) -> str:
return f'{runs}'
- # A clause a paragraph, numbered in line with its first word. A number on a
- # line of its own above the sentence reads as a list of scraps rather than as
- # a contract.
+ # Number each clause inline.
paragraphs = [
para(run(title, size=72, bold=True), 640),
para(run(lead, size=CONTRACT_TEXT), 420),
@@ -1177,14 +1107,10 @@ def para(runs: str, after: int) -> str:
)
)
- # An empty paragraph between them, rather than trusting w:spacing: the
- # renderer sets the clauses flush against each other whatever `w:after`
- # says, and a contract whose clauses touch reads as one block of text.
+ # Use empty paragraphs because odrcore ignores w:spacing here.
body = ''.join(paragraphs)
- # The appendix the last clauses promise, and the length that carries the
- # contract past the foot of the screen. Its two columns are words the report
- # already has in every language, so it costs no translation.
+ # Reuse localized table labels for the contract appendix.
_, rows, _ = table(words, columns=1, rows=ANNEX_ROWS)
body += cell_rows(
[[words["item"], words["total"]]] + [[line[0], str(line[-1])] for line in rows]
@@ -1200,10 +1126,7 @@ def para(runs: str, after: int) -> str:
'',
"_rels/.rels": OOXML_RELS.format(type=WORD_MAIN, target="word/document.xml"),
- # Not optional. odrcore opens /word/styles.xml whether or not the
- # document has a style in it, and a package without one is not read as a
- # Word file at all: it falls through to the web view, which draws the
- # text with no page around it and offers neither search nor editing.
+ # odrcore requires word/styles.xml even when no named styles are used.
"word/_rels/document.xml.rels": OOXML_RELS.format(
type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
target="styles.xml",
@@ -1220,9 +1143,7 @@ def para(runs: str, after: int) -> str:
"",
"word/document.xml": ''
''
- # A4 with 2cm margins, in twentieths of a point. Without it there is no
- # page for odrcore to lay the text on, and the document is drawn as a
- # bare column of text rather than as a sheet of paper.
+ # A4 with 2 cm margins, measured in twentieths of a point.
f"{body}"
''
''
@@ -1336,10 +1257,7 @@ def shape(identifier: int, name: str, box: str, lines: list, size: int) -> str:
}
-# Helvetica's own character widths, in thousandths of the point size, so the pdf
-# can be set the way a real one is: each word placed where it belongs rather
-# than a whole line handed over as one run. It is also what lets the lines wrap
-# where the text actually reaches the margin.
+# Helvetica character widths in thousandths of a point.
HELVETICA = {
"regular": (
"278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 "
@@ -1366,11 +1284,7 @@ def shape(identifier: int, name: str, box: str, lines: list, size: int) -> str:
def advance(text: str, weight: str, size: float) -> float:
- """How wide that text is set in Helvetica at that size.
-
- An accented letter is as wide as the letter it is built on - true across
- Helvetica's Latin range - so the table only has to hold the plain ones.
- """
+ """Measure Helvetica text in points, approximating accented letters with their base widths."""
table = WIDTHS[weight]
total = 0
for character in text:
@@ -1387,20 +1301,7 @@ def advance(text: str, weight: str, size: float) -> float:
def spellable(words: dict, others: dict) -> bool:
- """Whether Helvetica's encoding can write everything the invoice puts on the page.
-
- Everything, not a line or two of it: this used to read the title and the
- closing, which is a sample rather than an answer - a language those two happen
- to be spellable in can still hold a character further down that the encoding
- has no byte for, and that character reaches the page as mojibake. The forty
- rows the invoice bills for are forty more chances of that than it had.
-
- Three of the nine languages fail this and take the English invoice: pl and tr
- for a handful of letters, ru for its whole script. Fixing that means embedding
- a subset of a real font and writing the text as CIDs, which is a job of its own
- and not one to do inside a screenshot script - so it is written down here
- rather than left to be discovered in the store.
- """
+ """Check all invoice text against WinAnsi; unsupported locales use English."""
spoken = [words["item"], words["total"], words["title"], words["closing"]]
spoken += words["periods"] + words["rows"] + others["invoice"]
@@ -1411,23 +1312,12 @@ def spellable(words: dict, others: dict) -> bool:
PAGE = (595.0, 842.0)
MARGIN = 57.0
-# How many lines the invoice bills for. Enough to run onto a second page, for the
-# reason `report` gives: a page is two thirds of a phone's height, and what fills
-# the rest is the top of the page after it.
+# Enough invoice rows to span two pages.
INVOICE_ROWS = 40
def pdf_bytes(words: dict, others: dict) -> bytes:
- """A PDF written out by hand rather than through a library.
-
- An invoice, which is a page of placed labels and figures rather than of
- running prose: every cell is set where it belongs, so nothing has to be
- wrapped and `advance` is only asked how wide a number is.
-
- Helvetica and WinAnsi, so what it says is Latin text only - the languages
- this cannot spell get the English wording, which is also what the search
- screenshot then looks for.
- """
+ """Build a paginated Helvetica invoice PDF, falling back to English for unsupported characters."""
latin = spellable(words, others)
said = words if latin else WORDS["en"]
@@ -1445,9 +1335,7 @@ def literal(text: str) -> str:
pages = [[]]
def put(text, x, y, weight="regular", size=10, align="left"):
- """One line, placed on whichever page is open. Numbers are hung off the
- right, which is what makes a column of figures a column rather than a
- ragged list."""
+ """Place a line on the current page, optionally aligned right."""
name = "F2" if weight == "bold" else "F1"
at = x - advance(text, weight, size) if align == "right" else x
pages[-1].append(f"BT /{name} {size:g} Tf {at:.1f} {y:.1f} Td ({literal(text)}) Tj ET")
@@ -1477,10 +1365,7 @@ def put(text, x, y, weight="regular", size=10, align="left"):
amount = line[-1]
y -= 15
- # A line that would be set in the bottom margin opens the next page
- # instead, with the column heads written again above it - which is what a
- # producer does, and what makes the last page short rather than the first
- # page overfull.
+ # Start a new page and repeat column headers before reaching the bottom margin.
if y < MARGIN + 80:
pages.append([])
y = PAGE[1] - MARGIN - 26
diff --git a/scripts/make-test-fixtures.py b/scripts/make-test-fixtures.py
index 5cc7ca38..e06bfd34 100644
--- a/scripts/make-test-fixtures.py
+++ b/scripts/make-test-fixtures.py
@@ -1,12 +1,5 @@
#!/usr/bin/env python3
-"""Builds the minimal ODF packages the page selection tests translate.
-
-A few hundred bytes each, rather than the megabytes of third party material our
-sample documents are, because all a test asks of them is how many pages a two
-sheet spreadsheet turns into. Rerun when a fixture needs another sheet or slide:
-
- python3 scripts/make-test-fixtures.py
-"""
+"""Generate minimal ODF, PDF and archive test fixtures. Requires qpdf for encrypted PDFs."""
import shutil
import subprocess
@@ -88,11 +81,7 @@ def presentation(slides: list[str]) -> str:
def pdf(pages: list[str]) -> bytes:
- """A page per string, each showing it in Helvetica.
-
- Assembled by hand rather than with a library, because the trailer carries the
- byte offset of every object and nothing else about the file is hard.
- """
+ """Build a PDF with one Helvetica text line per page."""
font = 3
# 1 catalog, 2 pages, 3 font, then a page and its content stream each
page_numbers = [4 + 2 * index for index in range(len(pages))]
@@ -133,11 +122,7 @@ def pdf(pages: list[str]) -> bytes:
def encrypt_pdf(source: Path, target: Path) -> None:
- """AES-256, the standard security handler odrcore reads as `V 5`, `R 6`.
-
- Through qpdf rather than by hand: AES is not in the standard library, and
- R6 derives its key with it. Needs `brew install qpdf` to rerun.
- """
+ """Encrypt the fixture with qpdf using AES-256 (V 5, R 6)."""
if shutil.which("qpdf") is None:
raise SystemExit("qpdf not found; it writes the encrypted pdf fixture")
diff --git a/scripts/resolve-version.py b/scripts/resolve-version.py
index 14c0199f..203048db 100755
--- a/scripts/resolve-version.py
+++ b/scripts/resolve-version.py
@@ -1,19 +1,6 @@
#!/usr/bin/env python3
-#
-# Resolves the version a release run builds: the dispatch input, or nothing at
-# all on a dry run, which builds the 0.0.0 in project.pbxproj.
-#
-# The version comes from that input rather than a tag the run was pushed on: a
-# tag would be a promise made before the upload, and a version often takes more
-# than one build to clear review. The workflow writes the tags afterwards.
-#
-# The shape is checked here because xcodebuild never checks it: MARKETING_VERSION
-# is a free-form string to the build, so a typo would only surface when App Store
-# Connect rejects the upload at the very end. Whether the version is above what
-# is live is left to the store, which is the only thing that knows.
-#
-# Writes the resolved version to GITHUB_OUTPUT as `version`, empty when there is
-# none. Run it by hand to see what a dispatch would build.
+# Validate the release version and write it to GITHUB_OUTPUT.
+# A dry run may omit the version and build the project default.
import argparse
import os
diff --git a/scripts/store-copy.py b/scripts/store-copy.py
index 1091925e..4500970a 100755
--- a/scripts/store-copy.py
+++ b/scripts/store-copy.py
@@ -1,28 +1,6 @@
#!/usr/bin/env python3
-#
-# Writes the store copy of one release: the English "What's New" text, and one
-# translation per locale the listing has.
-#
-# scripts/store-copy.py 1.41 write whatever is missing
-# scripts/store-copy.py 1.41 --english rewrite the English too
-# scripts/store-copy.py 1.41 --dry-run print it, write nothing
-#
-# One `claude -p` per language rather than one call holding all of them. Each
-# agent is given that locale's own description.txt and the notes of the release
-# before it, so it reaches for the words the listing already uses in that
-# language instead of translating the English afresh every release. They run at
-# the same time, and a language that comes back wrong is retried on its own.
-#
-# A second agent then reads the draft against the English, in the same language,
-# and rewrites what reads as English wearing that language's words. `--no-review`
-# skips it.
-#
-# The English is written from the CHANGELOG.md section of that version, or from
-# Unreleased while the heading is still open. A file that is already there is
-# left alone and translated, since that is the copy that was reviewed.
-#
-# Nothing here uploads: `scripts/store_listing.py` checks and stages what this
-# writes, and the release run uploads it.
+# Generate and review localized release notes without uploading them.
+# Usage: scripts/store-copy.py VERSION [--english] [--dry-run] [--locales LOCALES]
import argparse
import concurrent.futures
@@ -240,9 +218,7 @@ def english(version, model, attempts):
def translate(locale, version, source, model, attempts, review=True):
- # the description is shown to the agent as the app already speaks that
- # language, so the ${...} an app fills in comes out rather than being read
- # as something the listing says
+ # Remove listing placeholders before supplying translation context.
path = listing.METADATA / locale / "description.txt"
description = listing.fill_in(path.read_text(encoding="utf-8"), [], where=path.name).strip()
@@ -262,9 +238,7 @@ def translate(locale, version, source, model, attempts, review=True):
if not review:
return draft
- # A second reader of the same language, because what a first draft gets
- # wrong is not something the draft can see: a word borrowed for its sound
- # rather than its sense reads fine to whoever wrote it.
+ # Review each translation against the English text.
return produce(
REVIEW_PROMPT.format(
app=APP,
diff --git a/scripts/store_listing.py b/scripts/store_listing.py
index f8a031cc..14779058 100755
--- a/scripts/store_listing.py
+++ b/scripts/store_listing.py
@@ -1,32 +1,6 @@
#!/usr/bin/env python3
-#
-# The App Store listing: where it is kept, and the deliver tree built out of it.
-#
-# App Store Connect keeps only the notes of the submission in flight, so the
-# history it throws away is kept here instead: one file per locale per marketing
-# version, `fastlane/metadata//changelogs/1.41.txt`.
-#
-# deliver reads none of that. It reads `release_notes.txt` beside it, one per
-# locale, and uploads every metadata file it finds - so it is pointed at a
-# staged directory rather than at fastlane/metadata itself, and what is copied
-# in is named here rather than being whatever happens to be lying around.
-#
-# scripts/store_listing.py --version 1.41 check the notes
-# scripts/store_listing.py --version 1.41 --stage DIR notes alone
-# scripts/store_listing.py --version 1.41 --stage DIR --app pro the whole listing
-#
-# The two apps share one listing and differ in a few places, so what is staged
-# is read in three passes - `fastlane/metadata//`, then the app's own
-# `all/`, then its `/` - and the last one to hold a file wins. A
-# `${name}` left in any of that text is filled in the same way, from the app's
-# `name.txt`, or with nothing where the app has none.
-#
-# A release run checks before it builds, so a version missing a translation
-# fails in seconds rather than once both apps are uploaded. Which languages
-# there are, and which files a listing cannot be without, are written down below
-# rather than counted up from what is on disk: either going missing should stop
-# the release, not leave that storefront quietly as the console had it.
-# `scripts/store-copy.py` writes the release notes this reads.
+# Validate and stage versioned store notes and app-specific listing metadata.
+# Usage: scripts/store_listing.py --version VERSION [--stage DIR --app pro|lite]
import argparse
import os
@@ -37,16 +11,11 @@
ROOT = Path(__file__).resolve().parent.parent
METADATA = ROOT / "fastlane" / "metadata"
-# What each app says instead. `/all/` is read into every locale, `/de-DE/`
-# into that one - so a name that is the same in every language is one file, and a
-# sentence that has to be translated is eleven.
+# Apply app-wide overrides before locale-specific overrides.
APPS = ("pro", "lite")
EVERY_LOCALE = "all"
-# The languages the store sells the app in. Written down rather than counted up
-# from whatever directories are there: a locale that loses its description, or
-# its directory altogether, would otherwise drop out of the check and out of the
-# upload alike, and the release would pass without ever mentioning it.
+# Require every supported storefront locale, even if its directory is missing.
LOCALES = (
"de-DE",
"en-US",
@@ -64,15 +33,7 @@
# what App Store Connect takes in one locale's "What's New"
LIMIT = 4000
-# The text of the listing, per locale, as deliver names it. Listed rather than
-# globbed so that adding a file here is a decision: everything in this set is
-# pushed over whatever App Store Connect currently says.
-#
-# What a listing cannot be without. None of the three passes holding one of these
-# is an error rather than a file left out of what is staged: deliver reads a
-# field it was not given as "leave this be", so the console would keep the old
-# words through the one run meant to replace them. The name is the easiest to
-# lose - each app says it in a single file, for all eleven languages at once.
+# Required deliver metadata fields; missing fields would retain stale store values.
REQUIRED = (
"name.txt",
"subtitle.txt",
@@ -93,14 +54,9 @@
# Attached to the version rather than to a locale.
NON_LOCALISED = ("copyright.txt",)
-# Left behind deliberately, though deliver would take them: `review_information`
-# is the account's own contact details and the note to the reviewer, and the
-# category files say where the app sits in the store. Neither is release copy,
-# and a release is a poor moment to discover either had drifted.
+# Keep review contact details and store categories out of listing uploads.
-# What App Store Connect refuses, rather than truncates. Checked against what is
-# staged, since that is what goes up - a name is short enough on its own and too
-# long once an app has added a word to it.
+# Validate final text after app overrides and placeholders are applied.
LIMITS = {
"name.txt": 30,
"subtitle.txt": 30,
@@ -161,9 +117,7 @@ def sources(app, locale, metadata=METADATA):
def read(name, places):
- """The last of `places` to hold `name`, or None. An empty file does not count:
- deliver reads one as "leave this be" rather than as "clear it", so a blank
- override would silently be no override at all."""
+ """Read the last nonempty override; deliver treats empty fields as unchanged."""
found = None
for place in places:
path = place / name
@@ -216,12 +170,7 @@ def collect(version, metadata=METADATA):
def stage(texts, directory, app=None, metadata=METADATA):
- """Write the metadata tree deliver uploads.
-
- The release notes of this version always. With `app`, the rest of the listing
- text beside them, as that app says it - which is what makes this repository,
- rather than App Store Connect, the place the listing is written.
- """
+ """Stage version notes and, when app is set, its complete listing metadata."""
directory = Path(directory)
if app and app not in APPS:
raise ValueError(f"no such app: {app} - one of {', '.join(APPS)}")
diff --git a/scripts/store_screenshots.py b/scripts/store_screenshots.py
index f125846f..bd8fac3d 100755
--- a/scripts/store_screenshots.py
+++ b/scripts/store_screenshots.py
@@ -1,20 +1,6 @@
#!/usr/bin/env python3
-#
-# The App Store screenshots: which ones there are, and the deliver tree built
-# out of what a capture run wrote.
-#
-# Unlike the store copy, these are not committed. A picture of the app is only
-# worth as much as the app it was taken from, so they are taken during the
-# release run, from the build going out, and handed to deliver from there.
-# `fastlane ios screenshots` takes them; this says what a full set is.
-#
-# scripts/store_screenshots.py --languages what to capture
-# scripts/store_screenshots.py check what was captured
-# scripts/store_screenshots.py --stage DIR --app A check it and stage A's set
-#
-# The store has eleven locales and the app is translated into nine of them.
-# The other two get the English pictures, which is what their storefront would
-# show anyway: the app has no UI in Hindi or Swedish either.
+# Validate screenshot locales, screens and sizes; optionally stage them for deliver.
+# Usage: scripts/store_screenshots.py [--languages] [--screenshots DIR] [--stage DIR --app APP]
import argparse
import os
@@ -26,10 +12,7 @@
ROOT = Path(__file__).resolve().parent.parent
SCREENSHOTS = ROOT / "fastlane" / "screenshots"
-# Store locale -> the language the app is in when it is photographed for it.
-# `None` means the app has none, so that locale reads the English pictures.
-# The keys are the locales `fastlane/metadata` has; the values are what iOS
-# resolves the locale to, which is why de-DE is one folder and pt-BR is another.
+# Map store locales to app languages; None uses English screenshots.
LOCALES = {
"de-DE": "de",
"en-US": "en",
@@ -57,22 +40,17 @@
"06-office",
)
-# What Lite shows in place of a screen of Pro's. The edit is the one screen
-# where the two apps differ: Lite dims the Pro tools behind a badge.
+# The Lite screen that replaces a Pro screen: Lite dims the Pro edit tools.
LITE = {
"04-edit": "04-edit-lite",
}
APPS = ("pro", "lite")
-# Every picture a run takes, longest name first, so a name that ends another
-# one is never matched in its place.
+# Longest name first, so a name that ends another one never matches in its place.
CAPTURED = tuple(sorted(SCREENS + tuple(LITE.values()), key=len, reverse=True))
-# What App Store Connect accepts, upright, in pixels. An app that runs on both
-# has to hand in both, and the store fits every smaller iPhone and iPad from
-# these two. More than one size per device because which simulator a runner has
-# depends on the Xcode it is running.
+# Accepted portrait screenshot sizes, grouped by device.
SIZES = {
"iphone": {
(1320, 2868), # 6.9", iPhone 16 Pro Max and later
@@ -118,13 +96,7 @@ def device(width, height):
def collect(directory):
- """What one capture run wrote. Returns (files by locale and device, problems).
-
- snapshot names its output `-.png`, one folder per
- language, so the simulator's name is read off the front and the size decides
- which device it counts as - the name of a simulator changes with Xcode, the
- number of pixels it has does not.
- """
+ """Collect screenshots by locale and device, returning files and validation errors."""
directory = Path(directory)
found = {}
problems = []
@@ -150,11 +122,7 @@ def collect(directory):
kind = device(width, height)
if kind is None:
- # Not ours to upload, and not a reason to stop: asked for one
- # simulator, snapshot photographs every one whose name starts
- # the same way, so a runner that has an iPhone 16 Pro as well as
- # the Pro Max hands back a third set nobody asked for. What has
- # to be there is still checked below, per device.
+ # Ignore extra simulator sizes; required devices are checked below.
continue
pictures.setdefault(kind, {})[screen] = path
@@ -170,13 +138,9 @@ def collect(directory):
def stage(found, directory, app):
- """Write the screenshot tree deliver uploads for `app`.
+ """Stage screenshots of `app` for deliver, copying English images for fallback locales.
- A folder per store locale, the borrowed ones copied from the English rather
- than left out: what deliver does not upload for a locale, App Store Connect
- keeps - which would be whatever was there before this release. Lite's
- pictures go under the name of the screen they replace, so they keep its
- place in the store.
+ Lite images take the name of the Pro screen they replace, so they keep its store position.
"""
directory = Path(directory)