From 9117e5146437e0542dc88ce9e390cb80c6ba9269 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 19 Aug 2026 09:09:18 +0200 Subject: [PATCH 1/3] Stand the phone whole, and give it a document long enough to fill Both of these landed on Android first; this is the same work here. The phone was anchored by its top left corner and left to run off the bottom and the right, which reads as a picture that did not fit rather than as a phone. It stands whole now. Play let the canvas grow to make room and App Store Connect does not - it takes the capture's own size and no other - so the device is fitted into the picture instead, and loses a little size doing it. Where it sits keeps the tabs overlapping its left edge exactly as before, so nothing else moved. That leaves more of the screen showing, which is where the documents came in. A page fitted to the width of a phone is about two thirds of its height, so three of the six pictures ended mid-screen with the backdrop behind them. The report carries a table of the figures now, the contract an appendix, and the invoice bills forty lines over two pages - all of it words these documents already have in every language, so there is nothing new to translate. Checked by photographing the English set on an iPhone 17 Pro Max. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W2XM93Raj1jyvUuYmpQrcz --- fastlane/screenshots/README.md | 10 +- scripts/frame-screenshots.py | 51 ++--- scripts/make-screenshot-documents.py | 269 +++++++++++++++++++-------- 3 files changed, 227 insertions(+), 103 deletions(-) diff --git a/fastlane/screenshots/README.md b/fastlane/screenshots/README.md index 9320b2a..502a55b 100644 --- a/fastlane/screenshots/README.md +++ b/fastlane/screenshots/README.md @@ -21,16 +21,18 @@ to the store, before it does on a dry run and after it on a real one. ## What is in a set -Four pictures per device, taken by relaunching the app onto one screen at a +Six pictures per device, taken by relaunching the app onto one screen at a time rather than by tapping through it. The screens, in the order the store shows them: | | | | --- | --- | -| `01-intro` | the onboarding pages, which is the app's own words | -| `02-text` | a text document open | +| `01-browser` | the document browser, with one of each format sitting in it | +| `02-text` | a text document open, with a search under way | | `03-sheet` | a spreadsheet, with the sheet tabs under the tool bar | -| `04-slides` | a presentation | +| `04-edit` | a document being edited, keyboard up | +| `05-pdf` | a pdf | +| `06-office` | the same reader on a Word file | Two devices, because an app that runs on iPhone and iPad has to hand in both: a 6.9" iPhone and a 13" iPad. `scripts/store_screenshots.py` holds the sizes App diff --git a/scripts/frame-screenshots.py b/scripts/frame-screenshots.py index b0886ca..07949aa 100755 --- a/scripts/frame-screenshots.py +++ b/scripts/frame-screenshots.py @@ -50,19 +50,23 @@ # how far across, is a fraction of the width. So a taller canvas gives # everything more room without stretching any of it. # -# The proportions came off the 2020 artwork at 1242x2208. A phone is a good deal -# taller than that now, so the phone is anchored by its top left corner and left -# to run off the bottom and the right - which is what the original did too, only -# by less. +# The proportions came off the 2020 artwork at 1242x2208, where the phone was +# anchored by its top left corner and left to run off the bottom and the right. +# It stands whole now, as the Play listing's does: a device with its corners cut +# off reads as a picture that did not fit rather than as a phone. The canvas +# cannot grow to make room - App Store Connect takes the capture's own size and +# no other - so the device is fitted into it instead, which costs it some size +# and leaves ground under it. LAYOUT = { "iphone": { "headline_top": 0.068, "headline_size": 0.070, # before it is shrunk to fit "headline_width": 0.86, # what it is shrunk to fit inside "headline_leading": 1.06, - "screen_left": 0.280, - "screen_top": 0.235, - "screen_width": 0.780, + "screen_left": 0.278, + "screen_top": 0.265, + "screen_width": 0.650, # as wide as it may be; `foot` is the other limit + "foot": 0.045, # ground left under the device, of the height # An iPhone 17 Pro Max, from its published dimensions: a 440pt screen # inside a 78.0mm body, which leaves 2.54mm - 15.3pt - of black border # and aluminium on every side, and a 62pt display corner. @@ -76,9 +80,7 @@ # artwork, which is also where the bezel, the island and the corner # come from - so all of it is the device rather than a guess at it. "buttons": [(0.189, 0.0423), (0.262, 0.0686), (0.349, 0.0686)], - # the side button, on the right edge, off the canvas where the phone - # sits today. Kept so the device is described whole: what is drawn - # follows from where it is placed, not the other way round. + # the side button, on the right edge "buttons_right": [(0.286, 0.1082)], "chip_top": 0.440, "chip_size": (0.240, 0.147), @@ -140,13 +142,10 @@ "headline_size": 0.050, "headline_width": 0.80, "headline_leading": 1.06, - # Off the right edge by a little, as the phone is, but only a little: - # an iPad's status icons sit within a fiftieth of its own edge, so any - # more of a bleed takes the battery with it. The phone can afford 6% - # because the Dynamic Island pushes its icons well inboard. - "screen_left": 0.188, - "screen_top": 0.245, - "screen_width": 0.825, + "screen_left": 0.215, + "screen_top": 0.250, + "screen_width": 0.720, + "foot": 0.055, # An iPad Pro 13-inch, likewise: a 1032pt screen in a 215.5mm body is # 8.44mm - 43.8pt - of border, near three times the phone's, and the # display corner is 18pt where the phone's is 62. Nothing on the left @@ -423,13 +422,21 @@ def phone(canvas, shot, layout): against each other without the ground showing through the seams. """ width, height = canvas.size - screen_width = layout["screen_width"] * width - screen_height = screen_width * shot.height / shot.width - left, top = layout["screen_left"] * width, layout["screen_top"] * height - screen = (left, top, left + screen_width, top + screen_height) - bezel = layout["bezel"] * width # screen edge to the outside of the body rim = bezel * layout["rim"] # how much of that is metal + + 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) diff --git a/scripts/make-screenshot-documents.py b/scripts/make-screenshot-documents.py index ad01ef7..8ba1691 100755 --- a/scripts/make-screenshot-documents.py +++ b/scripts/make-screenshot-documents.py @@ -69,6 +69,12 @@ 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""" @@ -107,24 +113,57 @@ def paragraph_style(name: str, *, size: str, weight: str = "normal", colour: str def report(words: dict) -> str: - """A page of text: a title, a lead, two headed sections and a closing line.""" + """A title, a lead, headed sections with the costs figures under theirs, and a + closing line. + + Long on purpose. A page fitted to the width of a phone is about two thirds of + its height, so a document that ends after one is photographed with a third of + the screen showing the backdrop behind it. Which is why there is a table in + here at all: the figures are the only length the report can be given that is + already written in all nine languages. + """ 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 heading, paragraphs in words["sections"]: + 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 nine 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) @@ -159,6 +198,41 @@ def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple 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( @@ -171,40 +245,15 @@ def sheet(words: dict) -> str: """ """, - """ - - - """ % (RULE, ACCENT), - """ - - - """ % (RULE,), + CELL_STYLES, ] ) - def row(cells: list, style: str | None = None) -> str: - marked = f' table:style-name="{style}"' if style else "" - out = [" "] - for cell in cells: - if isinstance(cell, int): - out.append( - f' ' - f"{cell}" - ) - else: - out.append( - f' ' - f"{escape(cell)}" - ) - out.append(" ") - - return "\n".join(out) - head, body, foot = table(words, columns=6) - overview = [row(head, "ceHead")] + [row(line) for line in body] + [row(foot, "ceTotal")] - costs = [row([words["item"], words["total"]], "ceHead")] - costs += [row([line[0], line[-1]]) for line in body] + 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 ( @@ -1066,6 +1115,36 @@ def query(words: dict) -> str: WORD_MAIN = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" +# How many lines the contract's appendix lists, and the half-points the whole of it +# is set in. Between them they are what carries it past the foot of a phone screen, +# the clauses above being fourteen short sentences - and they are the only length +# there is to give it, the renderer honouring neither `w:spacing` on a paragraph nor +# `w:trHeight` on a row. +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.""" @@ -1082,18 +1161,18 @@ def run(text: str, *, size: int, bold: bool = False, colour: str = "") -> str: def para(runs: str, after: int) -> str: return f'{runs}' - # A clause is two sentences in one paragraph, numbered in line with the - # first. A number on a line of its own above a single sentence reads as a - # list of scraps rather than as a contract. + # 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=22), 420), + para(run(lead, size=CONTRACT_TEXT), 420), ] - for number, index in enumerate(range(0, len(clauses) - 1, 2), start=1): - body = " ".join(clauses[index:index + 2]) + for number, clause in enumerate(clauses, start=1): paragraphs.append( para( - run(f"{number}. ", size=22, bold=True, colour=ACCENT[1:]) + run(body, size=22), + run(f"{number}. ", size=CONTRACT_TEXT, bold=True, colour=ACCENT[1:]) + + run(clause, size=CONTRACT_TEXT), 300, ) ) @@ -1103,6 +1182,14 @@ def para(runs: str, after: int) -> str: # 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. + _, 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": '' '' @@ -1299,68 +1386,71 @@ def advance(text: str, weight: str, size: float) -> float: WINANSI = set(bytes(range(32, 256)).decode("cp1252", errors="ignore")) -def spellable(words: dict) -> bool: - """Whether Helvetica's encoding can write this language's wording.""" - return all( - character in WINANSI for line in (words["title"], words["closing"]) for character in line - ) +def spellable(words: dict, others: dict) -> bool: + """Whether Helvetica's encoding can write everything the invoice puts on the page. + + Everything, not a line or two of it: this used to read the title and the + closing, which is a sample rather than an answer - a language those two happen + to be spellable in can still hold a character further down that the encoding + has no byte for, and that character reaches the page as mojibake. The forty + rows the invoice bills for are forty more chances of that than it had. + + Three of the nine languages fail this and take the English invoice: pl and tr + for a handful of letters, ru for its whole script. Fixing that means embedding + a subset of a real font and writing the text as CIDs, which is a job of its own + and not one to do inside a screenshot script - so it is written down here + rather than left to be discovered in the store. + """ + 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 -COLUMN = PAGE[0] - 2 * MARGIN + +# 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. + """A PDF written out by hand rather than through a library. - Each word is placed at its own position, the way a real producer writes one. - Handed over as one run per line instead, a reader that marks a search hit - inside the run has nothing to measure the offset with, and the highlight - lands beside the word rather than on it. + 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. """ - said = words if spellable(words) else WORDS["en"] - - def lay_out(text: str, weight: str, size: float) -> list: - """The text broken into lines of placed words.""" - lines, line, width = [], [], 0.0 - space = advance(" ", weight, size) - for word in text.split(): - reach = advance(word, weight, size) - if line and width + space + reach > COLUMN: - lines.append(line) - line, width = [], 0.0 - line.append((word, width)) - width += reach + space - if line: - lines.append(line) - - return lines + 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 spellable(words) else OTHERS["en"]["invoice"] + 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=20) + head, body, foot = table(said, columns=1, rows=INVOICE_ROWS) money = foot[-1] tax = round(money * 0.2) right = PAGE[0] - MARGIN - drawn = [] + pages = [[]] def put(text, x, y, weight="regular", size=10, align="left"): - """One line, placed. Numbers are hung off the right, which is what makes - a column of figures a column rather than a ragged list.""" + """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 - drawn.append(f"BT /{name} {size:g} Tf {at:.1f} {y:.1f} Td ({literal(text)}) Tj ET") + 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 @@ -1386,6 +1476,20 @@ def put(text, x, y, weight="regular", size=10, align="left"): 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") @@ -1402,17 +1506,28 @@ def put(text, x, y, weight="regular", size=10, align="left"): y -= 12 put(thanks, MARGIN, y) - stream = ("\n".join(drawn) + "\n").encode("cp1252") + # 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"<>", - b"<>", - b"<>>>/Contents 5 0 R>>", + f"<>".encode(), b"<>", - b"<>\nstream\n" + stream + b"endstream", 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 = [] From 478cf38a2a4cd1bcf8dc77ec25dcfa7b3c05521a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 19 Aug 2026 10:25:58 +0200 Subject: [PATCH 2/3] Keep the reader's place when the web view changes size The last fix here got a short document back to the top of its first page but not a long one. A document is loaded into a web view still at the width the storyboard gave it, and laid out again a moment later at the real one. The browser answers that by keeping whatever was against the top of the screen - and gets it wrong, because the scale changes with the width. It lands hundreds of pixels down, which on an iPad is a page or more of a long document, and it settles there a few frames after the resize, too late for anything done in the same breath to hold it. So the place the reader was actually at is re-asserted over the next half second, and dropped the moment they touch the page. Measured on an iPad with the four documents the screenshots use: every one of them opens at its own first line now, where three of the four opened between 430 and 643 pixels down. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W2XM93Raj1jyvUuYmpQrcz --- CHANGELOG.md | 3 +- .../DocumentViewController.swift | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dd02a..a10532d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,8 @@ once the version tag exists. - Tapping a document sets the cursor, so an edit can be typed. The keyboard never came up before. - A document opens at the top of its first page. On a wide screen it opened far - enough down to cut off the heading. + enough down to cut off the heading, and on an iPad a long one opened a page or + more in. - A text file with commas in it is shown as text, not as a table. ### Removed diff --git a/OpenDocumentReader/DocumentViewController.swift b/OpenDocumentReader/DocumentViewController.swift index 243ee77..53d6c4a 100644 --- a/OpenDocumentReader/DocumentViewController.swift +++ b/OpenDocumentReader/DocumentViewController.swift @@ -128,11 +128,37 @@ class DocumentViewController: UIViewController, DocumentDelegate, UISearchBarDel natural > available() ? 'width=' + natural + ',user-scalable=yes' : served); } + // Across a resize the browser keeps the reader's place by holding on + // to whatever was against the top of the screen, and here it gets it + // wrong: the scale changes with the width, and the page comes back + // hundreds of pixels down - a page or more of a long document on an + // iPad, which is where the width really does change. It settles + // there some frames after the resize, so the place the reader was + // actually at is re-asserted until it has finished, and dropped the + // moment they take hold of the page themselves. + var holding = []; + + function hold(place) { + holding.forEach(clearTimeout); + holding = [0, 16, 50, 150, 300, 500].map(function (ms) { + return setTimeout(function () { window.scrollTo(window.scrollX, place); }, ms); + }); + } + + window.addEventListener('touchstart', function () { + holding.forEach(clearTimeout); + holding = []; + }, { passive: true }); + // On every resize, not just now: this first runs before the web view // has the width it will keep, and a page held at a width it no // longer needs is left scrolled off its own top. fit(); - window.addEventListener('resize', fit); + window.addEventListener('resize', function () { + var was = window.scrollY; + fit(); + hold(was); + }); })(); """ } From 1bc8899a1d6c9afaeabf514ef2b9efd9b49d7b3b Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Wed, 19 Aug 2026 10:31:03 +0200 Subject: [PATCH 3/3] Put the fix under Unreleased, 1.41 having shipped It was written into 1.41's section, which is closed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W2XM93Raj1jyvUuYmpQrcz --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a10532d..00706f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ open: **a second build under the same version goes under the already cut heading, not back under `Unreleased`.** Date the heading and add its compare link once the version tag exists. +## [Unreleased] + +### Fixed + +- A long document opens at the top of its first page on an iPad. It opened a + page or more in. + ## [1.41] ### Added @@ -52,8 +59,7 @@ once the version tag exists. - Tapping a document sets the cursor, so an edit can be typed. The keyboard never came up before. - A document opens at the top of its first page. On a wide screen it opened far - enough down to cut off the heading, and on an iPad a long one opened a page or - more in. + enough down to cut off the heading. - A text file with commas in it is shown as text, not as a table. ### Removed