diff --git a/.github/scripts/pick-avd-profile.sh b/.github/scripts/pick-avd-profile.sh new file mode 100755 index 000000000000..bd1b241da3f1 --- /dev/null +++ b/.github/scripts/pick-avd-profile.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Prints the newest emulator device profile this runner has for a form factor, +# out of a list written newest first. +# +# .github/scripts/pick-avd-profile.sh phone +# +# Pinned to one name, a release job fails the day the image catalogue renames or +# retires it, and "photograph the newest Pixel Pro" becomes a name to walk +# forward by hand every autumn. So the newest one the runner has wins, and a +# runner with none of them says which it does have. + +set -euo pipefail + +case "${1:-}" in + phone) + candidates="pixel_10_pro_xl pixel_9_pro_xl pixel_8_pro pixel_7_pro pixel_6_pro" + ;; + tablet) + # Google has shipped one tablet and it is still the newest, so this list is + # short by nature rather than by neglect. + candidates="pixel_tablet pixel_c" + ;; + *) + echo "usage: $0 phone|tablet" >&2 + exit 2 + ;; +esac + +# Where the catalogue is read from. A runner has the sdk without cmdline-tools on +# its path, so `avdmanager` is looked for under it rather than called by name - +# and it is the emulator action, later in the job, that puts one there at all. +avdmanager=$(command -v avdmanager || true) +if [ -z "$avdmanager" ]; then + for root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}"; do + [ -n "$root" ] || continue + for found in "$root"/cmdline-tools/*/bin/avdmanager "$root"/tools/bin/avdmanager; do + if [ -x "$found" ]; then + avdmanager="$found" + break 2 + fi + done + done +fi + +# The newest one, unchecked, rather than no answer at all: this step is here to +# save the job twenty minutes, and a job that cannot run for want of a path is +# the thing it was written to avoid. A name the runner does not have is refused +# by the emulator action a minute later, and says so. +if [ -z "$avdmanager" ]; then + echo "::warning::no avdmanager to read the device catalogue with - taking the newest name unchecked" >&2 + echo "${candidates%% *}" + exit 0 +fi + +available=$("$avdmanager" list device -c) + +for candidate in $candidates; do + if grep -qx "$candidate" <<< "$available"; then + echo "$candidate" + exit 0 + fi +done + +echo "::error::this runner has none of: $candidates" >&2 +echo "it has: $(tr '\n' ' ' <<< "$available")" >&2 +exit 1 diff --git a/.github/scripts/take-screenshots.sh b/.github/scripts/take-screenshots.sh new file mode 100755 index 000000000000..c7f4dd70b74d --- /dev/null +++ b/.github/scripts/take-screenshots.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Takes the store screenshots inside reactivecircus/android-emulator-runner. +# +# The action executes its "script:" input line by line, each line in its own +# "sh -c" - so a multi-line if or loop is a syntax error there, and a variable +# does not survive to the next line. Everything that needs shell state lives +# here instead, behind a one-line invocation. Same arrangement, and the same +# reason, as run-instrumented-tests.sh next to it. + +set -u + +adb logcat -c || true + +status=0 +# One device in fifteen languages is ninety launches, each opening a document the +# core has to translate first; an hour is the honest budget and two is a wedged +# emulator. It has to end as an ordinary failure so the logcat below is still +# dumped and uploaded - that is the only view into what the guest was doing. +timeout --kill-after=1m 120m bundle exec fastlane android screenshots || status=$? + +adb logcat -d > logcat.txt || true + +if [ "$status" = 124 ] || [ "$status" = 137 ]; then + adb shell ps -A > processes.txt 2>&1 || true + adb shell "cat /data/anr/*trace* 2>/dev/null" > anr-traces.txt || true +fi + +# Nothing gets to outlive the run: the action's teardown is one "adb emu kill" +# with no check that anything came of it, and a step cannot end while anything +# the action started still holds its stdout open. See run-instrumented-tests.sh. +adb emu kill || true +for _ in $(seq 20); do + pgrep -f qemu-system > /dev/null || break + sleep 1 +done +pkill -9 -f qemu-system || true +pkill -9 -f crashpad_handler || true + +exit "$status" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70621df09912..50a6452900b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ name: release -# Builds three signed flavors once, uploads the two play ones to the internal track, then -# records what went out; foss rides on the github release. Three jobs so that a half -# uploaded release is repairable: "Re-run failed jobs" retries one upload against the -# bundle already built and signed. +# Builds three signed flavors once, photographs the store screenshots beside it, uploads +# the two play bundles to the internal track, writes their listings and records what went +# out; foss rides on the github release. Split into jobs so that a half uploaded release +# is repairable: "Re-run failed jobs" retries one upload against the bundle already built +# and signed, and a wedged emulator costs the release its pictures and not its binary. # # No tag triggers anything and none is written before an upload; build/ is # written afterwards, and the v tag only when the drafted release is published, @@ -171,7 +172,166 @@ jobs: if-no-files-found: error compression-level: 0 - # a job per flavor rather than a loop, so one half can be re-run alone. fail-fast + # Beside the build rather than behind it: it signs nothing and uploads nothing, it + # just drives an emulator, and it takes about as long. On a dry run too - the + # artifact is the only way to look at the pictures before the store does. + # + # A runner per device, because a runner has one emulator's worth of memory and + # because it halves the wall clock: the two halves photograph at once. + screenshots: + runs-on: ubuntu-24.04 + # long by nature - ninety launches per device - but the default is six hours for + # a wedged emulator to sit in + timeout-minutes: 180 + strategy: + # one device failing should not throw away the other's hour of work + fail-fast: false + matrix: + device: [phone, tablet] + steps: + - name: checkout + uses: actions/checkout@v7 + + - name: setup java + uses: actions/setup-java@v5.7.0 + with: + distribution: 'zulu' + java-version: 21 + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v6 + + # spelled out, as in the upload job: setup-ruby reads no .ruby-version here + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + # Pillow draws the frames. The fonts are for the three locales Nunito cannot + # set - hindi, japanese and chinese - which frame-screenshots.py refuses to + # draw as tofu, so a runner without them fails rather than shipping squares. + - name: python and fonts + run: | + python3 -m pip install --quiet Pillow + sudo apt-get update -qq + sudo apt-get install -y -qq fonts-noto-core fonts-noto-cjk + + # newest first, so a runner with a newer image uses it - see the script + - name: pick the ${{ matrix.device }} to photograph + id: profile + run: | + profile=$(.github/scripts/pick-avd-profile.sh "${{ matrix.device }}") + echo "profile=$profile" >> "$GITHUB_OUTPUT" + echo "photographing $profile" >> "$GITHUB_STEP_SUMMARY" + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # detached, for the reason build_test.yml gives: the step that hangs is the one + # running the emulator, so nothing after it in this job would get to run + - name: Reap the emulator's crash reporter + run: nohup setsid bash .github/scripts/reap-crashpad.sh > /dev/null 2>&1 & + + # api 36, and not the floor the test matrix covers: the app only tells the + # system bars to follow a light theme from api 35 on (values-v35/themes.xml), + # and below that every picture has a white clock on a white bar. ScreenshotTests + # refuses to run there rather than photograph it. + - name: photograph the ${{ matrix.device }} in every locale + uses: reactivecircus/android-emulator-runner@v2 + env: + ODR_SCREENSHOT_DEVICE: ${{ matrix.device }} + with: + api-level: 36 + arch: x86_64 + target: google_apis + profile: ${{ steps.profile.outputs.profile }} + force-avd-creation: false + ram-size: 4096M + emulator-options: -no-snapshot-save -no-snapshot-load -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -no-metrics + disable-animations: true + script: bash .github/scripts/take-screenshots.sh + + # what the store is given + - name: archive the framed screenshots + uses: actions/upload-artifact@v7 + with: + name: framed-${{ matrix.device }} + 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. also when the lane failed: a half finished set is what says which + # language it got to + - name: archive the raw captures + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: screenshots-${{ matrix.device }} + path: fastlane/screenshots + if-no-files-found: warn + compression-level: 0 + + - name: archive the logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: screenshot-logs-${{ matrix.device }} + path: | + logcat.txt + anr-traces.txt + processes.txt + app/build/reports/androidTests/ + if-no-files-found: warn + + # The two halves put back together, and only now checked: a set is both devices + # in every locale, and neither runner above can see the other's. Republished + # under the name the listing job reads. + screenshot-set: + needs: screenshots + runs-on: ubuntu-24.04 + steps: + - name: checkout + uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: fetch both halves + uses: actions/download-artifact@v8 + with: + pattern: framed-* + merge-multiple: true + path: fastlane/framed + + # the check the lane cannot do on half a set: every locale, both devices, at + # the size the framing draws + - name: check the set + run: python3 scripts/store_screenshots.py --screenshots fastlane/framed + + - name: archive the framed screenshots + uses: actions/upload-artifact@v7 + with: + name: framed + path: fastlane/framed + if-no-files-found: error + compression-level: 0 + + # The bundles alone. What the store says about them is the listing job below, so + # that a screenshot run that wedged an emulator costs the release its pictures + # and not its binary. + # + # A job per flavor rather than a loop, so one half can be re-run alone. fail-fast # off for the same reason upload: needs: build @@ -186,18 +346,10 @@ jobs: - flavor: lite lane: uploadLite steps: - # for the Gemfile, the lanes and the listing the upload stages + # for the Gemfile and the lanes - name: checkout uses: actions/checkout@v7 - # re-resolved rather than carried as a job output, as in the record job below: - # the upload needs it to name the release notes it sends with the bundle - - name: resolve version - id: version - env: - given: ${{ inputs.version }} - run: .github/scripts/resolve-version.py --input "$given" --dry-run "$dry_run" - # back where gradle put them: the Fastfile reads a fixed path under here - name: fetch the bundles uses: actions/download-artifact@v8 @@ -219,10 +371,68 @@ jobs: ruby-version: '3.4' bundler-cache: true - # no track: the Fastfile's DEFAULT_TRACK is internal, and wider is a promotion. + # no track: the Fastfile's DEFAULT_TRACK is internal, and wider is a promotion + - name: upload the ${{ matrix.flavor }} bundle to play store + env: + ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json + run: bundle exec fastlane android ${{ matrix.lane }} + + - name: drop credentials + if: always() + run: rm -f "${RUNNER_TEMP}/fastlane_google_play.json" + + # Its own job because the listing is editable for as long as the release is on + # the internal track, while a bundle cannot be uploaded twice - and because it + # is the half that waits on the emulators. + listing: + needs: [upload, screenshot-set] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - flavor: pro + lane: listingPro + - flavor: lite + lane: listingLite + steps: + - name: checkout + uses: actions/checkout@v7 + + # re-resolved rather than carried as a job output, as in the record job below: + # the listing needs it to name the release its notes belong to + - name: resolve version + id: version + env: + 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: play store credentials + env: + GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: .github/scripts/play-service-account-key.py "${RUNNER_TEMP}/fastlane_google_play.json" + + - name: setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + # ODR_VERSION rather than a lane argument: the Fastfile falls back to it, and it - # is what tells the staging script which release notes go up with the bundle - - name: upload ${{ matrix.flavor }} and its listing to play store + # is what tells the staging scripts which release the notes and pictures are for + - name: write the ${{ matrix.flavor }} listing to play store env: ODR_PLAY_JSON_KEY: ${{ runner.temp }}/fastlane_google_play.json ODR_VERSION: ${{ steps.version.outputs.version }} diff --git a/.gitignore b/.gitignore index d30eb178a1ed..42d6db5f0fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,11 @@ __pycache__/ # staged by scripts/store-listing.py for one upload, then thrown away fastlane/.listing/ + +# The store screenshots and what they are made of. None of it is committed: the +# documents are written by scripts/make-screenshot-documents.py before a capture +# run, and a picture of the app is only worth as much as the build it came off - +# so the release takes its own. See the README's "Screenshots" section. +app/src/androidTest/assets/screenshots/ +fastlane/screenshots/ +fastlane/framed/ diff --git a/CLAUDE.md b/CLAUDE.md index 4c67254be4b3..bb0c64d7235b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ Guidance for Claude Code (claude.ai/code) working in this repository. and `./gradlew lintProDebug`. Lint errors fail the build; spotless has its own workflow. - `fastlane android deployPro version:v4.8.0` / `deployLite version:v4.8.0`. The version is required; a lane handed none errors out. +- `fastlane android screenshots` photographs the store set off one emulator - see + **Store screenshots** below. ## Architecture @@ -46,6 +48,44 @@ and logcat for each. **`screen-tour`** walks a build through six screens and lay builds' screenshots side by side as a PDF; one tour walks both designs, so add to its lookup lists rather than forking it. Reach for it before `adb shell input tap`. +## Store screenshots + +The store *copy* is written down here; the screenshots are not. A picture of the app is +worth what the build it came off is worth, so the release run takes its own - six screens +on a phone and a tablet in all fifteen locales - frames them and hands them to supply. +Nothing is committed, and `.gitignore` says so. `OpenDocument.ios` does the same thing +against App Store Connect, and the python is deliberately close enough to lift out later. + +**`ScreenshotTests` is the whole of it.** An instrumented test runs in the app's own +process, so laying the samples out, filling the recent list and switching the app's +language need no code in a build that ships - there is no `ScreenshotMode` in the apk and +no line of this in `MainActivity`. Everything past that goes through the app the way a +user does. Do not add a back door to the app for a screenshot: whatever it would need, +the test can already reach. + +- It **skips itself unless a run names a device**, so `connectedCheck` on five API levels + does not photograph a store listing nobody asked for, and it **refuses anything below + API 35**, where the app does not tell the system bars to follow a light theme and every + picture gets a white clock on a white bar. +- It writes into gradle's `additionalTestOutputDir`, which gradle copies back *before* it + uninstalls the apks. `getExternalFilesDir` is the obvious answer and the wrong one: an + app's own storage goes with it when it is uninstalled, so the pictures were written, the + test passed, and there was nothing left to fetch. +- `scripts/make-screenshot-documents.py` writes the documents in them, into the *test* + apk's assets, reproducibly. `frame-screenshots.py` draws the frame - a Pixel, from its + published dimensions - onto a canvas of its own, because play refuses a picture more + than twice as long as it is wide and a Pixel 9 Pro XL is 2.23:1 before anything is drawn + around it. `store_screenshots.py` says what a full set is and stages it. +- Which locale reads which language's documents is one table, in + `store_screenshots.py`. The generator checks its own languages against it and writes it + into the assets, and `ScreenshotTests` reads it from there - do not write a second copy + into the test. The underscore in the name is not a slip either: `frame-screenshots.py` + and the generator import it, and a dash cannot be imported. + +The release runs the two devices on a runner each, checks the halves together, and only +then writes the listing - which is a job behind the bundle upload, so a wedged emulator +costs the release its pictures and not its binary. + ## Build Three flavors, and what separates them is what they *link*: diff --git a/README.md b/README.md index 6eef3c4f6328..856664b290c2 100644 --- a/README.md +++ b/README.md @@ -94,13 +94,16 @@ by hand, with the version it should build: gh workflow run release.yml -f version=v4.14.0 ``` -Nothing triggers it on a tag. It runs as three jobs: +Nothing triggers it on a tag. It runs as six jobs: | job | what it does | |---|---| | `build` | one gradle run producing all three signed flavors, archived on the run | -| `upload` | one job per play flavor, handing its bundle and listing to fastlane | -| `record` | once both landed: tag the commit, draft the GitHub release | +| `screenshots` | beside the build: one emulator per device, photographing six screens in fifteen locales | +| `screenshot-set` | the two devices put back together, and checked as one set | +| `upload` | one job per play flavor, handing its bundle to fastlane | +| `listing` | behind both: the copy and the screenshots, per flavor | +| `record` | once the bundles landed: tag the commit, draft the GitHub release | Lite and Pro always go out together, and nothing chooses one: they are the same app with ads and tracking switched off. Foss is built in the same run but uploaded nowhere - it is @@ -113,15 +116,21 @@ was tested onto the wider track instead of uploading a second one. It is also wh review that a production release waits on actually happens, so the workflow finishing is not the same as the release being out. -The listing goes up with the bundle: the title, both descriptions and the release notes -of that version, in all fifteen locales, for each app. **This overwrites what the Play -Console says**, which is the point - the copy is written here now, not there. The release -notes are no longer typed into the promotion box. Graphics are not uploaded; see -`fastlane/metadata/README.md`. +The listing goes up in its own job, behind the bundle: the title, both descriptions, the +release notes of that version and the screenshots, in all fifteen locales, for each app. +**This overwrites what the Play Console says**, which is the point - the copy is written +here now, not there. The release notes are no longer typed into the promotion box. The +icon and the feature graphic are still not uploaded; see `fastlane/metadata/README.md`. + +Separate from the bundle upload on purpose: a bundle cannot go up twice, while the listing +stays editable for as long as the release sits on the internal track - and the listing is +the half that waits on the emulators. A screenshot run that wedges costs the release its +pictures, not its binary. `fastlane android listingPro` and `listingLite` send the listing without a bundle, which is how a typo is fixed: Play refuses a version code twice, so repairing the words should -not need a version to carry them. +not need a version to carry them. With nothing under `fastlane/framed` they send the text +alone, so a word can be corrected without a quarter hour of emulators. **If one flavor's upload fails, press "Re-run failed jobs".** Only that upload runs again, against the bundle already built and signed, and `record` runs behind it. Re-running *all* @@ -129,6 +138,41 @@ jobs is the wrong button: Play refuses a version code it has already accepted, s that made it cannot go up twice. Past the roughly 30 days GitHub offers re-runs for, the way out is a new patch version for both flavors. +### Screenshots + +The store copy is written down here; the screenshots are not. A picture of the app is worth +what the build it came off is worth, so they are taken during the release run, from the +build going out, framed there, and handed to supply from there. Nothing is committed. + +Six screens - the recently opened list, a text document with a search running, a +spreadsheet, an edit under way, a PDF and a Word file - on a phone and on a tablet, in the +fifteen locales the listing is written in. That is 180 pictures a release. + +Taking them by hand needs one emulator on adb running **Android 15 or newer**, and Pillow: + +```sh +python3 -m pip install Pillow +bundle exec fastlane android screenshots # every locale, phone +ODR_SCREENSHOT_DEVICE=tablet bundle exec fastlane android screenshots +ODR_SCREENSHOT_LANGUAGES=en-US,de-DE bundle exec fastlane android screenshots +``` + +With more than one device attached, `ANDROID_SERIAL` picks which. The raw captures land in +`fastlane/screenshots/`, the framed set in `fastlane/framed/`, and only the second is what +the store is given. Re-running `scripts/frame-screenshots.py` alone re-frames what is +already captured, so changing a headline in `fastlane/frames/frames.json` costs a second of +Pillow rather than a quarter hour of emulators. + +Android 15 is the floor because the app only tells the system bars to follow a light theme +from API 35 on; below that every picture has a white clock on a white bar. +`ScreenshotTests` refuses to run there rather than photograph it, and skips itself entirely +unless a run names a device - `connectedCheck` is not the job for this. + +`hi-IN`, `ja-JP` and `zh-CN` are set in a system font, since Nunito has neither Devanagari +nor CJK and one that does is ten to sixteen megabytes per language. On Debian that is +`fonts-noto-core` and `fonts-noto-cjk`; without them the framing stops and says so rather +than drawing a row of squares. + `dry_run` builds and signs both flavors without uploading either. It is the only run allowed to go without a version, and the only one leaving neither tag nor draft. @@ -156,7 +200,8 @@ a `service_account` key before the build starts rather than letting fastlane tri it once the build is done. Releasing from a laptop still works: `fastlane android deployPro version:v4.8.0` builds -and uploads, and takes an optional `track:` (`... track:beta`). The version can come from +and uploads the bundle *and* its listing - the split into two jobs is the workflow's, not +the lane's - and takes an optional `track:` (`... track:beta`). The version can come from `ODR_VERSION` instead, but it cannot be left out - see below. That reads the key from `fastlane_google_play.json` in the repository root, as the `Appfile` says. diff --git a/app/src/androidTest/java/app/opendocument/droid/test/ScreenshotTests.kt b/app/src/androidTest/java/app/opendocument/droid/test/ScreenshotTests.kt new file mode 100644 index 000000000000..87fe1c59e26d --- /dev/null +++ b/app/src/androidTest/java/app/opendocument/droid/test/ScreenshotTests.kt @@ -0,0 +1,871 @@ +package app.opendocument.droid.test + +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.Instrumentation +import android.app.LocaleManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.LocaleList +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import android.widget.EditText +import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import app.opendocument.droid.R +import app.opendocument.droid.background.RecentDocumentList +import app.opendocument.droid.background.RecentDocumentsUtil +import app.opendocument.droid.ui.EditActionModeCallback +import app.opendocument.droid.ui.OpenFileIdling +import app.opendocument.droid.ui.activity.DocumentFragment +import app.opendocument.droid.ui.activity.MainActivity +import app.opendocument.droid.ui.widget.DocumentActions +import app.opendocument.droid.ui.widget.PageView +import java.io.File +import java.io.FileOutputStream +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.json.JSONObject +import org.junit.After +import org.junit.Assert +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The pictures the play store shows. + * + * Six screens, in every locale the listing is written in, on whichever device the runner is + * driving. `scripts/frame-screenshots.py` then frames what this writes and + * `scripts/store_screenshots.py` checks the set and stages it for supply - the names below are the + * names those expect, and their order is the order the store shows them in. + * + * Which locales those are, and which language's documents each of them reads, comes out of + * `screenshot-names.json` beside the samples: `scripts/store_screenshots.py` holds that table and + * `scripts/make-screenshot-documents.py` writes it in, so there is no second copy here to drift. + * + * **This is the whole back door.** An instrumented test runs in the app's own process, so laying + * the sample documents out, filling the recently opened list and switching the app's language are + * all things a test can do directly - and none of them needs a line of code in a build that ships. + * + * Everything after that goes through the app the way a user does: the recently opened list is + * tapped-through documents, edit mode is the button, and the find bar is typed into. Photographing + * a screen the app can only reach from a test would be photographing something nobody can get to. + * + * **Skipped unless a run names a device**, since `connectedCheck` runs everything there is on five + * api levels and this is an hour of emulator each. `fastlane android screenshots` is the way in; by + * hand it is the runner's own arguments, which is also how to look at one language without waiting + * out the other fourteen: + * ``` + * ./gradlew connectedProDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.class=app.opendocument.droid.test.ScreenshotTests \ + * -Pandroid.testInstrumentationRunnerArguments.device=phone \ + * -Pandroid.testInstrumentationRunnerArguments.locales=en-US,de-DE + * ``` + * + * Wants android 15 or newer, and says so rather than photographing what an older one draws. + */ +@LargeTest +@RunWith(AndroidJUnit4::class) +// api 33 for `LocaleManager`, which is what puts the app into a language. The pictures want +// android 15, which is a floor of its own and asserted below rather than skipped over. +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU) +class ScreenshotTests { + + // The activity is launched and finished per screen rather than once for the run: every screen + // starts from the landing screen, and an edit mode or a find bar left standing is the sort of + // thing that turns up in the next picture rather than in a failure. Ninety launches in a row + // is also more than ActivityTestRule is built for, which is why there is none here. + private var activity: MainActivity? = null + + private var idlingResource: IdlingResource? = null + + private var dressed = false + + @Before + fun setUp() { + val idlingResource = OpenFileIdling.idlingResource + this.idlingResource = idlingResource + IdlingRegistry.getInstance().register(idlingResource) + } + + @After + fun tearDown() { + finish() + + if (dressed) { + undressTheDevice() + } + + idlingResource?.let { IdlingRegistry.getInstance().unregister(it) } + } + + @Test + fun takesTheStoreScreenshots() { + // Skipped unless a run asked for it by naming a device. `connectedCheck` runs every test + // there is, on five api levels, and an hour of emulator per level photographing a store + // listing nobody asked for is not what that job is for. + Assume.assumeTrue( + "no device given, so this is not a screenshot run - see the class comment", + argument("device") != null, + ) + + // The app sets the system bar icons to suit the theme from api 35 on, and only there + // (values-v35/themes.xml). Below it, a light-themed screen gets white icons on a white + // bar: the clock and the battery are in every picture and none of them can be seen. + // Which is a thing to be told rather than to notice in the store. + Assert.assertTrue( + "the store screenshots want android 15 or newer, not api ${Build.VERSION.SDK_INT}: " + + "the status bar icons do not follow the light theme below it", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM, + ) + + // marked before rather than after: dressing that fails halfway has still changed + // the device, and tearDown is the only thing that puts it back + dressed = true + dressTheDevice() + + val details = JSONObject(asset("screenshots/screenshot-names.json").decodeToString()) + val spoken = details.getJSONObject("locales") + val languages = details.getJSONObject("languages") + + for (locale in locales(spoken)) { + val language = spoken.getString(locale) + val words = languages.optJSONObject(language) ?: languages.getJSONObject("en") + + speak(locale) + val folder = layOut(language, words.getJSONObject("files")) + fill(folder) + + landing(locale) + searching(locale, folder.getValue("text"), words.getString("search")) + editing(locale, folder.getValue("text")) + document(locale, Shot.SHEET, folder.getValue("sheet")) + document(locale, Shot.PDF, folder.getValue("paper")) + document(locale, Shot.OFFICE, folder.getValue("word")) + } + } + + // --- the screens -------------------------------------------------------- + + /** The landing screen, which is the recently opened documents and the way in. */ + private fun landing(locale: String) { + val activity = launch() + + Assert.assertTrue( + "the landing screen never listed the documents", + waitFor(LIST_TIMEOUT_MS) { hasRows(activity) }, + ) + + // the list fades its rows in, and the row heights settle a beat after the first of them + settle() + + shoot(locale, Shot.RECENTS) + finish() + } + + /** One document, open and drawn. */ + private fun document(locale: String, shot: Shot, uri: Uri) { + launchWith(uri) + + shoot(locale, shot) + finish() + } + + /** + * The same text document, being edited, with the keyboard up. + * + * The keyboard needs a real tap: WebKit raises it 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, so + * this works down the page rather than betting the run on one offset - the sample is a page of + * A4 and a tap into its margin reaches nothing. + */ + private fun editing(locale: String, uri: Uri) { + val activity = launchWith(uri) + val fragment = documentFragment(activity) + + val started = AtomicReference(false) + instrumentation.runOnMainSync { + started.set( + activity.startSupportActionMode(EditActionModeCallback(activity, fragment)) != null + ) + } + Assert.assertTrue("edit mode did not start", started.get()) + + val pageView = requireNotNull(fragment.pageView) { "the edit has no page to tap into" } + Assert.assertTrue( + "the page never became editable", + waitFor(EDIT_TIMEOUT_MS) { isEditable(pageView) }, + ) + + Assert.assertTrue( + "no tap down the page set a caret, so the keyboard never came up", + KEYBOARD_OFFSETS.any { offset -> + onView(withId(R.id.page_view)).perform(tapAt(0.5f, offset)) + + waitFor(KEYBOARD_TIMEOUT_MS) { keyboardIsUp(activity.window.decorView) } + }, + ) + + sendAwayWhatTheKeyboardIsAsking(activity) + + // the keyboard slides in, and a picture taken while it is halfway up is a picture of a + // keyboard halfway up + settle() + + shoot(locale, Shot.EDIT) + finish() + } + + /** + * The text document with a word of its own searched for, so the hits are highlighted. + * + * The text document and not the pdf: the word is counted out of the report, and a find bar over + * a document that does not say it is a picture of a search with no hits in it. + */ + private fun searching(locale: String, uri: Uri, query: String) { + val activity = launchWith(uri) + + instrumentation.runOnMainSync { activity.onDocumentAction(DocumentActions.ACTION_SEARCH) } + + Assert.assertTrue( + "the find bar never came up", + waitFor(FIND_TIMEOUT_MS) { activity.findViewById(R.id.edit) != null }, + ) + + // replaceText rather than typeText: the find bar searches on every keystroke, and typing + // a word letter by letter is a dozen searches of the whole document for nothing + onView(withId(R.id.edit)).perform(replaceText(query), closeSoftKeyboard()) + + // and then the next-match button, as somebody searching would: the web view tints every + // match on findAllAsync but only picks one of them out, and a picture of a search with no + // match picked out is a picture of a search that has not been used yet + onView(withId(R.id.find_next)).perform(click()) + + // the highlights are drawn by the web view a beat after the field says the word + settle() + + shoot(locale, Shot.TEXT) + finish() + } + + // --- the app's state ---------------------------------------------------- + + /** + * Puts the app into one language, and waits for the change to have reached it. + * + * The framework's own `LocaleManager`, not `AppCompatDelegate`: from api 33 on that only + * forwards to this, and only once an AppCompat activity has attached itself to it. Called with + * nothing on screen - which is where this has to be called - it returns having done nothing at + * all, and the whole set comes out in the language the last one was in. + * + * The system server is what applies it, so it is waited on rather than assumed: an activity + * launched before the new configuration reaches the process comes up in the language it was. + */ + private fun speak(locale: String) { + val wanted = Locale.forLanguageTag(locale) + context.getSystemService(LocaleManager::class.java).applicationLocales = + LocaleList.forLanguageTags(locale) + + Assert.assertTrue( + "the app never came up in $locale", + waitFor(LOCALE_TIMEOUT_MS) { + context.resources.configuration.locales[0].language == wanted.language + }, + ) + } + + /** + * Copies this language's samples out of the test apk and onto the device, under the names the + * folder should read as. + * + * Into the app's own cache directory, so they are reachable through the app's own + * `FileProvider` and need no storage access grant - which is also why the recently opened list + * below is one the app can really open. Seeding it with uris nobody holds a grant for would + * photograph a list that does nothing when tapped. + */ + private fun layOut(language: String, titles: JSONObject): Map { + val folder = File(context.cacheDir, "screenshots") + folder.deleteRecursively() + folder.mkdirs() + + val laid = LinkedHashMap() + for ((name, source) in SAMPLES) { + val extension = EXTENSIONS.getValue(source) + val title = titles.optString(name).ifEmpty { name } + + val file = File(folder, "$title.$extension") + FileOutputStream(file).use { out -> + out.write(asset("screenshots/sample-$source-$language.$extension")) + } + + laid[name] = + FileProvider.getUriForFile(context, context.packageName + ".provider", file) + } + + return laid + } + + /** + * Writes the recently opened list, newest first and spread over the last few days. + * + * `restoreRecentDocument` rather than `addRecentDocument`, because only the former takes the + * time the entry was opened at: added, all of them would carry this second and the column + * beside them would read "0 minutes ago" twenty-seven times over. + */ + private fun fill(folder: Map) { + File(context.filesDir, "recent_documents.json").delete() + + val now = System.currentTimeMillis() + folder.values.forEachIndexed { index, uri -> + // from one step back rather than from now: the newest entry would otherwise be + // "0 minutes ago", which is the app being opened for the picture rather than a list + // somebody has been using + val opened = now - OPENED_APART_MS * (index + 1) + val name = File(uri.path.orEmpty()).name + + RecentDocumentsUtil.restoreRecentDocument( + context, + RecentDocumentList.Entry(name, uri.toString(), opened), + index, + ) + } + } + + // --- driving the app ---------------------------------------------------- + + private fun launch(): MainActivity { + finish() + + val intent = + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + val launched = instrumentation.startActivitySync(intent) as MainActivity + activity = launched + + // espresso picks its root by window focus and fails where it finds none + Assert.assertTrue( + "the activity never took window focus", + waitFor(FOCUS_TIMEOUT_MS) { launched.hasWindowFocus() }, + ) + + return launched + } + + /** Launches and opens [uri], waiting for the page to have drawn rather than for the load. */ + private fun launchWith(uri: Uri): MainActivity { + val activity = launch() + + instrumentation.runOnMainSync { activity.loadUri(uri) } + + val fragment = documentFragment(activity) + Assert.assertTrue( + "$uri never finished loading", + waitFor(LOAD_TIMEOUT_MS) { fragment.hasLastResult() }, + ) + + val pageView = requireNotNull(fragment.pageView) { "$uri produced no page" } + Assert.assertTrue( + "$uri loaded but the page never drew anything", + waitFor(DRAW_TIMEOUT_MS) { hasDrawn(pageView) }, + ) + + // the page is there; what is still moving is the buttons fading in over it + settle() + + return activity + } + + private fun finish() { + val running = activity ?: return + activity = null + + instrumentation.runOnMainSync { running.finish() } + instrumentation.waitForIdleSync() + + waitFor(FOCUS_TIMEOUT_MS) { running.isDestroyed } + } + + private fun documentFragment(activity: MainActivity): DocumentFragment { + var fragment: DocumentFragment? = null + Assert.assertTrue( + "no document fragment came up", + waitFor(LOAD_TIMEOUT_MS) { + fragment = + activity.supportFragmentManager.findFragmentByTag("document_fragment") + as DocumentFragment? + fragment != null + }, + ) + + return checkNotNull(fragment) + } + + /** + * The way in, the section heading and a document under it: a list with fewer rows drawn than + * that is one the recently opened documents have not reached yet. + * + * Asked as a plain `ViewGroup` rather than as the `RecyclerView` it is, so this test needs + * nothing on its compile path that the app happens to bring along. + */ + private fun hasRows(activity: MainActivity): Boolean { + val list = activity.findViewById(R.id.landing_list) + + return (list?.childCount ?: 0) > 2 + } + + /** Whether the page has laid something out, which is a step past the load reporting success. */ + private fun hasDrawn(pageView: PageView): Boolean { + val answer = + javascript( + pageView, + "(function(){return document.readyState === 'complete' && !!document.body &&" + + " document.body.innerText.trim().length > 0;})()", + ) + + return "true".equals(answer?.replace("\"", ""), ignoreCase = true) + } + + private fun isEditable(pageView: PageView): Boolean { + val answer = + javascript( + pageView, + "(function(){return !!(document.body && document.body.isContentEditable) ||" + + " !!document.querySelector('[contenteditable=\"true\"],'+" + + " '[contenteditable=\"plaintext-only\"]');})()", + ) + + return "true".equals(answer?.replace("\"", ""), ignoreCase = true) + } + + /** + * Null when the page did not answer in time, which the caller's poll owns rather than fails. + */ + private fun javascript(pageView: PageView, script: String): String? { + val result = AtomicReference() + val latch = CountDownLatch(1) + + instrumentation.runOnMainSync { + pageView.evaluateJavascript(script) { value -> + result.set(value) + latch.countDown() + } + } + + return if (latch.await(JS_ANSWER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) result.get() else null + } + + /** + * Sends away whatever the keyboard has put over itself before it is photographed. + * + * Asked for a language it has no layout for, gboard covers its own keys with a picker - two + * layouts to choose between, `Skip` and `Next` - and the picture comes out of a keyboard being + * set up rather than of a document being edited. Japanese is the one that asks; a language + * whose layout is not in question never does. + * + * What is looked for is the *class*: a key is a `FrameLayout` carrying a description, while + * what these put up is a real `Button` with a word on it. So there is no list of buttons per + * language here, and the next thing gboard decides to ask is dismissed by the same code. + */ + private fun sendAwayWhatTheKeyboardIsAsking(activity: MainActivity) { + repeat(SETUP_ASKS) { + val asking = buttonOverTheKeyboard() ?: return + + // the first of them, which is the way out: `Skip` sits left of `Next`, and a picker + // that answers `Next` instead only comes back with the next thing it wants to know + instrumentation.runOnMainSync { + asking.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + settle() + } + + Assert.assertNull( + "the keyboard kept asking something, so it cannot be photographed", + buttonOverTheKeyboard(), + ) + + Assert.assertTrue( + "the keyboard went away with what it was asking", + waitFor(KEYBOARD_TIMEOUT_MS) { keyboardIsUp(activity.window.decorView) }, + ) + } + + /** The first button in the keyboard's own window, or null while it is only keys. */ + private fun buttonOverTheKeyboard(): AccessibilityNodeInfo? { + fun below(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + node ?: return null + if (node.className == "android.widget.Button") { + return node + } + + for (index in 0 until node.childCount) { + below(node.getChild(index))?.let { + return it + } + } + + return null + } + + return instrumentation.uiAutomation.windows + .filter { it.type == AccessibilityWindowInfo.TYPE_INPUT_METHOD } + .firstNotNullOfOrNull { below(it.root) } + } + + private fun keyboardIsUp(decorView: View): Boolean = + ViewCompat.getRootWindowInsets(decorView)?.isVisible(WindowInsetsCompat.Type.ime()) == true + + /** A tap at a place on the view rather than at its middle, which is where the margin is. */ + private fun tapAt(across: Float, down: Float): ViewAction = + GeneralClickAction( + Tap.SINGLE, + CoordinatesProvider { view -> + val at = IntArray(2) + view.getLocationOnScreen(at) + + floatArrayOf(at[0] + view.width * across, at[1] + view.height * down) + }, + Press.FINGER, + 0, + 0, + ) + + // --- the picture -------------------------------------------------------- + + private fun shoot(locale: String, shot: Shot) { + val bitmap = + requireNotNull(instrumentation.uiAutomation.takeScreenshot()) { + "the screen could not be photographed for $locale ${shot.fileName}" + } + + val folder = File(writable(), locale) + folder.mkdirs() + + val file = File(folder, "$device-${shot.fileName}.png") + FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } + bitmap.recycle() + + Assert.assertTrue("nothing was written to $file", file.length() > 0) + } + + /** + * Where the pictures go: the directory gradle hands the run for output of its own. + * + * Not `getExternalFilesDir`: `connectedAndroidTest` uninstalls both apks when the run ends, and + * an app's external directory goes with it - so the pictures were written, the test passed, and + * there was nothing left to fetch. + * + * `additionalTestOutputDir` is gradle's own answer to that: it copies what is in there back to + * `app/build/outputs/connected_android_test_additional_output/` *before* it uninstalls + * anything, so nothing has to be pulled by hand and nothing is racing the cleanup. + */ + private fun writable(): File { + val given = + requireNotNull(argument("additionalTestOutputDir")) { + "gradle passed no additionalTestOutputDir, so there is nowhere to write a " + + "screenshot that would outlive the run. Start this from " + + "`fastlane android screenshots` or from connectedProDebugAndroidTest, not " + + "from `adb shell am instrument`." + } + + return File(given, "screenshots") + } + + // --- what the run was asked for ----------------------------------------- + + /** The locales to photograph: every one the listing is written in, unless fewer were named. */ + private fun locales(spoken: JSONObject): List { + val known = spoken.keys().asSequence().sorted().toList() + + val given = argument("locales") + if (given.isNullOrBlank()) { + return known + } + + val wanted = given.split(",").map { it.trim() }.filter { it.isNotEmpty() } + val unknown = wanted.filterNot { it in known } + Assert.assertTrue( + "no such locale: ${unknown.joinToString()}. One of ${known.joinToString()}", + unknown.isEmpty(), + ) + + return wanted + } + + /** Which device the runner is driving, which only the runner knows. */ + private val device: String + get() { + val given = argument("device") ?: "phone" + Assert.assertTrue( + "no such device: $given. One of ${DEVICES.joinToString()}", + given in DEVICES, + ) + + return given + } + + private fun asset(name: String): ByteArray = + instrumentation.context.assets.open(name).use { it.readBytes() } + + private val instrumentation: Instrumentation + get() = InstrumentationRegistry.getInstrumentation() + + private val context: Context + get() = instrumentation.targetContext + + private fun settle() { + SystemClock.sleep(SETTLE_MS) + instrumentation.waitForIdleSync() + } + + private enum class Shot(val fileName: String) { + RECENTS("01-recents"), + TEXT("02-text"), + SHEET("03-sheet"), + EDIT("04-edit"), + PDF("05-pdf"), + OFFICE("06-office"), + } + + companion object { + private val DEVICES = listOf("phone", "tablet") + + /** + * The folder, in the order the landing screen should list it: the nine documents the + * screenshots open, and then the rest, so it reads as a folder somebody keeps things in + * rather than as a set of samples. Each of the rest is a copy of one of the nine under a + * name of its own, and none of them is ever opened. + */ + private val SAMPLES = + linkedMapOf( + "text" to "text", + "sheet" to "sheet", + "slides" to "slides", + "word" to "word", + "cells" to "cells", + "deck" to "deck", + "paper" to "paper", + "rows" to "rows", + "notes" to "notes", + "meeting" to "text", + "letter" to "text", + "travel" to "text", + "reading" to "text", + "household" to "sheet", + "hours" to "sheet", + "stocktake" to "sheet", + "kickoff" to "slides", + "course" to "slides", + "lease" to "word", + "resume" to "word", + "application" to "word", + "expenses" to "cells", + "inventory" to "cells", + "review" to "deck", + "ticket" to "paper", + "warranty" to "paper", + "manual" to "paper", + ) + + private val EXTENSIONS = + mapOf( + "text" to "odt", + "sheet" to "ods", + "slides" to "odp", + "word" to "docx", + "cells" to "xlsx", + "deck" to "pptx", + "paper" to "pdf", + "rows" to "csv", + "notes" to "txt", + ) + + /** Near the top: the sample is a page of A4 with a few lines on it. */ + private val KEYBOARD_OFFSETS = listOf(0.20f, 0.26f, 0.14f, 0.32f, 0.40f) + + // spread over the last few days, newest first, so the times beside the rows read like a + // list somebody has been using + private const val OPENED_APART_MS = 2 * 60 * 60 * 1000L + + // a cold emulator opening a document it has to translate first + private const val LOAD_TIMEOUT_MS = 60000L + private const val DRAW_TIMEOUT_MS = 30000L + private const val EDIT_TIMEOUT_MS = 30000L + private const val LIST_TIMEOUT_MS = 20000L + private const val FOCUS_TIMEOUT_MS = 10000L + private const val FIND_TIMEOUT_MS = 10000L + private const val KEYBOARD_TIMEOUT_MS = 5000L + + // one to choose a layout, and room for whatever it wants to know after that + private const val SETUP_ASKS = 3 + private const val LOCALE_TIMEOUT_MS = 10000L + private const val JS_ANSWER_TIMEOUT_MS = 10000L + + private const val POLL_MS = 200L + private const val SETTLE_MS = 1500L + + // long enough for system ui to come back after the theme and the navigation bar change + private const val RESTART_MS = 6000L + + // for the request to have reached the display, and then for it to have turned: a + // runner painting through swiftshader has taken longer over that than any beat worth + // waiting on every rotation + private const val ROTATE_MS = 2500L + private const val ROTATE_TIMEOUT_MS = 30000L + + private fun waitFor(timeoutMs: Long, until: () -> Boolean): Boolean { + val startMs = SystemClock.elapsedRealtime() + while (SystemClock.elapsedRealtime() - startMs < timeoutMs) { + if (until()) { + return true + } + SystemClock.sleep(POLL_MS) + } + + return until() + } + + private fun argument(name: String): String? = + InstrumentationRegistry.getArguments().getString(name) + + private fun shell(command: String) { + InstrumentationRegistry.getInstrumentation() + .uiAutomation + .executeShellCommand(command) + .use { /* the command runs whether or not anything reads its output */ } + } + + /** + * The device the store should see: upright, in the light, on gestures, and with the status + * bar every store screenshot has had since the first one. + * + * 9:41 and a full battery is what `override_status_bar` is on the App Store side; android + * has a demo mode for it, which has to be allowed before it can be entered. + * + * Light rather than whatever the emulator image happens to default to - which is dark on + * the ones CI creates. Both are the real app, but a set of pictures has to pick one, and + * the light one is what the app opens as on a phone out of the box. + * + * Gesture navigation for the same reason: the three button bar is a setting almost nobody + * changes back to, and it takes a strip off the bottom of every picture. + */ + fun dressTheDevice() { + // the keyboard is not the active window, and without this it is not among the ones + // `uiAutomation` will answer with at all + val automation = InstrumentationRegistry.getInstrumentation().uiAutomation + val service = automation.serviceInfo + service.flags = + service.flags or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + automation.serviceInfo = service + + standUpright() + + // Before demo mode and not after: both of these restart system ui, and a system ui + // that restarts forgets it was in demo mode - which is a status bar showing the real + // time in the corner of every picture, and nothing failing to say so. + shell("cmd uimode night no") + shell("cmd overlay enable com.android.internal.systemui.navbar.gestural") + SystemClock.sleep(RESTART_MS) + + shell("settings put global sysui_demo_allowed 1") + demo("enter") + demo("clock -e hhmm 0941") + + // plugged false, or the battery is drawn with a charging bolt in it - which says the + // picture was taken on a desk rather than that the app was being used + demo("battery -e level 100 -e plugged false -e powersave false") + + // Wifi at full, and no mobile signal at all: a bar of wifi and a full battery is what + // a store screenshot has looked like for fifteen years, and the alternative here is a + // 3G badge from the emulator's fake network in the corner of all 180 of them. + // mobile first: the extras are one flat bundle, so the `level` that follows belongs to + // whichever radio was named last, and wifi has to be the one that gets it + demo("network -e mobile hide -e wifi show -e level 4") + + // and everything the system puts up there of its own: the emulator arrives with a + // notification or two, and they are in every picture until they are told not to be + demo("notifications -e visible false") + demo( + "status -e volume hide -e bluetooth hide -e location hide -e alarm hide " + + "-e sync hide -e tty hide -e eri hide -e mute hide -e speakerphone hide" + ) + + SystemClock.sleep(SETTLE_MS) + } + + private fun demo(command: String) { + shell("am broadcast -a com.android.systemui.demo -e command $command") + } + + /** + * Turns the device upright and holds it there. + * + * Tried rather than told: rotation 0 is a device's *natural* orientation, and a tablet's + * natural orientation is landscape - so the same setting that stands a phone up lays a + * tablet down. + * + * What it settles on is checked by photographing the screen, which is the only thing that + * answers the question actually being asked: `wm size` reports the panel, not the way up + * the picture will come out. + */ + private fun standUpright() { + shell("settings put system accelerometer_rotation 0") + + for (rotation in 0..3) { + shell("settings put system user_rotation $rotation") + + // the beat first and the poll after: asked too early the screen still answers + // with the way up it is leaving, and a rotation that has really been refused + // answers the same way for as long as it is waited on + SystemClock.sleep(ROTATE_MS) + if (waitFor(ROTATE_TIMEOUT_MS) { isUpright() }) { + return + } + } + + throw AssertionError( + "no rotation stood this device upright, so it cannot be photographed" + ) + } + + private fun isUpright(): Boolean { + val shot = + InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + ?: return false + val upright = shot.height > shot.width + shot.recycle() + + return upright + } + + fun undressTheDevice() { + demo("exit") + shell("settings put system accelerometer_rotation 1") + } + } +} diff --git a/build.gradle b/build.gradle index a5f857fd67ec..737add96f4d3 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,8 @@ spotless { // output under build/ target '*.gradle', 'app/*.gradle', 'gradle/*.toml', '*.md', '.gitignore', '.github/**/*.yml', '_config.yml', '*.sh', - '.github/scripts/*', 'tools/**/*.sh', 'tools/**/*.py' + '.github/scripts/*', 'tools/**/*.sh', 'tools/**/*.py', + 'scripts/*.py', 'fastlane/Fastfile', 'fastlane/**/*.md' targetExclude '**/build/**' trimTrailingWhitespace() diff --git a/fastlane/Fastfile b/fastlane/Fastfile index bc9eee57628c..b6f1cc78848c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -21,6 +21,62 @@ ROOT = File.expand_path("..", __dir__).freeze # same thing, and the few places they differ are read over the shared text. STAGED_LISTING = File.join(ROOT, "fastlane", ".listing").freeze +# The screenshots, which unlike the copy are not written down here: they are taken +# during the release run, from the build going out, framed, and handed to supply +# from there. A picture of the app is worth what the build it came off is worth. +SCREENSHOT_DIR = File.join(ROOT, "fastlane", "screenshots").freeze +FRAMED_DIR = File.join(ROOT, "fastlane", "framed").freeze + +MAKE_DOCUMENTS = File.join(ROOT, "scripts", "make-screenshot-documents.py").freeze +FRAME_SCREENSHOTS = File.join(ROOT, "scripts", "frame-screenshots.py").freeze +STORE_SCREENSHOTS = File.join(ROOT, "scripts", "store_screenshots.py").freeze + +# The devices the store keeps a set for. The name is written into every file the +# capture run produces, so this is also what `store_screenshots.py` reads back. +SCREENSHOT_DEVICES = %w[phone tablet].freeze + +# Where gradle leaves what the run wrote. The test writes into the directory +# gradle hands it, and gradle copies that back off the device itself - before it +# uninstalls the apks, which is the part that matters: an app's own storage goes +# with it when it is uninstalled, so anything pulled by hand afterwards is gone. +# The middle directory is named after whichever emulator answered, so it is +# globbed rather than spelled. +ADDITIONAL_OUTPUT = File.join( + ROOT, "app", "build", "outputs", "connected_android_test_additional_output", + "proDebugAndroidTest", "connected" +).freeze + +# Which device this run is for. Both would need two emulators at once, so a run +# says which one it is driving - the release gives each runner one. +def screenshot_device + given = ENV["ODR_SCREENSHOT_DEVICE"].to_s.strip.downcase + return SCREENSHOT_DEVICES.first if given.empty? + + unless SCREENSHOT_DEVICES.include?(given) + UI.user_error!("no such device: #{given}. One of #{SCREENSHOT_DEVICES.join(', ')}.") + end + + given +end + +# The locales to photograph. Every one the listing is written in, unless +# ODR_SCREENSHOT_LANGUAGES names fewer - which is how to look at one language's +# pictures without waiting out the other fourteen. +def screenshot_languages + given = ENV["ODR_SCREENSHOT_LANGUAGES"].to_s.strip + return given.split(",").map(&:strip) unless given.empty? + + sh("python3", STORE_SCREENSHOTS, "--languages", log: false).split("\n").map(&:strip).reject(&:empty?) +end + +# Whether this run was asked for less than a full set, which is then not checked +# against one: half a set is what it was asked for, and the release checks the +# halves together once every runner has handed its own in. +def screenshots_narrowed? + %w[ODR_SCREENSHOT_LANGUAGES ODR_SCREENSHOT_DEVICE] + .any? { |name| !ENV[name].to_s.strip.empty? } +end + def require_version(version) version = version || ENV["ODR_VERSION"] UI.user_error!("no version. pass one as version:v4.15.0, or set ODR_VERSION") if version.to_s.empty? @@ -54,32 +110,60 @@ def stage_listing(flavor, version) staged end +# Puts the framed screenshots into the same tree, under the `images/` directory +# supply reads a locale's pictures from - so one directory is handed over and one +# edit goes to play. +# +# A run with nothing captured writes the text alone, so fixing a word in a +# description does not cost a quarter hour of emulators. 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, which is what the script checks before it copies anything. +# +# @return whether there were any, which is also what tells supply to replace what +# the store has rather than leave it alone. +def stage_screenshots(staged) + captured = !Dir.glob(File.join(FRAMED_DIR, "*", "*.png")).empty? + unless captured + UI.important("no screenshots under #{FRAMED_DIR} - writing the listing text only") + return false + end + + ok = system("python3", STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR, "--stage", staged) + UI.user_error!("the screenshots could not be staged - see above") unless ok + + true +end + platform :android do - desc "Build and upload the Pro version to Google Play" + # The whole of one flavor's release, which is what a hand run wants. The release + # workflow splits the two uploads across jobs instead - see uploadBundle. + desc "Build and upload the Pro version, and its listing, to Google Play" lane :deployPro do |options| buildBundle(flavor: "Pro", version: options[:version]) - uploadBundle(flavor: "Pro", track: options[:track], version: options[:version]) + uploadBundle(flavor: "Pro", track: options[:track]) + uploadListing(flavor: "Pro", track: options[:track], version: options[:version]) end - desc "Build and upload the Lite version to Google Play" + desc "Build and upload the Lite version, and its listing, to Google Play" lane :deployLite do |options| buildBundle(flavor: "Lite", version: options[:version]) - uploadBundle(flavor: "Lite", track: options[:track], version: options[:version]) + uploadBundle(flavor: "Lite", track: options[:track]) + uploadListing(flavor: "Lite", track: options[:track], version: options[:version]) end - desc "Upload an already built Pro bundle. Used by the release workflow, which builds " \ - "both flavors with gradle directly, in one job." + desc "Upload an already built Pro bundle, without its listing. Used by the release " \ + "workflow, which builds both flavors with gradle directly, in one job." lane :uploadPro do |options| - uploadBundle(flavor: "Pro", track: options[:track], version: options[:version]) + uploadBundle(flavor: "Pro", track: options[:track]) end - desc "Upload an already built Lite bundle" + desc "Upload an already built Lite bundle, without its listing" lane :uploadLite do |options| - uploadBundle(flavor: "Lite", track: options[:track], version: options[:version]) + uploadBundle(flavor: "Lite", track: options[:track]) end - desc "Upload the Pro listing on its own, without a bundle - to repair a typo, or a " \ - "locale that came out wrong, without needing a version to carry it" + desc "Upload the Pro listing on its own, without a bundle - what the release does once " \ + "the screenshots are in, and how a typo is repaired without a version to carry it" lane :listingPro do |options| uploadListing(flavor: "Pro", track: options[:track], version: options[:version]) end @@ -114,6 +198,10 @@ platform :android do ) end + # The bundle alone. What the store says about it goes up separately, in + # uploadListing, so that a screenshot run that wedged an emulator costs the + # release its pictures and not its binary - and so a rejected word can be + # rewritten and pushed again without touching a bundle play refuses twice. private_lane :uploadBundle do |options| flavor = options[:flavor] variant = flavor.downcase @@ -126,29 +214,101 @@ platform :android do json_key: ENV["ODR_PLAY_JSON_KEY"] || CredentialsManager::AppfileConfig.try_fetch_value(:json_key_file), aab: "app/build/outputs/bundle/#{variant}Release/app-#{variant}-release.aab", - metadata_path: stage_listing(flavor, options[:version]), - # the graphics are not written down here - see fastlane/metadata/README.md + # everything the listing job sends instead. left out here rather than sent + # twice: two edits saying the same thing is one edit too many + skip_upload_metadata: true, + skip_upload_changelogs: true, skip_upload_images: true, skip_upload_screenshots: true ) end + # What the store says about the app: the listing text, this version's release + # notes, and the screenshots a capture run left under fastlane/framed. private_lane :uploadListing do |options| flavor = options[:flavor] version = require_version(options[:version]) + staged = stage_listing(flavor, version) + captured = stage_screenshots(staged) + upload_to_play_store( track: options[:track] || DEFAULT_TRACK, package_name: PACKAGE_NAMES.fetch(flavor), json_key: ENV["ODR_PLAY_JSON_KEY"] || CredentialsManager::AppfileConfig.try_fetch_value(:json_key_file), - metadata_path: stage_listing(flavor, version), + metadata_path: staged, # without a bundle, supply has to be told which release the notes belong to skip_upload_aab: true, version_code: version_code(version), + # the icon and the feature graphic are still the pre-4.14 ones and are their + # own job - see fastlane/metadata/README.md. only the screenshots go up. skip_upload_images: true, - skip_upload_screenshots: true + skip_upload_screenshots: !captured ) end + desc "Take the play store screenshots of one device, in every locale the listing is written in" + lane :screenshots do + device = screenshot_device + languages = screenshot_languages + + UI.message("photographing the #{device} in #{languages.join(', ')}") + + # The documents in the pictures, written rather than committed: they are build + # output, and the only thing that opens them is the run below. Ahead of the + # build, because the test apk picks its assets up off the disk as it is + # packaged - a file written later lands in the next build, not this one. + sh("python3", MAKE_DOCUMENTS) + + # Left over from an earlier run, and gradle only ever adds to it - a locale + # dropped from the list would otherwise still be in the set it hands over. + FileUtils.rm_rf(ADDITIONAL_OUTPUT) + + # The Pro flavor: it links no ad sdk, so 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. + gradle( + task: "connectedProDebugAndroidTest", + properties: { + "android.testInstrumentationRunnerArguments.class" => + "app.opendocument.droid.test.ScreenshotTests", + "android.testInstrumentationRunnerArguments.device" => device, + "android.testInstrumentationRunnerArguments.locales" => languages.join(",") + } + ) + + taken = Dir.glob(File.join(ADDITIONAL_OUTPUT, "*", "screenshots", "*", "*.png")) + if taken.empty? + UI.user_error!( + "the run wrote no screenshots. A run that photographed nothing and still passed is a " \ + "run that skipped the test - it needs one emulator on adb, running android 15 or newer." + ) + end + + FileUtils.mkdir_p(SCREENSHOT_DIR) + taken.each do |path| + locale = File.basename(File.dirname(path)) + FileUtils.mkdir_p(File.join(SCREENSHOT_DIR, locale)) + FileUtils.cp(path, File.join(SCREENSHOT_DIR, locale, File.basename(path))) + end + + UI.message("took #{taken.length} screenshots") + + # 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 emulators - rerun this script alone after an edit to + # fastlane/frames/frames.json. + sh("python3", 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 + if screenshots_narrowed? + UI.important("#{device} in #{languages.join(', ')} is not a full set, so it is not checked") + else + sh("python3", STORE_SCREENSHOTS, "--screenshots", FRAMED_DIR) + end + end + end diff --git a/fastlane/README.md b/fastlane/README.md index a41c222012ee..49f497209251 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -21,7 +21,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do [bundle exec] fastlane android deployPro ``` -Build and upload the Pro version to Google Play +Build and upload the Pro version, and its listing, to Google Play ### android deployLite @@ -29,7 +29,7 @@ Build and upload the Pro version to Google Play [bundle exec] fastlane android deployLite ``` -Build and upload the Lite version to Google Play +Build and upload the Lite version, and its listing, to Google Play ### android uploadPro @@ -37,7 +37,7 @@ Build and upload the Lite version to Google Play [bundle exec] fastlane android uploadPro ``` -Upload an already built Pro bundle. Used by the release workflow, which builds both flavors with gradle directly, in one job. +Upload an already built Pro bundle, without its listing. Used by the release workflow, which builds both flavors with gradle directly, in one job. ### android uploadLite @@ -45,7 +45,7 @@ Upload an already built Pro bundle. Used by the release workflow, which builds b [bundle exec] fastlane android uploadLite ``` -Upload an already built Lite bundle +Upload an already built Lite bundle, without its listing ### android listingPro @@ -53,7 +53,7 @@ Upload an already built Lite bundle [bundle exec] fastlane android listingPro ``` -Upload the Pro listing on its own, without a bundle - to repair a typo, or a locale that came out wrong, without needing a version to carry it +Upload the Pro listing on its own, without a bundle - what the release does once the screenshots are in, and how a typo is repaired without a version to carry it ### android listingLite @@ -71,6 +71,14 @@ Upload the Lite listing on its own, without a bundle +### android screenshots + +```sh +[bundle exec] fastlane android screenshots +``` + +Take the play store screenshots of one device, in every locale the listing is written in + ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. diff --git a/fastlane/frames/Nunito[wght].ttf b/fastlane/frames/Nunito[wght].ttf new file mode 100644 index 000000000000..2ec1f4b0676c 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 000000000000..c8210f08ca3c --- /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 000000000000..b906b266d729 --- /dev/null +++ b/fastlane/frames/frames.json @@ -0,0 +1,459 @@ +{ + "_comment": [ + "What the play 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.", + "", + "Fifteen locales, the ones scripts/store-listing.py names. Nunito sets", + "eleven of them; hi-IN, ja-JP and zh-CN are set in a system font, which is", + "the one thing frame-screenshots.py needs that is not in this directory." + ], + "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-recents", + "background": "blue", + "chips": [ + "odt", + "ods", + "odp" + ], + "headline": { + "cs-CZ": [ + "Všechny vaše dokumenty,", + "kdekoli a kdykoli." + ], + "de-DE": [ + "Alle Ihre Dokumente,", + "überall. jederzeit." + ], + "en-US": [ + "All your documents,", + "anywhere. anytime." + ], + "es-ES": [ + "Todos tus documentos,", + "donde y cuando quieras." + ], + "et": [ + "Kõik teie dokumendid,", + "kõikjal ja alati." + ], + "fr-FR": [ + "Tous vos documents,", + "partout, à tout moment." + ], + "hi-IN": [ + "आपके सभी दस्तावेज़,", + "कहीं भी, कभी भी।" + ], + "it-IT": [ + "Tutti i tuoi documenti,", + "sempre e ovunque." + ], + "ja-JP": [ + "すべての書類を、", + "いつでも、どこでも。" + ], + "pl-PL": [ + "Wszystkie dokumenty,", + "zawsze i wszędzie." + ], + "pt-BR": [ + "Todos os seus documentos,", + "em qualquer lugar. sempre." + ], + "ru-RU": [ + "Все ваши документы —", + "везде и всегда." + ], + "sv-SE": [ + "Alla dina dokument,", + "var och när som helst." + ], + "tr-TR": [ + "Tüm belgeleriniz,", + "her yerde. her zaman." + ], + "zh-CN": [ + "你的所有文档,", + "随时随地打开。" + ] + } + }, + { + "name": "02-text", + "background": "green", + "chips": [ + "odt" + ], + "headline": { + "cs-CZ": [ + "Textové dokumenty,", + "čtěte a vyhledávejte." + ], + "de-DE": [ + "Textdokumente:", + "lesen und durchsuchen." + ], + "en-US": [ + "Text documents,", + "read and searchable." + ], + "es-ES": [ + "Documentos de texto,", + "léelos y búscalos." + ], + "et": [ + "Tekstidokumendid,", + "loe ja otsi." + ], + "fr-FR": [ + "Documents texte,", + "lecture et recherche." + ], + "hi-IN": [ + "टेक्स्ट दस्तावेज़,", + "पढ़ें और खोजें।" + ], + "it-IT": [ + "Documenti di testo,", + "da leggere e cercare." + ], + "ja-JP": [ + "文書ファイルを", + "読んで、検索。" + ], + "pl-PL": [ + "Dokumenty tekstowe,", + "czytaj i przeszukuj." + ], + "pt-BR": [ + "Documentos de texto,", + "leia e pesquise." + ], + "ru-RU": [ + "Текстовые документы —", + "чтение и поиск." + ], + "sv-SE": [ + "Textdokument,", + "läs och sök." + ], + "tr-TR": [ + "Metin belgeleri,", + "okuyun ve arayın." + ], + "zh-CN": [ + "文本文档,", + "阅读并搜索。" + ] + } + }, + { + "name": "03-sheet", + "background": "orange", + "chips": [ + "ods" + ], + "headline": { + "cs-CZ": [ + "Tabulky,", + "list po listu." + ], + "de-DE": [ + "Tabellen,", + "Blatt für Blatt." + ], + "en-US": [ + "Spreadsheets,", + "sheet by sheet." + ], + "es-ES": [ + "Hojas de cálculo,", + "hoja por hoja." + ], + "et": [ + "Arvutustabelid,", + "leht lehe haaval." + ], + "fr-FR": [ + "Feuilles de calcul,", + "feuille par feuille." + ], + "hi-IN": [ + "स्प्रेडशीट,", + "शीट दर शीट।" + ], + "it-IT": [ + "Fogli di calcolo,", + "foglio per foglio." + ], + "ja-JP": [ + "表計算ファイルも", + "シートごとに。" + ], + "pl-PL": [ + "Arkusze kalkulacyjne,", + "arkusz po arkuszu." + ], + "pt-BR": [ + "Planilhas,", + "aba por aba." + ], + "ru-RU": [ + "Электронные таблицы,", + "лист за листом." + ], + "sv-SE": [ + "Kalkylblad,", + "blad för blad." + ], + "tr-TR": [ + "Hesap tabloları,", + "sayfa sayfa." + ], + "zh-CN": [ + "电子表格,", + "逐张工作表查看。" + ] + } + }, + { + "name": "04-edit", + "background": "blue", + "chips": [ + "odt" + ], + "headline": { + "cs-CZ": [ + "Překlep?", + "Opravte ho rovnou tady." + ], + "de-DE": [ + "Ein Tippfehler?", + "Gleich hier korrigieren." + ], + "en-US": [ + "Found a typo?", + "Fix it right here." + ], + "es-ES": [ + "¿Una errata?", + "Corrígela aquí mismo." + ], + "et": [ + "Näpuviga?", + "Paranda kohe siin." + ], + "fr-FR": [ + "Une faute de frappe ?", + "Corrigez-la ici." + ], + "hi-IN": [ + "कोई गलती मिली?", + "यहीं ठीक करें।" + ], + "it-IT": [ + "Un refuso?", + "Correggilo qui." + ], + "ja-JP": [ + "誤字を見つけた?", + "その場で直せます。" + ], + "pl-PL": [ + "Literówka?", + "Popraw ją tutaj." + ], + "pt-BR": [ + "Um erro de digitação?", + "Corrija aqui mesmo." + ], + "ru-RU": [ + "Нашли опечатку?", + "Исправьте прямо здесь." + ], + "sv-SE": [ + "Ett stavfel?", + "Rätta det här." + ], + "tr-TR": [ + "Yazım hatası mı?", + "Hemen burada düzeltin." + ], + "zh-CN": [ + "发现错别字?", + "就在这里改。" + ] + } + }, + { + "name": "05-pdf", + "background": "green", + "chips": [ + "pdf" + ], + "headline": { + "cs-CZ": [ + "Otevřete jakékoli PDF", + "přímo v aplikaci." + ], + "de-DE": [ + "Jedes PDF öffnen,", + "direkt in der App." + ], + "en-US": [ + "Open any PDF,", + "right in the app." + ], + "es-ES": [ + "Abre cualquier PDF,", + "sin salir de la app." + ], + "et": [ + "Ava iga PDF", + "otse rakenduses." + ], + "fr-FR": [ + "Tous vos PDF,", + "directement dans l'appli." + ], + "hi-IN": [ + "कोई भी PDF खोलें,", + "ऐप में ही।" + ], + "it-IT": [ + "Apri qualsiasi PDF,", + "direttamente nell'app." + ], + "ja-JP": [ + "どんな PDF も", + "アプリの中で。" + ], + "pl-PL": [ + "Otwórz dowolny PDF,", + "od razu w aplikacji." + ], + "pt-BR": [ + "Abra qualquer PDF,", + "aqui mesmo no app." + ], + "ru-RU": [ + "Любой PDF —", + "прямо в приложении." + ], + "sv-SE": [ + "Öppna vilken PDF som helst,", + "direkt i appen." + ], + "tr-TR": [ + "Her PDF'i açın,", + "doğrudan uygulamada." + ], + "zh-CN": [ + "任何 PDF,", + "都能在应用里打开。" + ] + } + }, + { + "name": "06-office", + "background": "orange", + "chips": [ + "docx", + "xlsx", + "pptx" + ], + "headline": { + "cs-CZ": [ + "I soubory Office:", + ".docx, .xlsx a .pptx." + ], + "de-DE": [ + "Auch Office-Dateien:", + ".docx, .xlsx und .pptx." + ], + "en-US": [ + "Office files too:", + ".docx, .xlsx and .pptx." + ], + "es-ES": [ + "También archivos Office:", + ".docx, .xlsx y .pptx." + ], + "et": [ + "Ka Office'i failid:", + ".docx, .xlsx ja .pptx." + ], + "fr-FR": [ + "Les fichiers Office aussi :", + ".docx, .xlsx et .pptx." + ], + "hi-IN": [ + "Office फ़ाइलें भी:", + ".docx, .xlsx और .pptx." + ], + "it-IT": [ + "Anche i file Office:", + ".docx, .xlsx e .pptx." + ], + "ja-JP": [ + "Office ファイルにも対応:", + ".docx、.xlsx、.pptx。" + ], + "pl-PL": [ + "Także pliki Office:", + ".docx, .xlsx i .pptx." + ], + "pt-BR": [ + "Arquivos do Office também:", + ".docx, .xlsx e .pptx." + ], + "ru-RU": [ + "И файлы Office:", + ".docx, .xlsx и .pptx." + ], + "sv-SE": [ + "Även Office-filer:", + ".docx, .xlsx och .pptx." + ], + "tr-TR": [ + "Office dosyaları da:", + ".docx, .xlsx ve .pptx." + ], + "zh-CN": [ + "Office 文件也行:", + ".docx、.xlsx 和 .pptx。" + ] + } + } + ] +} diff --git a/fastlane/metadata/README.md b/fastlane/metadata/README.md index 82d432e358fc..96c27350a099 100644 --- a/fastlane/metadata/README.md +++ b/fastlane/metadata/README.md @@ -73,8 +73,24 @@ there is no translation of it that fits at all. `CHANGELOG.md` at the root is the other record of the same release, written for this repository rather than for the store. +## Screenshots + +Not here, and not committed anywhere: they are taken during the release run, from +the build going out, and staged into this tree beside the text - one directory to +supply, one edit to Play. `scripts/store_screenshots.py` puts them under +`/images/phoneScreenshots/` and `.../tenInchScreenshots/`, which is where +supply reads a locale's pictures from. + +The copy is written; a screenshot is taken. A picture of the app is worth what the +build it came off is worth, so it is not a file that sits in git going quietly out +of date. See the README's "Screenshots" section for how to take them by hand. + ## What is not uploaded -`images/` holds an icon, a feature graphic and four phone screenshots that predate -the 4.14 redesign, so uploading them would put the old screenshots back over the -current ones. The upload leaves them alone. Graphics are their own job. +`images/` holds an icon and a feature graphic that predate the 4.14 redesign. +`skip_upload_images` stays on so they are left where they are; only the screenshots +staged above go up. Those two graphics are their own job. + +The four phone screenshots that used to sit beside them are gone: the release now +uploads its own, taken from the build it is shipping, so a stale copy in the tree +could only ever disagree with the store. diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png deleted file mode 100644 index 71be021db8aa..000000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1_en-US.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png deleted file mode 100644 index f92ed1434def..000000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2_en-US.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png deleted file mode 100644 index e99f5c88b044..000000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png deleted file mode 100644 index fd6a79f10384..000000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4_en-US.png and /dev/null differ diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py new file mode 100755 index 000000000000..11452e98e76e --- /dev/null +++ b/scripts/frame-screenshots.py @@ -0,0 +1,809 @@ +#!/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 android 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 emulators. +# +# Nothing here is drawn from an image file. Every part of the design is a +# rounded rectangle, a plain rectangle or a line of text, so it is all in +# `fastlane/frames/frames.json` and in the numbers below - which is also what +# lets one canvas size become another. The only asset is the font. +# +# Needs Pillow, which is the one thing in this repository's scripts that is not +# in the standard library: +# +# python3 -m pip install Pillow + +import argparse +import bisect +import functools +import json +import math +import shutil +import sys +from pathlib import Path + +import store_screenshots as store + +try: + from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont +except ImportError: + sys.exit("this needs Pillow: python3 -m pip install Pillow") + +ROOT = Path(__file__).resolve().parent.parent +FRAMES = ROOT / "fastlane" / "frames" +CAPTURED = ROOT / "fastlane" / "screenshots" +FRAMED = ROOT / "fastlane" / "framed" + +# The design, as fractions of the canvas rather than pixels, so that one canvas +# size becomes another and the same numbers describe both devices. +# +# 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 canvas is a size of our own rather than the capture's: play refuses a +# screenshot whose long side is more than twice its short one, and a Pixel 9 Pro +# XL is 1344x2992 - 2.23:1 - before anything is drawn around it. +# `store_screenshots.CANVASES` is what the picture comes out as; the capture is a +# picture inside it, which leaves the device room to fit whole rather than be +# cropped past the buttons the app puts in the bottom right corner. +LAYOUT = { + "phone": { + "headline_top": 0.048, + "headline_size": 0.058, # before it is shrunk to fit + "headline_width": 0.86, # what it is shrunk to fit inside + "headline_leading": 1.06, + "screen_left": 0.308, + "screen_top": 0.200, + "screen_width": 0.600, # as wide as it may be; `foot` is the other limit + "foot": 0.036, # ground left under the device, of the height + # A Pixel 9 Pro XL, from its published dimensions: a 1344px screen at + # 486ppi is 70.2mm across a 76.6mm body, which leaves 3.2mm of aluminium + # and black border on every side - half again what an iPhone carries - + # and the display corner is a good deal tighter than Apple's. + "bezel": 0.0268, # screen edge to the outside of the body + # Of that, less than half is the black mask and the rest is the polished + # frame. Which way round this sits is most of whether the drawing reads + # as a current phone. + "rim": 0.42, # how much of the bezel is the black border + "corner": 0.122, # screen corner, of the screen's width + "corner_easing": 2.2, # near a circular arc - see squircle() + "hole": 0.046, # the front camera, of the screen's width + "hole_top": 0.028, + # Nothing at all on the left edge: a Pixel keeps both its keys on the + # right, and the tray is what the left carries - low on the edge, where + # the device has it. + "buttons": [], + "tray": (0.620, 0.075), # how far down the body, how long + # The power key and the volume rocker, on the right edge: (how far down + # the body, how long), both of the body's height. + "buttons_right": [(0.175, 0.055), (0.250, 0.105)], + "chip_top": 0.430, + "chip_size": (0.230, 0.140), + "chip_step": 0.157, + "chip_text": 0.107, + "dash_stroke": 0.0056, + "dash_on": 0.0236, + "dash_off": 0.0098, + # The line crossing every screen: each picture takes it in at the height + # the one before let it out at and hands it on, so no two screens carry + # the same line and the gallery still reads as one. One height per seam, + # which is one more than there are screens, and every one of them in the + # band 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.148, 0.176, 0.158, 0.180, 0.152, 0.172, 0.164], + # 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. One step each, at a different place, and + # two of them dip a little way down first - only the left quarter is + # free below the band, the device covers the rest, and a dip has to come + # back up left of the body's edge (0.255 here) or it goes behind the + # device and never comes out. + "routes": [ + [(0.34, "in"), (0.34, "out")], + [(0.17, "in"), (0.17, "out")], + [(0.52, "in"), (0.52, "out")], + [(0.10, "in"), (0.10, 0.245), (0.21, 0.245), (0.21, "out")], + [(0.26, "in"), (0.26, "out"), (0.60, "out")], + [(0.13, "in"), (0.13, 0.235), (0.23, 0.235), (0.23, "out")], + ], + "radii": [0.078, 0.066, 0.086, 0.062, 0.072, 0.070], + # The lower line: in from off the canvas, around a corner and out again. + # Points are (x, y) in canvas fractions and a point past 1 is off the + # edge on purpose. A y of "chips" hangs the line off the top of the tabs + # so it runs behind however many there are and comes out underneath - + # anchored to a number, a screen with one tab starts it in mid air, so a + # screen with no tabs takes one of the routes 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)], + ], + }, + "tablet": { + "headline_top": 0.060, + "headline_size": 0.052, + "headline_width": 0.80, + "headline_leading": 1.06, + "screen_left": 0.235, + "screen_top": 0.240, + "screen_width": 0.700, + "foot": 0.036, + "corner_easing": 2.2, + # A Pixel Tablet: a 2560px screen at 276mm of body leaves about 14.5mm + # of border on every side - four times the phone's, and the thing anyone + # who has held one would name first. Its display corners are rounder + # than a phone's are relative to the screen, and its keys are on the edge + # that becomes the top in portrait, which this frame does not show. No + # sim tray either: it is a wifi tablet. + "bezel": 0.0470, + # the other way round from the phone: a tablet's border really is mostly + # black mask, with the frame a thin bright edge outside it + "rim": 0.86, + "corner": 0.030, + "buttons": [], + "chip_top": 0.430, + "chip_size": (0.170, 0.0900), + "chip_step": 0.1010, + "chip_text": 0.080, + "dash_stroke": 0.0040, + "dash_on": 0.0147, + "dash_off": 0.0061, + "seams": [0.152, 0.186, 0.166, 0.192, 0.156, 0.180, 0.170], + "routes": [ + [(0.30, "in"), (0.30, "out")], + [(0.115, "in"), (0.115, "out")], + [(0.46, "in"), (0.46, "out")], + [(0.06, "in"), (0.06, 0.250), (0.15, 0.250), (0.15, "out")], + [(0.20, "in"), (0.20, "out"), (0.54, "out")], + [(0.08, "in"), (0.08, 0.240), (0.16, 0.240), (0.16, "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 device, 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" +# How warm the frame is, as a multiplier per channel: a Pixel's aluminium is a +# warm grey. Small on purpose - past about a twentieth it stops being aluminium +# and starts being gold. +ALUMINIUM = (1.035, 1.0, 0.955) +# The metal the keys wear. They read as a step in the edge rather than as marks +# on it, which is what they are. +BUTTON = ("#d8d8d6", "#a9a9a7", "#c4c4c2") +# The sim tray, which is a seam rather than a key: the same metal, a shade darker +# so it reads as a line cut into the edge instead of one standing off it. +TRAY = ("#9a9a98", "#7c7c7a", "#909090") +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 + + +# The scripts Nunito cannot set, and where to find one that can. +# +# Nunito covers Latin and Cyrillic, which is eleven of the fifteen locales. It +# has no Devanagari and no CJK, and a font that has them is ten to sixteen +# megabytes per language - not something to put in a git history when every +# machine that runs this is an apt-get away from one. So these three are looked +# for on the system, and a machine without one is told what to install rather +# than handed a headline full of tofu. +# +# A candidate is (regular, bold, marker). The marker picks a face out of a .ttc +# by family name: the Noto collections hold every CJK language at once and which +# index is which is not fixed, so it is searched for rather than counted to. +NOTO_CJK = "/usr/share/fonts/opentype/noto/NotoSansCJK-%s.ttc" + +# The last resort on a mac: Hiragino Sans and PingFang are downloadable rather than +# installed, so a machine that never asked for them has neither at a fixed path, while +# this one has been in /Library/Fonts since forever. One weight only, so the second +# headline line comes out light - a picture to look at, not one to ship. +ARIAL_UNICODE = "/Library/Fonts/Arial Unicode.ttf" + +SCRIPTS = { + "devanagari": ( + "fonts-noto-core on debian, or Devanagari Sangam MN on macos", + [ + ("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Bold.ttf", None), + ("/System/Library/Fonts/Supplemental/Devanagari Sangam MN.ttc", + "/System/Library/Fonts/Supplemental/Devanagari Sangam MN.ttc", None), + ("/System/Library/Fonts/Kohinoor.ttc", "/System/Library/Fonts/Kohinoor.ttc", None), + ], + ), + "japanese": ( + "fonts-noto-cjk on debian, or Hiragino Sans on macos", + [ + (NOTO_CJK % "Regular", NOTO_CJK % "Bold", "JP"), + ("/System/Library/Fonts/Hiragino Sans W4.ttc", + "/System/Library/Fonts/Hiragino Sans W7.ttc", None), + (ARIAL_UNICODE, ARIAL_UNICODE, None), + ], + ), + "chinese": ( + "fonts-noto-cjk on debian, or PingFang on macos", + [ + (NOTO_CJK % "Regular", NOTO_CJK % "Bold", "SC"), + ("/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/PingFang.ttc", "SC"), + ("/System/Library/Fonts/Hiragino Sans GB.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", None), + (ARIAL_UNICODE, ARIAL_UNICODE, None), + ], + ), +} + +# The locales written in one of them. Everything else is Nunito. +WRITTEN_IN = {"hi-IN": "devanagari", "ja-JP": "japanese", "zh-CN": "chinese"} + + +def face_in(path, size, marker): + """One face of a font file, picked out of a collection by family name. + + A .ttc holds several faces and the order is the font's business, not ours - + so the index is searched for. Without a marker the first face is the file's + own answer to what it is. + """ + if marker is None: + return ImageFont.truetype(path, size) + + for index in range(12): + try: + found = ImageFont.truetype(path, size, index=index) + except (OSError, ValueError): + break + if marker in "".join(part or "" for part in found.getname()): + return found + + raise OSError(f"{path} holds no {marker} face") + + +@functools.lru_cache(maxsize=None) +def font(size, weight, locale=None): + """The headline font at one weight, in the script the language is written in. + + Nunito ships as a single variable file these days, so both weights come out + of it by name; a system font is two files, or one that has only the weight it + has, which is why a missing variation is not an error. + """ + script = WRITTEN_IN.get(locale) + if script is None: + found = ImageFont.truetype(str(FRAMES / design()["font"]), size) + else: + wanted, candidates = SCRIPTS[script] + found = None + for regular, bold, marker in candidates: + path = bold if weight == "Bold" else regular + if not Path(path).exists(): + continue + try: + found = face_in(path, size, marker) + break + except OSError: + continue + + if found is None: + raise SystemExit( + f"nothing on this machine can set {script}, which {locale}'s headline " + f"is written in. Install {wanted}." + ) + + try: + found.set_variation_by_name(weight) + except (OSError, ValueError): + # not a variable font: it is already the weight its file says it is + pass + + return found + + +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=2.2, per_corner=40): + """A rounded rectangle whose corners are superellipse quadrants. + + The exponent is what shape of phone this is, and the difference is not + subtle at this size: a continuous curve, easing into the straight edge, takes + around 5 and is an iPhone corner. A Pixel's is near enough a circular arc, + which is 2 - 2.2 here, since a touch of easing is what the glass does where + it meets the frame and an exact circle reads as a render. + """ + 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 opens up around the corner and closes down the sides - so a + bezel drawn that way is visibly fatter at the corners. + """ + 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. + + A Pixel Pro's frame is polished aluminium: a narrow specular right at the + outer edge, a hard drop behind it, a weaker sheen where the flat turns down + to the glass, and dark where it meets the black surround. Brushed metal - one + broad highlight two thirds of the way in - is somebody else's phone, and a + flat fill is a grey stripe. + """ + stops = ((0.00, 150), (0.10, 240), (0.22, 208), (0.42, 138), (0.66, 192), (0.85, 164), (1.00, 96)) + place = bisect.bisect_right([at for at, _ in stops], share) + if place == 0: + level = stops[0][1] + elif place == len(stops): + level = stops[-1][1] + else: + (before, low), (after, high) = stops[place - 1], stops[place] + level = low + (high - low) * (share - before) / (after - before) + + return tuple(min(255, round(level * warm)) for warm in ALUMINIUM) + + +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 device_body(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 downloaded frame is the wrong shape for the next device. + + 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 + bezel = layout["bezel"] * width # screen edge to the outside of the body + rim = bezel * layout["rim"] # how much of that is metal + + left, top = layout["screen_left"] * width, layout["screen_top"] * height + + # Two limits rather than one fraction: `screen_width` is as wide as it may + # be, and `foot` is how much ground has to be left under it. Sized by the + # fraction alone, a device a little taller than the one the number was picked + # for runs its bottom rim off the canvas and a shorter one leaves a stripe of + # ground - neither of which is a decision anybody made. + standing = (height - layout["foot"] * height) - top - bezel + screen_width = min(layout["screen_width"] * width, standing * shot.width / shot.height) + screen_height = screen_width * shot.height / shot.width + + screen = (left, top, left + screen_width, top + screen_height) + corner = layout["corner"] * screen_width + + body = (screen[0] - bezel, screen[1] - bezel, screen[2] + bezel, screen[3] + bezel) + + # its own canvas, with room either side for the keys 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 + stand = screen_width * 0.0061 # how far a key stands proud, about 2.7pt + tall_as = body[3] - body[1] + + def along(edge, keys, proud): + marks = Image.new("L", size, 0) + draw = ImageDraw.Draw(marks) + for at_height, tall in keys: + y = here(body)[1] + tall_as * at_height + draw.rounded_rectangle((edge - proud, y, edge + proud, y + tall_as * tall), + radius=proud * 0.55, fill=255) + + return marks + + keys = Image.new("L", size, 0) + for edge, side in ((here(body)[0], layout["buttons"]), + (here(body)[2], layout.get("buttons_right", []))): + keys = ImageChops.lighter(keys, along(edge, side, stand)) + device.paste(brushed(size, BUTTON), (0, 0), keys) + + # The sim tray, which sits flush rather than proud - it is a seam in the edge, + # so it is drawn narrower and darker than a key and does not stand off the body + if layout.get("tray"): + device.paste( + brushed(size, TRAY), + (0, 0), + along(here(body)[0], [layout["tray"]], stand * 0.45), + ) + + # 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, layout["corner_easing"]) + 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 runs + # into the black bezel and the two read as one fat border. + 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 hole the front camera sits in, in the gap the status bar leaves for + # it. A circle rather than a pill, which is a Pixel and not an iPhone. + if layout.get("hole"): + across = layout["hole"] * screen_width + middle = (inside[0] + inside[2]) / 2 + hole_top = inside[1] + layout["hole_top"] * screen_width + ImageDraw.Draw(device).ellipse( + (middle - across / 2, hole_top, middle + across / 2, hole_top + across), 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, locale): + """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) + + faces = [font(size, weight, locale) for weight in weights] + while size > 8: + if max(draw.textlength(line, font=face) for line, face in zip(lines, faces)) <= allowed: + break + size -= 2 + faces = [font(size, weight, locale) for weight in weights] + + 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: measured across every format rather than the + # two or three on this screen, so the tabs are one length through the + # gallery rather than stepping in and out as the reader swipes. + 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, device, screen, locale, spec, order=0): + """One picture: ground, decorations, device, tabs, headline.""" + if device not in LAYOUT: + raise ValueError(f"no layout for {device} - one of {', '.join(LAYOUT)}") + + layout = LAYOUT[device] + size = store.CANVASES[device] + width, height = size + canvas = gradient(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, size)) for index in range(order)) + dashed( + canvas, rounded_path(crossing(layout, order, 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, + ) + + device_body(canvas, shot, layout) + chips(canvas, screen["chips"], spec["chips"], layout) + headline(canvas, copy(screen, locale), layout, locale) + + 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 play 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")): + # the capture run writes the device into the name, being the only + # thing that knows which emulator it was driving + device, name = store.named(path.stem) + if device is None or name not in screens: + print(f"{locale}: skipping {path.name}, which no screen is named after", + file=sys.stderr) + continue + + with Image.open(path) as shot: + picture = frame( + shot.convert("RGB"), device, 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 000000000000..bfcabd9e5d28 --- /dev/null +++ b/scripts/make-screenshot-documents.py @@ -0,0 +1,2190 @@ +#!/usr/bin/env python3 +"""Builds the documents the play 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 an emulator, to be photographed. + +They are written into the *test* apk's assets, not the app's, and laid out on +the device by `ScreenshotTests`. An instrumented test runs in the app's own +process, so nothing about the screenshots has to exist in a build that ships: +no back door, no debug-only asset, no line in `MainActivity`. + + python3 scripts/make-screenshot-documents.py + python3 scripts/make-screenshot-documents.py --language en one of them + +What it writes is not committed - the screenshot lane runs this before it +builds. The packages are byte for byte reproducible, so a rerun that changes +no wording writes the same bytes. +""" + +import argparse +import json +import re +import unicodedata +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +import store_screenshots as store + +# 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 / "app" / "src" / "androidTest" / "assets" / "screenshots" +) + +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" + +# Which section of the report the figures sit under - the costs one, second of the +# five - and how many rows of them there are. The rows are what carry the report +# past the foot of a phone screen, so this is the number to turn if it stops. +COSTS_SECTION = 1 +REPORT_ROWS = 26 + + +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 title, a lead, headed sections with the costs figures under theirs, and a + closing line. + + Long on purpose. A page fitted to the width of a phone is about two thirds of + its height, so a document that ends after one is photographed with a third of + the screen showing the backdrop behind it. Which is why there is a table in + here at all: the figures are the only length the report can be given that is + already written in all fifteen languages. + """ + 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"), + CELL_STYLES, + """ + + """, + """ + + """, + ] + ) + + head, body, foot = table(words, columns=3, rows=REPORT_ROWS) + marks = "\n".join( + [' '] + + [' '] * (len(head) - 1) + ) + figures = ( + f' \n{marks}\n' + + "\n".join([odf_row(head, "ceHead")] + [odf_row(line) for line in body] + [odf_row(foot, "ceTotal")]) + + "\n " + ) + + lines = [ + f' {escape(words["title"])}', + f' {escape(words["lead"])}', + ] + for index, (heading, paragraphs) in enumerate(words["sections"]): + lines.append( + f' {escape(heading)}' + ) + lines += [f' {escape(text)}' for text in paragraphs] + + # under the costs section, which is the one it is the figures for. Its own + # heading would be a word to translate fifteen times for nothing + if index == COSTS_SECTION: + lines.append(figures) + lines.append(' ') + + 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 odf_row(cells: list, style: str | None = None) -> str: + """One row of an ODF table, as the report and the sheet both write it. + + A figure carries its value in the attribute as well as in the text, or the + spreadsheet holds a column of text that happens to look like numbers. + """ + 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) + + +# The head and the totals row, which the report and the sheet mark the same way. +CELL_STYLES = """ + + + + + + + """ % (RULE, ACCENT, RULE) + + +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 + """ + + """, + """ + + """, + CELL_STYLES, + ] + ) + + head, body, foot = table(words, columns=6) + + overview = [odf_row(head, "ceHead")] + [odf_row(line) for line in body] + [odf_row(foot, "ceTotal")] + costs = [odf_row([words["item"], words["total"]], "ceHead")] + costs += [odf_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' • {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ünktlich erschienen.", + "sections": [ + ["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 das ganze Quartal über stabil.", + "Zwei Server sind ohne einen Tag Ausfall 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.", + ]], + ["Das Team", [ + "An der Version arbeiteten sechs Personen, zwei davon neu in diesem Jahr.", + "Die Urlaubsvertretung wurde im April geregelt und hat den ganzen Sommer über gehalten.", + "Alle haben die Schulung absolviert, die die neue Lizenz verlangt.", + ]], + ["Risiken", [ + "Der Umzug im November ist der einzige Termin, der sich nicht verschieben lässt.", + "Ein Lieferant hat die neuen Bedingungen noch nicht unterschrieben; wir haken nach.", + "Die Hostingkosten steigen im Januar, wenn der Vertrag nicht vorzeitig verlängert wird.", + ]], + ], + "closing": "Das nächste Treffen findet Ende Juli statt.", + "sheets": ["Übersicht", "Kosten"], + "item": "Position", + "total": "Gesamt", + "periods": ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun"], + "rows": ["Software", "Reisen", "Hardware", "Marketing", "Support", "Schulung", "Lizenzen", "Hosting", "Veranstaltungen", "Büro", "Cloud", "Personalsuche", "Recht", "Versicherung", "Verpflegung", "Versand", "Werbung", "Beratung", "Wartung", "Nebenkosten", "Ausstattung", "Abonnements", "Telefonie", "Internet", "Sicherheit", "Backups", "Domains", "Zertifikate", "Druck", "Büromaterial", "Porto", "Reinigung", "Reparaturen", "Möbel", "Lager", "Bankgebühren", "Mitgliedschaften", "Konferenzen", "Übersetzung", "Design"], + "slides": [ + ["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"]], + ], + }, + "es": { + "title": "Informe trimestral", + "lead": "El equipo cumplió todos los objetivos del segundo trimestre y la nueva versión salió a tiempo.", + "sections": [ + ["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 una sola interrupción del servicio.",]], + ["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 y se le está reclamando.", + "El alojamiento sube en enero si no se renueva antes el contrato.", + ]], + ], + "closing": "La próxima reunión es a finales de julio.", + "sheets": ["Resumen", "Costes"], + "item": "Concepto", + "total": "Total", + "periods": ["Ene", "Feb", "Mar", "Abr", "May", "Jun"], + "rows": ["Software", "Viajes", "Hardware", "Marketing", "Soporte", "Formación", "Licencias", "Alojamiento", "Eventos", "Oficina", "Nube", "Contratación", "Legal", "Seguros", "Catering", "Envíos", "Publicidad", "Consultoría", "Mantenimiento", "Suministros", "Equipamiento", "Suscripciones", "Telefonía", "Internet", "Seguridad", "Copias de seguridad", "Dominios", "Certificados", "Impresión", "Papelería", "Franqueo", "Limpieza", "Reparaciones", "Mobiliario", "Almacenamiento", "Comisiones bancarias", "Cuotas", "Congresos", "Traducción", "Diseño"], + "slides": [ + ["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"]], + ], + }, + "fr": { + "title": "Rapport trimestriel", + "lead": "L'équipe a atteint tous les objectifs du deuxième trimestre et la nouvelle version est sortie à temps.", + "sections": [ + ["Points forts", [ + "Les coûts sont restés dans 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 en logiciels ont augmenté avec les nouvelles licences, tandis que les déplacements 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 la moindre interruption de service.",]], + ["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 pour les congés ont été organisés en avril et ont tenu tout l'été.", + "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.", + "Le coût de l'hébergement augmente en janvier sans renouvellement anticipé.", + ]], + ], + "closing": "La prochaine réunion aura lieu fin juillet.", + "sheets": ["Aperçu", "Coûts"], + "item": "Poste", + "total": "Total", + "periods": ["Janv.", "Févr.", "Mars", "Avr.", "Mai", "Juin"], + "rows": ["Logiciels", "Déplacements", "Matériel", "Marketing", "Support", "Formation", "Licences", "Hébergement", "Événements", "Bureau", "Cloud", "Recrutement", "Juridique", "Assurance", "Traiteur", "Expédition", "Publicité", "Conseil", "Maintenance", "Charges", "Équipement", "Abonnements", "Téléphonie", "Internet", "Sécurité", "Sauvegardes", "Domaines", "Certificats", "Impression", "Fournitures", "Affranchissement", "Nettoyage", "Réparations", "Mobilier", "Stockage", "Frais bancaires", "Cotisations", "Conférences", "Traduction", "Design"], + "slides": [ + ["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"]], + ], + }, + "it": { + "title": "Relazione trimestrale", + "lead": "Il team ha raggiunto tutti gli obiettivi del secondo trimestre e la nuova versione è uscita in tempo.", + "sections": [ + ["In evidenza", [ + "I costi sono rimasti sotto il budget e due nuovi partner si sono uniti al progetto.", + "La nuova versione ha raggiunto in una settimana più persone di quante ne avesse raggiunte la precedente in un mese.", + "Il supporto ha risposto a nove richieste su dieci in giornata.",]], + ["Costi e budget", [ + "La spesa per il software è cresciuta con le nuove licenze, mentre quella per i viaggi è di nuovo calata.", + "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.", + "I costi di hosting aumentano a gennaio se il contratto non viene rinnovato in anticipo.", + ]], + ], + "closing": "Il prossimo incontro è a fine luglio.", + "sheets": ["Panoramica", "Costi"], + "item": "Voce", + "total": "Totale", + "periods": ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu"], + "rows": ["Software", "Viaggi", "Hardware", "Marketing", "Supporto", "Formazione", "Licenze", "Hosting", "Eventi", "Ufficio", "Cloud", "Selezione", "Legale", "Assicurazione", "Catering", "Spedizioni", "Pubblicità", "Consulenza", "Manutenzione", "Utenze", "Attrezzature", "Abbonamenti", "Telefonia", "Internet", "Sicurezza", "Backup", "Domini", "Certificati", "Stampa", "Cancelleria", "Affrancature", "Pulizie", "Riparazioni", "Arredi", "Archiviazione", "Spese bancarie", "Quote associative", "Conferenze", "Traduzioni", "Design"], + "slides": [ + ["Piano di progetto", ["Obiettivi del trimestre", "Budget e costi", "Prossimi passi"]], + ["Calendario", ["Versione a giugno", "Revisione a luglio", "Pianificazione ad agosto"]], + ["Team", ["Due nuovi partner", "Supporto in tre lingue", "Formazione in autunno"]], + ], + }, + "pl": { + "title": "Raport kwartalny", + "lead": "Zespół osiągnął wszystkie cele drugiego kwartału, a nowa wersja ukazała się na czas.", + "sections": [ + ["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 ani jednego 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, z czego dwie dołączyły w tym roku.", + "Zastępstwa urlopowe ustalono w kwietniu i utrzymały się przez 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 i jest ponaglany.", + "Koszty hostingu wzrosną w styczniu, jeśli umowa nie zostanie odnowiona wcześniej.", + ]], + ], + "closing": "Następne spotkanie odbędzie się pod koniec lipca.", + "sheets": ["Przegląd", "Koszty"], + "item": "Pozycja", + "total": "Razem", + "periods": ["sty", "lut", "mar", "kwi", "maj", "cze"], + "rows": ["Oprogramowanie", "Podróże", "Sprzęt", "Marketing", "Wsparcie", "Szkolenia", "Licencje", "Hosting", "Wydarzenia", "Biuro", "Chmura", "Rekrutacja", "Prawo", "Ubezpieczenie", "Catering", "Wysyłka", "Reklama", "Doradztwo", "Utrzymanie", "Media", "Wyposażenie", "Subskrypcje", "Telefonia", "Internet", "Bezpieczeństwo", "Kopie zapasowe", "Domeny", "Certyfikaty", "Druk", "Artykuły biurowe", "Opłaty pocztowe", "Sprzątanie", "Naprawy", "Meble", "Magazyn", "Opłaty bankowe", "Składki członkowskie", "Konferencje", "Tłumaczenia", "Projektowanie"], + "slides": [ + ["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ą"]], + ], + }, + "pt-BR": { + "title": "Relatório trimestral", + "lead": "A equipe alcançou todas as metas do segundo trimestre e a nova versão saiu no prazo.", + "sections": [ + ["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 escala de férias foi definida em abril e valeu 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.", + ]], + ], + "closing": "A próxima reunião é no fim de julho.", + "sheets": ["Visão geral", "Custos"], + "item": "Item", + "total": "Total", + "periods": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun"], + "rows": ["Software", "Viagens", "Hardware", "Marketing", "Suporte", "Treinamento", "Licenças", "Hospedagem", "Eventos", "Escritório", "Nuvem", "Recrutamento", "Jurídico", "Seguros", "Buffet", "Frete", "Publicidade", "Consultoria", "Manutenção", "Água e luz", "Equipamentos", "Assinaturas", "Telefonia", "Internet", "Segurança", "Backups", "Domínios", "Certificados", "Impressão", "Papelaria", "Correios", "Limpeza", "Reparos", "Móveis", "Armazenamento", "Tarifas bancárias", "Associações", "Conferências", "Tradução", "Design"], + "slides": [ + ["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"]], + ], + }, + "ru": { + "title": "Квартальный отчёт", + "lead": "Команда достигла всех целей второго квартала, и новая версия вышла в срок.", + "sections": [ + ["Главное", [ + "Расходы остались в рамках бюджета, а к проекту присоединились два новых партнёра.", + "За первую неделю новая версия охватила больше людей, чем предыдущая за месяц.", + "Поддержка ответила на девять из десяти обращений в тот же день.",]], + ["Расходы и бюджет", [ + "Затраты на ПО выросли из-за новых лицензий, а расходы на поездки снова снизились.", + "Оборудование меняли один раз, поддержка работала стабильно весь квартал.", + "Два сервера переехали к новому провайдеру без единого дня простоя.",]], + ["Следующий квартал", [ + "Версия в сентябре — последняя из запланированных в этом году.", + "Открыты две вакансии в поддержке и одна в дизайне.", + "Переезд офиса намечен на ноябрь, бюджет утверждён.", + ]], + ["Люди", [ + "Над версией работали шесть человек, двое из них пришли в этом году.", + "Замены на время отпусков согласовали в апреле, и график продержался всё лето.", + "Все прошли обучение, которого требует новая лицензия.", + ]], + ["Риски", [ + "Переезд в ноябре — единственная дата, которую нельзя сдвинуть.", + "Один поставщик так и не подписал новые условия, ему напоминают.", + "Хостинг подорожает в январе, если не продлить договор заранее.", + ]], + ], + "closing": "Следующая встреча — в конце июля.", + "sheets": ["Обзор", "Расходы"], + "item": "Статья", + "total": "Итого", + "periods": ["Янв.", "Февр.", "Март", "Апр.", "Май", "Июнь"], + "rows": ["ПО", "Поездки", "Оборудование", "Маркетинг", "Поддержка", "Обучение", "Лицензии", "Хостинг", "Мероприятия", "Офис", "Облако", "Наём", "Юристы", "Страхование", "Кейтеринг", "Доставка", "Реклама", "Консалтинг", "Обслуживание", "Коммунальные услуги", "Оснащение", "Подписки", "Телефония", "Интернет", "Безопасность", "Резервное копирование", "Домены", "Сертификаты", "Печать", "Канцтовары", "Почтовые расходы", "Уборка", "Ремонт", "Мебель", "Хранение", "Банковские комиссии", "Членские взносы", "Конференции", "Перевод", "Дизайн"], + "slides": [ + ["План проекта", ["Цели на квартал", "Бюджет и расходы", "Следующие шаги"]], + ["График", ["Версия в июне", "Итоги в июле", "Планирование в августе"]], + ["Команда", ["Два новых партнёра", "Поддержка на трёх языках", "Обучение осенью"]], + ], + }, + "tr": { + "title": "Üç aylık rapor", + "lead": "Ekip ikinci çeyreğin tüm hedeflerine ulaştı ve yeni sürüm zamanında yayınlandı.", + "sections": [ + ["Ö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 belirlendi ve yaz boyunca sorunsuz işledi.", + "Herkes yeni lisansın gerektirdiği eğitimi tamamladı.", + ]], + ["Riskler", [ + "Kasımdaki taşınma, ertelenemeyecek tek tarih.", + "Bir tedarikçi yeni koşulları henüz imzalamadı, takibi sürüyor.", + "Sözleşme erken yenilenmezse barındırma maliyeti ocakta artacak.", + ]], + ], + "closing": "Bir sonraki toplantı temmuz sonunda.", + "sheets": ["Genel bakış", "Maliyetler"], + "item": "Kalem", + "total": "Toplam", + "periods": ["Oca", "Şub", "Mar", "Nis", "May", "Haz"], + "rows": ["Yazılım", "Seyahat", "Donanım", "Pazarlama", "Destek", "Eğitim", "Lisanslar", "Barındırma", "Etkinlikler", "Ofis", "Bulut", "İşe alım", "Hukuk", "Sigorta", "İkram", "Kargo", "Reklam", "Danışmanlık", "Bakım", "Faturalar", "Ekipman", "Abonelikler", "Telefon", "İnternet", "Güvenlik", "Yedekleme", "Alan adları", "Sertifikalar", "Baskı", "Kırtasiye", "Posta", "Temizlik", "Onarım", "Mobilya", "Depolama", "Banka masrafları", "Üyelikler", "Konferanslar", "Çeviri", "Tasarım"], + "slides": [ + ["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"]], + ], + }, + "cs": { + "title": "Čtvrtletní zpráva", + "lead": "Tým splnil všechny cíle druhého čtvrtletí a nová verze vyšla včas.", + "sections": [ + ["Nejdůležitější", [ + "Náklady zůstaly pod rozpočtem a k projektu se připojili dva noví partneři.", + "Nová verze oslovila za první týden více lidí než ta předchozí za měsíc.", + "Podpora odpověděla na devět z deseti dotazů týž den.", + ]], + ["Náklady a rozpočet", [ + "Výdaje za software s novými licencemi vzrostly, cestovné opět kleslo.", + "Hardware byl jednou vyměněn a podpora zůstala po celé čtvrtletí stabilní.", + "Dva servery přešly k novému poskytovateli bez jediného dne výpadku.", + ]], + ["Příští čtvrtletí", [ + "Zářijová verze je poslední plánovaná letos.", + "V podpoře jsou volná dvě místa, v designu jedno.", + "Stěhování kanceláře je v listopadu a rozpočet na ně je schválený.", + ]], + ["Lidé", [ + "Na verzi pracovalo šest lidí, dva z nich jsou tu první rok.", + "Zástupy na dobu dovolených se domluvily v dubnu a vydržely celé léto.", + "Všichni absolvovali školení, které nová licence vyžaduje.", + ]], + ["Rizika", [ + "Listopadové stěhování je jediný termín, který nelze posunout.", + "Jeden dodavatel dosud nepodepsal nové podmínky a urgujeme ho.", + "Náklady na hosting v lednu vzrostou, pokud se smlouva neobnoví dříve.", + ]], + ], + "closing": "Další schůzka je na konci července.", + "sheets": ["Přehled", "Náklady"], + "item": "Položka", + "total": "Celkem", + "periods": ["Led", "Úno", "Bře", "Dub", "Kvě", "Čvn"], + "rows": ["Software", "Cestovné", "Hardware", "Marketing", "Podpora", "Školení", "Licence", "Hosting", "Akce", "Kancelář", "Cloud", "Nábor", "Právní služby", "Pojištění", "Občerstvení", "Doprava", "Reklama", "Poradenství", "Údržba", "Energie", "Vybavení", "Předplatné", "Telefonie", "Internet", "Bezpečnost", "Zálohování", "Domény", "Certifikáty", "Tisk", "Papírnictví", "Poštovné", "Úklid", "Opravy", "Nábytek", "Sklad", "Bankovní poplatky", "Členské příspěvky", "Konference", "Překlady", "Design"], + "slides": [ + ["Plán projektu", ["Cíle čtvrtletí", "Rozpočet a náklady", "Další kroky"]], + ["Harmonogram", ["Verze v červnu", "Hodnocení v červenci", "Plánování v srpnu"]], + ["Tým", ["Dva noví partneři", "Podpora ve třech jazycích", "Školení na podzim"]], + ], + }, + "et": { + "title": "Kvartaliaruanne", + "lead": "Meeskond täitis teise kvartali kõik eesmärgid ja uus versioon ilmus õigeks ajaks.", + "sections": [ + ["Peamine", [ + "Kulud püsisid eelarve piires ja projektiga liitus kaks uut partnerit.", + "Uus versioon jõudis esimese nädalaga rohkemate inimesteni kui eelmine kuuga.", + "Kasutajatugi vastas üheksale küsimusele kümnest samal päeval.", + ]], + ["Kulud ja eelarve", [ + "Tarkvarakulud kasvasid uute litsentsidega, lähetuskulud vähenesid taas.", + "Riistvara vahetati korra välja ja tugi püsis kogu kvartali ühtlasena.", + "Kaks serverit kolisid uue teenusepakkuja juurde ilma ainsagi katkestuseta.", + ]], + ["Järgmine kvartal", [ + "Septembri versioon on selle aasta viimane plaanitud versioon.", + "Toes on täitmata kaks kohta ja disainis üks.", + "Kontor kolib novembris ja selle eelarve on kokku lepitud.", + ]], + ["Inimesed", [ + "Versiooni kallal töötas kuus inimest, kaks neist on siin esimest aastat.", + "Puhkuseasendused lepiti kokku aprillis ja need pidasid terve suve.", + "Kõik on läbinud koolituse, mida uus litsents nõuab.", + ]], + ["Riskid", [ + "Novembri kolimine on ainus kuupäev, mida edasi lükata ei saa.", + "Üks tarnija pole uusi tingimusi veel allkirjastanud ja talle tuletatakse meelde.", + "Majutuse hind tõuseb jaanuaris, kui lepingut varem ei pikendata.", + ]], + ], + "closing": "Järgmine koosolek on juuli lõpus.", + "sheets": ["Ülevaade", "Kulud"], + "item": "Kirje", + "total": "Kokku", + "periods": ["Jaan", "Veebr", "Märts", "Apr", "Mai", "Juuni"], + "rows": ["Tarkvara", "Lähetused", "Riistvara", "Turundus", "Kasutajatugi", "Koolitus", "Litsentsid", "Majutus", "Üritused", "Kontor", "Pilv", "Värbamine", "Õigusabi", "Kindlustus", "Toitlustus", "Saatmine", "Reklaam", "Konsultatsioon", "Hooldus", "Kommunaalkulud", "Seadmed", "Tellimused", "Telefon", "Internet", "Turvalisus", "Varundus", "Domeenid", "Sertifikaadid", "Trükkimine", "Kontoritarbed", "Postikulud", "Koristus", "Remont", "Mööbel", "Ladu", "Pangateenused", "Liikmemaksud", "Konverentsid", "Tõlge", "Disain"], + "slides": [ + ["Projektiplaan", ["Kvartali eesmärgid", "Eelarve ja kulud", "Järgmised sammud"]], + ["Ajakava", ["Versioon juunis", "Ülevaatus juulis", "Planeerimine augustis"]], + ["Meeskond", ["Kaks uut partnerit", "Tugi kolmes keeles", "Koolitus sügisel"]], + ], + }, + "hi": { + "title": "तिमाही रिपोर्ट", + "lead": "टीम ने दूसरी तिमाही के सभी लक्ष्य पूरे किए और नया संस्करण समय पर जारी हुआ।", + "sections": [ + ["मुख्य बातें", [ + "लागत बजट के भीतर रही और परियोजना से दो नए साझेदार जुड़े।", + "नया संस्करण पहले सप्ताह में उतने लोगों तक पहुँचा, जितनों तक पिछला एक महीने में पहुँचा था।", + "सहायता टीम ने दस में से नौ सवालों का जवाब उसी दिन दिया।", + ]], + ["लागत और बजट", [ + "नए लाइसेंसों के साथ सॉफ़्टवेयर पर खर्च बढ़ा, जबकि यात्रा खर्च फिर घटा।", + "हार्डवेयर एक बार बदला गया और सहायता पूरी तिमाही स्थिर रही।", + "दो सर्वर एक दिन की भी रुकावट के बिना नए प्रदाता पर चले गए।", + ]], + ["अगली तिमाही", [ + "सितंबर का संस्करण इस साल का आख़िरी नियोजित संस्करण है।", + "सहायता में दो और डिज़ाइन में एक पद खाली है।", + "दफ़्तर नवंबर में बदलेगा और उसका बजट तय हो चुका है।", + ]], + ["लोग", [ + "इस संस्करण पर छह लोगों ने काम किया, जिनमें दो इस साल जुड़े।", + "छुट्टियों के दौरान की व्यवस्था अप्रैल में तय हुई और पूरी गर्मी चली।", + "सभी ने वह प्रशिक्षण पूरा कर लिया है जो नया लाइसेंस माँगता है।", + ]], + ["जोखिम", [ + "नवंबर का स्थानांतरण वह अकेली तारीख़ है जो टल नहीं सकती।", + "एक आपूर्तिकर्ता ने नई शर्तों पर अब तक हस्ताक्षर नहीं किए हैं और उनसे बात चल रही है।", + "अनुबंध जल्दी नवीनीकृत न हुआ तो जनवरी में होस्टिंग की लागत बढ़ेगी।", + ]], + ], + "closing": "अगली बैठक जुलाई के अंत में है।", + "sheets": ["सारांश", "लागत"], + "item": "मद", + "total": "कुल", + "periods": ["जन", "फ़र", "मार्च", "अप्रैल", "मई", "जून"], + "rows": ["सॉफ़्टवेयर", "यात्रा", "हार्डवेयर", "मार्केटिंग", "सहायता", "प्रशिक्षण", "लाइसेंस", "होस्टिंग", "आयोजन", "दफ़्तर", "क्लाउड", "भर्ती", "कानूनी सेवाएँ", "बीमा", "जलपान", "शिपिंग", "विज्ञापन", "परामर्श", "रखरखाव", "बिजली-पानी", "उपकरण", "सदस्यता शुल्क", "टेलीफ़ोन", "इंटरनेट", "सुरक्षा", "बैकअप", "डोमेन", "प्रमाणपत्र", "छपाई", "लेखन सामग्री", "डाक", "सफ़ाई", "मरम्मत", "फ़र्नीचर", "भंडारण", "बैंक शुल्क", "संघ सदस्यता", "सम्मेलन", "अनुवाद", "डिज़ाइन"], + "slides": [ + ["परियोजना योजना", ["तिमाही के लक्ष्य", "बजट और लागत", "अगले कदम"]], + ["समय-सारिणी", ["जून में संस्करण", "जुलाई में समीक्षा", "अगस्त में योजना"]], + ["टीम", ["दो नए साझेदार", "तीन भाषाओं में सहायता", "पतझड़ में प्रशिक्षण"]], + ], + }, + "ja": { + "title": "四半期報告", + "lead": "チームは第2四半期の目標をすべて達成し、新しいバージョンは予定どおり公開されました。", + "sections": [ + ["主な成果", [ + "費用は予算内に収まり、新しいパートナーが2社加わりました。", + "新しいバージョンは最初の1週間で、前回が1か月かけて届いた人数を上回りました。", + "サポートは10件のうち9件の問い合わせに当日中に回答しました。", + ]], + ["費用と予算", [ + "新しいライセンスによりソフトウェア費用は増え、出張費は再び減りました。", + "ハードウェアは1度だけ入れ替え、サポートは四半期を通じて安定していました。", + "サーバー2台を、1日も停止させずに新しい事業者へ移しました。", + ]], + ["次の四半期", [ + "9月のリリースが今年最後の予定です。", + "サポートで2名、デザインで1名を募集しています。", + "オフィスの移転は11月で、その予算はすでに決まっています。", + ]], + ["メンバー", [ + "このバージョンには6名が携わり、うち2名は今年からの参加です。", + "休暇中の担当は4月に決め、夏の間ずっと機能しました。", + "新しいライセンスが求める研修は全員が修了しています。", + ]], + ["リスク", [ + "11月の移転は、ほかの予定に押されて動かせない唯一の日程です。", + "1社の取引先がまだ新しい条件に署名しておらず、確認を続けています。", + "契約を早めに更新しなければ、ホスティング費用は1月に上がります。", + ]], + ], + "closing": "次回の打ち合わせは7月末です。", + "sheets": ["概要", "費用"], + "item": "項目", + "total": "合計", + "periods": ["1月", "2月", "3月", "4月", "5月", "6月"], + "rows": ["ソフトウェア", "出張", "ハードウェア", "マーケティング", "サポート", "研修", "ライセンス", "ホスティング", "イベント", "オフィス", "クラウド", "採用", "法務", "保険", "飲食", "配送", "広告", "コンサルティング", "保守", "光熱費", "備品", "定期購読", "電話", "インターネット", "セキュリティ", "バックアップ", "ドメイン", "証明書", "印刷", "文具", "郵送", "清掃", "修繕", "家具", "保管", "銀行手数料", "会費", "会議", "翻訳", "デザイン"], + "slides": [ + ["プロジェクト計画", ["四半期の目標", "予算と費用", "次のステップ"]], + ["スケジュール", ["6月にリリース", "7月に振り返り", "8月に計画"]], + ["チーム", ["新しいパートナー2社", "3か国語でのサポート", "秋に研修"]], + ], + }, + "sv": { + "title": "Kvartalsrapport", + "lead": "Teamet nådde alla mål för det andra kvartalet, och den nya versionen kom ut i tid.", + "sections": [ + ["Höjdpunkter", [ + "Kostnaderna höll sig under budget, och två nya partner anslöt sig till projektet.", + "Den nya versionen nådde fler människor på sin första vecka än den förra gjorde på en månad.", + "Supporten besvarade nio av tio frågor samma dag.", + ]], + ["Kostnader och budget", [ + "Utgifterna för programvara steg med de nya licenserna, medan resorna sjönk igen.", + "Hårdvaran byttes ut en gång, och supporten var stabil hela kvartalet.", + "Två servrar flyttade till den nya leverantören utan en enda dags avbrott.", + ]], + ["Nästa kvartal", [ + "Versionen i september är den sista som planeras i år.", + "Två tjänster är lediga i supporten och en i designen.", + "Kontoret flyttar i november, och budgeten för det är beslutad.", + ]], + ["Människorna", [ + "Sex personer arbetade med versionen, två av dem nya i år.", + "Semestervikariaten ordnades i april och höll hela sommaren.", + "Alla har gått den utbildning som den nya licensen kräver.", + ]], + ["Risker", [ + "Flytten i november är det enda datum som ingenting annat får skjuta framför sig.", + "En leverantör har ännu inte skrivit under de nya villkoren, och vi ligger på.", + "Kostnaden för drift stiger i januari om avtalet inte förnyas i förtid.", + ]], + ], + "closing": "Nästa möte är i slutet av juli.", + "sheets": ["Översikt", "Kostnader"], + "item": "Post", + "total": "Totalt", + "periods": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun"], + "rows": ["Programvara", "Resor", "Hårdvara", "Marknadsföring", "Support", "Utbildning", "Licenser", "Drift", "Evenemang", "Kontor", "Moln", "Rekrytering", "Juridik", "Försäkring", "Förtäring", "Frakt", "Annonsering", "Konsulter", "Underhåll", "El och vatten", "Utrustning", "Prenumerationer", "Telefoni", "Internet", "Säkerhet", "Säkerhetskopior", "Domäner", "Certifikat", "Tryck", "Kontorsmaterial", "Porto", "Städning", "Reparationer", "Möbler", "Lager", "Bankavgifter", "Medlemskap", "Konferenser", "Översättning", "Design"], + "slides": [ + ["Projektplan", ["Mål för kvartalet", "Budget och kostnader", "Nästa steg"]], + ["Tidsplan", ["Version i juni", "Genomgång i juli", "Planering i augusti"]], + ["Team", ["Två nya partner", "Support på tre språk", "Utbildning i höst"]], + ], + }, + "zh": { + "title": "季度报告", + "lead": "团队完成了第二季度的全部目标,新版本按时发布。", + "sections": [ + ["重点", [ + "成本控制在预算之内,另有两家新伙伴加入这个项目。", + "新版本在第一周触达的人数,超过上一版整整一个月的数字。", + "支持团队当天回复了十个问题中的九个。", + ]], + ["成本与预算", [ + "新许可证让软件开支上升,差旅费用再次下降。", + "硬件更换了一次,支持在整个季度保持稳定。", + "两台服务器迁到新的服务商,没有一天中断。", + ]], + ["下个季度", [ + "九月的版本是今年计划中的最后一个。", + "支持岗位空出两个,设计岗位一个。", + "办公室在十一月搬迁,预算已经定下。", + ]], + ["团队", [ + "六个人参与了这个版本,其中两位是今年新来的。", + "休假期间的顶班在四月就安排好,整个夏天都没出问题。", + "新许可证要求的培训,所有人都已完成。", + ]], + ["风险", [ + "十一月的搬迁是唯一无法推迟的日期。", + "有一家供应商还没有签署新条款,我们正在跟进。", + "如果合同不提前续签,托管费用会在一月上涨。", + ]], + ], + "closing": "下次会议在七月底。", + "sheets": ["概览", "成本"], + "item": "项目", + "total": "合计", + "periods": ["一月", "二月", "三月", "四月", "五月", "六月"], + "rows": ["软件", "差旅", "硬件", "市场推广", "支持", "培训", "许可证", "托管", "活动", "办公室", "云服务", "招聘", "法务", "保险", "餐饮", "运输", "广告", "咨询", "维护", "水电", "设备", "订阅", "电话", "网络", "安全", "备份", "域名", "证书", "印刷", "文具", "邮费", "保洁", "维修", "家具", "仓储", "银行手续费", "会员费", "会议", "翻译", "设计"], + "slides": [ + ["项目计划", ["本季度目标", "预算与成本", "下一步"]], + ["时间表", ["六月发布", "七月复盘", "八月规划"]], + ["团队", ["两家新伙伴", "三种语言的支持", "秋季培训"]], + ], + }, +} + +# What the sheets add up: forty rows over six periods, so there is enough of it +# to look like a spreadsheet. The first twenty keep the first four figures they +# had, because the invoice and the .xlsx take slices off the front. +FIGURES = [ + [1200, 1450, 1310, 1600, 1380, 1520], + [480, 620, 510, 470, 690, 540], + [3600, 900, 1200, 750, 830, 1150], + [820, 760, 930, 1010, 870, 940], + [540, 560, 580, 600, 610, 630], + [300, 450, 380, 520, 410, 470], + [1100, 1150, 1180, 1240, 1260, 1290], + [640, 640, 660, 680, 700, 700], + [420, 980, 350, 610, 1240, 380], + [260, 280, 270, 300, 290, 310], + [890, 910, 940, 980, 1000, 1030], + [1500, 400, 620, 350, 480, 390], + [340, 360, 350, 370, 380, 390], + [220, 220, 230, 230, 240, 240], + [180, 620, 210, 240, 190, 660], + [410, 430, 400, 450, 440, 460], + [760, 820, 690, 900, 850, 780], + [950, 480, 1100, 520, 560, 1180], + [280, 290, 300, 310, 320, 330], + [520, 540, 530, 560, 570, 590], + [1340, 1290, 1410, 1360, 1440, 1480], + [710, 730, 720, 750, 760, 780], + [190, 200, 190, 210, 200, 220], + [330, 330, 340, 340, 350, 350], + [860, 890, 1240, 910, 930, 960], + [240, 250, 260, 260, 270, 280], + [120, 130, 120, 140, 130, 150], + [460, 170, 180, 490, 180, 190], + [580, 610, 550, 640, 600, 670], + [150, 160, 170, 160, 180, 170], + [210, 230, 220, 250, 240, 260], + [390, 390, 400, 410, 410, 420], + [270, 1080, 310, 340, 290, 360], + [1620, 350, 380, 360, 400, 370], + [620, 650, 630, 680, 660, 700], + [110, 120, 110, 130, 120, 140], + [440, 450, 460, 470, 480, 490], + [980, 1020, 640, 1060, 1090, 720], + [560, 500, 590, 530, 610, 570], + [740, 770, 800, 790, 830, 860], +] + +# What the browser lists them as. Realistic rather than descriptive: the first +# screenshot is meant to look like somebody's folder, and the extensions do the +# talking about what the app opens. +FILE_NAMES = { + "en": {"text": "Quarterly report", "sheet": "Budget", "slides": "Project plan", + "word": "Contract", "cells": "Sales figures", "deck": "Team offsite", + "paper": "Invoice", "rows": "Contacts", "notes": "Notes"}, + "de": {"text": "Quartalsbericht", "sheet": "Budget", "slides": "Projektplan", + "word": "Vertrag", "cells": "Umsatzzahlen", "deck": "Teamtreffen", + "paper": "Rechnung", "rows": "Kontakte", "notes": "Notizen"}, + "es": {"text": "Informe trimestral", "sheet": "Presupuesto", "slides": "Plan del proyecto", + "word": "Contrato", "cells": "Cifras de ventas", "deck": "Jornada de equipo", + "paper": "Factura", "rows": "Contactos", "notes": "Notas"}, + "fr": {"text": "Rapport trimestriel", "sheet": "Budget", "slides": "Plan du projet", + "word": "Contrat", "cells": "Chiffres des ventes", "deck": "Réunion d'équipe", + "paper": "Facture", "rows": "Contacts", "notes": "Notes"}, + "it": {"text": "Relazione trimestrale", "sheet": "Budget", "slides": "Piano di progetto", + "word": "Contratto", "cells": "Dati di vendita", "deck": "Ritiro del team", + "paper": "Fattura", "rows": "Contatti", "notes": "Note"}, + "pl": {"text": "Raport kwartalny", "sheet": "Budżet", "slides": "Plan projektu", + "word": "Umowa", "cells": "Wyniki sprzedaży", "deck": "Spotkanie zespołu", + "paper": "Faktura", "rows": "Kontakty", "notes": "Notatki"}, + "pt-BR": {"text": "Relatório trimestral", "sheet": "Orçamento", "slides": "Plano do projeto", + "word": "Contrato", "cells": "Números de vendas", "deck": "Reunião da equipe", + "paper": "Fatura", "rows": "Contatos", "notes": "Notas"}, + "ru": {"text": "Квартальный отчёт", "sheet": "Бюджет", "slides": "План проекта", + "word": "Договор", "cells": "Продажи", "deck": "Встреча команды", + "paper": "Счёт", "rows": "Контакты", "notes": "Заметки"}, + "tr": {"text": "Üç aylık rapor", "sheet": "Bütçe", "slides": "Proje planı", + "word": "Sözleşme", "cells": "Satış rakamları", "deck": "Ekip çalıştayı", + "paper": "Fatura", "rows": "Kişiler", "notes": "Notlar"}, + "cs": {"text": "Čtvrtletní zpráva", "sheet": "Rozpočet", "slides": "Plán projektu", + "word": "Smlouva", "cells": "Čísla prodejů", "deck": "Setkání týmu", + "paper": "Faktura", "rows": "Kontakty", "notes": "Poznámky"}, + "et": {"text": "Kvartaliaruanne", "sheet": "Eelarve", "slides": "Projektiplaan", + "word": "Leping", "cells": "Müüginumbrid", "deck": "Meeskonnapäev", + "paper": "Arve", "rows": "Kontaktid", "notes": "Märkmed"}, + "hi": {"text": "तिमाही रिपोर्ट", "sheet": "बजट", "slides": "परियोजना योजना", + "word": "अनुबंध", "cells": "बिक्री के आँकड़े", "deck": "टीम बैठक", + "paper": "बिल", "rows": "संपर्क", "notes": "नोट्स"}, + "ja": {"text": "四半期報告", "sheet": "予算", "slides": "プロジェクト計画", + "word": "契約書", "cells": "売上データ", "deck": "チーム合宿", + "paper": "請求書", "rows": "連絡先", "notes": "メモ"}, + "sv": {"text": "Kvartalsrapport", "sheet": "Budget", "slides": "Projektplan", + "word": "Avtal", "cells": "Försäljningssiffror", "deck": "Teamdag", + "paper": "Faktura", "rows": "Kontakter", "notes": "Anteckningar"}, + "zh": {"text": "季度报告", "sheet": "预算", "slides": "项目计划", + "word": "合同", "cells": "销售数据", "deck": "团队会议", + "paper": "发票", "rows": "联系人", "notes": "笔记"}, +} + +# 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ückblick", + "ticket": "Ticket", "warranty": "Garantie", "manual": "Anleitung", + }, + "es": { + "meeting": "Notas de reunión", "letter": "Carta al casero", + "travel": "Plan de viaje", "reading": "Lista de lectura", + "household": "Presupuesto doméstico", "hours": "Horas", "stocktake": "Recuento", + "kickoff": "Arranque del proyecto", "course": "Diapositivas del curso", + "lease": "Alquiler del piso", "resume": "Currículum", "application": "Solicitud", + "expenses": "Gastos", "inventory": "Inventario", + "review": "Revisión trimestral", + "ticket": "Billete", "warranty": "Garantía", "manual": "Manual", + }, + "fr": { + "meeting": "Notes de réunion", "letter": "Lettre au propriétaire", + "travel": "Itinéraire", "reading": "Liste de lecture", + "household": "Budget familial", "hours": "Heures", "stocktake": "Inventaire", + "kickoff": "Lancement", "course": "Diapositives du cours", + "lease": "Bail", "resume": "CV", "application": "Candidature", + "expenses": "Dépenses", "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łaściciela mieszkania", + "travel": "Plan podróży", "reading": "Lista lektur", + "household": "Budżet domowy", "hours": "Godziny", "stocktake": "Inwentaryzacja", + "kickoff": "Spotkanie startowe", "course": "Slajdy kursu", + "lease": "Umowa najmu", "resume": "CV", "application": "Podanie", + "expenses": "Wydatki", "inventory": "Stan magazynu", + "review": "Przegląd kwartalny", + "ticket": "Bilet", "warranty": "Gwarancja", "manual": "Instrukcja", + }, + "pt-BR": { + "meeting": "Notas da reunião", "letter": "Carta ao locador", + "travel": "Plano de viagem", "reading": "Lista de leitura", + "household": "Orçamento doméstico", "hours": "Horas", "stocktake": "Balanço", + "kickoff": "Kickoff", "course": "Slides do curso", + "lease": "Contrato de aluguel", "resume": "Currículo", "application": "Inscrição", + "expenses": "Despesas", "inventory": "Estoque", + "review": "Revisão trimestral", + "ticket": "Passagem", "warranty": "Garantia", "manual": "Manual", + }, + "ru": { + "meeting": "Заметки со встречи", + "letter": "Письмо арендодателю", + "travel": "План поездки", + "reading": "Список чтения", + "household": "Домашний бюджет", + "hours": "Часы", + "stocktake": "Инвентаризация", + "kickoff": "Старт проекта", + "course": "Слайды курса", + "lease": "Договор аренды", + "resume": "Резюме", + "application": "Заявление", + "expenses": "Расходы", + "inventory": "Склад", + "review": "Квартальный обзор", + "ticket": "Билет", + "warranty": "Гарантия", + "manual": "Инструкция", + }, + "tr": { + "meeting": "Toplantı notları", "letter": "Ev sahibine mektup", + "travel": "Seyahat planı", "reading": "Okuma listesi", + "household": "Ev bütçesi", "hours": "Çalışma saatleri", + "stocktake": "Sayım", + "kickoff": "Proje başlangıcı", "course": "Kurs slaytları", + "lease": "Kira sözleşmesi", "resume": "Özgeçmiş", + "application": "Başvuru", + "expenses": "Giderler", "inventory": "Envanter", + "review": "Üç aylık değerlendirme", + "ticket": "Bilet", "warranty": "Garanti", "manual": "Kılavuz", + }, + "cs": { + "meeting": "Zápis z porady", "letter": "Dopis pronajímateli", + "travel": "Plán cesty", "reading": "Seznam ke čtení", + "household": "Rodinný rozpočet", "hours": "Odpracované hodiny", + "stocktake": "Inventura", + "kickoff": "Zahájení projektu", "course": "Slidy ke kurzu", + "lease": "Nájemní smlouva", "resume": "Životopis", + "application": "Přihláška", + "expenses": "Výdaje", "inventory": "Zásoby", + "review": "Čtvrtletní hodnocení", + "ticket": "Jízdenka", "warranty": "Záruka", "manual": "Návod", + }, + "et": { + "meeting": "Koosoleku märkmed", "letter": "Kiri üürileandjale", + "travel": "Reisiplaan", "reading": "Lugemisnimekiri", + "household": "Kodune eelarve", "hours": "Töötunnid", + "stocktake": "Inventuur", + "kickoff": "Projekti algus", "course": "Koolituse slaidid", + "lease": "Üürileping", "resume": "Elulookirjeldus", + "application": "Avaldus", + "expenses": "Kulud", "inventory": "Laoseis", + "review": "Kvartali ülevaade", + "ticket": "Pilet", "warranty": "Garantii", "manual": "Kasutusjuhend", + }, + "hi": { + "meeting": "बैठक के नोट्स", "letter": "मकान मालिक को पत्र", + "travel": "यात्रा योजना", "reading": "पढ़ने की सूची", + "household": "घर का बजट", "hours": "काम के घंटे", + "stocktake": "स्टॉक जाँच", + "kickoff": "शुरुआती बैठक", "course": "कोर्स स्लाइड", + "lease": "किरायानामा", "resume": "बायोडाटा", + "application": "आवेदन", + "expenses": "खर्च", "inventory": "सूची", + "review": "तिमाही समीक्षा", + "ticket": "टिकट", "warranty": "वारंटी", "manual": "मैनुअल", + }, + "ja": { + "meeting": "打ち合わせメモ", "letter": "大家さんへの手紙", + "travel": "旅行の予定", "reading": "読みたい本", + "household": "家計簿", "hours": "勤務時間", + "stocktake": "棚卸し", + "kickoff": "キックオフ", "course": "研修スライド", + "lease": "賃貸契約書", "resume": "履歴書", + "application": "申込書", + "expenses": "経費", "inventory": "在庫", + "review": "四半期レビュー", + "ticket": "チケット", "warranty": "保証書", "manual": "取扱説明書", + }, + "sv": { + "meeting": "Mötesanteckningar", "letter": "Brev till hyresvärden", + "travel": "Resplan", "reading": "Läslista", + "household": "Hushållsbudget", "hours": "Arbetstider", + "stocktake": "Inventering", + "kickoff": "Uppstart", "course": "Kursbilder", + "lease": "Hyresavtal", "resume": "CV", + "application": "Ansökan", + "expenses": "Utgifter", "inventory": "Lagerlista", + "review": "Kvartalsgenomgång", + "ticket": "Biljett", "warranty": "Garanti", "manual": "Bruksanvisning", + }, + "zh": { + "meeting": "会议记录", "letter": "给房东的信", + "travel": "行程安排", "reading": "阅读清单", + "household": "家庭预算", "hours": "工时", + "stocktake": "盘点", + "kickoff": "启动会", "course": "课程幻灯片", + "lease": "租赁合同", "resume": "简历", + "application": "申请表", + "expenses": "开支", "inventory": "库存", + "review": "季度回顾", + "ticket": "车票", "warranty": "保修单", "manual": "说明书", + }, +} + + +# The languages that do not put spaces between words at all, where counting runs +# of letters would hand the search a whole clause. Written down instead, and +# checked below against the document so a term that stops appearing in it is an +# error rather than a screenshot of a search that found nothing. +UNSPACED = { + "ja": "サポート", + "zh": "支持", +} + +# What separates one word from the next, everywhere else - written as what breaks +# a word rather than as what a word is made of. `\w` is the other way round and +# is wrong for half this list: a Devanagari vowel sign is not a letter, a digit +# or an underscore, so a Hindi word comes apart at every matra. +BREAK = re.compile(r"[\s.,;:!?()\[\]{}<>\"'«»„“”‘’—–\-/\\|…।]+") + + +# 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, language: str = "") -> 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] + ) + + if language in UNSPACED: + written = UNSPACED[language] + if text.count(written) < 2: + raise ValueError( + f"{language}: the report says '{written}' {text.count(written)} times, so " + "searching for it would photograph a search that found nothing. " + "Pick another word in UNSPACED." + ) + return written + + counted = {} + for word in BREAK.split(text.lower()): + if len(word) >= 5 and not word.isdigit(): + counted[word] = counted.get(word, 0) + 1 + + if not counted: + raise ValueError(f"{language or 'this language'}: nothing in the report to search for") + + # the most repeated, and the longest of those, so the choice is not a coin toss + return max(counted, key=lambda word: (counted[word], len(word))) + + +# The folder is not nine copies of one report. What each file is called says +# what it should hold, so the contract reads like a contract and the invoice +# like an invoice - a folder where every document has the same title is the one +# thing a picture of a folder must not be. +OTHERS = { + "en": { + "contract": ["Service agreement", + "This agreement is made between the two parties named below.", + ["The supplier provides the software described in the appendix for one year.", + "Payment is due within thirty days of each invoice.", + "Either party may end this agreement with three months' notice.", + "Changes to this agreement are valid only in writing.", + "The supplier keeps the service available on working days.", + "Both parties treat what they learn of each other as confidential.", + "Austrian law applies, and the court of Vienna has jurisdiction.", + "The supplier keeps a backup of the customer's data for thirty days.", + "Support requests are answered within one working day.", + "The customer names one person who may approve changes.", + "Prices hold for the first year and are reviewed each autumn.", + "Neither party may pass this agreement to a third party without consent.", + "The appendix lists the software covered and the version it starts at.", + "This agreement replaces every earlier arrangement between the parties."]], + "invoice": ["Invoice 2026-014", "Issued 12 June 2026", "Due within 30 days", "Billed to", "Subtotal", "VAT 20%", "Amount due", "Thank you for your business.", "Qty", "Unit price"], + "contacts": [["Name", "Team", "Email", "Phone"], + ["Design", "Support", "Sales", "Engineering"]], + }, + "de": { + "contract": ["Dienstleistungsvertrag", + "Dieser Vertrag wird zwischen den beiden unten genannten Parteien geschlossen.", + ["Der Anbieter stellt die im Anhang beschriebene Software für ein Jahr bereit.", + "Die Zahlung ist innerhalb von dreißig Tagen nach Rechnungsstellung fällig.", + "Beide Parteien können den Vertrag mit einer Frist von drei Monaten kündigen.", + "Änderungen dieses Vertrags bedürfen der Schriftform.", + "Der Anbieter hält den Dienst an Werktagen verfügbar.", + "Beide Parteien behandeln vertraulich, was sie voneinander erfahren.", + "Es gilt österreichisches Recht; Gerichtsstand ist Wien.", + "Der Anbieter bewahrt eine Sicherung der Daten des Kunden dreißig Tage lang auf.", + "Supportanfragen werden innerhalb eines Werktags beantwortet.", + "Der Kunde benennt eine Person, die Änderungen freigeben darf.", + "Die Preise gelten im ersten Jahr und werden jeden Herbst überprüft.", + "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üheren 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ür Ihren Auftrag.", "Menge", "Einzelpreis"], + "contacts": [["Name", "Team", "E-Mail", "Telefon"], + ["Design", "Support", "Vertrieb", "Entwicklung"]], + }, + "es": { + "contract": ["Contrato de servicios", + "Este contrato se celebra entre las dos partes indicadas a continuación.", + ["El proveedor facilita el software descrito en el anexo durante un año.", + "El pago vence a los treinta días de cada factura.", + "Cualquiera de las partes puede rescindirlo con tres meses de preaviso.", + "Las modificaciones solo son válidas por escrito.", + "El proveedor mantiene el servicio disponible los días 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ías.", + "Las consultas de soporte se responden en un día laborable.", + "El cliente designa a una persona que puede aprobar los cambios.", + "Los precios se mantienen el primer año y se revisan cada otoño.", + "Ninguna parte puede ceder este contrato a un tercero sin consentimiento.", + "El anexo enumera el software incluido y la versión 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ías", "Facturar a", "Subtotal", "IVA 20%", "Importe a pagar", "Gracias por su confianza.", "Cant.", "Precio unit."], + "contacts": [["Nombre", "Equipo", "Correo", "Teléfono"], + ["Diseño", "Soporte", "Ventas", "Ingeniería"]], + }, + "fr": { + "contract": ["Contrat de service", + "Ce contrat est conclu entre les deux parties désignées ci-dessous.", + ["Le prestataire fournit le logiciel décrit en annexe pendant un an.", + "Le paiement est dû dans les trente jours suivant chaque facture.", + "Chaque partie peut résilier le contrat avec un préavis de trois mois.", + "Toute modification n'est valable que par écrit.", + "Le prestataire maintient le service disponible les jours ouvrés.", + "Chaque partie traite comme confidentiel ce qu'elle apprend de l'autre.", + "Le droit autrichien s'applique et le tribunal de Vienne est compétent.", + "Le prestataire conserve une sauvegarde des données du client pendant trente jours.", + "Les demandes de support reçoivent une réponse sous un jour ouvré.", + "Le client désigne une personne habilitée à approuver les modifications.", + "Les prix sont fermes la première année et revus chaque automne.", + "Aucune des parties ne peut céder ce contrat à un tiers sans accord.", + "L'annexe indique le logiciel couvert et la version à partir de laquelle la couverture s'applique.", + "Ce contrat remplace tout accord antérieur entre les parties."]], + "invoice": ["Facture 2026-014", "Émise le 12 juin 2026", "À régler sous 30 jours", "Facturé à", "Sous-total", "TVA 20 %", "Montant dû", "Merci de votre confiance.", "Qté", "Prix unitaire"], + "contacts": [["Nom", "Équipe", "E-mail", "Téléphone"], + ["Design", "Support", "Ventes", "Développement"]], + }, + "it": { + "contract": ["Contratto di servizio", + "Il presente contratto è stipulato tra le due parti indicate di seguito.", + ["Il fornitore mette a disposizione per un anno il software descritto in allegato.", + "Il pagamento è dovuto entro trenta giorni da ogni fattura.", + "Ciascuna parte può 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 è 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ò cedere il contratto a terzi senza consenso.", + "L'allegato elenca il software coperto e la versione di partenza.", + "Il presente contratto sostituisce ogni accordo precedente tra le parti."]], + "invoice": ["Fattura 2026-014", "Emessa il 12 giugno 2026", "Da saldare entro 30 giorni", "Intestato a", "Subtotale", "IVA 20%", "Importo dovuto", "Grazie per la collaborazione.", "Qtà", "Prezzo unit."], + "contacts": [["Nome", "Reparto", "E-mail", "Telefono"], + ["Design", "Supporto", "Vendite", "Sviluppo"]], + }, + "pl": { + "contract": ["Umowa o świadczenie usług", + "Niniejsza umowa zostaje zawarta między dwiema stronami wymienionymi poniżej.", + ["Dostawca udostępnia oprogramowanie opisane w załączniku na okres roku.", + "Płatność jest wymagalna w terminie trzydziestu dni od daty każdej faktury.", + "Każda ze stron może rozwiązać umowę z trzymiesięcznym wypowiedzeniem.", + "Zmiany umowy wymagają formy pisemnej.", + "Dostawca utrzymuje dostępność usługi w dni robocze.", + "Obie strony traktują jako poufne to, czego dowiedzą się o sobie nawzajem.", + "Obowiązuje prawo austriackie, a sądem właściwym jest sąd w Wiedniu.", + "Dostawca przechowuje kopię danych klienta przez trzydzieści dni.", + "Zgłoszenia do wsparcia są rozpatrywane w ciągu jednego dnia roboczego.", + "Klient wskazuje jedną osobę uprawnioną do zatwierdzania zmian.", + "Ceny obowiązują przez pierwszy rok i są weryfikowane każdej jesieni.", + "Żadna ze stron nie może przenieść umowy na osobę trzecią bez zgody.", + "Załącznik wymienia oprogramowanie objęte umową oraz wersję początkową.", + "Niniejsza umowa zastępuje wszystkie wcześniejsze ustalenia stron."]], + "invoice": ["Faktura 2026-014", "Wystawiono 12 czerwca 2026", "Płatne w ciągu 30 dni", "Nabywca", "Wartość netto", "VAT 20%", "Do zapłaty", "Dziękujemy za współpracę.", "Ilość", "Cena jedn."], + "contacts": [["Imię i nazwisko", "Dział", "E-mail", "Telefon"], + ["Projektowanie", "Wsparcie", "Sprzedaż", "Rozwój"]], + }, + "pt-BR": { + "contract": ["Contrato de serviço", + "Este contrato é celebrado entre as duas partes indicadas abaixo.", + ["O fornecedor disponibiliza por um ano o software descrito no anexo.", + "O pagamento vence em trinta dias a contar de cada fatura.", + "Qualquer parte pode encerrar o contrato com aviso prévio de três meses.", + "Alterações só são válidas por escrito.", + "O fornecedor mantém o serviço disponível em dias úteis.", + "As partes tratam como confidencial o que souberem uma da outra.", + "Aplica-se a lei austríaca e o foro competente é o de Viena.", + "O fornecedor mantém uma cópia dos dados do cliente por trinta dias.", + "Os chamados de suporte são respondidos em um dia útil.", + "O cliente indica uma pessoa autorizada a aprovar alterações.", + "Os preços ficam fixos no primeiro ano e são revisados todo outono.", + "Nenhuma parte pode transferir este contrato a terceiros sem consentimento.", + "O anexo lista o software abrangido e a versão inicial coberta.", + "Este contrato substitui qualquer acordo anterior entre as partes."]], + "invoice": ["Fatura 2026-014", "Emitida em 12 de junho de 2026", "Vence em 30 dias", "Faturado para", "Subtotal", "Impostos 20%", "Valor a pagar", "Obrigado pela preferência.", "Qtd", "Preço unit."], + "contacts": [["Nome", "Equipe", "E-mail", "Telefone"], + ["Design", "Suporte", "Vendas", "Engenharia"]], + }, + "ru": { + "contract": ["Договор оказания услуг", + "Настоящий договор заключён между двумя сторонами, указанными ниже.", + ["Исполнитель предоставляет программное обеспечение, указанное в приложении, сроком на один год.", + "Оплата производится в течение тридцати дней с даты счёта.", + "Каждая из сторон может расторгнуть договор, уведомив за три месяца.", + "Изменения действительны только в письменном виде.", + "Исполнитель обеспечивает доступность сервиса в рабочие дни.", + "Стороны сохраняют в тайне сведения, полученные друг о друге.", + "Применяется австрийское право, споры рассматривает суд Вены.", + "Исполнитель хранит резервную копию данных заказчика тридцать дней.", + "Обращения в поддержку рассматриваются в течение одного рабочего дня.", + "Заказчик назначает одного сотрудника, который вправе утверждать изменения.", + "Цены фиксируются на первый год и пересматриваются каждую осень.", + "Ни одна из сторон не вправе передать договор третьему лицу без согласия.", + "В приложении указано, какое программное обеспечение входит в договор и с какой версии.", + "Настоящий договор заменяет все прежние договорённости сторон."]], + "invoice": ["Счёт 2026-014", "Выставлен 12 июня 2026 г.", "Оплата в течение 30 дней", "Плательщик", "Промежуточный итог", "НДС 20%", "К оплате", "Благодарим за сотрудничество.", "Кол-во", "Цена за ед."], + "contacts": [["ФИО", "Отдел", "Почта", "Телефон"], + ["Дизайн", "Поддержка", "Продажи", "Разработка"]], + }, + "tr": { + "contract": ["Hizmet sözleşmesi", + "Bu sözleşme aşağıda belirtilen iki taraf arasında yapılmıştır.", + ["Tedarikçi, ekte tanımlanan yazılımı bir yıl boyunca sağlar.", + "Ödeme, her faturadan sonra otuz gün içinde yapılır.", + "Taraflardan biri sözleşmeyi üç ay önceden bildirerek sonlandırabilir.", + "Değişiklikler yalnızca yazılı olarak geçerlidir.", + "Tedarikçi hizmeti iş günlerinde erişilebilir tutar.", + "Taraflar birbirleri hakkında öğrendiklerini gizli tutar.", + "Avusturya hukuku uygulanır ve yetkili mahkeme Viyana'dır.", + "Tedarikçi, müşterinin verilerinin yedeğini otuz gün saklar.", + "Destek talepleri bir iş günü içinde yanıtlanır.", + "Müşteri, değişiklikleri onaylayabilecek bir kişi belirler.", + "Fiyatlar ilk yıl sabittir ve her sonbahar gözden geçirilir.", + "Hiçbir taraf sözleşmeyi onay almadan üçüncü kişiye devredemez.", + "Ek, kapsanan yazılımı ve geçerli olduğu sürümü listeler.", + "Bu sözleşme, taraflar arasındaki önceki tüm düzenlemelerin yerine geçer."]], + "invoice": ["Fatura 2026-014", "12 Haziran 2026 tarihli", "30 gün içinde ödenir", "Alıcı", "Ara toplam", "KDV %20", "Ödenecek tutar", "İş birliğiniz için teşekkürler.", "Adet", "Birim fiyat"], + "contacts": [["Ad Soyad", "Ekip", "E-posta", "Telefon"], + ["Tasarım", "Destek", "Satış", "Geliştirme"]], + }, + "cs": { + "contract": ["Smlouva o poskytování služeb", + "Tato smlouva se uzavírá mezi oběma níže uvedenými stranami.", + ["Dodavatel poskytuje software popsaný v příloze po dobu jednoho roku.", + "Platba je splatná do třiceti dnů od vystavení každé faktury.", + "Kterákoli strana může smlouvu ukončit s tříměsíční výpovědní lhůtou.", + "Změny této smlouvy jsou platné pouze písemně.", + "Dodavatel udržuje službu dostupnou v pracovní dny.", + "Obě strany zachovávají mlčenlivost o tom, co se o sobě dozvědí.", + "Řídí se rakouským právem; místně příslušný je soud ve Vídni.", + "Dodavatel uchovává zálohu dat zákazníka po dobu třiceti dnů.", + "Požadavky na podporu jsou zodpovězeny do jednoho pracovního dne.", + "Zákazník určí jednu osobu, která smí schvalovat změny.", + "Ceny platí první rok a každý podzim se přehodnocují.", + "Žádná strana nesmí smlouvu bez souhlasu postoupit třetí straně.", + "Příloha uvádí zahrnutý software a verzi, od které platí.", + "Tato smlouva nahrazuje všechna dřívější ujednání mezi stranami."]], + "invoice": ["Faktura 2026-014", "Vystaveno 12. června 2026", "Splatnost do 30 dnů", "Odběratel", "Mezisoučet", "DPH 20 %", "K úhradě", "Děkujeme za spolupráci.", "Množ.", "Cena za ks"], + "contacts": [["Jméno a příjmení", "Tým", "E-mail", "Telefon"], + ["Design", "Podpora", "Prodej", "Vývoj"]], + }, + "et": { + "contract": ["Teenuse osutamise leping", + "Käesolev leping sõlmitakse allpool nimetatud kahe poole vahel.", + ["Tarnija annab lisas kirjeldatud tarkvara kasutada üheks aastaks.", + "Makse tähtaeg on kolmkümmend päeva iga arve kuupäevast.", + "Kumbki pool võib lepingu lõpetada kolmekuulise etteteatamisega.", + "Lepingu muudatused kehtivad üksnes kirjalikult.", + "Tarnija hoiab teenuse tööpäevadel kättesaadavana.", + "Mõlemad pooled hoiavad saladuses selle, mida teineteise kohta teada saavad.", + "Kohaldatakse Austria õigust ja kohtualluvus on Viinis.", + "Tarnija säilitab kliendi andmete varukoopiat kolmkümmend päeva.", + "Kasutajatoe päringutele vastatakse ühe tööpäeva jooksul.", + "Klient nimetab ühe inimese, kes tohib muudatusi kinnitada.", + "Hinnad kehtivad esimesel aastal ja need vaadatakse üle igal sügisel.", + "Kumbki pool ei tohi lepingut ilma nõusolekuta kolmandale isikule anda.", + "Lisa loetleb hõlmatud tarkvara ja versiooni, millest alates see kehtib.", + "Käesolev leping asendab kõik varasemad poolte vahelised kokkulepped."]], + "invoice": ["Arve 2026-014", "Väljastatud 12. juuni 2026", "Tasuda 30 päeva jooksul", "Maksja", "Vahesumma", "Käibemaks 20%", "Tasumisele kuulub", "Täname koostöö eest.", "Kogus", "Ühiku hind"], + "contacts": [["Nimi", "Tiim", "E-post", "Telefon"], + ["Disain", "Kasutajatugi", "Müük", "Arendus"]], + }, + "hi": { + "contract": ["सेवा अनुबंध", + "यह अनुबंध नीचे नामित दोनों पक्षों के बीच किया जाता है।", + ["आपूर्तिकर्ता परिशिष्ट में वर्णित सॉफ़्टवेयर एक वर्ष के लिए उपलब्ध कराता है।", + "प्रत्येक बिल की तारीख़ से तीस दिनों के भीतर भुगतान देय है।", + "कोई भी पक्ष तीन महीने का नोटिस देकर यह अनुबंध समाप्त कर सकता है।", + "इस अनुबंध में बदलाव केवल लिखित रूप में मान्य हैं।", + "आपूर्तिकर्ता कार्यदिवसों में सेवा उपलब्ध रखता है।", + "दोनों पक्ष एक-दूसरे के बारे में जो जानते हैं उसे गोपनीय रखते हैं।", + "ऑस्ट्रिया का कानून लागू होगा और वियना की अदालत को अधिकार क्षेत्र प्राप्त है।", + "आपूर्तिकर्ता ग्राहक के डेटा का बैकअप तीस दिनों तक रखता है।", + "सहायता अनुरोधों का उत्तर एक कार्यदिवस के भीतर दिया जाता है।", + "ग्राहक एक व्यक्ति नामित करता है जो बदलावों को मंज़ूरी दे सकता है।", + "कीमतें पहले वर्ष स्थिर रहती हैं और हर पतझड़ में उनकी समीक्षा होती है।", + "कोई भी पक्ष सहमति के बिना यह अनुबंध किसी तीसरे को नहीं सौंप सकता।", + "परिशिष्ट में शामिल सॉफ़्टवेयर और वह संस्करण दर्ज है जिससे यह लागू होता है।", + "यह अनुबंध पक्षों के बीच हुए सभी पूर्व समझौतों का स्थान लेता है।"]], + "invoice": ["बिल 2026-014", "जारी 12 जून 2026", "30 दिनों में देय", "प्राप्तकर्ता", "उप-योग", "कर 20%", "देय राशि", "आपके व्यवसाय के लिए धन्यवाद।", "मात्रा", "इकाई मूल्य"], + "contacts": [["नाम", "टीम", "ईमेल", "फ़ोन"], + ["डिज़ाइन", "सहायता", "बिक्री", "इंजीनियरिंग"]], + }, + "ja": { + "contract": ["業務委託契約書", + "本契約は、以下に記載する二者の間で締結されます。", + ["受託者は、付録に記載したソフトウェアを1年間提供します。", + "支払いは、各請求書の発行から30日以内に行うものとします。", + "いずれの当事者も、3か月前の通知により本契約を終了できます。", + "本契約の変更は、書面による場合にかぎり有効です。", + "受託者は、営業日において本サービスを利用可能な状態に保ちます。", + "両当事者は、相手方について知り得たことを秘密として扱います。", + "本契約にはオーストリア法が適用され、ウィーンの裁判所を管轄とします。", + "受託者は、委託者のデータのバックアップを30日間保管します。", + "サポートへの問い合わせには、1営業日以内に回答します。", + "委託者は、変更を承認できる担当者を1名定めます。", + "価格は初年度は据え置き、毎年秋に見直します。", + "いずれの当事者も、同意なく本契約を第三者に譲渡できません。", + "付録には、対象となるソフトウェアと適用開始のバージョンを記載します。", + "本契約は、両当事者間のこれまでの取り決めのすべてに代わるものです。"]], + "invoice": ["請求書 2026-014", "発行日 2026年6月12日", "お支払期限 30日以内", "請求先", "小計", "消費税 20%", "ご請求額", "お取引ありがとうございます。", "数量", "単価"], + "contacts": [["氏名", "チーム", "メール", "電話"], + ["デザイン", "サポート", "営業", "開発"]], + }, + "sv": { + "contract": ["Tjänsteavtal", + "Detta avtal ingås mellan de två parter som anges nedan.", + ["Leverantören tillhandahåller den programvara som beskrivs i bilagan i ett år.", + "Betalning ska ske inom trettio dagar från varje faktura.", + "Vardera parten kan säga upp avtalet med tre månaders varsel.", + "Ändringar av detta avtal gäller endast skriftligen.", + "Leverantören håller tjänsten tillgänglig på vardagar.", + "Båda parter behandlar det de får veta om varandra som konfidentiellt.", + "Österrikisk rätt tillämpas, och domstolen i Wien är behörig.", + "Leverantören sparar en säkerhetskopia av kundens data i trettio dagar.", + "Supportärenden besvaras inom en arbetsdag.", + "Kunden utser en person som får godkänna ändringar.", + "Priserna gäller det första året och ses över varje höst.", + "Ingen part får överlåta detta avtal till tredje part utan samtycke.", + "Bilagan anger vilken programvara som omfattas och från vilken version.", + "Detta avtal ersätter alla tidigare överenskommelser mellan parterna."]], + "invoice": ["Faktura 2026-014", "Utfärdad 12 juni 2026", "Betalas inom 30 dagar", "Faktureras till", "Delsumma", "Moms 20 %", "Att betala", "Tack för ditt förtroende.", "Antal", "Á-pris"], + "contacts": [["Namn", "Team", "E-post", "Telefon"], + ["Design", "Support", "Försäljning", "Utveckling"]], + }, + "zh": { + "contract": ["服务合同", + "本合同由以下列明的双方签订。", + ["供方按附件所述提供软件,期限为一年。", + "每张发票开具后三十日内付款。", + "任何一方均可提前三个月通知终止本合同。", + "对本合同的变更,仅以书面形式为有效。", + "供方在工作日保持服务可用。", + "双方对从对方处知悉的信息负有保密义务。", + "本合同适用奥地利法律,由维也纳法院管辖。", + "供方为客户数据保留三十天的备份。", + "支持请求在一个工作日内答复。", + "客户指定一名有权批准变更的联系人。", + "价格在第一年内保持不变,此后每年秋季复核。", + "未经同意,任何一方不得将本合同转让给第三方。", + "附件列明所涵盖的软件及其适用的起始版本。", + "本合同取代双方此前的全部约定。"]], + "invoice": ["发票 2026-014", "开具日期 2026年6月12日", "30 天内付款", "付款方", "小计", "税额 20%", "应付金额", "感谢您的惠顾。", "数量", "单价"], + "contacts": [["姓名", "团队", "邮箱", "电话"], + ["设计", "支持", "销售", "研发"]], + }, +} + +# 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, and an .odt beside an .xlsx beside a .pdf +# says what the app opens without a line of copy claiming it. Read by odrcore +# rather than 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" + + +# How many lines the contract's appendix lists, and the half-points the whole of it +# is set in. Between them they are what carries it past the foot of a phone screen, +# the clauses above being fourteen short sentences - and they are the only length +# there is to give it, the renderer honouring neither `w:spacing` on a paragraph nor +# `w:trHeight` on a row. +ANNEX_ROWS = 40 +CONTRACT_TEXT = 28 + + +def cell_rows(lines: list) -> str: + """A Word table of two columns, the first line of it the head.""" + out = [ + '' + '' + "" + '' + ] + for number, (name, value) in enumerate(lines): + marks = '' if number == 0 else "" + cells = "".join( + f'' + f'{marks}' + f'{escape(text)}' + for text, width in ((name, 6350), (value, 2720)) + ) + out.append(f"{cells}") + out.append("") + + return "".join(out) + + +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 a paragraph, numbered in line with its first word. A number on a + # line of its own above the sentence reads as a list of scraps rather than as + # a contract. + paragraphs = [ + para(run(title, size=72, bold=True), 640), + para(run(lead, size=CONTRACT_TEXT), 420), + ] + for number, clause in enumerate(clauses, start=1): + paragraphs.append( + para( + run(f"{number}. ", size=CONTRACT_TEXT, bold=True, colour=ACCENT[1:]) + + run(clause, size=CONTRACT_TEXT), + 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) + + # The appendix the last clauses promise, and the length that carries the + # contract past the foot of the screen. Its two columns are words the report + # already has in every language, so it costs no translation. + head, rows, _ = table(words, columns=1, rows=ANNEX_ROWS) + body += cell_rows( + [[words["item"], words["total"]]] + [[line[0], str(line[-1])] for line in rows] + ) + + 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, others: dict) -> bool: + """Whether Helvetica's encoding can write everything the invoice puts on the page. + + Everything, not a line or two of it: the check used to read the title and the + closing only, which is a sample rather than an answer - a language those two + happen to be spellable in can still hold a character further down that the + encoding has no byte for, and that character reaches the page as mojibake. + + Seven of the fifteen locales fail this and take the English invoice: cs, pl + and tr for a handful of letters, and hi, ja, ru and zh for their whole + script. Fixing that means embedding a subset of a real font and writing the + text as CIDs, which is a job of its own and not one to do inside a screenshot + script - so it is written down here rather than left to be discovered in the + store. + """ + spoken = [words["item"], words["total"], words["title"], words["closing"]] + spoken += words["periods"] + words["rows"] + others["invoice"] + + return all(character in WINANSI for line in spoken for character in line) + + +# A4 upright in points, with the same margin the ODF pages take. +PAGE = (595.0, 842.0) +MARGIN = 57.0 + +# How many lines the invoice bills for. Enough to run onto a second page, for the +# reason `report` gives: a page is two thirds of a phone's height, and what fills +# the rest is the top of the page after it. +INVOICE_ROWS = 40 + + +def pdf_bytes(words: dict, others: dict) -> bytes: + """A one page PDF, written out by hand rather than through a library. + + An invoice, which is a page of placed labels and figures rather than of + running prose: every cell is set where it belongs, so nothing has to be + wrapped and `advance` is only asked how wide a number is. + + Helvetica and WinAnsi, so what it says is Latin text only - the languages + this cannot spell get the English wording, which is also what the search + screenshot then looks for. + """ + latin = spellable(words, others) + said = words if latin else WORDS["en"] + + def literal(text: str) -> str: + return text.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") + + invoice = others["invoice"] if latin else OTHERS["en"]["invoice"] + number, issued, due, billed, subtotal, vat, due_label, thanks, quantity, unit = invoice + head, body, foot = table(said, columns=1, rows=INVOICE_ROWS) + + money = foot[-1] + tax = round(money * 0.2) + right = PAGE[0] - MARGIN + + pages = [[]] + + def put(text, x, y, weight="regular", size=10, align="left"): + """One line, placed on whichever page is open. Numbers are hung off the + right, which is what makes a column of figures a column rather than a + ragged list.""" + name = "F2" if weight == "bold" else "F1" + at = x - advance(text, weight, size) if align == "right" else x + pages[-1].append(f"BT /{name} {size:g} Tf {at:.1f} {y:.1f} Td ({literal(text)}) Tj ET") + + # 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 + + # A line that would be set in the bottom margin opens the next page + # instead, with the column heads written again above it - which is what a + # producer does, and what makes the last page short rather than the first + # page overfull. + if y < MARGIN + 80: + pages.append([]) + y = PAGE[1] - MARGIN - 26 + 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") + 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) + + # 1 catalog, 2 the page tree, 3 and 4 the two fonts, then a page each and a + # content stream each - so a page is 4+n and the stream it points at 4+len+n. + first_page = 5 + first_stream = first_page + len(pages) + kids = " ".join(f"{first_page + n} 0 R" for n in range(len(pages))) + + objects = [ + b"<>", + f"<>".encode(), + b"<>", + b"<>", + ] + objects += [ + f"<>>>/Contents {first_stream + n} 0 R>>".encode() + for n in range(len(pages)) + ] + for drawn in pages: + stream = ("\n".join(drawn) + "\n").encode("cp1252") + objects.append( + b"<>\nstream\n" + stream + b"endstream" + ) + + 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 + + # The locale table travels with the documents rather than being written out a + # second time in `ScreenshotTests`: which locale reads which language's + # documents is one fact, and a second copy of it can only disagree. + written_in = set(store.LOCALES.values()) + if written_in != set(WORDS): + raise SystemExit( + f"the languages here and the ones store_screenshots.py photographs have parted " + f"company: {sorted(written_in ^ set(WORDS))}" + ) + + details = { + "locales": store.LOCALES, + "languages": { + language: { + "files": FILE_NAMES[language] + | { + key: FILLER_NAMES.get(language, {}).get(key, FILLER_NAMES["en"][key]) + for key in FILLERS + }, + "search": query(words, language), + } + 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 000000000000..3637d9852555 --- /dev/null +++ b/scripts/store_screenshots.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +# +# The play store screenshots: which ones there are, and the supply 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 supply from there. +# `fastlane android 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 +# +# An underscore in the name, where every other script here has a dash: +# `frame-screenshots.py` imports this one, and a dash cannot be imported. +# +# OpenDocument.ios has the same script against App Store Connect's shape. + +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 its documents are written in. The one copy of +# this: `make-screenshot-documents.py` checks its own languages against it and +# writes it into the test apk's assets, which is where `ScreenshotTests` reads it +# rather than holding a table that could disagree. +# +# `None` would mean the app has no such language, so that locale reads the +# English pictures; nothing is None today. The keys are the locales +# `scripts/store-listing.py` names, and the same directories `fastlane/metadata` +# has. +LOCALES = { + "cs-CZ": "cs", + "de-DE": "de", + "en-US": "en", + "es-ES": "es", + "et": "et", + "fr-FR": "fr", + "hi-IN": "hi", + "it-IT": "it", + "ja-JP": "ja", + "pl-PL": "pl", + "pt-BR": "pt-BR", + "ru-RU": "ru", + "sv-SE": "sv", + "tr-TR": "tr", + "zh-CN": "zh", +} + +FALLBACK = "en-US" + +# What one device shows, in the order the store shows them. The same names the +# screenshot test writes - see `ScreenshotTests.kt` - and supply uploads a +# locale's pictures in filename order, which is why they are numbered. +SCREENS = ( + "01-recents", + "02-text", + "03-sheet", + "04-edit", + "05-pdf", + "06-office", +) + +# The devices photographed, and the directory supply uploads each one to. Play +# keeps a set per form factor and shows the phone one everywhere it has nothing +# better, so the tablet set is what makes the listing a tablet listing. +# +# `sevenInchScreenshots` is deliberately not among them: nothing is made for a +# 7" tablet in particular, and play falls back to the phone pictures there. +DIRECTORIES = { + "phone": "phoneScreenshots", + "tablet": "tenInchScreenshots", +} + +# What the framed picture is, per device, in pixels. Not the size of the capture: +# play refuses a screenshot whose long side is more than twice its short one, and +# a Pixel 9 Pro XL is 1344x2992 - 2.23:1 - before anything is drawn around it. So +# `frame-screenshots.py` draws onto a canvas of its own and the capture is a +# picture inside it, which is what the frame is for anyway. +# +# 16:9 for the phone, 16:10 for the tablet, both the proportions of the device +# they stand for rather than of the screenshot inside them. +CANVASES = { + "phone": (1440, 2560), + "tablet": (1600, 2560), +} + +# What play takes per form factor. Under two and it refuses the listing; over +# eight and it ignores the rest. +LEAST, MOST = 2, 8 + + +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 named(stem): + """The (device, screen) a picture's name says it is, or (None, None). + + `phone-02-text`. The device is written into the name by the capture run, + which is the only thing that knows which emulator it was driving - a framed + picture is the size of its canvas, and two devices could share one. + """ + for device in DIRECTORIES: + for screen in SCREENS: + if stem == f"{device}-{screen}": + return device, screen + + return None, None + + +def collect(directory): + """What one capture run wrote. Returns (files by locale and device, problems).""" + 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")): + device, screen = named(path.stem) + if device is None: + problems.append( + f"{locale}: {path.name} is not one of " + + ", ".join(f"{d}-{s}" for d in DIRECTORIES for s in SCREENS) + ) + continue + + try: + width, height = size(path) + except (OSError, ValueError) as reason: + problems.append(f"{locale}: {reason}") + continue + + if (width, height) != CANVASES[device]: + wanted = "x".join(str(side) for side in CANVASES[device]) + problems.append(f"{locale}: {path.name} is {width}x{height}, not {wanted}") + continue + + pictures.setdefault(device, {})[screen] = path + + for device in DIRECTORIES: + missing = [screen for screen in SCREENS if screen not in pictures.get(device, {})] + if missing: + problems.append(f"{locale}: no {device} {', '.join(missing)}") + + found[locale] = pictures + + return found, problems + + +def stage(found, directory): + """Write the screenshots into the metadata tree supply uploads. + + Into the same directory `scripts/store-listing.py` stages the text in, under + the `images/` subdirectory supply reads a locale's pictures from - so one + tree is handed over and one edit goes to play. + + The borrowed locales are copied from the English rather than left out: what + supply does not upload for a locale, play keeps - which would be whatever was + there before this release. + """ + directory = Path(directory) + + for locale, pictures in found.items(): + for device, screens in pictures.items(): + folder = directory / locale / "images" / DIRECTORIES[device] + folder.mkdir(parents=True, exist_ok=True) + for screen, path in screens.items(): + shutil.copyfile(path, folder / f"{screen}.png") + + for locale in borrowed(): + source = directory / FALLBACK / "images" + target = directory / locale / "images" + 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 play store screenshots, and stage it for supply." + ) + 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 screenshots into the supply metadata tree in DIR", + ) + args = parser.parse_args(argv) + + if args.languages: + print("\n".join(languages())) + return 0 + + if not LEAST <= len(SCREENS) <= MOST: + return fail(f"play takes {LEAST} to {MOST} screenshots per device, not {len(SCREENS)}") + + 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 android 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())