diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dd02a..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 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); + }); })(); """ } 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 = []