diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 36f3ea5..d7a327f 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -45,6 +45,16 @@ 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 + - name: build the screenshot tests + run: > + xcodebuild build-for-testing + -project OpenDocumentReader.xcodeproj + -scheme "ODR Screenshots" + -destination 'generic/platform=iOS Simulator' + - uses: actions/upload-artifact@v7 if: always() with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6ee562..98770f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,6 +181,62 @@ 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. + screenshots: + runs-on: macos-26 + steps: + - name: checkout + uses: actions/checkout@v7 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.xcode_version }} + + # the framing draws with Pillow, which is the one thing the scripts here + # want that is not in the standard library + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: python3 -m pip install --quiet Pillow + + # the lane checks the set it produced, so a language that came out short + # fails here rather than half way up to App Store Connect + - name: photograph both devices in every locale + run: bundle exec fastlane ios screenshots + + # what the store is given + - name: archive the framed screenshots + uses: actions/upload-artifact@v7 + with: + name: framed + path: fastlane/framed + if-no-files-found: error + # png, so there is nothing left to squeeze out of them + compression-level: 0 + + # and what they were framed from, which is where to look when a picture + # comes out wrong + - name: archive the raw captures + uses: actions/upload-artifact@v7 + with: + name: screenshots + path: fastlane/screenshots + if-no-files-found: error + compression-level: 0 + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: screenshot-log + path: ~/Library/Logs/snapshot + if-no-files-found: warn + # a job per app, fail-fast off, so "Re-run failed jobs" can retry one half upload: needs: build @@ -219,7 +275,7 @@ jobs: # while a build cannot be uploaded twice. No macOS runner: this touches the # listing, not the app listing: - needs: upload + needs: [upload, screenshots] if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04 strategy: @@ -244,6 +300,13 @@ jobs: given: ${{ inputs.version }} run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" + # where the lane looks for them, and the same set both apps are given + - name: fetch the screenshots + uses: actions/download-artifact@v8 + with: + name: framed + path: fastlane/framed + - name: write ${{ matrix.app }}'s listing env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} diff --git a/.gitignore b/.gitignore index 08373d5..7ee2c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,13 @@ Carthage/Build fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png +fastlane/screenshots/screenshots.html +fastlane/framed + +# The documents the screenshots are taken of, and the names the app lays +# them out under. Build output: `scripts/make-screenshot-documents.py` +# writes them, and the screenshot lane runs it before the build. +OpenDocumentReader/Samples/ fastlane/test_output .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a1017..085be0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,19 +21,20 @@ once the version tag exists. ### Changed -- The engine is odrcore 6.7.1, up from 6.6.0. A pdf, a text file and an archive - listing can be searched, not only a document. A pdf's text also sits where the - file puts it: words no longer drift to the right of where they belong, bold - and italic are bold and italic, and a hit is highlighted in one piece. +- The engine is odrcore 6.7.1, up from 6.6.0. Pdfs, text files and archive + listings can be searched too, and a pdf's text sits where the file puts it. - PDFs are rendered by odrcore instead of being handed to the web view, and a password protected one takes the prompt the other formats use. They can be - searched like a document, and a hit tints the text rather than covering it. + searched like a document. - The search button leaves the tool bar when the page cannot be searched, rather than greying out - the same as the edit button. - The pencil turns into a save button while editing, as on Android. Saving from the bar ends the edit and shows what was written; a save that failed stays in it. Saving has left the menu; discarding is still there and now leaves edit mode. +- The App Store screenshots are taken by the release run, from the build going + out, instead of being made by hand: six per device on an iPhone and an iPad, + in every language the app speaks. ### Fixed diff --git a/OpenDocumentReader.xcodeproj/project.pbxproj b/OpenDocumentReader.xcodeproj/project.pbxproj index 8f63183..ac7dab8 100644 --- a/OpenDocumentReader.xcodeproj/project.pbxproj +++ b/OpenDocumentReader.xcodeproj/project.pbxproj @@ -29,6 +29,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + A11E00000000000000000037 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E2F7ED47220B54D600D63515 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E2F7ED4E220B54D600D63515; + remoteInfo = OpenDocumentReader; + }; E22B25312557F0E2001D0C52 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = E2F7ED47220B54D600D63515 /* Project object */; @@ -49,6 +56,7 @@ A11E00000000000000000013 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; A11E00000000000000000014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A11E00000000000000000015 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenDocumentReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E1EB6C492C1A510D003EC5A0 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; E237952F2274844400BA7238 /* AdSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdSupport.framework; path = System/Library/Frameworks/AdSupport.framework; sourceTree = SDKROOT; }; E26C39382250DC6E009C484A /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; @@ -77,9 +85,21 @@ path = OpenDocumentReaderTests; sourceTree = ""; }; + A11E00000000000000000030 /* OpenDocumentReaderUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = OpenDocumentReaderUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + A11E00000000000000000034 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A11E00000000000000000003 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -171,10 +191,12 @@ A11E00000000000000000009 /* Ads */, A11E0000000000000000000A /* NoAds */, A11E00000000000000000022 /* OpenDocumentReaderTests */, + A11E00000000000000000030 /* OpenDocumentReaderUITests */, E2BB4B64220EF4F20056176B /* Frameworks */, 52A348B12A9262D100DACAB9 /* OpenDocumentReader.app */, A11E00000000000000000008 /* OpenDocumentReader Lite.app */, 52A348B22A9262D100DACAB9 /* OpenDocumentReaderTests.xctest */, + A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */, ); sourceTree = ""; }; @@ -228,6 +250,27 @@ productReference = 52A348B22A9262D100DACAB9 /* OpenDocumentReaderTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + A11E00000000000000000031 /* OpenDocumentReaderUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A11E00000000000000000032 /* Build configuration list for PBXNativeTarget "OpenDocumentReaderUITests" */; + buildPhases = ( + A11E00000000000000000033 /* Sources */, + A11E00000000000000000034 /* Frameworks */, + A11E00000000000000000035 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A11E00000000000000000036 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A11E00000000000000000030 /* OpenDocumentReaderUITests */, + ); + name = OpenDocumentReaderUITests; + productName = OpenDocumentReaderUITests; + productReference = A11E00000000000000000038 /* OpenDocumentReaderUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; E2F7ED4E220B54D600D63515 /* OpenDocumentReader */ = { isa = PBXNativeTarget; buildConfigurationList = E2F7ED65220B54D700D63515 /* Build configuration list for PBXNativeTarget "OpenDocumentReader" */; @@ -266,6 +309,10 @@ A11E00000000000000000001 = { CreatedOnToolsVersion = 26.0; }; + A11E00000000000000000031 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = E2F7ED4E220B54D600D63515; + }; E22B252B2557F0E2001D0C52 = { CreatedOnToolsVersion = 12.0; TestTargetID = E2F7ED4E220B54D600D63515; @@ -313,6 +360,7 @@ E2F7ED4E220B54D600D63515 /* OpenDocumentReader */, A11E00000000000000000001 /* OpenDocumentReader Lite */, E22B252B2557F0E2001D0C52 /* OpenDocumentReaderTests */, + A11E00000000000000000031 /* OpenDocumentReaderUITests */, ); }; /* End PBXProject section */ @@ -333,6 +381,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11E00000000000000000035 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E2F7ED4D220B54D600D63515 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -358,6 +413,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11E00000000000000000033 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E2F7ED4B220B54D600D63515 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -368,6 +430,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + A11E00000000000000000036 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E2F7ED4E220B54D600D63515 /* OpenDocumentReader */; + targetProxy = A11E00000000000000000037 /* PBXContainerItemProxy */; + }; E22B25322557F0E2001D0C52 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = E2F7ED4E220B54D600D63515 /* OpenDocumentReader */; @@ -423,6 +490,7 @@ ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_SOURCE_FILE_NAMES = "sample-*"; INFOPLIST_FILE = configs/lite/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "OpenDocument Reader"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -441,6 +509,45 @@ }; name = Release; }; + A11E00000000000000000039 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.opendocument.OpenDocumentReaderUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = OpenDocumentReader; + }; + name = Debug; + }; + A11E0000000000000000003A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.opendocument.OpenDocumentReaderUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = OpenDocumentReader; + }; + name = Release; + }; E22B25342557F0E2001D0C52 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -648,6 +755,7 @@ ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + EXCLUDED_SOURCE_FILE_NAMES = "sample-*"; INFOPLIST_FILE = configs/full/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "OpenDocument Reader"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -669,6 +777,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A11E00000000000000000032 /* Build configuration list for PBXNativeTarget "OpenDocumentReaderUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A11E00000000000000000039 /* Debug */, + A11E0000000000000000003A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A11E00000000000000000005 /* Build configuration list for PBXNativeTarget "OpenDocumentReader Lite" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme b/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme new file mode 100644 index 0000000..37c652d --- /dev/null +++ b/OpenDocumentReader.xcodeproj/xcshareddata/xcschemes/ODR Screenshots.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenDocumentReader/AppDelegate.swift b/OpenDocumentReader/AppDelegate.swift index b3104c7..8e1cb26 100644 --- a/OpenDocumentReader/AppDelegate.swift +++ b/OpenDocumentReader/AppDelegate.swift @@ -15,6 +15,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // does nothing unless this launch was asked for a screenshot, and has to + // be ahead of anything that reads the defaults it settles + ScreenshotMode.prepare() + StoreReviewHelper.incrementAppOpenedCount() return true diff --git a/OpenDocumentReader/DocumentBrowserViewController.swift b/OpenDocumentReader/DocumentBrowserViewController.swift index 7b09e66..92b9b25 100644 --- a/OpenDocumentReader/DocumentBrowserViewController.swift +++ b/OpenDocumentReader/DocumentBrowserViewController.swift @@ -20,10 +20,62 @@ class DocumentBrowserViewController: UIDocumentBrowserViewController, UIDocument override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // before anything that depends on how often the app has been opened: + // a screenshot run wants one screen and nothing over it + if let screen = ScreenshotMode.screen { + showForScreenshot(screen) + + return + } + StoreReviewHelper.checkAndAskForReview() + 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. + 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. + guard screen != .browser else { + reveal(ScreenshotMode.browserDocuments) { [weak self] in + guard let self else { return } + + ScreenshotMode.markReady(self.view) + } + + return + } + + // nothing on a missing sample, so the test fails waiting for a document + // rather than photographing the browser + guard let document = ScreenshotMode.document(for: screen) else { return } + + presentDocument(at: document) + } + + /// Reveals each document in turn, one after the previous has landed, and + /// then says so. One call per document because revealing takes one URL. + private func reveal(_ documents: [URL], then finish: @escaping () -> Void) { + guard let next = documents.first else { + finish() + + return + } + + revealDocument(at: next, importIfNeeded: false) { [weak self] _, _ in + self?.reveal(Array(documents.dropFirst()), then: finish) + } + } + // MARK: - Privacy /// Brings consent up to date and then offers the way back to it. diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 5477fff..37bb794 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -17,6 +17,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel private var browserTransition: DocumentBrowserTransitioningDelegate? private var hasStartedAds = false + /// The navigation that is putting a document on screen, as opposed to the + /// "loading" or the error page. Nothing but a screenshot asks. + private var documentNavigation: WKNavigation? public var transitionController: UIDocumentBrowserTransitionController? { didSet { if let controller = transitionController { @@ -250,6 +253,54 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { updateSearchButton() + + // 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" + if let documentNavigation, navigation === documentNavigation { + stageScreenshot() + } + } + + /// Puts the document into the state its screenshot is of, and only then + /// 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. + case .text: + let query = ScreenshotMode.query + showSearchBar() + searchBar.text = query + // 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. + callSearch("odr.search", with: query) { [weak self] in + guard let self else { return } + + ScreenshotMode.markReady(self.view) + } + + return + + case .edit: + // Entering an edit reloads the page as editable, so this comes back + // here a second time - and that pass is the one worth photographing. + guard document?.edit == true else { + editDocument() + + return + } + + default: + break + } + + ScreenshotMode.markReady(view) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -521,7 +572,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel callSearch("odr.search", with: searchText) } - private func callSearch(_ function: String, with searchText: String) { + private func callSearch( + _ function: String, with searchText: String, then finish: (() -> Void)? = nil + ) { // an unescaped quote or backslash in the query would break the call // apart rather than search for itself let escaped = @@ -529,10 +582,18 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") - webview?.evaluateJavaScript("\(function)(\"\(escaped)\")") { _, error in + guard let webview else { + finish?() + + return + } + + webview.evaluateJavaScript("\(function)(\"\(escaped)\")") { _, error in if let error { CrashManager.shared.log(error) } + + finish?() } } @@ -701,6 +762,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel func documentUpdateContent(_ doc: Document) { guard let url = doc.result else { + documentNavigation = nil self.webview.loadHTMLString( "

\(NSLocalizedString("loading", comment: ""))

", baseURL: nil) @@ -712,9 +774,9 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel // pages come off the loopback server; a file URL needs read access // granted along with it if url.isFileURL { - self.webview.loadFileURL(url, allowingReadAccessTo: url) + documentNavigation = self.webview.loadFileURL(url, allowingReadAccessTo: url) } else { - self.webview.load(URLRequest(url: url)) + documentNavigation = self.webview.load(URLRequest(url: url)) } } @@ -760,7 +822,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel let fileName = doc.fileURL.absoluteString.lowercased() if systemRenderedExtensions.contains(where: fileName.hasSuffix) { // not odrcore's to render, but the web view knows the format - self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) + documentNavigation = self.webview.loadFileURL(doc.fileURL, allowingReadAccessTo: doc.fileURL) canEdit = false canSearch = false @@ -775,6 +837,7 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return } + documentNavigation = nil self.webview.loadHTMLString( "

\(NSLocalizedString("error", comment: ""))

\(NSLocalizedString("toast_error_generic", comment: ""))", baseURL: nil) diff --git a/OpenDocumentReader/ScreenshotMode.swift b/OpenDocumentReader/ScreenshotMode.swift new file mode 100644 index 0000000..90153ce --- /dev/null +++ b/OpenDocumentReader/ScreenshotMode.swift @@ -0,0 +1,243 @@ +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`. +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. + enum Screen: String { + /// the document browser, with one of each format sitting in it + case browser + /// an ODF text document, read + case text + /// an ODF spreadsheet, with its sheet tabs + case sheet + /// the same reader on a Word file + case office + /// a pdf with a search under way + case pdf + /// a document being edited, keyboard up + case edit + + /// The sample this screen opens, `nil` where it opens none. + var sample: String? { + switch self { + case .browser: return nil + case .text, .edit: return "text" + case .sheet: return "sheet" + case .office: return "word" + case .pdf: return "paper" + } + } + } + + /// One entry in the folder, in the order the browser lists them. `source` is + /// the sample holding its bytes, which only a filler sets. + struct Sample { + let name: String + let format: String + let source: String + + init(name: String, format: String, from source: String? = nil) { + self.name = name + self.format = format + self.source = source ?? name + } + } + + static let folder = [ + Sample(name: "text", format: "odt"), + Sample(name: "sheet", format: "ods"), + Sample(name: "slides", format: "odp"), + Sample(name: "word", format: "docx"), + Sample(name: "cells", format: "xlsx"), + Sample(name: "deck", format: "pptx"), + Sample(name: "paper", format: "pdf"), + Sample(name: "rows", format: "csv"), + Sample(name: "notes", format: "txt"), + + // Fillers, so the folder looks like one somebody keeps things in. Each + // is a copy of a document above under a name of its own, never opened. + Sample(name: "meeting", format: "odt", from: "text"), + Sample(name: "letter", format: "odt", from: "text"), + Sample(name: "travel", format: "odt", from: "text"), + Sample(name: "reading", format: "odt", from: "text"), + Sample(name: "household", format: "ods", from: "sheet"), + Sample(name: "hours", format: "ods", from: "sheet"), + Sample(name: "stocktake", format: "ods", from: "sheet"), + Sample(name: "kickoff", format: "odp", from: "slides"), + Sample(name: "course", format: "odp", from: "slides"), + Sample(name: "lease", format: "docx", from: "word"), + Sample(name: "resume", format: "docx", from: "word"), + Sample(name: "application", format: "docx", from: "word"), + Sample(name: "expenses", format: "xlsx", from: "cells"), + Sample(name: "inventory", format: "xlsx", from: "cells"), + Sample(name: "review", format: "pptx", from: "deck"), + Sample(name: "ticket", format: "pdf", from: "paper"), + Sample(name: "warranty", format: "pdf", from: "paper"), + 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. + 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. + static var screen: Screen? { + #if DEBUG + guard let name = UserDefaults.standard.string(forKey: "ODRScreenshot") else { return nil } + + return Screen(rawValue: name) + #else + return nil + #endif + } + + /// 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. + 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. + static func prepare() { + guard screen != nil else { return } + + empty() + + let details = details() + query = details?.search ?? "" + + let titles = details?.files ?? [:] + laidOut = folder.reduce(into: [:]) { found, sample in + found[sample.name] = layOut(sample, called: titles[sample.name] ?? sample.name) + } + } + + /// 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. + private static func empty() { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first, + let existing = try? FileManager.default.contentsOfDirectory( + at: documents, includingPropertiesForKeys: nil) + else { + return + } + + for file in existing { + try? FileManager.default.removeItem(at: file) + } + } + + /// 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`. + private struct Details: Decodable { + let files: [String: String] + let search: String + } + + private static func details() -> Details? { + guard + let url = Bundle.main.url(forResource: "screenshot-names", withExtension: "json"), + let data = try? Data(contentsOf: url), + let all = try? JSONDecoder().decode([String: Details].self, from: data) + else { + assertionFailure("no screenshot-names.json bundled - run scripts/make-screenshot-documents.py") + + return nil + } + + for language in Bundle.main.preferredLocalizations where all[language] != nil { + return all[language] + } + + return all["en"] + } + + /// The word the search screenshot types. Whatever the pdf it is looking at + /// actually says, which is not always this language. + 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. + private static func layOut(_ sample: Sample, called title: String) -> URL? { + var source: URL? + for language in Bundle.main.preferredLocalizations + ["en"] { + source = Bundle.main.url( + forResource: "sample-\(sample.source)-\(language)", withExtension: sample.format) + + if source != nil { break } + } + + guard let source else { + assertionFailure("no \(sample.source) sample bundled - run scripts/make-screenshot-documents.py") + + return nil + } + + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + + let destination = documents.appendingPathComponent("\(title).\(sample.format)") + + do { + try? FileManager.default.removeItem(at: destination) + try FileManager.default.copyItem(at: source, to: destination) + } catch { + assertionFailure("could not lay out \(destination.lastPathComponent): \(error)") + + return nil + } + + return destination + } + + /// The document this screen opens, once ``prepare()`` has laid it out. + static func document(for screen: Screen) -> URL? { + guard let sample = screen.sample else { return nil } + + return laidOut[sample] + } + + /// Marks the view the test waits for. Does nothing outside a screenshot run. + static func markReady(_ view: UIView) { + guard screen != nil else { return } + + view.accessibilityIdentifier = readyIdentifier + } +} diff --git a/OpenDocumentReaderUITests/ScreenshotTests.swift b/OpenDocumentReaderUITests/ScreenshotTests.swift new file mode 100644 index 0000000..969be0b --- /dev/null +++ b/OpenDocumentReaderUITests/ScreenshotTests.swift @@ -0,0 +1,132 @@ +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. +final class ScreenshotTests: XCTestCase { + + /// Long, because it covers translating a document on a simulator that is + /// also running eleven other languages' worth of tests today. + private let readyTimeout: TimeInterval = 180 + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testTakesTheStoreScreenshots() throws { + let app = XCUIApplication() + + // 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. + app.launchArguments += ["-DidShowContinuousPathIntroduction", "1"] + + let arguments = app.launchArguments + + for (index, screen) in ["browser", "text", "sheet", "edit", "pdf", "office"].enumerated() { + app.launchArguments = arguments + ["-ODRScreenshot", screen] + app.launch() + + let ready = app.descendants(matching: .any)["odr-screenshot-ready"] + XCTAssertTrue( + ready.waitForExistence(timeout: readyTimeout), + "\(screen) never finished coming up, so there is nothing to photograph") + + if screen == "browser" { + waitForTheFolderToFill(in: app) + } + + if screen == "edit" { + raiseTheKeyboard(in: app) + } + + Snapshots.take(String(format: "%02d-%@", index + 1, screen)) + + // rather than leaving it running: the next launch has to go through + // didFinishLaunching again to be handed the next screen + app.terminate() + } + } + + /// 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. + @MainActor + private func waitForTheFolderToFill(in app: XCUIApplication) { + let cells = app.collectionViews.cells + + XCTAssertTrue( + cells.firstMatch.waitForExistence(timeout: 60), + "the browser never listed the documents") + + let deadline = Date().addingTimeInterval(60) + var listed = 0 + var stillFor = 0 + + // three seconds, not one and a half: it has been seen to pause for two + // beats and then hand over the last two files + while stillFor < 6, Date() < deadline { + Thread.sleep(forTimeInterval: 0.5) + + let count = cells.count + stillFor = count == listed ? stillFor + 1 : 0 + listed = count + } + } + + /// 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. + @MainActor + private func raiseTheKeyboard(in app: XCUIApplication) { + let page = app.webViews.firstMatch + XCTAssertTrue(page.waitForExistence(timeout: 30), "the edit has no page to tap into") + + // near the top: the sample is a page of A4 with a few lines on it, so + // most of what is on screen is the blank rest of the sheet + for offset in [0.10, 0.14, 0.07, 0.18, 0.22, 0.28] { + page.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: offset)).tap() + + if app.keyboards.firstMatch.waitForExistence(timeout: 5) { + dismissTheKeyboardTutorial(in: app) + + return + } + } + + 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. + @MainActor + private func dismissTheKeyboardTutorial(in app: XCUIApplication) { + let continueButton = app.buttons["Continue"] + + if continueButton.waitForExistence(timeout: 2) { + continueButton.tap() + } + } +} diff --git a/OpenDocumentReaderUITests/Snapshots.swift b/OpenDocumentReaderUITests/Snapshots.swift new file mode 100644 index 0000000..0a18b0c --- /dev/null +++ b/OpenDocumentReaderUITests/Snapshots.swift @@ -0,0 +1,115 @@ +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/ +enum Snapshots { + + /// Where fastlane leaves what it wants, and looks for what it gets back. + /// Absent off a simulator, which is the one place this cannot run. + private static var handOff: URL? { + guard let host = ProcessInfo.processInfo.environment["SIMULATOR_HOST_HOME"] else { + return nil + } + + return URL(fileURLWithPath: host).appendingPathComponent("Library/Caches/tools.fastlane") + } + + private static var pictures: URL? { + 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. + @MainActor + static func prepare(_ app: XCUIApplication) { + guard let handOff else { + XCTFail("no SIMULATOR_HOST_HOME: screenshots are taken on a simulator, by fastlane") + + return + } + + let language = read(handOff.appendingPathComponent("language.txt")) + if let language { + 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. + let region = + read(handOff.appendingPathComponent("locale.txt")) + ?? language.map { Locale(identifier: $0).identifier } + if let region { + app.launchArguments += ["-AppleLocale", "\"\(region)\""] + } + } + + /// 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. + @MainActor + static func take(_ name: String) { + guard let pictures, + let simulator = ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] + else { + XCTFail("nowhere to write \(name): fastlane did not set this run up") + + return + } + + // whatever is still easing into place, which no element can be waited on + // for - the tab bar settling, a keyboard finishing its way up + Thread.sleep(forTimeInterval: 0.5) + + let file = pictures.appendingPathComponent("\(model(of: simulator))-\(name).png") + + do { + try FileManager.default.createDirectory(at: pictures, withIntermediateDirectories: true) + try XCUIScreen.main.screenshot().pngRepresentation.write(to: file, options: .atomic) + } catch { + XCTFail("could not write \(file.lastPathComponent): \(error.localizedDescription)") + } + } + + /// The model's own name. xcodebuild calls a device "Clone 2 of iPhone 17 Pro + /// Max" when it runs several at once, and the release expects the plain one. + private static func model(of simulator: String) -> String { + guard simulator.hasPrefix("Clone "), let of = simulator.range(of: " of ") else { + return simulator + } + + return String(simulator[of.upperBound...]) + } + + /// A line fastlane left, or `nil` where it left nothing to say. + private static func read(_ file: URL) -> String? { + guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil } + + let line = text.trimmingCharacters(in: .whitespacesAndNewlines) + + return line.isEmpty ? nil : line + } +} diff --git a/README.md b/README.md index 4afa177..fabb71f 100644 --- a/README.md +++ b/README.md @@ -120,13 +120,14 @@ in App Store Connect: gh workflow run release.yml -f version=1.38 ``` -It runs as four jobs: +It runs as five jobs: | job | what it does | | --- | --- | | `build` | one run producing both signed `.ipa`s, archived on the run | +| `screenshots` | beside the build: photographs the app on two devices, in every locale | | `upload` | one job per app, uploading its `.ipa` | -| `listing` | one job per app, writing what the store says about it | +| `listing` | one job per app, writing what the store says and shows about it | | `record` | once both landed: tag the build, draft the GitHub release | Both apps always go out together, and nothing chooses one: Pro and Lite are the @@ -169,6 +170,36 @@ an app's name is unique in the store, and the one sentence about ads inside the description. `review_information` and the categories are left out. See `fastlane/metadata/README.md`. +### The screenshots + +Taken during the release run rather than committed, because a screenshot is only +worth what the build it was taken from is worth: + +```sh +bundle exec fastlane ios screenshots +``` + +Six pictures per device - the folder, a text document, a spreadsheet, an edit +under way, a pdf and a Word file - on a 6.9" iPhone and a 13" iPad, in the nine +store locales the app is translated into. `hi` and `sv` are given the English +set, which is what those storefronts would show anyway. + +The documents in them are localized too. They are not committed: the lane runs +`scripts/make-screenshot-documents.py` before the build, because they are build +output and nothing but a screenshot run opens them. Building the app needs +none of it. + +Nothing is tapped to get there. The app takes `-ODRScreenshot ` in Debug +builds and puts itself on that screen, so the same picture comes out in every +language without driving Apple's document browser in eleven of them - see +`OpenDocumentReader/ScreenshotMode.swift` and the `ODR Screenshots` scheme. +`scripts/store_screenshots.py` checks the set against the sizes App Store +Connect takes, and the `listing` job hands it to `deliver` alongside the text. +The same set goes to both apps. + +The run archives them as the `screenshots` artifact, on a dry run too - which is +how to look at them before the store does. See `fastlane/screenshots/README.md`. + Nothing has to be committed to cut a release, and a release leaves no commit behind either. Both halves of the version come from outside the tree: @@ -232,6 +263,11 @@ ODR_DRY_RUN=true bundle exec fastlane deployPro # build and sign only separate jobs. `uploadPro` takes the `.ipa` already in `build/` rather than making one, and `resolveBuildNumber` prints the number both apps would get. +`uploadListingPro` writes the text and, if `fastlane/screenshots` holds a set, +the pictures with it - so fixing a word in a description by hand does not cost a +quarter of an hour of simulators, while a release run, which always captures +first, sends both. + ### Tags Nothing is triggered by a tag, and no tag is pushed before a build: a version diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 825c34c..6b2ea02 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,9 +18,26 @@ APPS = { # Resolved at parse time, which fastlane also does from fastlane/. IPA_DIR = File.expand_path("../build").freeze -# Absolute for the same reason. It resolves the repository from its own path, so -# it does not care which of the two it is called from. +# Absolute for the same reason. They resolve the repository from their own path, +# so they do not care which of the two they are called from. STORE_LISTING = File.expand_path("../scripts/store-listing.py").freeze +STORE_SCREENSHOTS = File.expand_path("../scripts/store_screenshots.py").freeze +FRAME_SCREENSHOTS = File.expand_path("../scripts/frame-screenshots.py").freeze +MAKE_DOCUMENTS = File.expand_path("../scripts/make-screenshot-documents.py").freeze + +# Where snapshot writes, and where the framed set goes. Neither is committed: +# a picture of the app is worth what the build it was taken from is. +SCREENSHOT_DIR = File.expand_path("screenshots").freeze +FRAMED_DIR = File.expand_path("framed").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. +SCREENSHOT_DEVICES = [ + ["iPhone 17 Pro Max", "iPhone 16 Pro Max", "iPhone 15 Pro Max"], + ["iPad Pro 13-inch (M5)", "iPad Pro 13-inch (M4)", "iPad Pro (12.9-inch) (6th generation)"], +].freeze def dry_run? ENV["ODR_DRY_RUN"].to_s.strip == "true" @@ -31,6 +48,62 @@ def github_output(name, value) File.open(output, "a") { |out| out.write("#{name}=#{value}\n") } if output end +# The simulators this machine has, by the iOS they run: the same model name +# usually exists on several, and which one a bare name resolves to is luck. +def simulators_by_ios + runtime = nil + + `xcrun simctl list devices available`.lines.each_with_object({}) do |line, found| + if (version = line[/^-- iOS ([0-9.]+) --/, 1]) + runtime = version + found[runtime] ||= [] + elsif runtime && (name = line[/^\s+(.+?) \([0-9A-F-]{36}\)/, 1]) + found[runtime] << name + end + end +end + +# The newest iOS installed. The store shows what the app looks like now, and a +# picture taken on last year's iOS is last year's buttons. +def screenshot_ios + given = ENV["ODR_SCREENSHOT_IOS"].to_s.strip + return given unless given.empty? + + newest = simulators_by_ios.keys.max_by { |version| Gem::Version.new(version) } + UI.user_error!("this machine has no iOS simulator runtime installed") unless newest + + 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. +def screenshot_languages + given = ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip + return given.split(",").map(&:strip) unless given.empty? + + 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. +def screenshot_devices(ios) + given = ENV["ODR_SCREENSHOT_DEVICES"].to_s.strip + return given.split(",").map(&:strip) unless given.empty? + + available = simulators_by_ios.fetch(ios, []) + + SCREENSHOT_DEVICES.map do |candidates| + candidates.find { |name| available.include?(name) } || + UI.user_error!( + "none of #{candidates.join(', ')} runs iOS #{ios}. That runtime has: " \ + "#{available.uniq.sort.join(', ')}. Set ODR_SCREENSHOT_DEVICES or " \ + "ODR_SCREENSHOT_IOS to pick by hand." + ) + end +end + platform :ios do desc "Build a signed .ipa of the paid app" lane :buildPro do @@ -62,6 +135,69 @@ platform :ios do upload_listing(APPS[:lite]) end + desc "Take the App Store screenshots of both devices, in every locale the app is translated into" + lane :screenshots do + languages = screenshot_languages + ios = screenshot_ios + devices = screenshot_devices(ios) + + 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. + sh(MAKE_DOCUMENTS) + + # The Pro scheme, so no ad sdk is linked and no consent form can come up in + # front of a picture. The two apps are the same app, and what differs - the + # banner Lite carries - is not in a screenshot either way, so one set of + # pictures goes to both listings. + capture_ios_screenshots( + project: "OpenDocumentReader.xcodeproj", + scheme: "ODR Screenshots", + devices: devices, + # pinned, or the same model name on an older runtime answers instead and + # the store gets a picture of last year's iOS + 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. + 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. + 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 + ) + + # 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. + 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 + # a few languages by hand has nothing to check against: half a set is what it + # was asked for. + if ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip.empty? + sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR) + else + UI.important("only #{languages.join(', ')} were captured, so the set is not checked") + end + end + desc "Build and upload the paid app" lane :deployPro do build_ipa(APPS[:pro]) @@ -244,12 +380,22 @@ platform :ios do UI.user_error!("no version to write notes for: set ODR_VERSION (e.g. ODR_VERSION=1.41)") if version.empty? notes_dir = Dir.mktmpdir("release-notes") + shots_dir = Dir.mktmpdir("release-screenshots") key_path = nil begin # first, so a version some locale has no copy for fails before a key is # 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. + captured = !Dir.glob(File.join(FRAMED_DIR, "*", "*.png")).empty? + sh(STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR, "--stage", shots_dir) if captured + UI.important("no screenshots under #{FRAMED_DIR} - writing the text only") unless captured + key_path = api_key_file upload_to_app_store( @@ -259,9 +405,13 @@ platform :ios do # usual case: the build is still processing when this runs app_version: version, metadata_path: notes_dir, + screenshots_path: shots_dir, skip_binary_upload: true, - skip_screenshots: true, + skip_screenshots: !captured, skip_metadata: false, + # the set replaces what is up there rather than joining it, or a locale + # keeps whichever of last release's pictures this one has no answer to + overwrite_screenshots: captured, # deliver otherwise opens an HTML preview and waits to be told it is fine force: true, # same as the binary upload: precheck cannot read in-app purchases with @@ -272,6 +422,7 @@ platform :ios do ensure FileUtils.remove_entry(File.dirname(key_path), true) if key_path FileUtils.remove_entry(notes_dir, true) + FileUtils.remove_entry(shots_dir, true) end end end diff --git a/fastlane/README.md b/fastlane/README.md index cd8c419..a070310 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -63,6 +63,14 @@ Write the paid app's listing, and ODR_VERSION's release notes, to the store Write the ad supported app's listing, and ODR_VERSION's release notes, to the store +### ios screenshots + +```sh +[bundle exec] fastlane ios screenshots +``` + +Take the App Store screenshots of both devices, in every locale the app is translated into + ### ios deployPro ```sh diff --git a/fastlane/frames/Nunito[wght].ttf b/fastlane/frames/Nunito[wght].ttf new file mode 100644 index 0000000..2ec1f4b Binary files /dev/null and b/fastlane/frames/Nunito[wght].ttf differ diff --git a/fastlane/frames/OFL.txt b/fastlane/frames/OFL.txt new file mode 100644 index 0000000..c8210f0 --- /dev/null +++ b/fastlane/frames/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fastlane/frames/frames.json b/fastlane/frames/frames.json new file mode 100644 index 0000000..c8637bb --- /dev/null +++ b/fastlane/frames/frames.json @@ -0,0 +1,359 @@ +{ + "_comment": [ + "What the App Store pictures show, and what they say in every language.", + "scripts/frame-screenshots.py draws them; see that file for the geometry.", + "", + "One entry per screen, in the order the store shows them, named as the", + "screenshot test writes them. `headline` is two lines: the first is set", + "light and the second bold, so each language picks its own break rather", + "than inheriting the English one." + ], + "font": "Nunito[wght].ttf", + "backgrounds": { + "orange": [ + "#f4b663", + "#e8ad5e" + ], + "green": [ + "#9fbc77", + "#94b46c" + ], + "blue": [ + "#90acd6", + "#86a2cc" + ] + }, + "chips": { + "odt": "#4bb3e8", + "ods": "#62bc77", + "odp": "#fca110", + "pdf": "#d63a2f", + "docx": "#3b7dd8", + "xlsx": "#3fa06a", + "pptx": "#e08a2e" + }, + "screens": [ + { + "name": "01-browser", + "background": "blue", + "chips": [ + "odt", + "ods", + "odp" + ], + "headline": { + "en-US": [ + "All your documents,", + "anywhere. anytime." + ], + "de-DE": [ + "Alle Ihre Dokumente,", + "überall. jederzeit." + ], + "es-ES": [ + "Todos tus documentos,", + "donde y cuando quieras." + ], + "fr-FR": [ + "Tous vos documents,", + "partout, à tout moment." + ], + "hi": [ + "आपके सभी दस्तावेज़,", + "कहीं भी. कभी भी." + ], + "it": [ + "Tutti i tuoi documenti,", + "sempre e ovunque." + ], + "pl": [ + "Wszystkie dokumenty,", + "zawsze i wszędzie." + ], + "pt-BR": [ + "Todos os seus documentos,", + "em qualquer lugar. sempre." + ], + "ru": [ + "Все ваши документы —", + "везде и всегда." + ], + "sv": [ + "Alla dina dokument,", + "var som helst. när som helst." + ], + "tr": [ + "Tüm belgeleriniz,", + "her yerde. her zaman." + ] + } + }, + { + "name": "02-text", + "background": "green", + "chips": [ + "odt" + ], + "headline": { + "en-US": [ + "Text documents,", + "read and searchable." + ], + "de-DE": [ + "Textdokumente:", + "lesen und durchsuchen." + ], + "es-ES": [ + "Documentos de texto,", + "léelos y búscalos." + ], + "fr-FR": [ + "Documents texte,", + "lecture et recherche." + ], + "hi": [ + "टेक्स्ट दस्तावेज़,", + "पढ़ें और खोजें." + ], + "it": [ + "Documenti di testo,", + "da leggere e cercare." + ], + "pl": [ + "Dokumenty tekstowe,", + "czytaj i przeszukuj." + ], + "pt-BR": [ + "Documentos de texto,", + "leia e pesquise." + ], + "ru": [ + "Текстовые документы —", + "чтение и поиск." + ], + "sv": [ + "Textdokument,", + "läs och sök." + ], + "tr": [ + "Metin belgeleri,", + "okuyun ve arayın." + ] + } + }, + { + "name": "03-sheet", + "background": "orange", + "chips": [ + "ods" + ], + "headline": { + "en-US": [ + "Spreadsheets,", + "sheet by sheet." + ], + "de-DE": [ + "Tabellen,", + "Blatt für Blatt." + ], + "es-ES": [ + "Hojas de cálculo,", + "hoja por hoja." + ], + "fr-FR": [ + "Feuilles de calcul,", + "feuille par feuille." + ], + "hi": [ + "स्प्रेडशीट,", + "शीट दर शीट." + ], + "it": [ + "Fogli di calcolo,", + "foglio per foglio." + ], + "pl": [ + "Arkusze kalkulacyjne,", + "arkusz po arkuszu." + ], + "pt-BR": [ + "Planilhas,", + "aba por aba." + ], + "ru": [ + "Электронные таблицы,", + "лист за листом." + ], + "sv": [ + "Kalkylblad,", + "blad för blad." + ], + "tr": [ + "Hesap tabloları,", + "sayfa sayfa." + ] + } + }, + { + "name": "04-edit", + "background": "blue", + "chips": [ + "odt" + ], + "headline": { + "en-US": [ + "Found a typo?", + "Fix it right here." + ], + "de-DE": [ + "Ein Tippfehler?", + "Gleich hier korrigieren." + ], + "es-ES": [ + "¿Una errata?", + "Corrígela aquí mismo." + ], + "fr-FR": [ + "Une faute de frappe ?", + "Corrigez-la ici." + ], + "hi": [ + "कोई गलती?", + "यहीं ठीक करें." + ], + "it": [ + "Un refuso?", + "Correggilo qui." + ], + "pl": [ + "Literówka?", + "Popraw ją tutaj." + ], + "pt-BR": [ + "Um erro de digitação?", + "Corrija aqui mesmo." + ], + "ru": [ + "Нашли опечатку?", + "Исправьте прямо здесь." + ], + "sv": [ + "Ett stavfel?", + "Rätta det här." + ], + "tr": [ + "Yazım hatası mı?", + "Hemen burada düzeltin." + ] + } + }, + { + "name": "05-pdf", + "background": "green", + "chips": [ + "pdf" + ], + "headline": { + "en-US": [ + "Open any PDF,", + "right in the app." + ], + "de-DE": [ + "Jedes PDF öffnen,", + "direkt in der App." + ], + "es-ES": [ + "Abre cualquier PDF,", + "sin salir de la app." + ], + "fr-FR": [ + "Tous vos PDF,", + "directement dans l'appli." + ], + "hi": [ + "कोई भी PDF खोलें,", + "ऐप में ही." + ], + "it": [ + "Apri qualsiasi PDF,", + "direttamente nell'app." + ], + "pl": [ + "Otwórz dowolny PDF,", + "od razu w aplikacji." + ], + "pt-BR": [ + "Abra qualquer PDF,", + "aqui mesmo no app." + ], + "ru": [ + "Любой PDF —", + "прямо в приложении." + ], + "sv": [ + "Öppna vilken PDF som helst,", + "direkt i appen." + ], + "tr": [ + "Her PDF'i açın,", + "doğrudan uygulamada." + ] + } + }, + { + "name": "06-office", + "background": "orange", + "chips": [ + "docx", + "xlsx", + "pptx" + ], + "headline": { + "en-US": [ + "Office files too:", + ".docx, .xlsx and .pptx." + ], + "de-DE": [ + "Auch Office-Dateien:", + ".docx, .xlsx und .pptx." + ], + "es-ES": [ + "También archivos Office:", + ".docx, .xlsx y .pptx." + ], + "fr-FR": [ + "Les fichiers Office aussi :", + ".docx, .xlsx et .pptx." + ], + "hi": [ + "Office फ़ाइलें भी:", + ".docx, .xlsx और .pptx." + ], + "it": [ + "Anche i file Office:", + ".docx, .xlsx e .pptx." + ], + "pl": [ + "Także pliki Office:", + ".docx, .xlsx i .pptx." + ], + "pt-BR": [ + "Arquivos do Office também:", + ".docx, .xlsx e .pptx." + ], + "ru": [ + "И файлы Office:", + ".docx, .xlsx и .pptx." + ], + "sv": [ + "Även Office-filer:", + ".docx, .xlsx och .pptx." + ], + "tr": [ + "Office dosyaları da:", + ".docx, .xlsx ve .pptx." + ] + } + } + ] +} diff --git a/fastlane/screenshots/README.md b/fastlane/screenshots/README.md new file mode 100644 index 0000000..9320b2a --- /dev/null +++ b/fastlane/screenshots/README.md @@ -0,0 +1,59 @@ +# Store screenshots + +What App Store Connect shows of the app, one directory per locale - written +here by a capture run, and not committed. This directory is empty in the +repository on purpose. + +The store copy next door is text somebody wrote, so it lives in git and the +release uploads what is committed. A screenshot is not written, it is taken: +it is only worth what the build it was taken from is worth, and a picture of +1.38 sitting in git through 1.41 is a picture of an app nobody can install any +more. So they are taken during the release run, from the build going out. + +```sh +bundle exec fastlane ios screenshots +``` + +That drives the app on both devices in every locale and writes them here. +`.gitignore` keeps what it wrote out of commits, and the release run archives +the same set as the `screenshots` artifact - which is how you look at what went +to the store, before it does on a dry run and after it on a real one. + +## What is in a set + +Four pictures per device, taken by relaunching the app onto one screen at a +time rather than by tapping through it. The screens, in the order the store +shows them: + +| | | +| --- | --- | +| `01-intro` | the onboarding pages, which is the app's own words | +| `02-text` | a text document open | +| `03-sheet` | a spreadsheet, with the sheet tabs under the tool bar | +| `04-slides` | a presentation | + +Two devices, because an app that runs on iPhone and iPad has to hand in both: a +6.9" iPhone and a 13" iPad. `scripts/store_screenshots.py` holds the sizes App +Store Connect accepts and checks the set against them; `Fastfile` holds the +simulators to look for, newest first, because what a simulator is called +changes with every Xcode and what it is worth does not. + +## Locales + +Eleven in the store, nine in the app. `de-DE`, `en-US`, `es-ES`, `fr-FR`, `it`, +`pl`, `pt-BR`, `ru` and `tr` are photographed in their own language. `hi` and +`sv` are given the English pictures, because the app has no Hindi or Swedish UI +either - that is what those storefronts would show whatever we upload. + +The documents in the pictures are localized too, which is most of what a reader +has to show. The lane writes them with `scripts/make-screenshot-documents.py` +before it builds, rather than keeping them in git: they are build output, and +they are bundled into Debug builds only. `-ODRScreenshot ` is how the +app is asked to open one. See `OpenDocumentReader/ScreenshotMode.swift`. + +## Both apps get the same pictures + +Pro and Lite are one app built twice, and the one thing that differs on screen - +the banner Lite carries - is not in a screenshot either way. The set is taken +once, with the Pro scheme, which links no ad sdk and so cannot raise a consent +form in front of the camera. Both listings are then given it. diff --git a/fastlane/screenshots/README.txt b/fastlane/screenshots/README.txt deleted file mode 100644 index 8b015ec..0000000 --- a/fastlane/screenshots/README.txt +++ /dev/null @@ -1,7 +0,0 @@ -Put all screenshots you want to use inside the folder of its language (e.g. en-US). -The device type will automatically be recognized using the image resolution. Apple TV screenshots -should be stored in a subdirectory named appleTV with language folders inside of it. iMessage -screenshots, like Apple TV screenshots, should also be stored in a subdirectory named iMessage -with language folders inside of it. - -The screenshots can be named whatever you want, but keep in mind they are sorted alphabetically. diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py new file mode 100755 index 0000000..b0886ca --- /dev/null +++ b/scripts/frame-screenshots.py @@ -0,0 +1,681 @@ +#!/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 + +import argparse +import bisect +import functools +import json +import math +import shutil +import sys +from pathlib import Path + +import store_screenshots as store + +try: + from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont +except ImportError: + sys.exit("this needs Pillow: python3 -m pip install Pillow") + +ROOT = Path(__file__).resolve().parent.parent +FRAMES = ROOT / "fastlane" / "frames" +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. A phone is a good deal +# taller than that now, so the phone is anchored by its top left corner and left +# to run off the bottom and the right - which is what the original did too, only +# by less. +LAYOUT = { + "iphone": { + "headline_top": 0.068, + "headline_size": 0.070, # before it is shrunk to fit + "headline_width": 0.86, # what it is shrunk to fit inside + "headline_leading": 1.06, + "screen_left": 0.280, + "screen_top": 0.235, + "screen_width": 0.780, + # 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. + "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. + "buttons": [(0.189, 0.0423), (0.262, 0.0686), (0.349, 0.0686)], + # the side button, on the right edge, off the canvas where the phone + # sits today. Kept so the device is described whole: what is drawn + # follows from where it is placed, not the other way round. + "buttons_right": [(0.286, 0.1082)], + "chip_top": 0.440, + "chip_size": (0.240, 0.147), + "chip_step": 0.165, + "chip_text": 0.113, + "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. + "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": [ + [(0.34, "in"), (0.34, "out")], + [(0.17, "in"), (0.17, "out")], + [(0.52, "in"), (0.52, "out")], + [(0.12, "in"), (0.12, 0.248), (0.28, 0.248), (0.28, "out")], + [(0.26, "in"), (0.26, "out"), (0.60, "out")], + [(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. + "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)], + [("chips", "chips"), ("chips", 0.880), (-0.2, 0.880)], + [(-0.2, 0.845), (0.185, 0.845), (0.185, 0.640), (0.58, 0.640)], + ], + }, + "ipad": { + "headline_top": 0.068, + "headline_size": 0.050, + "headline_width": 0.80, + "headline_leading": 1.06, + # Off the right edge by a little, as the phone is, but only a little: + # an iPad's status icons sit within a fiftieth of its own edge, so any + # more of a bleed takes the battery with it. The phone can afford 6% + # because the Dynamic Island pushes its icons well inboard. + "screen_left": 0.188, + "screen_top": 0.245, + "screen_width": 0.825, + # 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. + "bezel": 0.0350, + "rim": 0.86, + "corner": 0.0174, + "buttons": [], + "chip_top": 0.440, + "chip_size": (0.170, 0.0828), + "chip_step": 0.0935, + "chip_text": 0.080, + "dash_stroke": 0.0040, + "dash_on": 0.0147, + "dash_off": 0.0061, + "seams": [0.162, 0.208, 0.180, 0.224, 0.170, 0.202, 0.186], + "routes": [ + [(0.30, "in"), (0.30, "out")], + [(0.115, "in"), (0.115, "out")], + [(0.46, "in"), (0.46, "out")], + [(0.08, "in"), (0.08, 0.248), (0.19, 0.248), (0.19, "out")], + [(0.18, "in"), (0.18, "out"), (0.54, "out")], + [(0.10, "in"), (0.10, 0.238), (0.225, 0.238), (0.225, "out")], + ], + "radii": [0.060, 0.052, 0.068, 0.050, 0.056, 0.054], + "decorations": [ + [("chips", "chips"), ("chips", 0.930), (0.52, 0.930)], + [(-0.2, 0.620), (0.105, 0.620), (0.105, 0.930), (0.58, 0.930)], + [("chips", "chips"), ("chips", 0.880), (-0.2, 0.880)], + [(-0.2, 0.810), (0.125, 0.810), (0.125, 0.520), (0.54, 0.520)], + ], + }, +} + +# The phone, which is drawn rather than photographed. The rim is read across the +# body's width: bright where the edge turns towards the light, dark on the flat. +BODY = "#08080a" +RIM = ("#c9c9cf", "#6f7076", "#8e8f95", "#7c7d83", "#6a6b71", "#b6b7bd") +# The metal the device wears, off Apple's own bezel. They read as a step in the +# edge rather than as marks on it, which is what they are. +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. +SHADOW = (0, 0, 0, 105) +SHADOW_OFFSET = (0.30, 0.65) # of the bezel, across and down +SHADOW_BLUR = 0.85 # of the bezel + + +@functools.lru_cache(maxsize=None) +def design(): + text = json.loads((FRAMES / "frames.json").read_text()) + text.pop("_comment", None) + return text + + +@functools.lru_cache(maxsize=None) +def font(size, weight): + """Nunito at one weight. It ships as a single variable file these days.""" + face = ImageFont.truetype(str(FRAMES / design()["font"]), size) + face.set_variation_by_name(weight) + return face + + +def gradient(size, top, bottom): + """The ground: the same colour top to bottom, a little darker at the foot.""" + width, height = size + strip = Image.new("RGB", (1, height)) + start = Image.new("RGB", (1, 1), top).getpixel((0, 0)) + end = Image.new("RGB", (1, 1), bottom).getpixel((0, 0)) + for y in range(height): + share = y / max(1, height - 1) + strip.putpixel((0, y), tuple(round(start[i] + (end[i] - start[i]) * share) for i in range(3))) + + return strip.resize((width, height)).convert("RGBA") + + +def rounded_path(points, radius, per_corner=24): + """A polyline with its corners rounded off, as points to walk along.""" + walk = [points[0]] + for before, corner, after in zip(points, points[1:], points[2:]): + into = math.hypot(corner[0] - before[0], corner[1] - before[1]) + out = math.hypot(after[0] - corner[0], after[1] - corner[1]) + r = min(radius, into / 2, out / 2) + start = (corner[0] + (before[0] - corner[0]) * r / into, + corner[1] + (before[1] - corner[1]) * r / into) + end = (corner[0] + (after[0] - corner[0]) * r / out, + corner[1] + (after[1] - corner[1]) * r / out) + walk.append(start) + for i in range(1, per_corner): + t = i / per_corner + # one quadratic bend, with the corner itself as the control point + walk.append(( + (1 - t) ** 2 * start[0] + 2 * (1 - t) * t * corner[0] + t ** 2 * end[0], + (1 - t) ** 2 * start[1] + 2 * (1 - t) * t * corner[1] + t ** 2 * end[1], + )) + walk.append(end) + walk.append(points[-1]) + + return walk + + +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. + """ + 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])) + total = reached[-1] + if not total: + return + + def at(distance): + """The point that far along the path.""" + index = max(1, min(len(reached) - 1, bisect.bisect_left(reached, distance))) + span = reached[index] - reached[index - 1] + share = 0.0 if not span else (distance - reached[index - 1]) / span + before, after = points[index - 1], points[index] + + return (before[0] + (after[0] - before[0]) * share, + before[1] + (after[1] - before[1]) * share) + + draw = ImageDraw.Draw(canvas) + width = max(1, round(stroke)) + + period = on + off + phase = phase % period + for number in range(int((total + phase) // period) + 2): + start = number * period - phase + end = min(start + on, total) + if start >= total: + break + start = max(start, 0.0) + if start >= end: + continue + + # the path's own corners inside this dash, so a dash that lands on a + # bend is drawn bent rather than as a chord across it + run = [at(start)] + run += [point for point, so_far in zip(points, reached) if start < so_far < end] + run.append(at(end)) + draw.line(run, fill=colour, width=width, joint="curve") + + +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. + """ + 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. + 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): + share = step / per_corner if sx * sy > 0 else 1 - step / per_corner + angle = math.pi / 2 * share + points.append(( + cx + sx * r * math.cos(angle) ** (2 / exponent), + cy + sy * r * math.sin(angle) ** (2 / exponent), + )) + + return points + + +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. + """ + 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 = [] + + for index, (x, y) in enumerate(points): + (ax, ay), (bx, by) = points[index - 1], points[(index + 1) % len(points)] + run, rise = bx - ax, by - ay + length = math.hypot(run, rise) or 1.0 + moved.append((x + facing * rise / length * distance, y - facing * run / length * distance)) + + return moved + + +def stencil(size, points, supersample=3): + """An antialiased mask of one shape. Pillow's polygon has hard edges, so it + is drawn large and shrunk, which is cheaper than it sounds on a mask.""" + big = Image.new("L", (size[0] * supersample, size[1] * supersample), 0) + ImageDraw.Draw(big).polygon([(x * supersample, y * supersample) for x, y in points], fill=255) + + return big.resize(size, Image.LANCZOS) + + +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. + """ + 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: + return (stops[0][1],) * 3 + if place == len(stops): + return (stops[-1][1],) * 3 + + (before, low), (after, high) = stops[place - 1], stops[place] + level = round(low + (high - low) * (share - before) / (after - before)) + + return (level,) * 3 + + +def brushed(size, colours): + """The rim: a metal that catches the light differently across its width.""" + width, height = size + strip = Image.new("RGB", (len(colours), 1)) + for index, colour in enumerate(colours): + strip.putpixel((index, 0), Image.new("RGB", (1, 1), colour).getpixel((0, 0))) + + return strip.resize((width, height), Image.BICUBIC) + + +def crossing(layout, order, size): + """The line this screen hands on: in at one height, out at the next.""" + width, height = size + seams = layout["seams"] + enters = seams[order % len(seams)] * height + leaves = seams[(order + 1) % len(seams)] * height + route = layout["routes"][order % len(layout["routes"])] + + def down(y): + return enters if y == "in" else leaves if y == "out" else y * height + + return ( + [(-0.2 * width, enters)] + + [(x * width, down(y)) for x, y in route] + + [(1.2 * width, leaves)] + ) + + +def walked(points): + """How far a path runs, so the next one can pick the dashes up.""" + return sum( + math.hypot(after[0] - before[0], after[1] - before[1]) + for before, after in zip(points, points[1:]) + ) + + +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. + """ + width, height = canvas.size + screen_width = layout["screen_width"] * width + screen_height = screen_width * shot.height / shot.width + left, top = layout["screen_left"] * width, layout["screen_top"] * height + screen = (left, top, left + screen_width, top + screen_height) + + bezel = layout["bezel"] * width # screen edge to the outside of the body + rim = bezel * layout["rim"] # how much of that is metal + corner = layout["corner"] * screen_width + + body = (screen[0] - bezel, screen[1] - bezel, screen[2] + bezel, screen[3] + bezel) + + # its own canvas, with room to the left for the buttons that stand proud + margin = round(bezel * 3) + origin = (round(body[0]) - margin, round(body[1]) - margin) + size = (round(body[2]) - origin[0] + margin, round(body[3]) - origin[1] + margin) + here = lambda box: tuple(v - origin[i % 2] for i, v in enumerate(box)) + + device = Image.new("RGBA", size, (0, 0, 0, 0)) + + # the buttons first, so the body's own edge covers where they meet it + metal = brushed(size, BUTTON) + buttons = Image.new("L", size, 0) + draw = ImageDraw.Draw(buttons) + stand = screen_width * 0.0061 # how far a button stands proud, 2.7pt + tall_as = body[3] - body[1] + for edge, keys in ((here(body)[0], layout["buttons"]), + (here(body)[2], layout.get("buttons_right", []))): + for at_height, tall in keys: + y = here(body)[1] + tall_as * at_height + draw.rounded_rectangle((edge - stand, y, edge + stand, y + tall_as * tall), + 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. + 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. + band = bezel - rim + lit = Image.new("RGB", size, chamfer(1.0)) + rings = ImageDraw.Draw(lit) + steps = max(8, round(band)) + for step in range(steps + 1): + share = step / steps + rings.polygon(outset(face, bezel - band * share), fill=chamfer(share)) + + device.paste(lit, (0, 0), stencil(size, outline)) + + # the black surround the glass sits in, and then the glass + device.paste(Image.new("RGB", size, BODY), (0, 0), stencil(size, outset(face, rim))) + + fitted = shot.resize((round(screen_width), round(screen_height)), Image.LANCZOS).convert("RGBA") + inside = here(screen) + device.paste(fitted, (round(inside[0]), round(inside[1])), + stencil(size, face).crop( + (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. + hair = max(1.0, screen_width * 0.0012) + halo = ImageChops.subtract( + stencil(size, face), stencil(size, outset(face, -hair)) + ).point(lambda level: level * GLASS // 255) + device.paste(Image.new("RGB", size, "white"), (0, 0), halo) + + # the pill the camera sits in, over the gap the status bar leaves for it + if layout.get("island"): + island_width, island_height = (share * screen_width for share in layout["island"]) + middle = (inside[0] + inside[2]) / 2 + island_top = inside[1] + layout["island_top"] * screen_width + ImageDraw.Draw(device).rounded_rectangle( + (middle - island_width / 2, island_top, middle + island_width / 2, island_top + island_height), + radius=island_height / 2, fill=BODY) + + shadow = Image.new("RGBA", size, (0, 0, 0, 0)) + shadow.paste(Image.new("RGB", size, SHADOW[:3]), (0, 0), + stencil(size, outline).point(lambda v: v * SHADOW[3] // 255)) + canvas.alpha_composite( + shadow.filter(ImageFilter.GaussianBlur(bezel * SHADOW_BLUR)), + (origin[0] + round(bezel * SHADOW_OFFSET[0]), origin[1] + round(bezel * SHADOW_OFFSET[1]))) + canvas.alpha_composite(device, origin) + + +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. + """ + width, height = canvas.size + size = round(layout["headline_size"] * width) + allowed = layout["headline_width"] * width + weights = ("Regular", "Bold") + draw = ImageDraw.Draw(canvas) + + while size > 8: + faces = [font(size, weight) for weight in weights] + if max(draw.textlength(line, font=face) for line, face in zip(lines, faces)) <= allowed: + break + size -= 2 + + leading = size * layout["headline_leading"] + y = layout["headline_top"] * height + for line, face in zip(lines, faces): + draw.text((width / 2, y), line, font=face, fill="white", anchor="ma") + y += leading + + +def chips(canvas, names, palette, layout): + """The odt/ods/odp tabs, running off the left edge as the design has them.""" + width, height = canvas.size + least, chip_height = (share * width for share in layout["chip_size"]) + 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. + padding = layout["chip_text"] * width * 0.42 + chip_width = max([least] + [draw.textlength(name, font=face) + 2 * padding for name in palette]) + + for index, name in enumerate(names): + top = layout["chip_top"] * height + layout["chip_step"] * width * index + draw.rectangle((-2, top, chip_width, top + chip_height), fill=palette[name]) + draw.text((chip_width / 2, top + chip_height / 2), name, font=face, fill="white", anchor="mm") + + return chip_width + + +def frame(shot, screen, locale, spec, order=0): + """One picture: ground, decorations, phone, tabs, headline.""" + kind = store.device(*shot.size) + if kind is None: + raise ValueError(f"{shot.size[0]}x{shot.size[1]} is no size the store takes") + + layout = LAYOUT[kind] + width, height = shot.size + canvas = gradient(shot.size, *spec["backgrounds"][screen["background"]]) + + on, off = layout["dash_on"] * width, layout["dash_off"] * width + 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. + 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 + ) + + lower = layout["decorations"][order % len(layout["decorations"])] + + # a line hanging off tabs that are not there reads as a line starting in mid + # air, so a screen without them takes one that comes in from the edge + if not screen["chips"] and any("chips" in point for point in lower): + lower = next( + points for points in layout["decorations"] if not any("chips" in p for p in points) + ) + + # "chips" is the middle of the tabs, so the line runs behind however many + # there are and comes out underneath + placed = [ + (layout["chip_size"][0] / 2 if x == "chips" else x, + layout["chip_top"] if y == "chips" else y) + for x, y in lower + ] + dashed( + canvas, + rounded_path([(x * width, y * height) for x, y in placed], radius), + stroke, on, off, + ) + + phone(canvas, shot, layout) + chips(canvas, screen["chips"], spec["chips"], layout) + headline(canvas, copy(screen, locale), layout) + + return canvas.convert("RGB") + + +def copy(screen, locale): + """This screen's two lines in that language, or the English if it has none.""" + lines = screen["headline"].get(locale) or screen["headline"][store.FALLBACK] + + return lines + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Frame the captured App Store screenshots.") + parser.add_argument("--captured", metavar="DIR", default=CAPTURED, + help=f"where the capture run wrote (default {CAPTURED.relative_to(ROOT)})") + parser.add_argument("--framed", metavar="DIR", default=FRAMED, + help=f"where to write the framed set (default {FRAMED.relative_to(ROOT)})") + parser.add_argument("--locale", action="append", + help="only this locale, repeatable; default is everything captured") + args = parser.parse_args(argv) + + spec = design() + screens = {screen["name"]: screen for screen in spec["screens"]} + captured, framed = Path(args.captured), Path(args.framed) + + wanted = args.locale or store.languages() + written = 0 + + for locale in wanted: + folder = captured / locale + if not folder.is_dir(): + print(f"{locale}: no {folder}", file=sys.stderr) + continue + + # emptied rather than written over, so a screen that was renamed does + # not leave yesterday's picture behind for the release to find + out = framed / locale + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True, exist_ok=True) + + for path in sorted(folder.glob("*.png")): + name = next((n for n in screens if path.stem.endswith(n)), None) + if name is None: + continue + + with Image.open(path) as shot: + # a capture of a device the store does not ask for is left where + # it is; store_screenshots.py is what says the set is wrong + if store.device(*shot.size) is None: + print(f"{locale}: skipping {path.name}, {shot.width}x{shot.height} is no " + "size the store takes", file=sys.stderr) + continue + + picture = frame( + shot.convert("RGB"), screens[name], locale, spec, order=list(screens).index(name) + ) + + picture.save(out / path.name) + written += 1 + + print(f"framed {written} screenshots into {framed}") + + return 0 if written else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make-screenshot-documents.py b/scripts/make-screenshot-documents.py new file mode 100755 index 0000000..2ae07cd --- /dev/null +++ b/scripts/make-screenshot-documents.py @@ -0,0 +1,1560 @@ +#!/usr/bin/env python3 +"""Builds the documents the App Store screenshots are taken of. + +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. +""" + +import argparse +import json +import re +import unicodedata +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +# 1980-01-01, what zip stores when it is given nothing: a rerun with the same +# words has to produce the same bytes, or every run is a commit +EPOCH = (1980, 1, 1, 0, 0, 0) + +SAMPLES = Path(__file__).resolve().parent.parent / "OpenDocumentReader" / "Samples" + +NAMESPACES = " ".join( + [ + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"', + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"', + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"', + 'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"', + 'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"', + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"', + 'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"', + ] +) + +MANIFEST = """ + + + + + +""" + +# 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. +PAGE_LAYOUTS = { + "document": '', + "slide": '', +} + +# One accent, used for the report's headings and the slide titles, so the three +# documents read as one set. Blue, because the app's own tint is. +ACCENT = "#1c6fd6" +RULE = "#d4d9e0" + + +def styles(kind: str) -> str: + return f""" + + + + + {PAGE_LAYOUTS[kind]} + + + + + + +""" + + +def content(body: str, automatic: str = "") -> str: + return f""" + + +{automatic} + + +{body} + + +""" + + +def paragraph_style(name: str, *, size: str, weight: str = "normal", colour: str = "#1a1a1a", space: str = "0.4cm") -> str: + return f""" + + + """ + + +def report(words: dict) -> str: + """A page of text: a title, a lead, two headed sections and a closing line.""" + automatic = "\n".join( + [ + paragraph_style("Title", size="26pt", weight="bold", space="0.8cm"), + paragraph_style("Heading", size="16pt", weight="bold", colour=ACCENT, space="0.3cm"), + paragraph_style("Body", size="12pt", space="0.5cm"), + ] + ) + + lines = [ + f' {escape(words["title"])}', + f' {escape(words["lead"])}', + ] + for heading, paragraphs in words["sections"]: + lines.append( + f' {escape(heading)}' + ) + lines += [f' {escape(text)}' for text in paragraphs] + lines.append(f' {escape(words["closing"])}') + + return content(" \n" + "\n".join(lines) + "\n ", automatic) + + +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. + """ + periods = words["periods"][:columns] + columns = len(periods) + taken = FIGURES[:rows] if rows else FIGURES + names = words["rows"][: len(taken)] + + head = [words["item"]] + periods + [words["total"]] + body = [ + [name] + [value * scale for value in figures[:columns]] + [sum(figures[:columns]) * scale] + for name, figures in zip(names, taken) + ] + foot = ( + [words["total"]] + + [sum(line[column + 1] for line in body) for column in range(len(periods))] + + [sum(line[-1] for line in body)] + ) + + return head, body, foot + + +def sheet(words: dict) -> str: + """Two sheets, so the tab bar under the document has something to show.""" + automatic = "\n".join( + [ + # two widths: eight at the label's width put half the sheet off + # the right edge, and a figure needs less room than its row's name + """ + + """, + """ + + """, + """ + + + """ % (RULE, ACCENT), + """ + + + """ % (RULE,), + ] + ) + + def row(cells: list, style: str | None = None) -> str: + marked = f' table:style-name="{style}"' if style else "" + out = [" "] + for cell in cells: + if isinstance(cell, int): + out.append( + f' ' + f"{cell}" + ) + else: + out.append( + f' ' + f"{escape(cell)}" + ) + out.append(" ") + + return "\n".join(out) + + head, body, foot = table(words, columns=6) + + overview = [row(head, "ceHead")] + [row(line) for line in body] + [row(foot, "ceTotal")] + costs = [row([words["item"], words["total"]], "ceHead")] + costs += [row([line[0], line[-1]]) for line in body] + + tables = [] + for name, rows, columns in ( + (words["sheets"][0], overview, len(head)), + (words["sheets"][1], costs, 2), + ): + # the label column, then a figure column for each of the rest + marks = "\n".join( + [' '] + + [' '] * (columns - 1) + ) + tables.append( + f' \n{marks}\n' + + "\n".join(rows) + + "\n " + ) + + return content(" \n" + "\n".join(tables) + "\n ", automatic) + + +def deck(words: dict) -> str: + """Three slides, each with a title and its bullets.""" + automatic = "\n".join( + [ + paragraph_style("SlideTitle", size="32pt", weight="bold", colour=ACCENT, space="0.6cm"), + paragraph_style("Bullet", size="18pt", space="0.35cm"), + ] + ) + + pages = [] + for title, bullets in words["slides"]: + lines = [f' {escape(title)}'] + lines += [ + f' \u2022 {escape(point)}' for point in bullets + ] + pages.append( + f' \n' + ' \n' + " \n" + "\n".join(lines) + "\n \n" + " \n " + ) + + return content(" \n" + "\n".join(pages) + "\n ", automatic) + + +# Short and plain on purpose: this is a document over someone's shoulder in a +# store screenshot, not copy that has to sell anything. +WORDS = { + "en": { + "title": "Quarterly report", + "lead": "The team met every goal of the second quarter, and the new release went out on time.", + "sections": [ + ["Highlights", [ + "Costs stayed below budget, and two new partners joined the project.", + "The new release reached more people in its first week than the last one did in a month.", + "Support answered nine of ten questions the same day.",]], + ["Costs and budget", [ + "Spending on software rose with the new licences, while travel fell again.", + "Hardware was replaced once, and support stayed steady through the quarter.", + "Two servers moved to the new provider without a day of downtime.",]], + ["Next quarter", [ + "The release in September is the last one planned this year.", + "Two positions open in support, and one in design.", + "The office moves in November, and the budget for it is agreed.", + ]], + ["The people", [ + "Six people worked on the release, two of them new this year.", + "Holiday cover was arranged in April and held through the summer.", + "Everyone has taken the training the new licence requires.", + ]], + ["Risks", [ + "The move in November is the one date nothing else can slip past.", + "One supplier has not signed the new terms, and is being chased.", + "Hosting costs rise in January unless the contract is renewed early.", + ]], + ], + "closing": "The next meeting is at the end of July.", + "sheets": ["Overview", "Costs"], + "item": "Item", + "total": "Total", + "periods": ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + "rows": ["Software", "Travel", "Hardware", "Marketing", "Support", "Training", "Licences", "Hosting", "Events", "Office", "Cloud", "Recruiting", "Legal", "Insurance", "Catering", "Shipping", "Advertising", "Consulting", "Maintenance", "Utilities", "Equipment", "Subscriptions", "Telephony", "Internet", "Security", "Backups", "Domains", "Certificates", "Printing", "Stationery", "Postage", "Cleaning", "Repairs", "Furniture", "Storage", "Bank fees", "Memberships", "Conferences", "Translation", "Design"], + "slides": [ + ["Project plan", ["Goals for the quarter", "Budget and costs", "Next steps"]], + ["Schedule", ["Release in June", "Review in July", "Planning in August"]], + ["Team", ["Two new partners", "Support in three languages", "Training in autumn"]], + ], + }, + "de": { + "title": "Quartalsbericht", + "lead": "Das Team hat alle Ziele des zweiten Quartals erreicht, und die neue Version ist p\u00fcnktlich erschienen.", + "sections": [ + ["Das Wichtigste", [ + "Die Kosten blieben unter dem Budget, und zwei neue Partner sind zum Projekt gesto\u00dfen.", + "Die neue Version erreichte in der ersten Woche mehr Menschen als die letzte in einem Monat.", + "Der Support beantwortete neun von zehn Anfragen noch am selben Tag.",]], + ["Kosten und Budget", [ + "Die Ausgaben f\u00fcr Software stiegen mit den neuen Lizenzen, die Reisekosten sanken erneut.", + "Die Hardware wurde einmal ersetzt, der Support blieb das ganze Quartal \u00fcber stabil.", + "Zwei Server sind ohne einen Tag Ausfall zum neuen Anbieter umgezogen.",]], + ["N\xe4chstes Quartal", [ + "Die Version im September ist die letzte f\xfcr dieses Jahr.", + "Zwei Stellen im Support sind offen, eine im Design.", + "Der Umzug ins neue B\xfcro ist f\xfcr November geplant und budgetiert.", + ]], + ["Das Team", [ + "An der Version arbeiteten sechs Personen, zwei davon neu in diesem Jahr.", + "Die Urlaubsvertretung wurde im April geregelt und hat den ganzen Sommer über gehalten.", + "Alle haben die Schulung absolviert, die die neue Lizenz verlangt.", + ]], + ["Risiken", [ + "Der Umzug im November ist der einzige Termin, der sich nicht verschieben lässt.", + "Ein Lieferant hat die neuen Bedingungen noch nicht unterschrieben; wir haken nach.", + "Die Hostingkosten steigen im Januar, wenn der Vertrag nicht vorzeitig verl\xe4ngert wird.", + ]], + ], + "closing": "Das n\u00e4chste Treffen findet Ende Juli statt.", + "sheets": ["\u00dcbersicht", "Kosten"], + "item": "Position", + "total": "Gesamt", + "periods": ["Jan", "Feb", "M\u00e4r", "Apr", "Mai", "Jun"], + "rows": ["Software", "Reisen", "Hardware", "Marketing", "Support", "Schulung", "Lizenzen", "Hosting", "Veranstaltungen", "B\u00fcro", "Cloud", "Personalsuche", "Recht", "Versicherung", "Verpflegung", "Versand", "Werbung", "Beratung", "Wartung", "Nebenkosten", "Ausstattung", "Abonnements", "Telefonie", "Internet", "Sicherheit", "Backups", "Domains", "Zertifikate", "Druck", "B\u00fcromaterial", "Porto", "Reinigung", "Reparaturen", "M\u00f6bel", "Lager", "Bankgeb\u00fchren", "Mitgliedschaften", "Konferenzen", "\u00dcbersetzung", "Design"], + "slides": [ + ["Projektplan", ["Ziele f\u00fcr das Quartal", "Budget und Kosten", "N\u00e4chste Schritte"]], + ["Zeitplan", ["Version im Juni", "R\u00fcckblick im Juli", "Planung im August"]], + ["Team", ["Zwei neue Partner", "Support in drei Sprachen", "Schulung im Herbst"]], + ], + }, + "es": { + "title": "Informe trimestral", + "lead": "El equipo cumpli\u00f3 todos los objetivos del segundo trimestre y la nueva versi\u00f3n sali\u00f3 a tiempo.", + "sections": [ + ["Lo m\u00e1s destacado", [ + "Los costes se mantuvieron por debajo del presupuesto y dos nuevos socios se unieron al proyecto.", + "La nueva versi\u00f3n lleg\u00f3 a m\u00e1s gente en su primera semana que la anterior en un mes.", + "El soporte respondi\xf3 nueve de cada diez consultas el mismo d\xeda.",]], + ["Costes y presupuesto", [ + "El gasto en software subi\u00f3 con las nuevas licencias, mientras que los viajes volvieron a bajar.", + "El hardware se sustituy\u00f3 una vez y el soporte se mantuvo estable durante el trimestre.", + "Dos servidores pasaron al nuevo proveedor sin una sola interrupci\xf3n del servicio.",]], + ["Pr\xf3ximo trimestre", [ + "La versi\xf3n de septiembre es la \xfaltima prevista este a\xf1o.", + "Hay dos vacantes en soporte y una en dise\xf1o.", + "La mudanza de oficina es en noviembre y ya tiene presupuesto.", + ]], + ["Las personas", [ + "En la versi\xf3n trabajaron seis personas, dos de ellas nuevas este a\xf1o.", + "La cobertura de vacaciones se organiz\xf3 en abril y aguant\xf3 todo el verano.", + "Todos han hecho la formaci\xf3n que exige la nueva licencia.", + ]], + ["Riesgos", [ + "La mudanza de noviembre es la \xfanica fecha que no puede moverse.", + "Un proveedor a\xfan no ha firmado las nuevas condiciones y se le est\xe1 reclamando.", + "El alojamiento sube en enero si no se renueva antes el contrato.", + ]], + ], + "closing": "La pr\u00f3xima reuni\u00f3n es a finales de julio.", + "sheets": ["Resumen", "Costes"], + "item": "Concepto", + "total": "Total", + "periods": ["Ene", "Feb", "Mar", "Abr", "May", "Jun"], + "rows": ["Software", "Viajes", "Hardware", "Marketing", "Soporte", "Formaci\u00f3n", "Licencias", "Alojamiento", "Eventos", "Oficina", "Nube", "Contrataci\u00f3n", "Legal", "Seguros", "Catering", "Env\u00edos", "Publicidad", "Consultor\u00eda", "Mantenimiento", "Suministros", "Equipamiento", "Suscripciones", "Telefon\u00eda", "Internet", "Seguridad", "Copias de seguridad", "Dominios", "Certificados", "Impresi\u00f3n", "Papeler\u00eda", "Franqueo", "Limpieza", "Reparaciones", "Mobiliario", "Almacenamiento", "Comisiones bancarias", "Cuotas", "Congresos", "Traducci\u00f3n", "Dise\u00f1o"], + "slides": [ + ["Plan del proyecto", ["Objetivos del trimestre", "Presupuesto y costes", "Pr\u00f3ximos pasos"]], + ["Calendario", ["Versi\u00f3n en junio", "Revisi\u00f3n en julio", "Planificaci\u00f3n en agosto"]], + ["Equipo", ["Dos nuevos socios", "Soporte en tres idiomas", "Formaci\u00f3n en oto\u00f1o"]], + ], + }, + "fr": { + "title": "Rapport trimestriel", + "lead": "L'\u00e9quipe a atteint tous les objectifs du deuxi\u00e8me trimestre et la nouvelle version est sortie \u00e0 temps.", + "sections": [ + ["Points forts", [ + "Les co\u00fbts sont rest\u00e9s dans le budget et deux nouveaux partenaires ont rejoint le projet.", + "La nouvelle version a touch\u00e9 plus de monde en une semaine que la pr\u00e9c\u00e9dente en un mois.", + "Le support a r\xe9pondu \xe0 neuf demandes sur dix le jour m\xeame.",]], + ["Co\u00fbts et budget", [ + "Les d\u00e9penses en logiciels ont augment\u00e9 avec les nouvelles licences, tandis que les d\u00e9placements ont encore baiss\u00e9.", + "Le mat\u00e9riel a \u00e9t\u00e9 remplac\u00e9 une fois et le support est rest\u00e9 stable sur le trimestre.", + "Deux serveurs ont migr\xe9 vers le nouveau prestataire sans la moindre interruption de service.",]], + ["Trimestre prochain", [ + "La version de septembre est la derni\xe8re pr\xe9vue cette ann\xe9e.", + "Deux postes sont ouverts au support, un au design.", + "Le d\xe9m\xe9nagement est pr\xe9vu en novembre, et le budget est valid\xe9.", + ]], + ["Les personnes", [ + "Six personnes ont travaill\xe9 sur la version, dont deux arriv\xe9es cette ann\xe9e.", + "Les remplacements pour les cong\xe9s ont \xe9t\xe9 organis\xe9s en avril et ont tenu tout l'\xe9t\xe9.", + "Tout le monde a suivi la formation qu'exige la nouvelle licence.", + ]], + ["Risques", [ + "Le d\xe9m\xe9nagement de novembre est la seule date qui ne peut pas bouger.", + "Un prestataire n'a pas encore sign\xe9 les nouvelles conditions.", + "Le co\xfbt de l'h\xe9bergement augmente en janvier sans renouvellement anticip\xe9.", + ]], + ], + "closing": "La prochaine r\u00e9union aura lieu fin juillet.", + "sheets": ["Aper\u00e7u", "Co\u00fbts"], + "item": "Poste", + "total": "Total", + "periods": ["Janv.", "F\u00e9vr.", "Mars", "Avr.", "Mai", "Juin"], + "rows": ["Logiciels", "D\u00e9placements", "Mat\u00e9riel", "Marketing", "Support", "Formation", "Licences", "H\u00e9bergement", "\u00c9v\u00e9nements", "Bureau", "Cloud", "Recrutement", "Juridique", "Assurance", "Traiteur", "Exp\u00e9dition", "Publicit\u00e9", "Conseil", "Maintenance", "Charges", "\u00c9quipement", "Abonnements", "T\u00e9l\u00e9phonie", "Internet", "S\u00e9curit\u00e9", "Sauvegardes", "Domaines", "Certificats", "Impression", "Fournitures", "Affranchissement", "Nettoyage", "R\u00e9parations", "Mobilier", "Stockage", "Frais bancaires", "Cotisations", "Conf\u00e9rences", "Traduction", "Design"], + "slides": [ + ["Plan du projet", ["Objectifs du trimestre", "Budget et co\u00fbts", "Prochaines \u00e9tapes"]], + ["Calendrier", ["Version en juin", "Bilan en juillet", "Planification en ao\u00fbt"]], + ["\u00c9quipe", ["Deux nouveaux partenaires", "Support en trois langues", "Formation \u00e0 l'automne"]], + ], + }, + "it": { + "title": "Relazione trimestrale", + "lead": "Il team ha raggiunto tutti gli obiettivi del secondo trimestre e la nuova versione \u00e8 uscita in tempo.", + "sections": [ + ["In evidenza", [ + "I costi sono rimasti sotto il budget e due nuovi partner si sono uniti al progetto.", + "La nuova versione ha raggiunto in una settimana pi\u00f9 persone di quante ne avesse raggiunte la precedente in un mese.", + "Il supporto ha risposto a nove richieste su dieci in giornata.",]], + ["Costi e budget", [ + "La spesa per il software \u00e8 cresciuta con le nuove licenze, mentre quella per i viaggi è di nuovo calata.", + "L'hardware \u00e8 stato sostituito una volta e il supporto \u00e8 rimasto stabile per tutto il trimestre.", + "Due server sono passati al nuovo fornitore senza un giorno di fermo.",]], + ["Prossimo trimestre", [ + "La versione di settembre \xe8 l'ultima prevista quest'anno.", + "Ci sono due posizioni aperte nel supporto e una nel design.", + "Il trasloco \xe8 a novembre e il budget \xe8 approvato.", + ]], + ["Le persone", [ + "Alla versione hanno lavorato sei persone, due delle quali nuove quest'anno.", + "Le sostituzioni estive sono state organizzate ad aprile e hanno retto.", + "Tutti hanno seguito il corso richiesto dalla nuova licenza.", + ]], + ["Rischi", [ + "Il trasloco di novembre \xe8 l'unica data che non pu\xf2 slittare.", + "Un fornitore non ha ancora firmato le nuove condizioni.", + "I costi di hosting aumentano a gennaio se il contratto non viene rinnovato in anticipo.", + ]], + ], + "closing": "Il prossimo incontro \u00e8 a fine luglio.", + "sheets": ["Panoramica", "Costi"], + "item": "Voce", + "total": "Totale", + "periods": ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu"], + "rows": ["Software", "Viaggi", "Hardware", "Marketing", "Supporto", "Formazione", "Licenze", "Hosting", "Eventi", "Ufficio", "Cloud", "Selezione", "Legale", "Assicurazione", "Catering", "Spedizioni", "Pubblicit\u00e0", "Consulenza", "Manutenzione", "Utenze", "Attrezzature", "Abbonamenti", "Telefonia", "Internet", "Sicurezza", "Backup", "Domini", "Certificati", "Stampa", "Cancelleria", "Affrancature", "Pulizie", "Riparazioni", "Arredi", "Archiviazione", "Spese bancarie", "Quote associative", "Conferenze", "Traduzioni", "Design"], + "slides": [ + ["Piano di progetto", ["Obiettivi del trimestre", "Budget e costi", "Prossimi passi"]], + ["Calendario", ["Versione a giugno", "Revisione a luglio", "Pianificazione ad agosto"]], + ["Team", ["Due nuovi partner", "Supporto in tre lingue", "Formazione in autunno"]], + ], + }, + "pl": { + "title": "Raport kwartalny", + "lead": "Zesp\u00f3\u0142 osi\u0105gn\u0105\u0142 wszystkie cele drugiego kwarta\u0142u, a nowa wersja ukaza\u0142a si\u0119 na czas.", + "sections": [ + ["Najwa\u017cniejsze", [ + "Koszty pozosta\u0142y poni\u017cej bud\u017cetu, a do projektu do\u0142\u0105czy\u0142o dw\u00f3ch nowych partner\u00f3w.", + "Nowa wersja dotar\u0142a w pierwszym tygodniu do wi\u0119kszej liczby os\u00f3b ni\u017c poprzednia w miesi\u0105c.", + "Wsparcie odpowiedzia\u0142o na dziewi\u0119\u0107 z dziesi\u0119ciu zg\u0142osze\u0144 tego samego dnia.",]], + ["Koszty i bud\u017cet", [ + "Wydatki na oprogramowanie wzros\u0142y wraz z nowymi licencjami, a koszty podr\u00f3\u017cy zn\u00f3w spad\u0142y.", + "Sprz\u0119t wymieniono raz, a wsparcie by\u0142o stabilne przez ca\u0142y kwarta\u0142.", + "Dwa serwery przeniesiono do nowego dostawcy bez ani jednego dnia przestoju.",]], + ["Nast\u0119pny kwarta\u0142", [ + "Wersja z wrze\u015bnia jest ostatni\u0105 zaplanowan\u0105 w tym roku.", + "Otwarte s\u0105 dwa etaty we wsparciu i jeden w dziale projektowym.", + "Przeprowadzka biura wypada w listopadzie i ma ju\u017c bud\u017cet.", + ]], + ["Ludzie", [ + "Nad wersj\u0105 pracowa\u0142o sze\u015b\u0107 os\xf3b, z czego dwie do\u0142\u0105czy\u0142y w tym roku.", + "Zast\u0119pstwa urlopowe ustalono w kwietniu i utrzyma\u0142y si\u0119 przez ca\u0142e lato.", + "Wszyscy przeszli szkolenie wymagane przez now\u0105 licencj\u0119.", + ]], + ["Ryzyka", [ + "Przeprowadzka w listopadzie to jedyny termin, kt\xf3ry nie mo\u017ce si\u0119 przesun\u0105\u0107.", + "Jeden dostawca nie podpisa\u0142 jeszcze nowych warunk\xf3w i jest ponaglany.", + "Koszty hostingu wzrosn\u0105 w styczniu, je\u015bli umowa nie zostanie odnowiona wcze\u015bniej.", + ]], + ], + "closing": "Nast\u0119pne spotkanie odb\u0119dzie si\u0119 pod koniec lipca.", + "sheets": ["Przegl\u0105d", "Koszty"], + "item": "Pozycja", + "total": "Razem", + "periods": ["sty", "lut", "mar", "kwi", "maj", "cze"], + "rows": ["Oprogramowanie", "Podr\u00f3\u017ce", "Sprz\u0119t", "Marketing", "Wsparcie", "Szkolenia", "Licencje", "Hosting", "Wydarzenia", "Biuro", "Chmura", "Rekrutacja", "Prawo", "Ubezpieczenie", "Catering", "Wysy\u0142ka", "Reklama", "Doradztwo", "Utrzymanie", "Media", "Wyposa\u017cenie", "Subskrypcje", "Telefonia", "Internet", "Bezpiecze\u0144stwo", "Kopie zapasowe", "Domeny", "Certyfikaty", "Druk", "Artyku\u0142y biurowe", "Op\u0142aty pocztowe", "Sprz\u0105tanie", "Naprawy", "Meble", "Magazyn", "Op\u0142aty bankowe", "Sk\u0142adki cz\u0142onkowskie", "Konferencje", "T\u0142umaczenia", "Projektowanie"], + "slides": [ + ["Plan projektu", ["Cele na kwarta\u0142", "Bud\u017cet i koszty", "Kolejne kroki"]], + ["Harmonogram", ["Wersja w czerwcu", "Podsumowanie w lipcu", "Planowanie w sierpniu"]], + ["Zesp\u00f3\u0142", ["Dw\u00f3ch nowych partner\u00f3w", "Wsparcie w trzech j\u0119zykach", "Szkolenia jesieni\u0105"]], + ], + }, + "pt-BR": { + "title": "Relat\u00f3rio trimestral", + "lead": "A equipe alcan\u00e7ou todas as metas do segundo trimestre e a nova vers\u00e3o saiu no prazo.", + "sections": [ + ["Destaques", [ + "Os custos ficaram abaixo do or\u00e7amento e dois novos parceiros entraram no projeto.", + "A nova vers\u00e3o alcan\u00e7ou mais pessoas na primeira semana do que a anterior em um m\u00eas.", + "O suporte respondeu nove de cada dez chamados no mesmo dia.",]], + ["Custos e or\u00e7amento", [ + "Os gastos com software subiram com as novas licen\u00e7as, enquanto as viagens ca\u00edram de novo.", + "O hardware foi substitu\u00eddo uma vez e o suporte se manteve est\u00e1vel no trimestre.", + "Dois servidores migraram para o novo provedor sem um dia de indisponibilidade.",]], + ["Pr\xf3ximo trimestre", [ + "A vers\xe3o de setembro \xe9 a \xfaltima prevista para este ano.", + "H\xe1 duas vagas no suporte e uma no design.", + "A mudan\xe7a de escrit\xf3rio \xe9 em novembro e j\xe1 tem or\xe7amento.", + ]], + ["As pessoas", [ + "Seis pessoas trabalharam na vers\xe3o, duas delas novas este ano.", + "A escala de f\xe9rias foi definida em abril e valeu o ver\xe3o todo.", + "Todos fizeram o treinamento que a nova licen\xe7a exige.", + ]], + ["Riscos", [ + "A mudan\xe7a de novembro \xe9 a \xfanica data que n\xe3o pode atrasar.", + "Um fornecedor ainda n\xe3o assinou as novas condi\xe7\xf5es.", + "A hospedagem sobe em janeiro se o contrato n\xe3o for renovado antes.", + ]], + ], + "closing": "A pr\u00f3xima reuni\u00e3o \u00e9 no fim de julho.", + "sheets": ["Vis\u00e3o geral", "Custos"], + "item": "Item", + "total": "Total", + "periods": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun"], + "rows": ["Software", "Viagens", "Hardware", "Marketing", "Suporte", "Treinamento", "Licen\u00e7as", "Hospedagem", "Eventos", "Escrit\u00f3rio", "Nuvem", "Recrutamento", "Jur\u00eddico", "Seguros", "Buffet", "Frete", "Publicidade", "Consultoria", "Manuten\u00e7\u00e3o", "\u00c1gua e luz", "Equipamentos", "Assinaturas", "Telefonia", "Internet", "Seguran\u00e7a", "Backups", "Dom\u00ednios", "Certificados", "Impress\u00e3o", "Papelaria", "Correios", "Limpeza", "Reparos", "M\u00f3veis", "Armazenamento", "Tarifas banc\u00e1rias", "Associa\u00e7\u00f5es", "Confer\u00eancias", "Tradu\u00e7\u00e3o", "Design"], + "slides": [ + ["Plano do projeto", ["Metas do trimestre", "Or\u00e7amento e custos", "Pr\u00f3ximos passos"]], + ["Cronograma", ["Vers\u00e3o em junho", "Revis\u00e3o em julho", "Planejamento em agosto"]], + ["Equipe", ["Dois novos parceiros", "Suporte em tr\u00eas idiomas", "Treinamento no outono"]], + ], + }, + "ru": { + "title": "\u041a\u0432\u0430\u0440\u0442\u0430\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0447\u0451\u0442", + "lead": "\u041a\u043e\u043c\u0430\u043d\u0434\u0430 \u0434\u043e\u0441\u0442\u0438\u0433\u043b\u0430 \u0432\u0441\u0435\u0445 \u0446\u0435\u043b\u0435\u0439 \u0432\u0442\u043e\u0440\u043e\u0433\u043e \u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430, \u0438 \u043d\u043e\u0432\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f \u0432\u044b\u0448\u043b\u0430 \u0432 \u0441\u0440\u043e\u043a.", + "sections": [ + ["\u0413\u043b\u0430\u0432\u043d\u043e\u0435", [ + "\u0420\u0430\u0441\u0445\u043e\u0434\u044b \u043e\u0441\u0442\u0430\u043b\u0438\u0441\u044c \u0432 \u0440\u0430\u043c\u043a\u0430\u0445 \u0431\u044e\u0434\u0436\u0435\u0442\u0430, \u0430 \u043a \u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u043f\u0440\u0438\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u043b\u0438\u0441\u044c \u0434\u0432\u0430 \u043d\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u0442\u043d\u0451\u0440\u0430.", + "\u0417\u0430 \u043f\u0435\u0440\u0432\u0443\u044e \u043d\u0435\u0434\u0435\u043b\u044e \u043d\u043e\u0432\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f \u043e\u0445\u0432\u0430\u0442\u0438\u043b\u0430 \u0431\u043e\u043b\u044c\u0448\u0435 \u043b\u044e\u0434\u0435\u0439, \u0447\u0435\u043c \u043f\u0440\u0435\u0434\u044b\u0434\u0443\u0449\u0430\u044f \u0437\u0430 \u043c\u0435\u0441\u044f\u0446.", + "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u043e\u0442\u0432\u0435\u0442\u0438\u043b\u0430 \u043d\u0430 \u0434\u0435\u0432\u044f\u0442\u044c \u0438\u0437 \u0434\u0435\u0441\u044f\u0442\u0438 \u043e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0439 \u0432 \u0442\u043e\u0442 \u0436\u0435 \u0434\u0435\u043d\u044c.",]], + ["\u0420\u0430\u0441\u0445\u043e\u0434\u044b \u0438 \u0431\u044e\u0434\u0436\u0435\u0442", [ + "\u0417\u0430\u0442\u0440\u0430\u0442\u044b \u043d\u0430 \u041f\u041e \u0432\u044b\u0440\u043e\u0441\u043b\u0438 \u0438\u0437-\u0437\u0430 \u043d\u043e\u0432\u044b\u0445 \u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0439, \u0430 \u0440\u0430\u0441\u0445\u043e\u0434\u044b \u043d\u0430 \u043f\u043e\u0435\u0437\u0434\u043a\u0438 \u0441\u043d\u043e\u0432\u0430 \u0441\u043d\u0438\u0437\u0438\u043b\u0438\u0441\u044c.", + "\u041e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u0435 \u043c\u0435\u043d\u044f\u043b\u0438 \u043e\u0434\u0438\u043d \u0440\u0430\u0437, \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0430 \u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u043e \u0432\u0435\u0441\u044c \u043a\u0432\u0430\u0440\u0442\u0430\u043b.", + "\u0414\u0432\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u0435\u0440\u0435\u0435\u0445\u0430\u043b\u0438 \u043a \u043d\u043e\u0432\u043e\u043c\u0443 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u0443 \u0431\u0435\u0437 \u0435\u0434\u0438\u043d\u043e\u0433\u043e \u0434\u043d\u044f \u043f\u0440\u043e\u0441\u0442\u043e\u044f.",]], + ["\u0421\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0439 \u043a\u0432\u0430\u0440\u0442\u0430\u043b", [ + "\u0412\u0435\u0440\u0441\u0438\u044f \u0432 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u0435 \u2014 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u0438\u0437 \u0437\u0430\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u0432 \u044d\u0442\u043e\u043c \u0433\u043e\u0434\u0443.", + "\u041e\u0442\u043a\u0440\u044b\u0442\u044b \u0434\u0432\u0435 \u0432\u0430\u043a\u0430\u043d\u0441\u0438\u0438 \u0432 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0435 \u0438 \u043e\u0434\u043d\u0430 \u0432 \u0434\u0438\u0437\u0430\u0439\u043d\u0435.", + "\u041f\u0435\u0440\u0435\u0435\u0437\u0434 \u043e\u0444\u0438\u0441\u0430 \u043d\u0430\u043c\u0435\u0447\u0435\u043d \u043d\u0430 \u043d\u043e\u044f\u0431\u0440\u044c, \u0431\u044e\u0434\u0436\u0435\u0442 \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d.", + ]], + ["\u041b\u044e\u0434\u0438", [ + "\u041d\u0430\u0434 \u0432\u0435\u0440\u0441\u0438\u0435\u0439 \u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438 \u0448\u0435\u0441\u0442\u044c \u0447\u0435\u043b\u043e\u0432\u0435\u043a, \u0434\u0432\u043e\u0435 \u0438\u0437 \u043d\u0438\u0445 \u043f\u0440\u0438\u0448\u043b\u0438 \u0432 \u044d\u0442\u043e\u043c \u0433\u043e\u0434\u0443.", + "\u0417\u0430\u043c\u0435\u043d\u044b \u043d\u0430 \u0432\u0440\u0435\u043c\u044f \u043e\u0442\u043f\u0443\u0441\u043a\u043e\u0432 \u0441\u043e\u0433\u043b\u0430\u0441\u043e\u0432\u0430\u043b\u0438 \u0432 \u0430\u043f\u0440\u0435\u043b\u0435, \u0438 \u0433\u0440\u0430\u0444\u0438\u043a \u043f\u0440\u043e\u0434\u0435\u0440\u0436\u0430\u043b\u0441\u044f \u0432\u0441\u0451 \u043b\u0435\u0442\u043e.", + "\u0412\u0441\u0435 \u043f\u0440\u043e\u0448\u043b\u0438 \u043e\u0431\u0443\u0447\u0435\u043d\u0438\u0435, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u043d\u043e\u0432\u0430\u044f \u043b\u0438\u0446\u0435\u043d\u0437\u0438\u044f.", + ]], + ["\u0420\u0438\u0441\u043a\u0438", [ + "\u041f\u0435\u0440\u0435\u0435\u0437\u0434 \u0432 \u043d\u043e\u044f\u0431\u0440\u0435 \u2014 \u0435\u0434\u0438\u043d\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u0434\u0430\u0442\u0430, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043b\u044c\u0437\u044f \u0441\u0434\u0432\u0438\u043d\u0443\u0442\u044c.", + "\u041e\u0434\u0438\u043d \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0442\u0430\u043a \u0438 \u043d\u0435 \u043f\u043e\u0434\u043f\u0438\u0441\u0430\u043b \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u043b\u043e\u0432\u0438\u044f, \u0435\u043c\u0443 \u043d\u0430\u043f\u043e\u043c\u0438\u043d\u0430\u044e\u0442.", + "\u0425\u043e\u0441\u0442\u0438\u043d\u0433 \u043f\u043e\u0434\u043e\u0440\u043e\u0436\u0430\u0435\u0442 \u0432 \u044f\u043d\u0432\u0430\u0440\u0435, \u0435\u0441\u043b\u0438 \u043d\u0435 \u043f\u0440\u043e\u0434\u043b\u0438\u0442\u044c \u0434\u043e\u0433\u043e\u0432\u043e\u0440 \u0437\u0430\u0440\u0430\u043d\u0435\u0435.", + ]], + ], + "closing": "\u0421\u043b\u0435\u0434\u0443\u044e\u0449\u0430\u044f \u0432\u0441\u0442\u0440\u0435\u0447\u0430 \u2014 \u0432 \u043a\u043e\u043d\u0446\u0435 \u0438\u044e\u043b\u044f.", + "sheets": ["\u041e\u0431\u0437\u043e\u0440", "\u0420\u0430\u0441\u0445\u043e\u0434\u044b"], + "item": "\u0421\u0442\u0430\u0442\u044c\u044f", + "total": "\u0418\u0442\u043e\u0433\u043e", + "periods": ["\u042f\u043d\u0432.", "\u0424\u0435\u0432\u0440.", "\u041c\u0430\u0440\u0442", "\u0410\u043f\u0440.", "\u041c\u0430\u0439", "\u0418\u044e\u043d\u044c"], + "rows": ["\u041f\u041e", "\u041f\u043e\u0435\u0437\u0434\u043a\u0438", "\u041e\u0431\u043e\u0440\u0443\u0434\u043e\u0432\u0430\u043d\u0438\u0435", "\u041c\u0430\u0440\u043a\u0435\u0442\u0438\u043d\u0433", "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430", "\u041e\u0431\u0443\u0447\u0435\u043d\u0438\u0435", "\u041b\u0438\u0446\u0435\u043d\u0437\u0438\u0438", "\u0425\u043e\u0441\u0442\u0438\u043d\u0433", "\u041c\u0435\u0440\u043e\u043f\u0440\u0438\u044f\u0442\u0438\u044f", "\u041e\u0444\u0438\u0441", "\u041e\u0431\u043b\u0430\u043a\u043e", "\u041d\u0430\u0451\u043c", "\u042e\u0440\u0438\u0441\u0442\u044b", "\u0421\u0442\u0440\u0430\u0445\u043e\u0432\u0430\u043d\u0438\u0435", "\u041a\u0435\u0439\u0442\u0435\u0440\u0438\u043d\u0433", "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430", "\u0420\u0435\u043a\u043b\u0430\u043c\u0430", "\u041a\u043e\u043d\u0441\u0430\u043b\u0442\u0438\u043d\u0433", "\u041e\u0431\u0441\u043b\u0443\u0436\u0438\u0432\u0430\u043d\u0438\u0435", "\u041a\u043e\u043c\u043c\u0443\u043d\u0430\u043b\u044c\u043d\u044b\u0435 \u0443\u0441\u043b\u0443\u0433\u0438", "\u041e\u0441\u043d\u0430\u0449\u0435\u043d\u0438\u0435", "\u041f\u043e\u0434\u043f\u0438\u0441\u043a\u0438", "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u044f", "\u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442", "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", "\u0420\u0435\u0437\u0435\u0440\u0432\u043d\u043e\u0435 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435", "\u0414\u043e\u043c\u0435\u043d\u044b", "\u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b", "\u041f\u0435\u0447\u0430\u0442\u044c", "\u041a\u0430\u043d\u0446\u0442\u043e\u0432\u0430\u0440\u044b", "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0435 \u0440\u0430\u0441\u0445\u043e\u0434\u044b", "\u0423\u0431\u043e\u0440\u043a\u0430", "\u0420\u0435\u043c\u043e\u043d\u0442", "\u041c\u0435\u0431\u0435\u043b\u044c", "\u0425\u0440\u0430\u043d\u0435\u043d\u0438\u0435", "\u0411\u0430\u043d\u043a\u043e\u0432\u0441\u043a\u0438\u0435 \u043a\u043e\u043c\u0438\u0441\u0441\u0438\u0438", "\u0427\u043b\u0435\u043d\u0441\u043a\u0438\u0435 \u0432\u0437\u043d\u043e\u0441\u044b", "\u041a\u043e\u043d\u0444\u0435\u0440\u0435\u043d\u0446\u0438\u0438", "\u041f\u0435\u0440\u0435\u0432\u043e\u0434", "\u0414\u0438\u0437\u0430\u0439\u043d"], + "slides": [ + ["\u041f\u043b\u0430\u043d \u043f\u0440\u043e\u0435\u043a\u0442\u0430", ["\u0426\u0435\u043b\u0438 \u043d\u0430 \u043a\u0432\u0430\u0440\u0442\u0430\u043b", "\u0411\u044e\u0434\u0436\u0435\u0442 \u0438 \u0440\u0430\u0441\u0445\u043e\u0434\u044b", "\u0421\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u0448\u0430\u0433\u0438"]], + ["\u0413\u0440\u0430\u0444\u0438\u043a", ["\u0412\u0435\u0440\u0441\u0438\u044f \u0432 \u0438\u044e\u043d\u0435", "\u0418\u0442\u043e\u0433\u0438 \u0432 \u0438\u044e\u043b\u0435", "\u041f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432 \u0430\u0432\u0433\u0443\u0441\u0442\u0435"]], + ["\u041a\u043e\u043c\u0430\u043d\u0434\u0430", ["\u0414\u0432\u0430 \u043d\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u0442\u043d\u0451\u0440\u0430", "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 \u043d\u0430 \u0442\u0440\u0451\u0445 \u044f\u0437\u044b\u043a\u0430\u0445", "\u041e\u0431\u0443\u0447\u0435\u043d\u0438\u0435 \u043e\u0441\u0435\u043d\u044c\u044e"]], + ], + }, + "tr": { + "title": "\u00dc\u00e7 ayl\u0131k rapor", + "lead": "Ekip ikinci \u00e7eyre\u011fin t\u00fcm hedeflerine ula\u015ft\u0131 ve yeni s\u00fcr\u00fcm zaman\u0131nda yay\u0131nland\u0131.", + "sections": [ + ["\u00d6ne \u00e7\u0131kanlar", [ + "Maliyetler b\u00fct\u00e7enin alt\u0131nda kald\u0131 ve projeye iki yeni ortak kat\u0131ld\u0131.", + "Yeni s\u00fcr\u00fcm ilk haftas\u0131nda, \u00f6ncekinin bir ayda ula\u015ft\u0131\u011f\u0131ndan daha fazla ki\u015fiye ula\u015ft\u0131.", + "Destek, on sorudan dokuzunu ayn\u0131 g\xfcn yan\u0131tlad\u0131.",]], + ["Maliyetler ve b\u00fct\u00e7e", [ + "Yeni lisanslarla yaz\u0131l\u0131m harcamalar\u0131 artt\u0131, seyahat giderleri yeniden d\u00fc\u015ft\u00fc.", + "Donan\u0131m bir kez yenilendi ve destek \u00e7eyrek boyunca istikrarl\u0131 kald\u0131.", + "\u0130ki sunucu, bir g\xfcn bile kesinti olmadan yeni sa\u011flay\u0131c\u0131ya ta\u015f\u0131nd\u0131.",]], + ["Gelecek \xe7eyrek", [ + "Eyl\xfcldeki s\xfcr\xfcm bu y\u0131l planlanan son s\xfcr\xfcm.", + "Destekte iki, tasar\u0131mda bir pozisyon a\xe7\u0131k.", + "Ofis ta\u015f\u0131nmas\u0131 kas\u0131mda ve b\xfct\xe7esi onayland\u0131.", + ]], + ["Ekip", [ + "S\xfcr\xfcm \xfczerinde alt\u0131 ki\u015fi \xe7al\u0131\u015ft\u0131, ikisi bu y\u0131l kat\u0131ld\u0131.", + "\u0130zin d\xf6nemi vekaletleri nisanda belirlendi ve yaz boyunca sorunsuz i\u015fledi.", + "Herkes yeni lisans\u0131n gerektirdi\u011fi e\u011fitimi tamamlad\u0131.", + ]], + ["Riskler", [ + "Kas\u0131mdaki ta\u015f\u0131nma, ertelenemeyecek tek tarih.", + "Bir tedarik\xe7i yeni ko\u015fullar\u0131 hen\xfcz imzalamad\u0131, takibi s\xfcr\xfcyor.", + "S\xf6zle\u015fme erken yenilenmezse bar\u0131nd\u0131rma maliyeti ocakta artacak.", + ]], + ], + "closing": "Bir sonraki toplant\u0131 temmuz sonunda.", + "sheets": ["Genel bak\u0131\u015f", "Maliyetler"], + "item": "Kalem", + "total": "Toplam", + "periods": ["Oca", "\u015eub", "Mar", "Nis", "May", "Haz"], + "rows": ["Yaz\u0131l\u0131m", "Seyahat", "Donan\u0131m", "Pazarlama", "Destek", "E\u011fitim", "Lisanslar", "Bar\u0131nd\u0131rma", "Etkinlikler", "Ofis", "Bulut", "\u0130\u015fe al\u0131m", "Hukuk", "Sigorta", "\u0130kram", "Kargo", "Reklam", "Dan\u0131\u015fmanl\u0131k", "Bak\u0131m", "Faturalar", "Ekipman", "Abonelikler", "Telefon", "\u0130nternet", "G\u00fcvenlik", "Yedekleme", "Alan adlar\u0131", "Sertifikalar", "Bask\u0131", "K\u0131rtasiye", "Posta", "Temizlik", "Onar\u0131m", "Mobilya", "Depolama", "Banka masraflar\u0131", "\u00dcyelikler", "Konferanslar", "\u00c7eviri", "Tasar\u0131m"], + "slides": [ + ["Proje plan\u0131", ["\u00c7eyrek hedefleri", "B\u00fct\u00e7e ve maliyetler", "Sonraki ad\u0131mlar"]], + ["Takvim", ["Haziranda s\u00fcr\u00fcm", "Temmuzda de\u011ferlendirme", "A\u011fustosta planlama"]], + ["Ekip", ["\u0130ki yeni ortak", "\u00dc\u00e7 dilde destek", "Sonbaharda e\u011fitim"]], + ], + }, +} + +# 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. +FIGURES = [ + [1200, 1450, 1310, 1600, 1380, 1520], + [480, 620, 510, 470, 690, 540], + [3600, 900, 1200, 750, 830, 1150], + [820, 760, 930, 1010, 870, 940], + [540, 560, 580, 600, 610, 630], + [300, 450, 380, 520, 410, 470], + [1100, 1150, 1180, 1240, 1260, 1290], + [640, 640, 660, 680, 700, 700], + [420, 980, 350, 610, 1240, 380], + [260, 280, 270, 300, 290, 310], + [890, 910, 940, 980, 1000, 1030], + [1500, 400, 620, 350, 480, 390], + [340, 360, 350, 370, 380, 390], + [220, 220, 230, 230, 240, 240], + [180, 620, 210, 240, 190, 660], + [410, 430, 400, 450, 440, 460], + [760, 820, 690, 900, 850, 780], + [950, 480, 1100, 520, 560, 1180], + [280, 290, 300, 310, 320, 330], + [520, 540, 530, 560, 570, 590], + [1340, 1290, 1410, 1360, 1440, 1480], + [710, 730, 720, 750, 760, 780], + [190, 200, 190, 210, 200, 220], + [330, 330, 340, 340, 350, 350], + [860, 890, 1240, 910, 930, 960], + [240, 250, 260, 260, 270, 280], + [120, 130, 120, 140, 130, 150], + [460, 170, 180, 490, 180, 190], + [580, 610, 550, 640, 600, 670], + [150, 160, 170, 160, 180, 170], + [210, 230, 220, 250, 240, 260], + [390, 390, 400, 410, 410, 420], + [270, 1080, 310, 340, 290, 360], + [1620, 350, 380, 360, 400, 370], + [620, 650, 630, 680, 660, 700], + [110, 120, 110, 130, 120, 140], + [440, 450, 460, 470, 480, 490], + [980, 1020, 640, 1060, 1090, 720], + [560, 500, 590, 530, 610, 570], + [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. +FILE_NAMES = { + "en": {"text": "Quarterly report", "sheet": "Budget", "slides": "Project plan", + "word": "Contract", "cells": "Sales figures", "deck": "Team offsite", + "paper": "Invoice", "rows": "Contacts", "notes": "Notes"}, + "de": {"text": "Quartalsbericht", "sheet": "Budget", "slides": "Projektplan", + "word": "Vertrag", "cells": "Umsatzzahlen", "deck": "Teamtreffen", + "paper": "Rechnung", "rows": "Kontakte", "notes": "Notizen"}, + "es": {"text": "Informe trimestral", "sheet": "Presupuesto", "slides": "Plan del proyecto", + "word": "Contrato", "cells": "Cifras de ventas", "deck": "Jornada de equipo", + "paper": "Factura", "rows": "Contactos", "notes": "Notas"}, + "fr": {"text": "Rapport trimestriel", "sheet": "Budget", "slides": "Plan du projet", + "word": "Contrat", "cells": "Chiffres des ventes", "deck": "R\u00e9union d'\u00e9quipe", + "paper": "Facture", "rows": "Contacts", "notes": "Notes"}, + "it": {"text": "Relazione trimestrale", "sheet": "Budget", "slides": "Piano di progetto", + "word": "Contratto", "cells": "Dati di vendita", "deck": "Ritiro del team", + "paper": "Fattura", "rows": "Contatti", "notes": "Note"}, + "pl": {"text": "Raport kwartalny", "sheet": "Bud\u017cet", "slides": "Plan projektu", + "word": "Umowa", "cells": "Wyniki sprzeda\u017cy", "deck": "Spotkanie zespo\u0142u", + "paper": "Faktura", "rows": "Kontakty", "notes": "Notatki"}, + "pt-BR": {"text": "Relat\u00f3rio trimestral", "sheet": "Or\u00e7amento", "slides": "Plano do projeto", + "word": "Contrato", "cells": "N\u00fameros de vendas", "deck": "Reuni\u00e3o da equipe", + "paper": "Fatura", "rows": "Contatos", "notes": "Notas"}, + "ru": {"text": "\u041a\u0432\u0430\u0440\u0442\u0430\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0447\u0451\u0442", "sheet": "\u0411\u044e\u0434\u0436\u0435\u0442", "slides": "\u041f\u043b\u0430\u043d \u043f\u0440\u043e\u0435\u043a\u0442\u0430", + "word": "\u0414\u043e\u0433\u043e\u0432\u043e\u0440", "cells": "\u041f\u0440\u043e\u0434\u0430\u0436\u0438", "deck": "\u0412\u0441\u0442\u0440\u0435\u0447\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u044b", + "paper": "\u0421\u0447\u0451\u0442", "rows": "\u041a\u043e\u043d\u0442\u0430\u043a\u0442\u044b", "notes": "\u0417\u0430\u043c\u0435\u0442\u043a\u0438"}, + "tr": {"text": "\u00dc\u00e7 ayl\u0131k rapor", "sheet": "B\u00fct\u00e7e", "slides": "Proje plan\u0131", + "word": "S\u00f6zle\u015fme", "cells": "Sat\u0131\u015f rakamlar\u0131", "deck": "Ekip \u00e7al\u0131\u015ftay\u0131", + "paper": "Fatura", "rows": "Ki\u015filer", "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. +FILLERS = { + "meeting": "text", + "letter": "text", + "travel": "text", + "reading": "text", + "household": "sheet", + "hours": "sheet", + "stocktake": "sheet", + "kickoff": "slides", + "course": "slides", + "lease": "word", + "resume": "word", + "application": "word", + "expenses": "cells", + "inventory": "cells", + "review": "deck", + "ticket": "paper", + "warranty": "paper", + "manual": "paper", +} + +# A language with none of its own falls back to English. +FILLER_NAMES = { + "en": { + "meeting": "Meeting notes", "letter": "Letter to the landlord", + "travel": "Travel plan", "reading": "Reading list", + "household": "Household budget", "hours": "Hours", "stocktake": "Stocktake", + "kickoff": "Kickoff", "course": "Course slides", + "lease": "Lease", "resume": "CV", "application": "Application", + "expenses": "Expenses", "inventory": "Inventory", + "review": "Quarterly review", + "ticket": "Ticket", "warranty": "Warranty", "manual": "Manual", + }, + "de": { + "meeting": "Besprechungsnotizen", "letter": "Brief an den Vermieter", + "travel": "Reiseplan", "reading": "Leseliste", + "household": "Haushaltsbudget", "hours": "Arbeitszeiten", "stocktake": "Inventur", + "kickoff": "Auftakt", "course": "Kursfolien", + "lease": "Mietvertrag", "resume": "Lebenslauf", "application": "Bewerbung", + "expenses": "Ausgaben", "inventory": "Bestand", + "review": "Quartalsr\u00fcckblick", + "ticket": "Ticket", "warranty": "Garantie", "manual": "Anleitung", + }, + "es": { + "meeting": "Notas de reuni\u00f3n", "letter": "Carta al casero", + "travel": "Plan de viaje", "reading": "Lista de lectura", + "household": "Presupuesto dom\u00e9stico", "hours": "Horas", "stocktake": "Recuento", + "kickoff": "Arranque del proyecto", "course": "Diapositivas del curso", + "lease": "Alquiler del piso", "resume": "Curr\u00edculum", "application": "Solicitud", + "expenses": "Gastos", "inventory": "Inventario", + "review": "Revisi\u00f3n trimestral", + "ticket": "Billete", "warranty": "Garant\u00eda", "manual": "Manual", + }, + "fr": { + "meeting": "Notes de r\u00e9union", "letter": "Lettre au propri\u00e9taire", + "travel": "Itinéraire", "reading": "Liste de lecture", + "household": "Budget familial", "hours": "Heures", "stocktake": "Inventaire", + "kickoff": "Lancement", "course": "Diapositives du cours", + "lease": "Bail", "resume": "CV", "application": "Candidature", + "expenses": "D\u00e9penses", "inventory": "Stock", + "review": "Bilan trimestriel", + "ticket": "Billet", "warranty": "Garantie", "manual": "Manuel", + }, + "it": { + "meeting": "Note della riunione", "letter": "Lettera al locatore", + "travel": "Piano di viaggio", "reading": "Lista di lettura", + "household": "Bilancio familiare", "hours": "Ore", "stocktake": "Inventario", + "kickoff": "Avvio", "course": "Diapositive del corso", + "lease": "Contratto d'affitto", "resume": "Curriculum", "application": "Candidatura", + "expenses": "Spese", "inventory": "Magazzino", + "review": "Revisione trimestrale", + "ticket": "Biglietto", "warranty": "Garanzia", "manual": "Manuale", + }, + "pl": { + "meeting": "Notatki ze spotkania", "letter": "List do w\u0142a\u015bciciela mieszkania", + "travel": "Plan podr\u00f3\u017cy", "reading": "Lista lektur", + "household": "Bud\u017cet domowy", "hours": "Godziny", "stocktake": "Inwentaryzacja", + "kickoff": "Spotkanie startowe", "course": "Slajdy kursu", + "lease": "Umowa najmu", "resume": "CV", "application": "Podanie", + "expenses": "Wydatki", "inventory": "Stan magazynu", + "review": "Przegl\u0105d kwartalny", + "ticket": "Bilet", "warranty": "Gwarancja", "manual": "Instrukcja", + }, + "pt-BR": { + "meeting": "Notas da reuni\u00e3o", "letter": "Carta ao locador", + "travel": "Plano de viagem", "reading": "Lista de leitura", + "household": "Or\u00e7amento dom\u00e9stico", "hours": "Horas", "stocktake": "Balan\u00e7o", + "kickoff": "Kickoff", "course": "Slides do curso", + "lease": "Contrato de aluguel", "resume": "Curr\u00edculo", "application": "Inscri\u00e7\u00e3o", + "expenses": "Despesas", "inventory": "Estoque", + "review": "Revis\u00e3o trimestral", + "ticket": "Passagem", "warranty": "Garantia", "manual": "Manual", + }, + "ru": { + "meeting": "\u0417\u0430\u043c\u0435\u0442\u043a\u0438 \u0441\u043e \u0432\u0441\u0442\u0440\u0435\u0447\u0438", + "letter": "\u041f\u0438\u0441\u044c\u043c\u043e \u0430\u0440\u0435\u043d\u0434\u043e\u0434\u0430\u0442\u0435\u043b\u044e", + "travel": "\u041f\u043b\u0430\u043d \u043f\u043e\u0435\u0437\u0434\u043a\u0438", + "reading": "\u0421\u043f\u0438\u0441\u043e\u043a \u0447\u0442\u0435\u043d\u0438\u044f", + "household": "\u0414\u043e\u043c\u0430\u0448\u043d\u0438\u0439 \u0431\u044e\u0434\u0436\u0435\u0442", + "hours": "\u0427\u0430\u0441\u044b", + "stocktake": "\u0418\u043d\u0432\u0435\u043d\u0442\u0430\u0440\u0438\u0437\u0430\u0446\u0438\u044f", + "kickoff": "\u0421\u0442\u0430\u0440\u0442 \u043f\u0440\u043e\u0435\u043a\u0442\u0430", + "course": "\u0421\u043b\u0430\u0439\u0434\u044b \u043a\u0443\u0440\u0441\u0430", + "lease": "\u0414\u043e\u0433\u043e\u0432\u043e\u0440 \u0430\u0440\u0435\u043d\u0434\u044b", + "resume": "\u0420\u0435\u0437\u044e\u043c\u0435", + "application": "\u0417\u0430\u044f\u0432\u043b\u0435\u043d\u0438\u0435", + "expenses": "\u0420\u0430\u0441\u0445\u043e\u0434\u044b", + "inventory": "\u0421\u043a\u043b\u0430\u0434", + "review": "\u041a\u0432\u0430\u0440\u0442\u0430\u043b\u044c\u043d\u044b\u0439 \u043e\u0431\u0437\u043e\u0440", + "ticket": "\u0411\u0438\u043b\u0435\u0442", + "warranty": "\u0413\u0430\u0440\u0430\u043d\u0442\u0438\u044f", + "manual": "\u0418\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f", + }, + "tr": { + "meeting": "Toplant\u0131 notlar\u0131", "letter": "Ev sahibine mektup", + "travel": "Seyahat plan\u0131", "reading": "Okuma listesi", + "household": "Ev b\u00fct\u00e7esi", "hours": "\u00c7al\u0131\u015fma saatleri", + "stocktake": "Say\u0131m", + "kickoff": "Proje ba\u015flang\u0131c\u0131", "course": "Kurs slaytlar\u0131", + "lease": "Kira s\u00f6zle\u015fmesi", "resume": "\u00d6zge\u00e7mi\u015f", + "application": "Ba\u015fvuru", + "expenses": "Giderler", "inventory": "Envanter", + "review": "\u00dc\u00e7 ayl\u0131k de\u011ferlendirme", + "ticket": "Bilet", "warranty": "Garanti", "manual": "K\u0131lavuz", + }, +} + + +# 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. +def query(words: dict) -> str: + """The most repeated long word of the report, which is what to search for.""" + text = " ".join( + [words["title"], words["lead"], words["closing"]] + + [heading for heading, _ in words["sections"]] + + [line for _, paragraphs in words["sections"] for line in paragraphs] + ) + + counted = {} + for word in re.findall(r"\w+", text.lower(), flags=re.UNICODE): + if len(word) >= 5 and not word.isdigit(): + counted[word] = counted.get(word, 0) + 1 + + # the most repeated, and the longest of those, so the choice is not a coin toss + 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. +OTHERS = { + "en": { + "contract": ["Service agreement", + "This agreement is made between the two parties named below.", + ["The supplier provides the software described in the appendix for one year.", + "Payment is due within thirty days of each invoice.", + "Either party may end this agreement with three months' notice.", + "Changes to this agreement are valid only in writing.", + "The supplier keeps the service available on working days.", + "Both parties treat what they learn of each other as confidential.", + "Austrian law applies, and the court of Vienna has jurisdiction.", + "The supplier keeps a backup of the customer's data for thirty days.", + "Support requests are answered within one working day.", + "The customer names one person who may approve changes.", + "Prices hold for the first year and are reviewed each autumn.", + "Neither party may pass this agreement to a third party without consent.", + "The appendix lists the software covered and the version it starts at.", + "This agreement replaces every earlier arrangement between the parties."]], + "invoice": ["Invoice 2026-014", "Issued 12 June 2026", "Due within 30 days", "Billed to", "Subtotal", "VAT 20%", "Amount due", "Thank you for your business.", "Qty", "Unit price"], + "contacts": [["Name", "Team", "Email", "Phone"], + ["Design", "Support", "Sales", "Engineering"]], + }, + "de": { + "contract": ["Dienstleistungsvertrag", + "Dieser Vertrag wird zwischen den beiden unten genannten Parteien geschlossen.", + ["Der Anbieter stellt die im Anhang beschriebene Software f\u00fcr ein Jahr bereit.", + "Die Zahlung ist innerhalb von drei\u00dfig Tagen nach Rechnungsstellung f\u00e4llig.", + "Beide Parteien k\u00f6nnen den Vertrag mit einer Frist von drei Monaten k\u00fcndigen.", + "\u00c4nderungen dieses Vertrags bed\u00fcrfen der Schriftform.", + "Der Anbieter h\xe4lt den Dienst an Werktagen verf\xfcgbar.", + "Beide Parteien behandeln vertraulich, was sie voneinander erfahren.", + "Es gilt \xf6sterreichisches Recht; Gerichtsstand ist Wien.", + "Der Anbieter bewahrt eine Sicherung der Daten des Kunden drei\xdfig Tage lang auf.", + "Supportanfragen werden innerhalb eines Werktags beantwortet.", + "Der Kunde benennt eine Person, die \xc4nderungen freigeben darf.", + "Die Preise gelten im ersten Jahr und werden jeden Herbst \xfcberpr\xfcft.", + "Keine Partei darf diesen Vertrag ohne Zustimmung an Dritte weitergeben.", + "Der Anhang nennt die erfasste Software und die Version, ab der sie gilt.", + "Dieser Vertrag ersetzt alle fr\xfcheren Vereinbarungen zwischen den Parteien."]], + "invoice": ["Rechnung 2026-014", "Ausgestellt am 12. Juni 2026", "Zahlbar innerhalb von 30 Tagen", "Rechnung an", "Zwischensumme", "USt. 20%", "Zahlbetrag", "Vielen Dank f\xfcr Ihren Auftrag.", "Menge", "Einzelpreis"], + "contacts": [["Name", "Team", "E-Mail", "Telefon"], + ["Design", "Support", "Vertrieb", "Entwicklung"]], + }, + "es": { + "contract": ["Contrato de servicios", + "Este contrato se celebra entre las dos partes indicadas a continuaci\u00f3n.", + ["El proveedor facilita el software descrito en el anexo durante un a\u00f1o.", + "El pago vence a los treinta d\u00edas de cada factura.", + "Cualquiera de las partes puede rescindirlo con tres meses de preaviso.", + "Las modificaciones solo son v\u00e1lidas por escrito.", + "El proveedor mantiene el servicio disponible los d\xedas laborables.", + "Ambas partes tratan como confidencial lo que conozcan de la otra.", + "Se aplica la ley austriaca y el tribunal de Viena es competente.", + "El proveedor conserva una copia de los datos del cliente durante treinta d\xedas.", + "Las consultas de soporte se responden en un d\xeda laborable.", + "El cliente designa a una persona que puede aprobar los cambios.", + "Los precios se mantienen el primer a\xf1o y se revisan cada oto\xf1o.", + "Ninguna parte puede ceder este contrato a un tercero sin consentimiento.", + "El anexo enumera el software incluido y la versi\xf3n desde la que se aplica.", + "Este contrato sustituye cualquier acuerdo anterior entre las partes."]], + "invoice": ["Factura 2026-014", "Emitida el 12 de junio de 2026", "Vence en 30 d\u00edas", "Facturar a", "Subtotal", "IVA 20%", "Importe a pagar", "Gracias por su confianza.", "Cant.", "Precio unit."], + "contacts": [["Nombre", "Equipo", "Correo", "Tel\u00e9fono"], + ["Dise\u00f1o", "Soporte", "Ventas", "Ingenier\u00eda"]], + }, + "fr": { + "contract": ["Contrat de service", + "Ce contrat est conclu entre les deux parties d\u00e9sign\u00e9es ci-dessous.", + ["Le prestataire fournit le logiciel d\u00e9crit en annexe pendant un an.", + "Le paiement est d\u00fb dans les trente jours suivant chaque facture.", + "Chaque partie peut r\u00e9silier le contrat avec un pr\u00e9avis de trois mois.", + "Toute modification n'est valable que par \u00e9crit.", + "Le prestataire maintient le service disponible les jours ouvr\xe9s.", + "Chaque partie traite comme confidentiel ce qu'elle apprend de l'autre.", + "Le droit autrichien s'applique et le tribunal de Vienne est comp\xe9tent.", + "Le prestataire conserve une sauvegarde des donn\xe9es du client pendant trente jours.", + "Les demandes de support re\xe7oivent une r\xe9ponse sous un jour ouvr\xe9.", + "Le client d\xe9signe une personne habilit\xe9e \xe0 approuver les modifications.", + "Les prix sont fermes la premi\xe8re ann\xe9e et revus chaque automne.", + "Aucune des parties ne peut c\xe9der ce contrat \xe0 un tiers sans accord.", + "L'annexe indique le logiciel couvert et la version \xe0 partir de laquelle la couverture s'applique.", + "Ce contrat remplace tout accord ant\xe9rieur entre les parties."]], + "invoice": ["Facture 2026-014", "\u00c9mise le 12 juin 2026", "\u00c0 r\u00e9gler sous 30 jours", "Factur\xe9 \xe0", "Sous-total", "TVA 20 %", "Montant d\xfb", "Merci de votre confiance.", "Qt\xe9", "Prix unitaire"], + "contacts": [["Nom", "\u00c9quipe", "E-mail", "T\u00e9l\u00e9phone"], + ["Design", "Support", "Ventes", "D\u00e9veloppement"]], + }, + "it": { + "contract": ["Contratto di servizio", + "Il presente contratto \u00e8 stipulato tra le due parti indicate di seguito.", + ["Il fornitore mette a disposizione per un anno il software descritto in allegato.", + "Il pagamento \u00e8 dovuto entro trenta giorni da ogni fattura.", + "Ciascuna parte pu\u00f2 recedere con un preavviso di tre mesi.", + "Le modifiche sono valide solo in forma scritta.", + "Il fornitore mantiene il servizio disponibile nei giorni lavorativi.", + "Le parti trattano come riservato quanto apprendono l'una dell'altra.", + "Si applica il diritto austriaco e il foro competente \xe8 Vienna.", + "Il fornitore conserva una copia dei dati del cliente per trenta giorni.", + "Le richieste di supporto ricevono risposta entro un giorno lavorativo.", + "Il cliente indica una persona autorizzata ad approvare le modifiche.", + "I prezzi restano fermi il primo anno e sono rivisti ogni autunno.", + "Nessuna parte pu\xf2 cedere il contratto a terzi senza consenso.", + "L'allegato elenca il software coperto e la versione di partenza.", + "Il presente contratto sostituisce ogni accordo precedente tra le parti."]], + "invoice": ["Fattura 2026-014", "Emessa il 12 giugno 2026", "Da saldare entro 30 giorni", "Intestato a", "Subtotale", "IVA 20%", "Importo dovuto", "Grazie per la collaborazione.", "Qt\xe0", "Prezzo unit."], + "contacts": [["Nome", "Reparto", "E-mail", "Telefono"], + ["Design", "Supporto", "Vendite", "Sviluppo"]], + }, + "pl": { + "contract": ["Umowa o \u015bwiadczenie us\u0142ug", + "Niniejsza umowa zostaje zawarta mi\u0119dzy dwiema stronami wymienionymi poni\u017cej.", + ["Dostawca udost\u0119pnia oprogramowanie opisane w za\u0142\u0105czniku na okres roku.", + "P\u0142atno\u015b\u0107 jest wymagalna w terminie trzydziestu dni od daty ka\u017cdej faktury.", + "Ka\u017cda ze stron mo\u017ce rozwi\u0105za\u0107 umow\u0119 z trzymiesi\u0119cznym wypowiedzeniem.", + "Zmiany umowy wymagaj\u0105 formy pisemnej.", + "Dostawca utrzymuje dost\u0119pno\u015b\u0107 us\u0142ugi w dni robocze.", + "Obie strony traktuj\u0105 jako poufne to, czego dowiedz\u0105 si\u0119 o sobie nawzajem.", + "Obowi\u0105zuje prawo austriackie, a s\u0105dem w\u0142a\u015bciwym jest s\u0105d w Wiedniu.", + "Dostawca przechowuje kopi\u0119 danych klienta przez trzydzie\u015bci dni.", + "Zg\u0142oszenia do wsparcia s\u0105 rozpatrywane w ci\u0105gu jednego dnia roboczego.", + "Klient wskazuje jedn\u0105 osob\u0119 uprawnion\u0105 do zatwierdzania zmian.", + "Ceny obowi\u0105zuj\u0105 przez pierwszy rok i s\u0105 weryfikowane ka\u017cdej jesieni.", + "\u017badna ze stron nie mo\u017ce przenie\u015b\u0107 umowy na osob\u0119 trzeci\u0105 bez zgody.", + "Za\u0142\u0105cznik wymienia oprogramowanie obj\u0119te umow\u0105 oraz wersj\u0119 pocz\u0105tkow\u0105.", + "Niniejsza umowa zast\u0119puje wszystkie wcze\u015bniejsze ustalenia stron."]], + "invoice": ["Faktura 2026-014", "Wystawiono 12 czerwca 2026", "P\u0142atne w ci\u0105gu 30 dni", "Nabywca", "Warto\u015b\u0107 netto", "VAT 20%", "Do zap\u0142aty", "Dzi\u0119kujemy za wsp\xf3\u0142prac\u0119.", "Ilo\u015b\u0107", "Cena jedn."], + "contacts": [["Imi\u0119 i nazwisko", "Dział", "E-mail", "Telefon"], + ["Projektowanie", "Wsparcie", "Sprzeda\u017c", "Rozw\u00f3j"]], + }, + "pt-BR": { + "contract": ["Contrato de servi\u00e7o", + "Este contrato \u00e9 celebrado entre as duas partes indicadas abaixo.", + ["O fornecedor disponibiliza por um ano o software descrito no anexo.", + "O pagamento vence em trinta dias a contar de cada fatura.", + "Qualquer parte pode encerrar o contrato com aviso pr\u00e9vio de tr\u00eas meses.", + "Altera\u00e7\u00f5es s\u00f3 s\u00e3o v\u00e1lidas por escrito.", + "O fornecedor mant\xe9m o servi\xe7o dispon\xedvel em dias \xfateis.", + "As partes tratam como confidencial o que souberem uma da outra.", + "Aplica-se a lei austr\xedaca e o foro competente \xe9 o de Viena.", + "O fornecedor mant\xe9m uma c\xf3pia dos dados do cliente por trinta dias.", + "Os chamados de suporte s\xe3o respondidos em um dia \xfatil.", + "O cliente indica uma pessoa autorizada a aprovar altera\xe7\xf5es.", + "Os pre\xe7os ficam fixos no primeiro ano e s\xe3o revisados todo outono.", + "Nenhuma parte pode transferir este contrato a terceiros sem consentimento.", + "O anexo lista o software abrangido e a vers\xe3o inicial coberta.", + "Este contrato substitui qualquer acordo anterior entre as partes."]], + "invoice": ["Fatura 2026-014", "Emitida em 12 de junho de 2026", "Vence em 30 dias", "Faturado para", "Subtotal", "Impostos 20%", "Valor a pagar", "Obrigado pela prefer\xeancia.", "Qtd", "Pre\xe7o unit."], + "contacts": [["Nome", "Equipe", "E-mail", "Telefone"], + ["Design", "Suporte", "Vendas", "Engenharia"]], + }, + "ru": { + "contract": ["\u0414\u043e\u0433\u043e\u0432\u043e\u0440 \u043e\u043a\u0430\u0437\u0430\u043d\u0438\u044f \u0443\u0441\u043b\u0443\u0433", + "\u041d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u0434\u043e\u0433\u043e\u0432\u043e\u0440 \u0437\u0430\u043a\u043b\u044e\u0447\u0451\u043d \u043c\u0435\u0436\u0434\u0443 \u0434\u0432\u0443\u043c\u044f \u0441\u0442\u043e\u0440\u043e\u043d\u0430\u043c\u0438, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c\u0438 \u043d\u0438\u0436\u0435.", + ["\u0418\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0435 \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u0435, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u0435 \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438, \u0441\u0440\u043e\u043a\u043e\u043c \u043d\u0430 \u043e\u0434\u0438\u043d \u0433\u043e\u0434.", + "\u041e\u043f\u043b\u0430\u0442\u0430 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 \u0442\u0440\u0438\u0434\u0446\u0430\u0442\u0438 \u0434\u043d\u0435\u0439 \u0441 \u0434\u0430\u0442\u044b \u0441\u0447\u0451\u0442\u0430.", + "\u041a\u0430\u0436\u0434\u0430\u044f \u0438\u0437 \u0441\u0442\u043e\u0440\u043e\u043d \u043c\u043e\u0436\u0435\u0442 \u0440\u0430\u0441\u0442\u043e\u0440\u0433\u043d\u0443\u0442\u044c \u0434\u043e\u0433\u043e\u0432\u043e\u0440, \u0443\u0432\u0435\u0434\u043e\u043c\u0438\u0432 \u0437\u0430 \u0442\u0440\u0438 \u043c\u0435\u0441\u044f\u0446\u0430.", + "\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u043f\u0438\u0441\u044c\u043c\u0435\u043d\u043d\u043e\u043c \u0432\u0438\u0434\u0435.", + "\u0418\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0438\u0432\u0430\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0441\u0442\u044c \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u0432 \u0440\u0430\u0431\u043e\u0447\u0438\u0435 \u0434\u043d\u0438.", + "\u0421\u0442\u043e\u0440\u043e\u043d\u044b \u0441\u043e\u0445\u0440\u0430\u043d\u044f\u044e\u0442 \u0432 \u0442\u0430\u0439\u043d\u0435 \u0441\u0432\u0435\u0434\u0435\u043d\u0438\u044f, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0435 \u0434\u0440\u0443\u0433 \u043e \u0434\u0440\u0443\u0433\u0435.", + "\u041f\u0440\u0438\u043c\u0435\u043d\u044f\u0435\u0442\u0441\u044f \u0430\u0432\u0441\u0442\u0440\u0438\u0439\u0441\u043a\u043e\u0435 \u043f\u0440\u0430\u0432\u043e, \u0441\u043f\u043e\u0440\u044b \u0440\u0430\u0441\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u0435\u0442 \u0441\u0443\u0434 \u0412\u0435\u043d\u044b.", + "\u0418\u0441\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c \u0445\u0440\u0430\u043d\u0438\u0442 \u0440\u0435\u0437\u0435\u0440\u0432\u043d\u0443\u044e \u043a\u043e\u043f\u0438\u044e \u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a\u0430 \u0442\u0440\u0438\u0434\u0446\u0430\u0442\u044c \u0434\u043d\u0435\u0439.", + "\u041e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u044f \u0432 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0443 \u0440\u0430\u0441\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 \u043e\u0434\u043d\u043e\u0433\u043e \u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e \u0434\u043d\u044f.", + "\u0417\u0430\u043a\u0430\u0437\u0447\u0438\u043a \u043d\u0430\u0437\u043d\u0430\u0447\u0430\u0435\u0442 \u043e\u0434\u043d\u043e\u0433\u043e \u0441\u043e\u0442\u0440\u0443\u0434\u043d\u0438\u043a\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432\u043f\u0440\u0430\u0432\u0435 \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u0442\u044c \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f.", + "\u0426\u0435\u043d\u044b \u0444\u0438\u043a\u0441\u0438\u0440\u0443\u044e\u0442\u0441\u044f \u043d\u0430 \u043f\u0435\u0440\u0432\u044b\u0439 \u0433\u043e\u0434 \u0438 \u043f\u0435\u0440\u0435\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u043a\u0430\u0436\u0434\u0443\u044e \u043e\u0441\u0435\u043d\u044c.", + "\u041d\u0438 \u043e\u0434\u043d\u0430 \u0438\u0437 \u0441\u0442\u043e\u0440\u043e\u043d \u043d\u0435 \u0432\u043f\u0440\u0430\u0432\u0435 \u043f\u0435\u0440\u0435\u0434\u0430\u0442\u044c \u0434\u043e\u0433\u043e\u0432\u043e\u0440 \u0442\u0440\u0435\u0442\u044c\u0435\u043c\u0443 \u043b\u0438\u0446\u0443 \u0431\u0435\u0437 \u0441\u043e\u0433\u043b\u0430\u0441\u0438\u044f.", + "\u0412 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 \u0443\u043a\u0430\u0437\u0430\u043d\u043e, \u043a\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0435 \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u0438\u0442 \u0432 \u0434\u043e\u0433\u043e\u0432\u043e\u0440 \u0438 \u0441 \u043a\u0430\u043a\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438.", + "\u041d\u0430\u0441\u0442\u043e\u044f\u0449\u0438\u0439 \u0434\u043e\u0433\u043e\u0432\u043e\u0440 \u0437\u0430\u043c\u0435\u043d\u044f\u0435\u0442 \u0432\u0441\u0435 \u043f\u0440\u0435\u0436\u043d\u0438\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0451\u043d\u043d\u043e\u0441\u0442\u0438 \u0441\u0442\u043e\u0440\u043e\u043d."]], + "invoice": ["\u0421\u0447\u0451\u0442 2026-014", "\u0412\u044b\u0441\u0442\u0430\u0432\u043b\u0435\u043d 12 \u0438\u044e\u043d\u044f 2026 \u0433.", "\u041e\u043f\u043b\u0430\u0442\u0430 \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0434\u043d\u0435\u0439", "\u041f\u043b\u0430\u0442\u0435\u043b\u044c\u0449\u0438\u043a", "\u041f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043e\u0447\u043d\u044b\u0439 \u0438\u0442\u043e\u0433", "\u041d\u0414\u0421 20%", "\u041a \u043e\u043f\u043b\u0430\u0442\u0435", "\u0411\u043b\u0430\u0433\u043e\u0434\u0430\u0440\u0438\u043c \u0437\u0430 \u0441\u043e\u0442\u0440\u0443\u0434\u043d\u0438\u0447\u0435\u0441\u0442\u0432\u043e.", "\u041a\u043e\u043b-\u0432\u043e", "\u0426\u0435\u043d\u0430 \u0437\u0430 \u0435\u0434."], + "contacts": [["\u0424\u0418\u041e", "\u041e\u0442\u0434\u0435\u043b", "\u041f\u043e\u0447\u0442\u0430", "\u0422\u0435\u043b\u0435\u0444\u043e\u043d"], + ["\u0414\u0438\u0437\u0430\u0439\u043d", "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430", "\u041f\u0440\u043e\u0434\u0430\u0436\u0438", "\u0420\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0430"]], + }, + "tr": { + "contract": ["Hizmet s\u00f6zle\u015fmesi", + "Bu s\u00f6zle\u015fme a\u015fa\u011f\u0131da belirtilen iki taraf aras\u0131nda yap\u0131lm\u0131\u015ft\u0131r.", + ["Tedarik\u00e7i, ekte tan\u0131mlanan yaz\u0131l\u0131m\u0131 bir y\u0131l boyunca sa\u011flar.", + "\u00d6deme, her faturadan sonra otuz g\u00fcn i\u00e7inde yap\u0131l\u0131r.", + "Taraflardan biri s\u00f6zle\u015fmeyi \u00fc\u00e7 ay \u00f6nceden bildirerek sonland\u0131rabilir.", + "De\u011fi\u015fiklikler yaln\u0131zca yaz\u0131l\u0131 olarak ge\u00e7erlidir.", + "Tedarik\xe7i hizmeti i\u015f g\xfcnlerinde eri\u015filebilir tutar.", + "Taraflar birbirleri hakk\u0131nda \xf6\u011frendiklerini gizli tutar.", + "Avusturya hukuku uygulan\u0131r ve yetkili mahkeme Viyana'd\u0131r.", + "Tedarik\xe7i, m\xfc\u015fterinin verilerinin yede\u011fini otuz g\xfcn saklar.", + "Destek talepleri bir i\u015f g\xfcn\xfc i\xe7inde yan\u0131tlan\u0131r.", + "M\xfc\u015fteri, de\u011fi\u015fiklikleri onaylayabilecek bir ki\u015fi belirler.", + "Fiyatlar ilk y\u0131l sabittir ve her sonbahar g\xf6zden ge\xe7irilir.", + "Hi\xe7bir taraf s\xf6zle\u015fmeyi onay almadan \xfc\xe7\xfcnc\xfc ki\u015fiye devredemez.", + "Ek, kapsanan yaz\u0131l\u0131m\u0131 ve ge\xe7erli oldu\u011fu s\xfcr\xfcm\xfc listeler.", + "Bu s\xf6zle\u015fme, taraflar aras\u0131ndaki \xf6nceki t\xfcm d\xfczenlemelerin yerine ge\xe7er."]], + "invoice": ["Fatura 2026-014", "12 Haziran 2026 tarihli", "30 g\u00fcn i\u00e7inde \u00f6denir", "Alıcı", "Ara toplam", "KDV %20", "\xd6denecek tutar", "\u0130\u015f birli\u011finiz i\xe7in te\u015fekk\xfcrler.", "Adet", "Birim fiyat"], + "contacts": [["Ad Soyad", "Ekip", "E-posta", "Telefon"], + ["Tasar\u0131m", "Destek", "Sat\u0131\u015f", "Geli\u015ftirme"]], + }, +} + +# Names are names in every language, so these are not translated. +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. + +OOXML_RELS = """ + + + +""" + +WORD_MAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + + +def docx_parts(words: dict, others: dict) -> dict: + """The Word file is the contract, not another copy of the report.""" + title, lead, clauses = others["contract"] + + def run(text: str, *, size: int, bold: bool = False, colour: str = "") -> str: + marks = ("" if bold else "") + (f'' if colour else "") + + return ( + f"{marks}" + f'{escape(text)}' + ) + + def para(runs: str, after: int) -> str: + return f'{runs}' + + # A clause is two sentences in one paragraph, numbered in line with the + # first. A number on a line of its own above a single sentence reads as a + # list of scraps rather than as a contract. + paragraphs = [ + para(run(title, size=72, bold=True), 640), + para(run(lead, size=22), 420), + ] + for number, index in enumerate(range(0, len(clauses) - 1, 2), start=1): + body = " ".join(clauses[index:index + 2]) + paragraphs.append( + para( + run(f"{number}. ", size=22, bold=True, colour=ACCENT[1:]) + run(body, size=22), + 300, + ) + ) + + # 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. + body = ''.join(paragraphs) + + return { + "[Content_Types].xml": '' + '' + '' + '' + '' + '', + "_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. + "word/_rels/document.xml.rels": OOXML_RELS.format( + type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + target="styles.xml", + ), + "word/styles.xml": '' + '' + "" + '' + "" + '' + "" + '' + '' + "", + "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. + f"{body}" + '' + '' + "", + } + + +def xlsx_parts(words: dict) -> dict: + """A workbook of its own figures, so it is not the .ods twice.""" + head, body, foot = table(words, columns=2, rows=8, scale=3) + rows_of = [head] + body + [foot] + + def cell(column: int, row: int, value) -> str: + reference = f"{chr(ord('A') + column)}{row}" + if isinstance(value, int): + return f'{value}' + + return f'{escape(value)}' + + rows = "".join( + f'' + + "".join(cell(column, index + 1, value) for column, value in enumerate(line)) + + "" + for index, line in enumerate(rows_of) + ) + + return { + "[Content_Types].xml": '' + '' + '' + '' + '' + '', + "_rels/.rels": OOXML_RELS.format(type=WORD_MAIN, target="xl/workbook.xml"), + "xl/_rels/workbook.xml.rels": OOXML_RELS.format( + type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + target="worksheets/sheet1.xml", + ), + "xl/workbook.xml": '' + '' + f'', + "xl/worksheets/sheet1.xml": '' + '' + f"{rows}", + } + + +def pptx_parts(words: dict) -> dict: + """One slide, titled and bulleted, so a deck opens on something.""" + drawing = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"' + presentation = 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"' + + def shape(identifier: int, name: str, box: str, lines: list, size: int) -> str: + paragraphs = "".join( + f'' + f"{escape(line)}" + for line in lines + ) + + return ( + f'' + f"{box}" + '' + f"{paragraphs}" + ) + + slide = ( + f'' + "" + '' + + shape( + 2, + "Title", + '', + [words["slides"][0][0]], + 4000, + ) + + shape( + 3, + "Body", + '', + words["slides"][0][1], + 2000, + ) + + "" + ) + + return { + "[Content_Types].xml": '' + '' + '' + '' + '' + '', + "_rels/.rels": OOXML_RELS.format(type=WORD_MAIN, target="ppt/presentation.xml"), + "ppt/_rels/presentation.xml.rels": OOXML_RELS.format( + type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + target="slides/slide1.xml", + ), + "ppt/presentation.xml": f'' + f'' + '' + '', + "ppt/slides/slide1.xml": slide, + } + + +# 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 = { + "regular": ( + "278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 " + "556 556 556 556 556 556 556 556 556 556 278 278 584 584 584 556 " + "1015 667 667 722 722 667 611 778 722 278 500 667 556 833 722 778 " + "667 778 722 667 611 722 667 944 667 667 611 278 278 278 469 556 " + "333 556 556 500 556 556 278 556 556 222 222 500 222 833 556 556 " + "556 556 333 500 278 556 500 722 500 500 500 334 260 334 584" + ), + "bold": ( + "278 333 474 556 556 889 722 238 333 333 389 584 278 333 278 278 " + "556 556 556 556 556 556 556 556 556 556 333 333 584 584 584 611 " + "975 722 722 722 722 667 611 778 722 278 556 722 611 833 722 778 " + "667 778 722 667 611 722 667 944 667 667 611 333 278 333 584 556 " + "333 556 611 556 611 556 333 611 611 278 278 556 278 889 611 611 " + "611 611 389 556 333 611 556 778 556 556 500 389 280 389 584" + ), +} + +WIDTHS = { + weight: {chr(32 + index): int(value) for index, value in enumerate(table.split())} + for weight, table in HELVETICA.items() +} + + +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. + """ + table = WIDTHS[weight] + total = 0 + for character in text: + width = table.get(character) + if width is None: + plain = unicodedata.normalize("NFD", character)[0] + width = table.get(plain, 556) + total += width + + return total * size / 1000 + + +WINANSI = set(bytes(range(32, 256)).decode("cp1252", errors="ignore")) + + +def spellable(words: dict) -> bool: + """Whether Helvetica's encoding can write this language's wording.""" + return all( + character in WINANSI for line in (words["title"], words["closing"]) for character in line + ) + + +# A4 upright in points, with the same margin the ODF pages take. +PAGE = (595.0, 842.0) +MARGIN = 57.0 +COLUMN = PAGE[0] - 2 * MARGIN + + +def pdf_bytes(words: dict, others: dict) -> bytes: + """A one page PDF, written out by hand rather than through a library. + + Each word is placed at its own position, the way a real producer writes one. + Handed over as one run per line instead, a reader that marks a search hit + inside the run has nothing to measure the offset with, and the highlight + lands beside the word rather than on it. + + 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. + """ + said = words if spellable(words) else WORDS["en"] + + def lay_out(text: str, weight: str, size: float) -> list: + """The text broken into lines of placed words.""" + lines, line, width = [], [], 0.0 + space = advance(" ", weight, size) + for word in text.split(): + reach = advance(word, weight, size) + if line and width + space + reach > COLUMN: + lines.append(line) + line, width = [], 0.0 + line.append((word, width)) + width += reach + space + if line: + lines.append(line) + + return lines + + def literal(text: str) -> str: + return text.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") + + invoice = others["invoice"] if spellable(words) else OTHERS["en"]["invoice"] + number, issued, due, billed, subtotal, vat, due_label, thanks, quantity, unit = invoice + head, body, foot = table(said, columns=1, rows=20) + + money = foot[-1] + tax = round(money * 0.2) + right = PAGE[0] - MARGIN + + drawn = [] + + def put(text, x, y, weight="regular", size=10, align="left"): + """One line, placed. Numbers are hung off the right, which is what makes + a column of figures a column rather than a ragged list.""" + name = "F2" if weight == "bold" else "F1" + at = x - advance(text, weight, size) if align == "right" else x + drawn.append(f"BT /{name} {size:g} Tf {at:.1f} {y:.1f} Td ({literal(text)}) Tj ET") + + # the head: who it is from and when, against who it is to + y = PAGE[1] - MARGIN - 26 + put(number, MARGIN, y, "bold", 20) + put(issued, right, y, "regular", 10, "right") + put(due, right, y - 14, "regular", 10, "right") + + y -= 46 + put(billed, MARGIN, y, "bold", 11) + for line in ("Muster GmbH", "Praterstrasse 12", "1020 Wien"): + y -= 14 + put(line, MARGIN, y) + + # the table, in four columns across the width + columns = (MARGIN, MARGIN + 300, MARGIN + 390, right) + y -= 34 + put(head[0], columns[0], y, "bold", 10) + put(quantity, columns[1], y, "bold", 10, "right") + put(unit, columns[2], y, "bold", 10, "right") + put(head[-1], columns[3], y, "bold", 10, "right") + + for index, line in enumerate(body): + count = index % 4 + 1 + amount = line[-1] + y -= 15 + put(str(line[0]), columns[0], y) + put(str(count), columns[1], y, align="right") + put(f"{amount / count:.2f}", columns[2], y, align="right") + put(str(amount), columns[3], y, align="right") + + y -= 24 + for label, value, weight in ( + (subtotal, money, "regular"), (vat, tax, "regular"), (due_label, money + tax, "bold") + ): + put(label, columns[2], y, weight, 10 if weight == "regular" else 12, "right") + put(str(value), columns[3], y, weight, 10 if weight == "regular" else 12, "right") + y -= 17 + + y -= 12 + put(thanks, MARGIN, y) + + stream = ("\n".join(drawn) + "\n").encode("cp1252") + + objects = [ + b"<>", + b"<>", + b"<>>>/Contents 5 0 R>>", + b"<>", + b"<>\nstream\n" + stream + b"endstream", + b"<>", + ] + + out = bytearray(b"%PDF-1.4\n") + offsets = [] + for number, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{number} 0 obj\n".encode() + body + b"\nendobj\n" + + table_at = len(out) + out += f"xref\n0 {len(objects) + 1}\n".encode() + b"0000000000 65535 f \n" + for offset in offsets: + out += f"{offset:010d} 00000 n \n".encode() + out += f"trailer\n<>\nstartxref\n{table_at}\n%%EOF\n".encode() + + return bytes(out) + + +def csv_text(words: dict, others: dict) -> str: + """The contact list its name promises.""" + headers, roles = others["contacts"] + lines = [",".join(headers)] + for index, person in enumerate(PEOPLE): + handle = person.split(". ")[-1].lower() + lines.append( + ",".join([person, roles[index % len(roles)], f"{handle}@example.org", f"+43 1 234 56{index}0"]) + ) + + return "\n".join(lines) + "\n" + + +def txt_text(words: dict) -> str: + """The notes: the report in plain text, with the deck's points under it.""" + lines = [words["title"], "=" * len(words["title"]), "", words["lead"], ""] + for heading, paragraphs in words["sections"]: + lines += [heading, "-" * len(heading), ""] + for text in paragraphs: + lines += [text, ""] + for title, bullets in words["slides"]: + lines += [title, "-" * len(title), ""] + lines += [f"* {point}" for point in bullets] + lines.append("") + lines.append(words["closing"]) + + return "\n".join(lines) + "\n" + + +# What the app asks the bundle for. The first three are the documents the +# screenshots open; the rest sit in the folder the first screenshot is of. +DOCUMENTS = { + "text": ("odt", "application/vnd.oasis.opendocument.text", "document", report), + "sheet": ("ods", "application/vnd.oasis.opendocument.spreadsheet", "document", sheet), + "slides": ("odp", "application/vnd.oasis.opendocument.presentation", "slide", deck), +} + +PACKAGES = { + "word": ("docx", docx_parts), + "cells": ("xlsx", xlsx_parts), + "deck": ("pptx", pptx_parts), +} + +PLAIN = { + "rows": ("csv", csv_text), + "notes": ("txt", txt_text), +} + + +def package(path: Path, parts: dict) -> None: + """A zip of the given parts, reproducibly.""" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, text in parts.items(): + info = zipfile.ZipInfo(name, date_time=EPOCH) + info.external_attr = 0o644 << 16 + archive.writestr(info, text, compress_type=zipfile.ZIP_DEFLATED) + + +def write(path: Path, mimetype: str, kind: str, content_xml: str) -> None: + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as package: + def entry(name: str, text: str, stored: bool = False) -> None: + info = zipfile.ZipInfo(name, date_time=EPOCH) + info.external_attr = 0o644 << 16 + package.writestr( + info, text, compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED + ) + + # first and uncompressed, or the package is only recognised by sniffing + entry("mimetype", mimetype, stored=True) + entry("META-INF/manifest.xml", MANIFEST.format(mimetype=mimetype)) + entry("styles.xml", styles(kind)) + entry("content.xml", content_xml) + + +def main(argv=None) -> None: + parser = argparse.ArgumentParser(description="Write the documents the store screenshots open.") + parser.add_argument( + "--language", action="append", choices=sorted(WORDS), + help="only this language, repeatable; default is all of them. What to reach for when a " + "change is worded in English first and the rest are to follow.") + args = parser.parse_args(argv) + + languages = args.language or list(WORDS) + + SAMPLES.mkdir(parents=True, exist_ok=True) + + written = 0 + for language in languages: + words = WORDS[language] + for name, (extension, mimetype, kind, build) in DOCUMENTS.items(): + path = SAMPLES / f"sample-{name}-{language}.{extension}" + write(path, mimetype, kind, build(words)) + written += 1 + + others = OTHERS[language] + + for name, (extension, build) in PACKAGES.items(): + parts = build(words, others) if name == "word" else build(words) + package(SAMPLES / f"sample-{name}-{language}.{extension}", parts) + written += 1 + + for name, (extension, build) in PLAIN.items(): + text = build(words, others) if name == "rows" else build(words) + (SAMPLES / f"sample-{name}-{language}.{extension}").write_text(text, encoding="utf-8") + written += 1 + + (SAMPLES / f"sample-paper-{language}.pdf").write_bytes(pdf_bytes(words, others)) + written += 1 + + details = { + language: { + "files": FILE_NAMES[language] + | { + key: FILLER_NAMES.get(language, {}).get(key, FILLER_NAMES["en"][key]) + for key in FILLERS + }, + "search": query(words), + } + for language, words in WORDS.items() + } + (SAMPLES / "screenshot-names.json").write_text( + json.dumps(details, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8" + ) + + print(f"wrote {written} documents in {len(languages)} languages to {SAMPLES}") + + +if __name__ == "__main__": + main() diff --git a/scripts/store_screenshots.py b/scripts/store_screenshots.py new file mode 100755 index 0000000..e454636 --- /dev/null +++ b/scripts/store_screenshots.py @@ -0,0 +1,249 @@ +#!/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 check it and stage it +# +# Named with an underscore, unlike its neighbours, because frame-screenshots.py +# imports it and a hyphen is not an identifier. +# +# 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. + +import argparse +import os +import shutil +import struct +import sys +from pathlib import Path + +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. +LOCALES = { + "de-DE": "de", + "en-US": "en", + "es-ES": "es", + "fr-FR": "fr", + "hi": None, + "it": "it", + "pl": "pl", + "pt-BR": "pt-BR", + "ru": "ru", + "sv": None, + "tr": "tr", +} + +FALLBACK = "en-US" + +# What one device shows, in the order the store shows them. The same names the +# screenshot test writes - see `OpenDocumentReaderUITests/ScreenshotTests.swift`. +SCREENS = ( + "01-browser", + "02-text", + "03-sheet", + "04-edit", + "05-pdf", + "06-office", +) + +# 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. +SIZES = { + "iphone": { + (1320, 2868), # 6.9", iPhone 16 Pro Max and later + (1290, 2796), # 6.9"/6.7", iPhone 15 Pro Max and 16 Plus + (1284, 2778), # 6.5", iPhone 12/13 Pro Max + (1242, 2688), # 6.5", iPhone 11 Pro Max + }, + "ipad": { + (2064, 2752), # 13", iPad Pro M4 + (2048, 2732), # 12.9", iPad Pro + }, +} + + +def languages(): + """The locales worth capturing: the ones the app can be photographed in.""" + return [locale for locale, language in LOCALES.items() if language] + + +def borrowed(): + """The locales that read another one's pictures.""" + return [locale for locale, language in LOCALES.items() if not language] + + +def size(path): + """The pixel size of a PNG, off its header rather than through a library.""" + with path.open("rb") as file: + header = file.read(24) + + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise ValueError(f"{path.name} is not a PNG") + + return struct.unpack(">II", header[16:24]) + + +def device(width, height): + """Which device a picture that size belongs to, or None.""" + for name, sizes in SIZES.items(): + if (width, height) in sizes: + return name + + return None + + +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. + """ + directory = Path(directory) + found = {} + problems = [] + + for locale in languages(): + folder = directory / locale + if not folder.is_dir(): + problems.append(f"{locale}: no {folder}") + continue + + pictures = {} + for path in sorted(folder.glob("*.png")): + screen = next((name for name in SCREENS if path.stem.endswith(name)), None) + if screen is None: + problems.append(f"{locale}: {path.name} is not one of {', '.join(SCREENS)}") + continue + + try: + width, height = size(path) + except (OSError, ValueError) as reason: + problems.append(f"{locale}: {reason}") + continue + + 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. + continue + + pictures.setdefault(kind, {})[screen] = path + + for kind in SIZES: + missing = [screen for screen in SCREENS if screen not in pictures.get(kind, {})] + if missing: + problems.append(f"{locale}: no {kind} {', '.join(missing)}") + + found[locale] = pictures + + return found, problems + + +def stage(found, directory): + """Write the screenshot tree deliver uploads. + + 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. + """ + directory = Path(directory) + + for locale, pictures in found.items(): + folder = directory / locale + folder.mkdir(parents=True, exist_ok=True) + for kind, screens in pictures.items(): + for screen, path in screens.items(): + shutil.copyfile(path, folder / f"{kind}-{screen}.png") + + for locale in borrowed(): + source = directory / FALLBACK + target = directory / locale + shutil.rmtree(target, ignore_errors=True) + shutil.copytree(source, target) + + return directory + + +def fail(message): + if os.environ.get("GITHUB_ACTIONS"): + # also surfaces as an annotation on the run, not only inside the step log + print(f"::error::{message}") + else: + print(message, file=sys.stderr) + return 1 + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check a run of App Store screenshots, and stage it for deliver." + ) + parser.add_argument( + "--languages", + action="store_true", + help="print the locales to capture, one per line, and do nothing else", + ) + parser.add_argument( + "--screenshots", + metavar="DIR", + default=SCREENSHOTS, + help=f"where the capture run wrote (default {SCREENSHOTS.relative_to(ROOT)})", + ) + parser.add_argument( + "--stage", + metavar="DIR", + help="also write the deliver screenshot tree into DIR", + ) + args = parser.parse_args(argv) + + if args.languages: + print("\n".join(languages())) + return 0 + + found, problems = collect(args.screenshots) + + if problems: + return fail( + "no full set of screenshots to release with:\n " + + "\n ".join(problems) + + "\nRun `bundle exec fastlane ios screenshots` to take them." + ) + + if args.stage: + try: + stage(found, args.stage) + except OSError as reason: + return fail(str(reason)) + print( + f"staged {len(SCREENS)} screenshots per device for " + f"{len(found) + len(borrowed())} locales in {args.stage}" + ) + else: + pictures = sum(len(screens) for locale in found.values() for screens in locale.values()) + print(f"{pictures} screenshots in all {len(found)} captured locales: {', '.join(found)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())