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 = []