diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dbd6575 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The sample pdfs are written with uncompressed content streams, so git reads +# them as text and puts a hundred and thirty lines of placed words in the diff +# for each one. They are generated files; nobody reads them as a diff. +*.pdf binary 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..fc53004 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ Carthage/Build fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png +fastlane/screenshots/screenshots.html +fastlane/framed fastlane/test_output .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index d08bc83..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 @@ -45,6 +46,8 @@ once the version tag exists. showed it for most of the rest. - Tapping a document sets the cursor, so an edit can be typed. The keyboard never came up before. +- A document opens at the top of its first page. On a wide screen it opened far + enough down to cut off the heading. ### Removed 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 6aefc72..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 { @@ -108,10 +111,28 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel return; } - var width = document.documentElement.scrollWidth; - if (width > window.innerWidth) { - meta.setAttribute('content', 'width=' + width + ',user-scalable=yes'); + var served = meta.content; + var natural = document.documentElement.scrollWidth; + + // The web view's own width, which the viewport named below does not + // change: the visual viewport is that many CSS pixels at that scale. + function available() { + var seen = window.visualViewport; + + return seen ? Math.round(seen.width * seen.scale) : window.innerWidth; + } + + function fit() { + meta.setAttribute( + 'content', + natural > available() ? 'width=' + natural + ',user-scalable=yes' : served); } + + // On every resize, not just now: this first runs before the web view + // has the width it will keep, and a page held at a width it no + // longer needs is left scrolled off its own top. + fit(); + window.addEventListener('resize', fit); })(); """ } @@ -232,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?) { @@ -503,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 = @@ -511,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?() } } @@ -683,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) @@ -694,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)) } } @@ -742,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 @@ -757,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/Samples/sample-cells-de.xlsx b/OpenDocumentReader/Samples/sample-cells-de.xlsx new file mode 100644 index 0000000..55ac228 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-de.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-en.xlsx b/OpenDocumentReader/Samples/sample-cells-en.xlsx new file mode 100644 index 0000000..c812d78 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-en.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-es.xlsx b/OpenDocumentReader/Samples/sample-cells-es.xlsx new file mode 100644 index 0000000..e9a4dc3 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-es.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-fr.xlsx b/OpenDocumentReader/Samples/sample-cells-fr.xlsx new file mode 100644 index 0000000..f70efba Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-fr.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-it.xlsx b/OpenDocumentReader/Samples/sample-cells-it.xlsx new file mode 100644 index 0000000..d1b57a3 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-it.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-pl.xlsx b/OpenDocumentReader/Samples/sample-cells-pl.xlsx new file mode 100644 index 0000000..ac11b86 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-pl.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-pt-BR.xlsx b/OpenDocumentReader/Samples/sample-cells-pt-BR.xlsx new file mode 100644 index 0000000..a620566 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-pt-BR.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-ru.xlsx b/OpenDocumentReader/Samples/sample-cells-ru.xlsx new file mode 100644 index 0000000..1b51119 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-ru.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-cells-tr.xlsx b/OpenDocumentReader/Samples/sample-cells-tr.xlsx new file mode 100644 index 0000000..b26e2c4 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-cells-tr.xlsx differ diff --git a/OpenDocumentReader/Samples/sample-deck-de.pptx b/OpenDocumentReader/Samples/sample-deck-de.pptx new file mode 100644 index 0000000..cc96264 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-de.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-en.pptx b/OpenDocumentReader/Samples/sample-deck-en.pptx new file mode 100644 index 0000000..dd3cec5 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-en.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-es.pptx b/OpenDocumentReader/Samples/sample-deck-es.pptx new file mode 100644 index 0000000..a683c70 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-es.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-fr.pptx b/OpenDocumentReader/Samples/sample-deck-fr.pptx new file mode 100644 index 0000000..7e72b34 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-fr.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-it.pptx b/OpenDocumentReader/Samples/sample-deck-it.pptx new file mode 100644 index 0000000..7761c37 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-it.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-pl.pptx b/OpenDocumentReader/Samples/sample-deck-pl.pptx new file mode 100644 index 0000000..036d230 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-pl.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-pt-BR.pptx b/OpenDocumentReader/Samples/sample-deck-pt-BR.pptx new file mode 100644 index 0000000..f1493dd Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-pt-BR.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-ru.pptx b/OpenDocumentReader/Samples/sample-deck-ru.pptx new file mode 100644 index 0000000..c45ebb2 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-ru.pptx differ diff --git a/OpenDocumentReader/Samples/sample-deck-tr.pptx b/OpenDocumentReader/Samples/sample-deck-tr.pptx new file mode 100644 index 0000000..ac6d2d3 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-deck-tr.pptx differ diff --git a/OpenDocumentReader/Samples/sample-notes-de.txt b/OpenDocumentReader/Samples/sample-notes-de.txt new file mode 100644 index 0000000..4e247fc --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-de.txt @@ -0,0 +1,72 @@ +Quartalsbericht +=============== + +Das Team hat alle Ziele des zweiten Quartals erreicht, und die neue Version ist pünktlich erschienen. + +Das Wichtigste +-------------- + +Die Kosten blieben unter dem Budget, und zwei neue Partner sind zum Projekt gestoßen. + +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ür Software stiegen mit den neuen Lizenzen, die Reisekosten sanken erneut. + +Die Hardware wurde einmal ersetzt, der Support blieb über das Quartal stabil. + +Zwei Server sind ohne Ausfalltag zum neuen Anbieter umgezogen. + +Nächstes Quartal +---------------- + +Die Version im September ist die letzte für dieses Jahr. + +Zwei Stellen im Support sind offen, eine im Design. + +Der Umzug ins neue Büro ist für November geplant und budgetiert. + +Die Menschen +------------ + +An der Version arbeiteten sechs Personen, zwei davon neu in diesem Jahr. + +Die Urlaubsvertretung wurde im April geregelt und trug durch den Sommer. + +Alle haben die Schulung absolviert, die die neue Lizenz verlangt. + +Risiken +------- + +Der Umzug im November ist der einzige Termin, an dem nichts vorbeigeht. + +Ein Lieferant hat die neuen Bedingungen noch nicht unterschrieben. + +Die Hostingkosten steigen im Januar, wenn der Vertrag nicht früher erneuert wird. + +Projektplan +----------- + +* Ziele für das Quartal +* Budget und Kosten +* Nächste Schritte + +Zeitplan +-------- + +* Version im Juni +* Rückblick im Juli +* Planung im August + +Team +---- + +* Zwei neue Partner +* Support in drei Sprachen +* Schulung im Herbst + +Das nächste Treffen findet Ende Juli statt. diff --git a/OpenDocumentReader/Samples/sample-notes-en.txt b/OpenDocumentReader/Samples/sample-notes-en.txt new file mode 100644 index 0000000..573e95d --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-en.txt @@ -0,0 +1,72 @@ +Quarterly report +================ + +The team met every goal of the second quarter, and the new release went out on time. + +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. + +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 + +The next meeting is at the end of July. diff --git a/OpenDocumentReader/Samples/sample-notes-es.txt b/OpenDocumentReader/Samples/sample-notes-es.txt new file mode 100644 index 0000000..7471d3d --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-es.txt @@ -0,0 +1,72 @@ +Informe trimestral +================== + +El equipo cumplió todos los objetivos del segundo trimestre y la nueva versión salió a tiempo. + +Lo más destacado +---------------- + +Los costes se mantuvieron por debajo del presupuesto y dos nuevos socios se unieron al proyecto. + +La nueva versión llegó a más gente en su primera semana que la anterior en un mes. + +El soporte respondió nueve de cada diez consultas el mismo día. + +Costes y presupuesto +-------------------- + +El gasto en software subió con las nuevas licencias, mientras que los viajes volvieron a bajar. + +El hardware se sustituyó una vez y el soporte se mantuvo estable durante el trimestre. + +Dos servidores pasaron al nuevo proveedor sin un día de caída. + +Próximo trimestre +----------------- + +La versión de septiembre es la última prevista este año. + +Hay dos vacantes en soporte y una en diseño. + +La mudanza de oficina es en noviembre y ya tiene presupuesto. + +Las personas +------------ + +En la versión trabajaron seis personas, dos de ellas nuevas este año. + +La cobertura de vacaciones se organizó en abril y aguantó todo el verano. + +Todos han hecho la formación que exige la nueva licencia. + +Riesgos +------- + +La mudanza de noviembre es la única fecha que no puede moverse. + +Un proveedor aún no ha firmado las nuevas condiciones. + +El alojamiento sube en enero si no se renueva antes el contrato. + +Plan del proyecto +----------------- + +* Objetivos del trimestre +* Presupuesto y costes +* Próximos pasos + +Calendario +---------- + +* Versión en junio +* Revisión en julio +* Planificación en agosto + +Equipo +------ + +* Dos nuevos socios +* Soporte en tres idiomas +* Formación en otoño + +La próxima reunión es a finales de julio. diff --git a/OpenDocumentReader/Samples/sample-notes-fr.txt b/OpenDocumentReader/Samples/sample-notes-fr.txt new file mode 100644 index 0000000..e4264cb --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-fr.txt @@ -0,0 +1,72 @@ +Rapport trimestriel +=================== + +L'équipe a atteint tous les objectifs du deuxième trimestre et la nouvelle version est sortie à temps. + +Points forts +------------ + +Les coûts sont restés sous le budget et deux nouveaux partenaires ont rejoint le projet. + +La nouvelle version a touché plus de monde en une semaine que la précédente en un mois. + +Le support a répondu à neuf demandes sur dix le jour même. + +Coûts et budget +--------------- + +Les dépenses de logiciels ont augmenté avec les nouvelles licences, tandis que les voyages ont encore baissé. + +Le matériel a été remplacé une fois et le support est resté stable sur le trimestre. + +Deux serveurs ont migré vers le nouveau prestataire sans une journée d'arrêt. + +Trimestre prochain +------------------ + +La version de septembre est la dernière prévue cette année. + +Deux postes sont ouverts au support, un au design. + +Le déménagement est prévu en novembre, et le budget est validé. + +Les personnes +------------- + +Six personnes ont travaillé sur la version, dont deux arrivées cette année. + +Les remplacements d'été ont été organisés en avril et ont tenu. + +Tout le monde a suivi la formation qu'exige la nouvelle licence. + +Risques +------- + +Le déménagement de novembre est la seule date qui ne peut pas bouger. + +Un prestataire n'a pas encore signé les nouvelles conditions. + +L'hébergement augmente en janvier sans renouvellement anticipé. + +Plan du projet +-------------- + +* Objectifs du trimestre +* Budget et coûts +* Prochaines étapes + +Calendrier +---------- + +* Version en juin +* Bilan en juillet +* Planification en août + +Équipe +------ + +* Deux nouveaux partenaires +* Support en trois langues +* Formation à l'automne + +La prochaine réunion aura lieu fin juillet. diff --git a/OpenDocumentReader/Samples/sample-notes-it.txt b/OpenDocumentReader/Samples/sample-notes-it.txt new file mode 100644 index 0000000..19ddeb3 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-it.txt @@ -0,0 +1,72 @@ +Relazione trimestrale +===================== + +Il team ha raggiunto tutti gli obiettivi del secondo trimestre e la nuova versione è uscita in tempo. + +In evidenza +----------- + +I costi sono rimasti sotto il budget e due nuovi partner si sono uniti al progetto. + +La nuova versione ha raggiunto più persone in una settimana di quante la precedente in un mese. + +Il supporto ha risposto a nove richieste su dieci in giornata. + +Costi e budget +-------------- + +La spesa per il software è cresciuta con le nuove licenze, mentre i viaggi sono di nuovo calati. + +L'hardware è stato sostituito una volta e il supporto è rimasto stabile per tutto il trimestre. + +Due server sono passati al nuovo fornitore senza un giorno di fermo. + +Prossimo trimestre +------------------ + +La versione di settembre è l'ultima prevista quest'anno. + +Ci sono due posizioni aperte nel supporto e una nel design. + +Il trasloco è a novembre e il budget è 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 è l'unica data che non può slittare. + +Un fornitore non ha ancora firmato le nuove condizioni. + +L'hosting aumenta a gennaio se il contratto non si rinnova prima. + +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 + +Il prossimo incontro è a fine luglio. diff --git a/OpenDocumentReader/Samples/sample-notes-pl.txt b/OpenDocumentReader/Samples/sample-notes-pl.txt new file mode 100644 index 0000000..f3de8b2 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-pl.txt @@ -0,0 +1,72 @@ +Raport kwartalny +================ + +Zespół osiągnął wszystkie cele drugiego kwartału, a nowa wersja ukazała się na czas. + +Najważniejsze +------------- + +Koszty pozostały poniżej budżetu, a do projektu dołączyło dwóch nowych partnerów. + +Nowa wersja dotarła w pierwszym tygodniu do większej liczby osób niż poprzednia w miesiąc. + +Wsparcie odpowiedziało na dziewięć z dziesięciu zgłoszeń tego samego dnia. + +Koszty i budżet +--------------- + +Wydatki na oprogramowanie wzrosły wraz z nowymi licencjami, a koszty podróży znów spadły. + +Sprzęt wymieniono raz, a wsparcie było stabilne przez cały kwartał. + +Dwa serwery przeniesiono do nowego dostawcy bez dnia przestoju. + +Następny kwartał +---------------- + +Wersja z września jest ostatnią zaplanowaną w tym roku. + +Otwarte są dwa etaty we wsparciu i jeden w dziale projektowym. + +Przeprowadzka biura wypada w listopadzie i ma już budżet. + +Ludzie +------ + +Nad wersją pracowało sześć osób, dwie z nich dołączyły w tym roku. + +Zastępstwa urlopowe ustalono w kwietniu i wytrzymały całe lato. + +Wszyscy przeszli szkolenie wymagane przez nową licencję. + +Ryzyka +------ + +Przeprowadzka w listopadzie to jedyny termin, który nie może się przesunąć. + +Jeden dostawca nie podpisał jeszcze nowych warunków. + +Koszty hostingu rosną w styczniu, jeśli umowa nie zostanie odnowiona wcześniej. + +Plan projektu +------------- + +* Cele na kwartał +* Budżet i koszty +* Kolejne kroki + +Harmonogram +----------- + +* Wersja w czerwcu +* Podsumowanie w lipcu +* Planowanie w sierpniu + +Zespół +------ + +* Dwóch nowych partnerów +* Wsparcie w trzech językach +* Szkolenia jesienią + +Następne spotkanie odbędzie się pod koniec lipca. diff --git a/OpenDocumentReader/Samples/sample-notes-pt-BR.txt b/OpenDocumentReader/Samples/sample-notes-pt-BR.txt new file mode 100644 index 0000000..88fc24a --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-pt-BR.txt @@ -0,0 +1,72 @@ +Relatório trimestral +==================== + +A equipe alcançou todas as metas do segundo trimestre e a nova versão saiu no prazo. + +Destaques +--------- + +Os custos ficaram abaixo do orçamento e dois novos parceiros entraram no projeto. + +A nova versão alcançou mais pessoas na primeira semana do que a anterior em um mês. + +O suporte respondeu nove de cada dez chamados no mesmo dia. + +Custos e orçamento +------------------ + +Os gastos com software subiram com as novas licenças, enquanto as viagens caíram de novo. + +O hardware foi substituído uma vez e o suporte se manteve estável no trimestre. + +Dois servidores migraram para o novo provedor sem um dia de indisponibilidade. + +Próximo trimestre +----------------- + +A versão de setembro é a última prevista para este ano. + +Há duas vagas no suporte e uma no design. + +A mudança de escritório é em novembro e já tem orçamento. + +As pessoas +---------- + +Seis pessoas trabalharam na versão, duas delas novas este ano. + +A cobertura de férias foi combinada em abril e durou o verão todo. + +Todos fizeram o treinamento que a nova licença exige. + +Riscos +------ + +A mudança de novembro é a única data que não pode atrasar. + +Um fornecedor ainda não assinou as novas condições. + +A hospedagem sobe em janeiro se o contrato não for renovado antes. + +Plano do projeto +---------------- + +* Metas do trimestre +* Orçamento e custos +* Próximos passos + +Cronograma +---------- + +* Versão em junho +* Revisão em julho +* Planejamento em agosto + +Equipe +------ + +* Dois novos parceiros +* Suporte em três idiomas +* Treinamento no outono + +A próxima reunião é no fim de julho. diff --git a/OpenDocumentReader/Samples/sample-notes-ru.txt b/OpenDocumentReader/Samples/sample-notes-ru.txt new file mode 100644 index 0000000..8cda42f --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-ru.txt @@ -0,0 +1,72 @@ +Квартальный отчёт +================= + +Команда выполнила все цели второго квартала, и новая версия вышла в срок. + +Главное +------- + +Расходы остались в рамках бюджета, а к проекту присоединились два новых партнёра. + +За первую неделю новая версия охватила больше людей, чем предыдущая за месяц. + +Поддержка ответила на девять из десяти обращений в тот же день. + +Расходы и бюджет +---------------- + +Затраты на программы выросли из-за новых лицензий, а расходы на поездки снова снизились. + +Оборудование меняли один раз, поддержка работала стабильно весь квартал. + +Два сервера переехали к новому провайдеру без единого дня простоя. + +Следующий квартал +----------------- + +Версия в сентябре — последняя из запланированных в этом году. + +Открыты две вакансии в поддержке и одна в дизайне. + +Переезд офиса намечен на ноябрь, бюджет утверждён. + +Люди +---- + +Над версией работали шесть человек, двое из них пришли в этом году. + +Замены на время отпусков согласовали в апреле, и летом всё сошлось. + +Все прошли обучение, которого требует новая лицензия. + +Риски +----- + +Переезд в ноябре — единственная дата, которую нельзя сдвинуть. + +Один поставщик так и не подписал новые условия. + +Хостинг подорожает в январе, если не продлить договор заранее. + +План проекта +------------ + +* Цели на квартал +* Бюджет и расходы +* Следующие шаги + +График +------ + +* Версия в июне +* Итоги в июле +* Планирование в августе + +Команда +------- + +* Два новых партнёра +* Поддержка на трёх языках +* Обучение осенью + +Следующая встреча — в конце июля. diff --git a/OpenDocumentReader/Samples/sample-notes-tr.txt b/OpenDocumentReader/Samples/sample-notes-tr.txt new file mode 100644 index 0000000..a7f82e2 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-notes-tr.txt @@ -0,0 +1,72 @@ +Üç aylık rapor +============== + +Ekip ikinci çeyreğin tüm hedeflerine ulaştı ve yeni sürüm zamanında yayınlandı. + +Öne çıkanlar +------------ + +Maliyetler bütçenin altında kaldı ve projeye iki yeni ortak katıldı. + +Yeni sürüm ilk haftasında, öncekinin bir ayda ulaştığından daha fazla kişiye ulaştı. + +Destek, on sorudan dokuzunu aynı gün yanıtladı. + +Maliyetler ve bütçe +------------------- + +Yeni lisanslarla yazılım harcamaları arttı, seyahat giderleri yeniden düştü. + +Donanım bir kez yenilendi ve destek çeyrek boyunca istikrarlı kaldı. + +İki sunucu, bir gün bile kesinti olmadan yeni sağlayıcıya taşındı. + +Gelecek çeyrek +-------------- + +Eylüldeki sürüm bu yıl planlanan son sürüm. + +Destekte iki, tasarımda bir pozisyon açık. + +Ofis taşınması kasımda ve bütçesi onaylandı. + +Ekip +---- + +Sürüm üzerinde altı kişi çalıştı, ikisi bu yıl katıldı. + +İzin dönemi vekaletleri nisanda ayarlandı ve yaz boyunca yürüdü. + +Herkes yeni lisansın gerektirdiği eğitimi tamamladı. + +Riskler +------- + +Kasımdaki taşınma, kayamayacak tek tarih. + +Bir tedarikçi yeni koşulları henüz imzalamadı. + +Sözleşme erken yenilenmezse barındırma maliyeti ocakta artıyor. + +Proje planı +----------- + +* Çeyrek hedefleri +* Bütçe ve maliyetler +* Sonraki adımlar + +Takvim +------ + +* Haziranda sürüm +* Temmuzda değerlendirme +* Ağustosta planlama + +Ekip +---- + +* İki yeni ortak +* Üç dilde destek +* Sonbaharda eğitim + +Bir sonraki toplantı temmuz sonunda. diff --git a/OpenDocumentReader/Samples/sample-paper-de.pdf b/OpenDocumentReader/Samples/sample-paper-de.pdf new file mode 100644 index 0000000..bc87605 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-de.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-en.pdf b/OpenDocumentReader/Samples/sample-paper-en.pdf new file mode 100644 index 0000000..3540f17 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-en.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-es.pdf b/OpenDocumentReader/Samples/sample-paper-es.pdf new file mode 100644 index 0000000..bf14442 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-es.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-fr.pdf b/OpenDocumentReader/Samples/sample-paper-fr.pdf new file mode 100644 index 0000000..7e984ca Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-fr.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-it.pdf b/OpenDocumentReader/Samples/sample-paper-it.pdf new file mode 100644 index 0000000..9dd1c5d Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-it.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-pl.pdf b/OpenDocumentReader/Samples/sample-paper-pl.pdf new file mode 100644 index 0000000..3540f17 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-pl.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-pt-BR.pdf b/OpenDocumentReader/Samples/sample-paper-pt-BR.pdf new file mode 100644 index 0000000..49a8c81 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-pt-BR.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-ru.pdf b/OpenDocumentReader/Samples/sample-paper-ru.pdf new file mode 100644 index 0000000..3540f17 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-ru.pdf differ diff --git a/OpenDocumentReader/Samples/sample-paper-tr.pdf b/OpenDocumentReader/Samples/sample-paper-tr.pdf new file mode 100644 index 0000000..3540f17 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-paper-tr.pdf differ diff --git a/OpenDocumentReader/Samples/sample-rows-de.csv b/OpenDocumentReader/Samples/sample-rows-de.csv new file mode 100644 index 0000000..f42b5d1 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-de.csv @@ -0,0 +1,7 @@ +Name,Rolle,E-Mail,Telefon +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Support,rossi@example.org,+43 1 234 5610 +J. Novak,Vertrieb,novak@example.org,+43 1 234 5620 +L. Dubois,Entwicklung,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Support,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-en.csv b/OpenDocumentReader/Samples/sample-rows-en.csv new file mode 100644 index 0000000..4b3409b --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-en.csv @@ -0,0 +1,7 @@ +Name,Role,Email,Phone +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Support,rossi@example.org,+43 1 234 5610 +J. Novak,Sales,novak@example.org,+43 1 234 5620 +L. Dubois,Engineering,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Support,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-es.csv b/OpenDocumentReader/Samples/sample-rows-es.csv new file mode 100644 index 0000000..1f89d28 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-es.csv @@ -0,0 +1,7 @@ +Nombre,Puesto,Correo,Teléfono +A. Bauer,Diseño,bauer@example.org,+43 1 234 5600 +M. Rossi,Soporte,rossi@example.org,+43 1 234 5610 +J. Novak,Ventas,novak@example.org,+43 1 234 5620 +L. Dubois,Ingeniería,dubois@example.org,+43 1 234 5630 +S. Meyer,Diseño,meyer@example.org,+43 1 234 5640 +K. Larsen,Soporte,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-fr.csv b/OpenDocumentReader/Samples/sample-rows-fr.csv new file mode 100644 index 0000000..df84a66 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-fr.csv @@ -0,0 +1,7 @@ +Nom,Rôle,E-mail,Téléphone +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Support,rossi@example.org,+43 1 234 5610 +J. Novak,Ventes,novak@example.org,+43 1 234 5620 +L. Dubois,Développement,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Support,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-it.csv b/OpenDocumentReader/Samples/sample-rows-it.csv new file mode 100644 index 0000000..256b09c --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-it.csv @@ -0,0 +1,7 @@ +Nome,Ruolo,E-mail,Telefono +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Supporto,rossi@example.org,+43 1 234 5610 +J. Novak,Vendite,novak@example.org,+43 1 234 5620 +L. Dubois,Sviluppo,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Supporto,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-pl.csv b/OpenDocumentReader/Samples/sample-rows-pl.csv new file mode 100644 index 0000000..846489c --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-pl.csv @@ -0,0 +1,7 @@ +Imię,Rola,E-mail,Telefon +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Wsparcie,rossi@example.org,+43 1 234 5610 +J. Novak,Sprzedaż,novak@example.org,+43 1 234 5620 +L. Dubois,Rozwój,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Wsparcie,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-pt-BR.csv b/OpenDocumentReader/Samples/sample-rows-pt-BR.csv new file mode 100644 index 0000000..de0f727 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-pt-BR.csv @@ -0,0 +1,7 @@ +Nome,Função,E-mail,Telefone +A. Bauer,Design,bauer@example.org,+43 1 234 5600 +M. Rossi,Suporte,rossi@example.org,+43 1 234 5610 +J. Novak,Vendas,novak@example.org,+43 1 234 5620 +L. Dubois,Engenharia,dubois@example.org,+43 1 234 5630 +S. Meyer,Design,meyer@example.org,+43 1 234 5640 +K. Larsen,Suporte,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-ru.csv b/OpenDocumentReader/Samples/sample-rows-ru.csv new file mode 100644 index 0000000..1d49415 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-ru.csv @@ -0,0 +1,7 @@ +Имя,Роль,Почта,Телефон +A. Bauer,Дизайн,bauer@example.org,+43 1 234 5600 +M. Rossi,Поддержка,rossi@example.org,+43 1 234 5610 +J. Novak,Продажи,novak@example.org,+43 1 234 5620 +L. Dubois,Разработка,dubois@example.org,+43 1 234 5630 +S. Meyer,Дизайн,meyer@example.org,+43 1 234 5640 +K. Larsen,Поддержка,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-rows-tr.csv b/OpenDocumentReader/Samples/sample-rows-tr.csv new file mode 100644 index 0000000..1380b79 --- /dev/null +++ b/OpenDocumentReader/Samples/sample-rows-tr.csv @@ -0,0 +1,7 @@ +Ad,Görev,E-posta,Telefon +A. Bauer,Tasarım,bauer@example.org,+43 1 234 5600 +M. Rossi,Destek,rossi@example.org,+43 1 234 5610 +J. Novak,Satış,novak@example.org,+43 1 234 5620 +L. Dubois,Geliştirme,dubois@example.org,+43 1 234 5630 +S. Meyer,Tasarım,meyer@example.org,+43 1 234 5640 +K. Larsen,Destek,larsen@example.org,+43 1 234 5650 diff --git a/OpenDocumentReader/Samples/sample-sheet-de.ods b/OpenDocumentReader/Samples/sample-sheet-de.ods new file mode 100644 index 0000000..db64526 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-de.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-en.ods b/OpenDocumentReader/Samples/sample-sheet-en.ods new file mode 100644 index 0000000..4c22603 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-en.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-es.ods b/OpenDocumentReader/Samples/sample-sheet-es.ods new file mode 100644 index 0000000..2521bde Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-es.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-fr.ods b/OpenDocumentReader/Samples/sample-sheet-fr.ods new file mode 100644 index 0000000..265e44f Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-fr.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-it.ods b/OpenDocumentReader/Samples/sample-sheet-it.ods new file mode 100644 index 0000000..3011475 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-it.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-pl.ods b/OpenDocumentReader/Samples/sample-sheet-pl.ods new file mode 100644 index 0000000..61ba389 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-pl.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-pt-BR.ods b/OpenDocumentReader/Samples/sample-sheet-pt-BR.ods new file mode 100644 index 0000000..4160f3a Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-pt-BR.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-ru.ods b/OpenDocumentReader/Samples/sample-sheet-ru.ods new file mode 100644 index 0000000..0b46656 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-ru.ods differ diff --git a/OpenDocumentReader/Samples/sample-sheet-tr.ods b/OpenDocumentReader/Samples/sample-sheet-tr.ods new file mode 100644 index 0000000..44fadad Binary files /dev/null and b/OpenDocumentReader/Samples/sample-sheet-tr.ods differ diff --git a/OpenDocumentReader/Samples/sample-slides-de.odp b/OpenDocumentReader/Samples/sample-slides-de.odp new file mode 100644 index 0000000..69d56fd Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-de.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-en.odp b/OpenDocumentReader/Samples/sample-slides-en.odp new file mode 100644 index 0000000..776db28 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-en.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-es.odp b/OpenDocumentReader/Samples/sample-slides-es.odp new file mode 100644 index 0000000..d86d280 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-es.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-fr.odp b/OpenDocumentReader/Samples/sample-slides-fr.odp new file mode 100644 index 0000000..cde0944 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-fr.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-it.odp b/OpenDocumentReader/Samples/sample-slides-it.odp new file mode 100644 index 0000000..bad684a Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-it.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-pl.odp b/OpenDocumentReader/Samples/sample-slides-pl.odp new file mode 100644 index 0000000..5f9a67f Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-pl.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-pt-BR.odp b/OpenDocumentReader/Samples/sample-slides-pt-BR.odp new file mode 100644 index 0000000..ab21029 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-pt-BR.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-ru.odp b/OpenDocumentReader/Samples/sample-slides-ru.odp new file mode 100644 index 0000000..58cb65d Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-ru.odp differ diff --git a/OpenDocumentReader/Samples/sample-slides-tr.odp b/OpenDocumentReader/Samples/sample-slides-tr.odp new file mode 100644 index 0000000..3d0838f Binary files /dev/null and b/OpenDocumentReader/Samples/sample-slides-tr.odp differ diff --git a/OpenDocumentReader/Samples/sample-text-de.odt b/OpenDocumentReader/Samples/sample-text-de.odt new file mode 100644 index 0000000..a1d01fd Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-de.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-en.odt b/OpenDocumentReader/Samples/sample-text-en.odt new file mode 100644 index 0000000..1149cf7 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-en.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-es.odt b/OpenDocumentReader/Samples/sample-text-es.odt new file mode 100644 index 0000000..c295f9f Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-es.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-fr.odt b/OpenDocumentReader/Samples/sample-text-fr.odt new file mode 100644 index 0000000..4258475 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-fr.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-it.odt b/OpenDocumentReader/Samples/sample-text-it.odt new file mode 100644 index 0000000..819e7de Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-it.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-pl.odt b/OpenDocumentReader/Samples/sample-text-pl.odt new file mode 100644 index 0000000..2fa2c42 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-pl.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-pt-BR.odt b/OpenDocumentReader/Samples/sample-text-pt-BR.odt new file mode 100644 index 0000000..42eeaca Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-pt-BR.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-ru.odt b/OpenDocumentReader/Samples/sample-text-ru.odt new file mode 100644 index 0000000..727f2cf Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-ru.odt differ diff --git a/OpenDocumentReader/Samples/sample-text-tr.odt b/OpenDocumentReader/Samples/sample-text-tr.odt new file mode 100644 index 0000000..e2a5862 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-text-tr.odt differ diff --git a/OpenDocumentReader/Samples/sample-word-de.docx b/OpenDocumentReader/Samples/sample-word-de.docx new file mode 100644 index 0000000..df39df2 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-de.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-en.docx b/OpenDocumentReader/Samples/sample-word-en.docx new file mode 100644 index 0000000..f90cf6f Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-en.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-es.docx b/OpenDocumentReader/Samples/sample-word-es.docx new file mode 100644 index 0000000..0d5b335 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-es.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-fr.docx b/OpenDocumentReader/Samples/sample-word-fr.docx new file mode 100644 index 0000000..0907b71 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-fr.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-it.docx b/OpenDocumentReader/Samples/sample-word-it.docx new file mode 100644 index 0000000..1f874cf Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-it.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-pl.docx b/OpenDocumentReader/Samples/sample-word-pl.docx new file mode 100644 index 0000000..21c73b5 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-pl.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-pt-BR.docx b/OpenDocumentReader/Samples/sample-word-pt-BR.docx new file mode 100644 index 0000000..7a23144 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-pt-BR.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-ru.docx b/OpenDocumentReader/Samples/sample-word-ru.docx new file mode 100644 index 0000000..04a3177 Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-ru.docx differ diff --git a/OpenDocumentReader/Samples/sample-word-tr.docx b/OpenDocumentReader/Samples/sample-word-tr.docx new file mode 100644 index 0000000..548a3dd Binary files /dev/null and b/OpenDocumentReader/Samples/sample-word-tr.docx differ diff --git a/OpenDocumentReader/Samples/screenshot-names.json b/OpenDocumentReader/Samples/screenshot-names.json new file mode 100644 index 0000000..93c52e7 --- /dev/null +++ b/OpenDocumentReader/Samples/screenshot-names.json @@ -0,0 +1,290 @@ +{ + "de": { + "files": { + "application": "Bewerbung", + "cells": "Umsatzzahlen", + "course": "Kursfolien", + "deck": "Teamtreffen", + "expenses": "Ausgaben", + "hours": "Arbeitszeiten", + "household": "Haushaltsbudget", + "inventory": "Bestand", + "kickoff": "Auftakt", + "lease": "Mietvertrag", + "letter": "Brief an den Vermieter", + "manual": "Anleitung", + "meeting": "Besprechungsnotizen", + "notes": "Notizen", + "paper": "Rechnung", + "reading": "Leseliste", + "resume": "Lebenslauf", + "review": "Quartalsrückblick", + "rows": "Kontakte", + "sheet": "Budget", + "slides": "Projektplan", + "stocktake": "Inventur", + "text": "Quartalsbericht", + "ticket": "Ticket", + "travel": "Reiseplan", + "warranty": "Garantie", + "word": "Vertrag" + }, + "search": "version" + }, + "en": { + "files": { + "application": "Application", + "cells": "Sales figures", + "course": "Course slides", + "deck": "Team offsite", + "expenses": "Expenses", + "hours": "Hours", + "household": "Household budget", + "inventory": "Inventory", + "kickoff": "Kickoff", + "lease": "Lease", + "letter": "Letter to the landlord", + "manual": "Manual", + "meeting": "Meeting notes", + "notes": "Notes", + "paper": "Invoice", + "reading": "Reading list", + "resume": "CV", + "review": "Quarterly review", + "rows": "Contacts", + "sheet": "Budget", + "slides": "Project plan", + "stocktake": "Stocktake", + "text": "Quarterly report", + "ticket": "Ticket", + "travel": "Travel plan", + "warranty": "Warranty", + "word": "Contract" + }, + "search": "release" + }, + "es": { + "files": { + "application": "Solicitud", + "cells": "Cifras de ventas", + "course": "Diapositivas del curso", + "deck": "Reunión de equipo", + "expenses": "Gastos", + "hours": "Horas", + "household": "Presupuesto doméstico", + "inventory": "Inventario", + "kickoff": "Inicio", + "lease": "Contrato de alquiler", + "letter": "Carta al casero", + "manual": "Manual", + "meeting": "Notas de reunión", + "notes": "Notas", + "paper": "Factura", + "reading": "Lista de lectura", + "resume": "Currículum", + "review": "Revisión trimestral", + "rows": "Contactos", + "sheet": "Presupuesto", + "slides": "Plan del proyecto", + "stocktake": "Recuento", + "text": "Informe trimestral", + "ticket": "Billete", + "travel": "Plan de viaje", + "warranty": "Garantía", + "word": "Contrato" + }, + "search": "versión" + }, + "fr": { + "files": { + "application": "Candidature", + "cells": "Chiffres de ventes", + "course": "Diapositives du cours", + "deck": "Réunion d'équipe", + "expenses": "Dépenses", + "hours": "Heures", + "household": "Budget familial", + "inventory": "Stock", + "kickoff": "Lancement", + "lease": "Bail", + "letter": "Lettre au propriétaire", + "manual": "Manuel", + "meeting": "Notes de réunion", + "notes": "Notes", + "paper": "Facture", + "reading": "Liste de lecture", + "resume": "CV", + "review": "Bilan trimestriel", + "rows": "Contacts", + "sheet": "Budget", + "slides": "Plan du projet", + "stocktake": "Inventaire", + "text": "Rapport trimestriel", + "ticket": "Billet", + "travel": "Plan de voyage", + "warranty": "Garantie", + "word": "Contrat" + }, + "search": "version" + }, + "it": { + "files": { + "application": "Candidatura", + "cells": "Dati di vendita", + "course": "Diapositive del corso", + "deck": "Riunione del team", + "expenses": "Spese", + "hours": "Ore", + "household": "Bilancio familiare", + "inventory": "Magazzino", + "kickoff": "Avvio", + "lease": "Contratto d'affitto", + "letter": "Lettera al locatore", + "manual": "Manuale", + "meeting": "Note della riunione", + "notes": "Note", + "paper": "Fattura", + "reading": "Lista di lettura", + "resume": "Curriculum", + "review": "Revisione trimestrale", + "rows": "Contatti", + "sheet": "Bilancio", + "slides": "Piano di progetto", + "stocktake": "Inventario", + "text": "Relazione trimestrale", + "ticket": "Biglietto", + "travel": "Piano di viaggio", + "warranty": "Garanzia", + "word": "Contratto" + }, + "search": "versione" + }, + "pl": { + "files": { + "application": "Podanie", + "cells": "Wyniki sprzedaży", + "course": "Slajdy kursu", + "deck": "Spotkanie zespołu", + "expenses": "Wydatki", + "hours": "Godziny", + "household": "Budżet domowy", + "inventory": "Stan magazynu", + "kickoff": "Rozpoczęcie", + "lease": "Umowa najmu", + "letter": "List do właściciela", + "manual": "Instrukcja", + "meeting": "Notatki ze spotkania", + "notes": "Notatki", + "paper": "Faktura", + "reading": "Lista lektur", + "resume": "CV", + "review": "Przegląd kwartalny", + "rows": "Kontakty", + "sheet": "Budżet", + "slides": "Plan projektu", + "stocktake": "Inwentaryzacja", + "text": "Raport kwartalny", + "ticket": "Bilet", + "travel": "Plan podróży", + "warranty": "Gwarancja", + "word": "Umowa" + }, + "search": "koszty" + }, + "pt-BR": { + "files": { + "application": "Inscrição", + "cells": "Números de vendas", + "course": "Slides do curso", + "deck": "Reunião da equipe", + "expenses": "Despesas", + "hours": "Horas", + "household": "Orçamento doméstico", + "inventory": "Estoque", + "kickoff": "Início", + "lease": "Contrato de aluguel", + "letter": "Carta ao locador", + "manual": "Manual", + "meeting": "Notas da reunião", + "notes": "Notas", + "paper": "Fatura", + "reading": "Lista de leitura", + "resume": "Currículo", + "review": "Revisão trimestral", + "rows": "Contatos", + "sheet": "Orçamento", + "slides": "Plano do projeto", + "stocktake": "Balanço", + "text": "Relatório trimestral", + "ticket": "Passagem", + "travel": "Plano de viagem", + "warranty": "Garantia", + "word": "Contrato" + }, + "search": "versão" + }, + "ru": { + "files": { + "application": "Заявление", + "cells": "Продажи", + "course": "Слайды курса", + "deck": "Встреча команды", + "expenses": "Расходы", + "hours": "Часы", + "household": "Домашний бюджет", + "inventory": "Склад", + "kickoff": "Старт проекта", + "lease": "Договор аренды", + "letter": "Письмо арендодателю", + "manual": "Инструкция", + "meeting": "Заметки со встречи", + "notes": "Заметки", + "paper": "Счёт", + "reading": "Список чтения", + "resume": "Резюме", + "review": "Квартальный обзор", + "rows": "Контакты", + "sheet": "Бюджет", + "slides": "План проекта", + "stocktake": "Инвентаризация", + "text": "Квартальный отчёт", + "ticket": "Билет", + "travel": "План поездки", + "warranty": "Гарантия", + "word": "Договор" + }, + "search": "расходы" + }, + "tr": { + "files": { + "application": "Başvuru", + "cells": "Satış rakamları", + "course": "Kurs slaytları", + "deck": "Ekip toplantısı", + "expenses": "Giderler", + "hours": "Çalışma saatleri", + "household": "Ev bütçesi", + "inventory": "Envanter", + "kickoff": "Başlangıç", + "lease": "Kira sözleşmesi", + "letter": "Ev sahibine mektup", + "manual": "Kılavuz", + "meeting": "Toplantı notları", + "notes": "Notlar", + "paper": "Fatura", + "reading": "Okuma listesi", + "resume": "Özgeçmiş", + "review": "Üç aylık değerlendirme", + "rows": "Kişiler", + "sheet": "Bütçe", + "slides": "Proje planı", + "stocktake": "Sayım", + "text": "Üç aylık rapor", + "ticket": "Bilet", + "travel": "Seyahat planı", + "warranty": "Garanti", + "word": "Sözleşme" + }, + "search": "sürüm" + } +} 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..fd85872 --- /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() + + // adds the language, the locale and fastlane's own arguments, once + setupSnapshot(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) + } + + snapshot(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/SnapshotHelper.swift b/OpenDocumentReaderUITests/SnapshotHelper.swift new file mode 100644 index 0000000..fed8a2b --- /dev/null +++ b/OpenDocumentReaderUITests/SnapshotHelper.swift @@ -0,0 +1,320 @@ +// Copied verbatim from fastlane, which is where `snapshot` expects it, and MIT +// licensed like the rest of it - not our MPL 2.0. Update it by copying the file +// again rather than by editing it; the version marker at the bottom is what +// fastlane checks it against. +// +// https://github.com/fastlane/fastlane/blob/master/snapshot/lib/assets/SnapshotHelper.swift +// +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch let error { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch let error { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.30] diff --git a/README.md b/README.md index 4afa177..ea41752 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,32 @@ 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 +``` + +Four pictures per device - the onboarding pages, a text document, a spreadsheet +and a presentation - 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, and written by `scripts/make-screenshot-documents.py`. + +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 +259,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..98f2a63 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -18,9 +18,25 @@ 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 + +# 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 +47,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 +134,58 @@ 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 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, + # 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 +368,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 +393,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 +410,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..98096a0 --- /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,", + "en cualquier momento." + ], + "fr-FR": [ + "Tous vos documents,", + "partout. à tout moment." + ], + "hi": [ + "आपके सभी दस्तावेज़,", + "कहीं भी. कभी भी." + ], + "it": [ + "Tutti i tuoi documenti,", + "ovunque. sempre." + ], + "pl": [ + "Wszystkie dokumenty,", + "wszędzie. zawsze." + ], + "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,", + "leer y buscar." + ], + "fr-FR": [ + "Documents texte,", + "lire et rechercher." + ], + "hi": [ + "टेक्स्ट दस्तावेज़,", + "पढ़ें और खोजें." + ], + "it": [ + "Documenti di testo,", + "leggi e cerca." + ], + "pl": [ + "Dokumenty tekstowe,", + "czytaj i wyszukuj." + ], + "pt-BR": [ + "Documentos de texto,", + "leia e pesquise." + ], + "ru": [ + "Текстовые документы,", + "читайте и ищите." + ], + "sv": [ + "Textdokument,", + "läs och sök." + ], + "tr": [ + "Metin belgeleri,", + "okuyun ve arayin." + ] + } + }, + { + "name": "03-sheet", + "background": "orange", + "chips": [ + "ods" + ], + "headline": { + "en-US": [ + "Spreadsheets,", + "sheet by sheet." + ], + "de-DE": [ + "Tabellenkalkulationen,", + "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,", + "planilha por planilha." + ], + "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 ausbessern." + ], + "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,", + "aquí mismo." + ], + "fr-FR": [ + "Ouvrez tout PDF,", + "directement dans l'app." + ], + "hi": [ + "कोई भी PDF खोलें,", + "ऐप में ही." + ], + "it": [ + "Apri qualsiasi PDF,", + "direttamente nell'app." + ], + "pl": [ + "Otwórz każdy 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": [ + "Aussi les fichiers Office :", + ".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..86f6d5c --- /dev/null +++ b/fastlane/screenshots/README.md @@ -0,0 +1,58 @@ +# 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: `scripts/make-screenshot-documents.py` writes them, they are +bundled into Debug builds only, and `-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/format.sh b/scripts/format.sh index 8a07f15..6c72cd7 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -27,6 +27,11 @@ if ! xcrun --find swift-format >/dev/null 2>&1; then exit 1 fi +# SnapshotHelper.swift is left out: it belongs to fastlane and is kept byte for +# byte as they ship it, so the next version can be copied over it. +# +# The comment stays out here. bash 3.2, which is the one macOS ships and the one +# CI runs, cannot parse an apostrophe in a comment inside a <( ). files=() while IFS= read -r -d '' file; do # tracked files can be staged for deletion and no longer exist on disk @@ -34,7 +39,8 @@ while IFS= read -r -d '' file; do files+=("$file") fi done < <( - git ls-files -z --cached --others --exclude-standard -- '*.swift' + git ls-files -z --cached --others --exclude-standard -- \ + '*.swift' ':!:OpenDocumentReaderUITests/SnapshotHelper.swift' ) if [ ${#files[@]} -eq 0 ]; then diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py new file mode 100755 index 0000000..69af2c5 --- /dev/null +++ b/scripts/frame-screenshots.py @@ -0,0 +1,691 @@ +#!/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 importlib.util +import json +import math +import shutil +import sys +from pathlib import Path + +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" + + +def load(path, name): + """Import a sibling script, whose file name is not an identifier.""" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +store = load(ROOT / "scripts" / "store-screenshots.py", "store_screenshots") + +# 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..86f8034 --- /dev/null +++ b/scripts/make-screenshot-documents.py @@ -0,0 +1,1559 @@ +#!/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 + +Rerun it when the wording changes, and commit what it writes. The packages are +byte for byte reproducible, so a rerun that changes nothing leaves no diff. +""" + +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 \u00fcber das Quartal stabil.", + "Zwei Server sind ohne Ausfalltag 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.", + ]], + ["Die Menschen", [ + "An der Version arbeiteten sechs Personen, zwei davon neu in diesem Jahr.", + "Die Urlaubsvertretung wurde im April geregelt und trug durch den Sommer.", + "Alle haben die Schulung absolviert, die die neue Lizenz verlangt.", + ]], + ["Risiken", [ + "Der Umzug im November ist der einzige Termin, an dem nichts vorbeigeht.", + "Ein Lieferant hat die neuen Bedingungen noch nicht unterschrieben.", + "Die Hostingkosten steigen im Januar, wenn der Vertrag nicht fr\xfcher erneuert wird.", + ]], + ], + "closing": "Das n\u00e4chste Treffen findet Ende Juli statt.", + "sheets": ["\u00dcbersicht", "Kosten"], + "item": "Position", + "total": "Gesamt", + "periods": ["Q1", "Q2", "Q3", "Q4"], + "rows": ["Software", "Reisen", "Hardware", "Marketing", "Support", "Schulung", "Lizenzen", "Hosting", "Veranstaltungen", "B\u00fcro", "Cloud", "Personalsuche", "Recht", "Versicherung", "Verpflegung", "Versand", "Werbung", "Beratung", "Wartung", "Nebenkosten"], + "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 un d\xeda de ca\xedda.",]], + ["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.", + "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": ["T1", "T2", "T3", "T4"], + "rows": ["Software", "Viajes", "Hardware", "Marketing", "Soporte", "Formaci\u00f3n", "Licencias", "Alojamiento", "Eventos", "Oficina", "Nube", "Contrataci\xf3n", "Legal", "Seguros", "Catering", "Env\xedos", "Publicidad", "Consultor\xeda", "Mantenimiento", "Suministros"], + "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 sous 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 de logiciels ont augment\u00e9 avec les nouvelles licences, tandis que les voyages 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 une journ\xe9e d'arr\xeat.",]], + ["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 d'\xe9t\xe9 ont \xe9t\xe9 organis\xe9s en avril et ont tenu.", + "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.", + "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": ["T1", "T2", "T3", "T4"], + "rows": ["Logiciels", "Voyages", "Mat\u00e9riel", "Marketing", "Support", "Formation", "Licences", "H\u00e9bergement", "\u00c9v\u00e9nements", "Bureau", "Cloud", "Recrutement", "Juridique", "Assurance", "Traiteur", "Exp\xe9dition", "Publicit\xe9", "Conseil", "Maintenance", "Charges"], + "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 pi\u00f9 persone in una settimana di quante 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 i viaggi sono di nuovo calati.", + "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.", + "L'hosting aumenta a gennaio se il contratto non si rinnova prima.", + ]], + ], + "closing": "Il prossimo incontro \u00e8 a fine luglio.", + "sheets": ["Panoramica", "Costi"], + "item": "Voce", + "total": "Totale", + "periods": ["T1", "T2", "T3", "T4"], + "rows": ["Software", "Viaggi", "Hardware", "Marketing", "Supporto", "Formazione", "Licenze", "Hosting", "Eventi", "Ufficio", "Cloud", "Selezione", "Legale", "Assicurazione", "Catering", "Spedizioni", "Pubblicit\xe0", "Consulenza", "Manutenzione", "Utenze"], + "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 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, dwie z nich do\u0142\u0105czy\u0142y w tym roku.", + "Zast\u0119pstwa urlopowe ustalono w kwietniu i wytrzyma\u0142y 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.", + "Koszty hostingu rosn\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": ["I kw.", "II kw.", "III kw.", "IV kw."], + "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"], + "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 cobertura de f\xe9rias foi combinada em abril e durou 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": ["T1", "T2", "T3", "T4"], + "rows": ["Software", "Viagens", "Hardware", "Marketing", "Suporte", "Treinamento", "Licen\u00e7as", "Hospedagem", "Eventos", "Escrit\u00f3rio", "Nuvem", "Recrutamento", "Jur\xeddico", "Seguros", "Buffet", "Frete", "Publicidade", "Consultoria", "Manuten\xe7\xe3o", "Utilidades"], + "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 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0430 \u0432\u0441\u0435 \u0446\u0435\u043b\u0438 \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 \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \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 \u043b\u0435\u0442\u043e\u043c \u0432\u0441\u0451 \u0441\u043e\u0448\u043b\u043e\u0441\u044c.", + "\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.", + "\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": ["I \u043a\u0432.", "II \u043a\u0432.", "III \u043a\u0432.", "IV \u043a\u0432."], + "rows": ["\u041f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b", "\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\u0439\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"], + "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 ayarland\u0131 ve yaz boyunca y\xfcr\xfcd\xfc.", + "Herkes yeni lisans\u0131n gerektirdi\u011fi e\u011fitimi tamamlad\u0131.", + ]], + ["Riskler", [ + "Kas\u0131mdaki ta\u015f\u0131nma, kayamayacak tek tarih.", + "Bir tedarik\xe7i yeni ko\u015fullar\u0131 hen\xfcz imzalamad\u0131.", + "S\xf6zle\u015fme erken yenilenmezse bar\u0131nd\u0131rma maliyeti ocakta art\u0131yor.", + ]], + ], + "closing": "Bir sonraki toplant\u0131 temmuz sonunda.", + "sheets": ["Genel bak\u0131\u015f", "Maliyetler"], + "item": "Kalem", + "total": "Toplam", + "periods": ["1. \u00c7", "2. \u00c7", "3. \u00c7", "4. \u00c7"], + "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"], + "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": "Reuni\u00f3n de equipo", + "paper": "Factura", "rows": "Contactos", "notes": "Notas"}, + "fr": {"text": "Rapport trimestriel", "sheet": "Budget", "slides": "Plan du projet", + "word": "Contrat", "cells": "Chiffres de ventes", "deck": "R\u00e9union d'\u00e9quipe", + "paper": "Facture", "rows": "Contacts", "notes": "Notes"}, + "it": {"text": "Relazione trimestrale", "sheet": "Bilancio", "slides": "Piano di progetto", + "word": "Contratto", "cells": "Dati di vendita", "deck": "Riunione 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 toplant\u0131s\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": "Inicio", "course": "Diapositivas del curso", + "lease": "Contrato de alquiler", "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": "Plan de voyage", "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", + "travel": "Plan podr\u00f3\u017cy", "reading": "Lista lektur", + "household": "Bud\u017cet domowy", "hours": "Godziny", "stocktake": "Inwentaryzacja", + "kickoff": "Rozpocz\u0119cie", "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": "In\u00edcio", "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": "Ba\u015flang\u0131\u00e7", "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", "Role", "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", "Rolle", "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", "Puesto", "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 partie ne peut c\xe9der ce contrat \xe0 un tiers sans accord.", + "L'annexe indique le logiciel couvert et la version \xe0 partir de laquelle il 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", "R\u00f4le", "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 da cui decorre.", + "Il presente contratto sostituisce ogni accordo precedente tra le parti."]], + "invoice": ["Fattura 2026-014", "Emessa il 12 giugno 2026", "Da saldare entro 30 giorni", "Fatturato a", "Subtotale", "IVA 20%", "Importo dovuto", "Grazie per la collaborazione.", "Qt\xe0", "Prezzo unit."], + "contacts": [["Nome", "Ruolo", "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 wymagana w ci\u0105gu trzydziestu dni od 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.", + "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 obj\u0119te oprogramowanie i wersj\u0119, od kt\xf3rej obowi\u0105zuje.", + "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", "Suma cz\u0119\u015bciowa", "VAT 20%", "Do zap\u0142aty", "Dzi\u0119kujemy za wsp\xf3\u0142prac\u0119.", "Ilo\u015b\u0107", "Cena jedn."], + "contacts": [["Imi\u0119", "Rola", "E-mail", "Telefon"], + ["Design", "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 tr\u00eas meses de aviso.", + "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 valem no primeiro ano e s\xe3o revistos a cada outono.", + "Nenhuma parte pode transferir este contrato a terceiros sem consentimento.", + "O anexo lista o software abrangido e a vers\xe3o a partir da qual vale.", + "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", "IVA 20%", "Valor a pagar", "Obrigado pela prefer\xeancia.", "Qtd", "Pre\xe7o unit."], + "contacts": [["Nome", "Fun\u00e7\u00e3o", "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 \u0438\u0437 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \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 \u0447\u0435\u043b\u043e\u0432\u0435\u043a\u0430, \u0443\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e\u0449\u0435\u0433\u043e \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f.", + "\u0426\u0435\u043d\u044b \u0434\u0435\u0440\u0436\u0430\u0442\u0441\u044f \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 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u043e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u043d\u043e\u0435 \u043e\u0431\u0435\u0441\u043f\u0435\u0447\u0435\u043d\u0438\u0435 \u0438 \u0432\u0435\u0440\u0441\u0438\u044f, \u0441 \u043a\u043e\u0442\u043e\u0440\u043e\u0439 \u043e\u043d\u043e \u0434\u0435\u0439\u0441\u0442\u0432\u0443\u0435\u0442.", + "\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", "\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": [["\u0418\u043c\u044f", "\u0420\u043e\u043b\u044c", "\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", "Fatura edilen", "Ara toplam", "KDV %20", "\xd6denecek tutar", "\u0130\u015f birli\u011finiz i\xe7in te\u015fekk\xfcrler.", "Adet", "Birim fiyat"], + "contacts": [["Ad", "G\u00f6rev", "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..829496b --- /dev/null +++ b/scripts/store-screenshots.py @@ -0,0 +1,246 @@ +#!/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 +# +# 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())