Measure the String Before You Translate It
The Problem
Customer QA reported cut-off text in the small stat cards of the Hello Weather iOS app, in Spanish. The obvious fix was to shorten the Spanish strings. That’s the loop every localized app runs: ship a screen that fits in English, translate it into 26 more languages, wait for a support ticket, fix one string, ship an app update, repeat.
The loop is slow because nothing in our tools knows how wide a string will be. The localization catalog stores text. The SwiftUI layout works out widths at runtime, on a device, in one font at one Dynamic Type size. The two never meet until a person looks at a screenshot.
The same string breaks in more than one place. If Air Quality overflows a stat card title in Vietnamese, it overflows in the widget too, because it’s the same catalog key in the same kind of slot. And some strings come from the server, like air quality level names and advisory phrases. Fixing one word there meant an app release for a change that wasn’t in the app. So instead of fixing the Spanish strings, we went looking for a way to find every string in every language that wouldn’t fit, before anyone reported it.
The Solution
Three parts, and none of them needs the app running:
- A JSON file, checked in, that lists which catalog keys render in tight slots and how much room each one gets
- A Swift script with no dependencies that reads that file and the localization catalog and measures every string
- A markdown report, also checked in, that works as the work-list, the diff, and the baseline
The tool starts in audit mode: it reports what it finds and exits 0, because there’s a backlog to work through. Once the backlog is gone, it flips to gate mode and fails the run instead.
We built it twice. The first version counted characters. The second measures rendered widths, because counting characters missed things.
Generation 1: A Character-Budget Registry
The registry is a plain JSON file in the repo:
{
"_readme": "Width budgets for catalog keys rendering in width-constrained slots (stat cards, chart legends, widget rows, complication labels). budget = max Character count for every language value except cjkExempt. scales = per-language values within one group must stay pairwise distinct.",
"cjkExempt": ["ja", "ko", "zh-Hans", "zh-Hant"],
"keys": {
"Air Quality": { "slots": ["statTitle"], "budget": 17 },
"Cloudy": { "slots": ["chartLegend"], "budget": 9 },
"AQI": { "slots": ["complicationLabel"], "budget": 6 },
"Actual": { "slots": ["miniTitle"], "budget": 9 }
},
"scales": {
"uvLegend": ["Low", "Mid", "High", "Max"],
"pressureLegend": ["Low", "Normal", "High"],
"visibilityLegend": ["Good", "Fair", "Poor"]
}
}
The registry holds 74 keys across six slot types and 14 scale groups. It records two things no linter could work out on its own. The first is where each key renders. A key has a budget because of the slot it lands in, and one key can land in several, so each entry names its slots. A reviewer can then ask “is 17 characters really the stat title budget?” without reading layout code. The second is the scale groups. A scale is a set of labels that appear together in one chart legend, and its rule has nothing to do with length: within a scale, every language’s values must all be different from each other. A translator working one key at a time can’t see that two English words map to the same word in their language.
The scale rule found two shipping bugs on the first run:
- The Czech cloud legend rendered the same word for both
CloudyandOvercast - The Russian UV legend rendered the same word for both
HighandMax
Two swatches with the same label and different colors, in a chart that had shipped. Measuring widths would never have caught those.
The tool is about 90 lines of Foundation. It reads the registry and the localization catalog as plain JSON, with no dependency on the app and no test target:
for scale in scales.keys.sorted() {
for language in checkedLanguages {
var seen: [String: String] = [:]
for key in scales[scale] ?? [] {
guard let translated = value(key, language) else { continue }
if let previous = seen[translated] {
findings.append("FINDING: within-scale duplicate in \(scale) " +
"\(language): \"\(previous)\" and \"\(key)\" " +
"both \"\(translated)\"")
} else {
seen[translated] = key
}
}
}
}
A bash wrapper compiles it into a temp directory and runs it, so the whole thing is one command, ./tools/validate-compact-strings, with no build system involved:
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/compact-string-validation.XXXXXX")"
trap 'rm -rf "$tmp_dir"' EXIT
swiftc -parse-as-library "$script_dir/validate-compact-strings.swift" -o "$tmp_dir/validator"
TOOLS_DIR="$script_dir" "$tmp_dir/validator"
The first run gave 211 findings across 26 languages. That number is a work-list, not a failure.
Generation 2: Measuring What Actually Renders
Character counts are a rough stand-in for width. Ω and l are both one character. Cyrillic is wider than Latin at the same count. And a character budget can’t tell a 13pt regular description from an 11pt semibold uppercased title in the same card.
The second version measures real rendered widths with AppKit on the Mac, with macOS SF Pro standing in for iOS SF Pro:
static func width(_ string: String, _ size: CGFloat, weight: NSFont.Weight = .regular) -> CGFloat {
let font = NSFont.systemFont(ofSize: size, weight: weight)
return ceil(NSAttributedString(string: string, attributes: [.font: font]).size().width)
}
The budget is the part to copy. Instead of a hand-picked number, the tool works it out from the same grid formula the SwiftUI view uses:
static let deviceWidth: CGFloat = 375 // smallest supported width
static let gridOuterPadding: CGFloat = 32
static let gridSpacing: CGFloat = 10
static let gridMinimumColumn: CGFloat = 165 // adaptive grid minimum
static let cardPadding: CGFloat = 32
static let iconAllowance: CGFloat = 36
static let headroom: CGFloat = 0.95 // proxy-font margin
static var descriptionBudget: CGFloat {
let available = deviceWidth - gridOuterPadding
let columns = floor((available + gridSpacing) / (gridMinimumColumn + gridSpacing))
let column = (available - (columns - 1) * gridSpacing) / columns
return column - cardPadding
}
static var titleBudget: CGFloat { descriptionBudget - iconAllowance }
static var passBar: CGFloat { descriptionBudget * headroom }
That gives 134.5pt for descriptions and 98.5pt for titles and subtitles. The formula matches the view, so if someone changes the spacing or the column minimum, the tool changes in one line and nobody re-guesses every budget. The 5% headroom is there because the Mac font isn’t the real one. So each row lands in one of three buckets instead of two: OK, MARGIN (inside the 5% band, so check it on a device), and OVER.
Worst-Case Format Arguments
Most of the strings in tight slots are format templates, not fixed text. Sunrise at %@. has no width until you fill it in. Measuring the template tells you nothing. Measuring it with whatever value is handy is worse, because it passes and you learn nothing. So the tool builds the widest value that could really appear for each placeholder:
// Widest clock string for this locale (both 12h and 24h are measured)
static func worstTime12(_ language: String) -> String {
formattedDate(language, pattern: "h:mma", hour: 12, minute: 59)
.lowercased(with: locale(language))
}
// Widest noun that can fill a precip template
static func worstPrecipNoun(_ language: String) -> String {
["Rain", "Snow", "Sleet", "Hail", "Precip"]
.compactMap { catalogValue($0, language) }
.max(by: { width($0, 13) < width($1, 13) }) ?? "Rain"
}
The catalog reader does the same for plurals. When an entry has plural forms instead of one string, it returns the widest form, not the other case:
if let plural = (localization["variations"] as? [String: Any])?["plural"] as? [String: Any] {
let values = plural.values.compactMap {
(($0 as? [String: Any])?["stringUnit"] as? [String: Any])?["value"] as? String
}
return values.max(by: { width($0, 13) < width($1, 13) })
}
The last kind of placeholder matters most. Many of the widest strings in the app aren’t in the app at all. Level names like “Very Unhealthy”, advisory phrases like “Health effects possible.”, wind bearings, and pollen phrases all come from our API, translated on the server. So the tool reads the server repo’s locale files directly. The wrapper converts them from YAML to JSON and passes the directory in:
for yml in "$web_dir"/config/locales/*.yml; do
lang="$(basename "$yml" .yml)"
ruby -ryaml -rjson -e 'puts JSON.generate(YAML.safe_load(File.read(ARGV[0])))' \
"$yml" > "$web_json_dir/$lang.json"
done
web_head="$(git -C "$web_dir" rev-parse --short HEAD)"
The report records the commit SHA of the server checkout and warns when it differs from that repo’s main branch, so you can tell when the baseline is stale. If the checkout is missing, the tool checks only the app’s own keys and prints a warning instead of failing. Temperatures, precip amounts, and wind units have no catalog value, so the tool invents one, and those rows are tagged [estimate] so a reader knows which findings are guesses.
The Committed Report
The tool writes a markdown file, and we check it in:
## Summary
- Rows measured: 1620 (27 languages)
- Over budget at default type size: **483**
- Inside margin (127.8-134.5pt band): 78
- Over budget at the xxLarge cap: 674
| Card | Slot | Language | Width | Verdict | Source | Rendered |
|---|---|---|---|---|---|---|
| AQI | description | de | 242/134pt | OVER | `server:aqiLevelPhrase` | Gesundheitliche Auswirkungen möglich. |
| AQI | description | en | 144/134pt | OVER | `server:aqiLevelPhrase` | Health effects possible. |
| AQI | subtitle | it | 134/98pt | OVER | `server:aqiLevelName.capitalized` | Molto Insalubre |
Checking in generated output feels wrong until you’ve used it once. It gives us three things:
- A work-list. It’s sorted worst-first per card, so whoever is shortening strings knows what to fix and in what order.
- A diff. Run the tool on a branch and
git diffshows which rows moved. That’s what a reviewer reads on a translation PR. - A baseline. The report defines “no worse than before.”
It also turned up things nobody was looking for. Stat card titles truncate today. Vietnamese Chất lượng không khí renders at 141pt in a 98pt slot, and eight more languages are over on the same key. English fails 9 rows of its own. And a .capitalized call in the app was title-casing Spanish level names in the middle of a sentence.
The Server Loop
Of the 483 over-budget rows, 140 came from server strings. Those live in the API’s locale files, not in the app, so fixing them is a deploy: no App Store review, no version check, no waiting for users to update. Each of those 140 was a copy edit.
The server pass shortened 300+ locale values across 22 languages under one rule:
The dual-surface rule: every value must still read as natural prose on the iOS detail screens and the web product, not just fit the card.
The rule keeps “make it fit” from turning every phrase into a clipped fragment. A pressure trend name has to work on its own as a card label and inside a sentence:
# before -> after, es
pressure:
trend_ext_name:
falling-quickly: "Cae rápido" # was "Bajando rápido"
falling: "Bajando"
falling-quickly and falling are neighbors in the same scale, so the shorter value also has to stay a different word from the one next to it. That’s the scale rule from the first version, now applied on the server.
To check the pass, we pointed the app’s width tool at the server branch and ran it again. Server-string findings dropped from 140 to 74.
The remaining 74 are rows we decided to keep, because there’s no natural short form. Each one is written down with its reason:
- Two-item pollen phrases stay over in ~10 languages, because the two nouns joined together already come close to the budget. That one got fixed on the server the same day, by naming only the dominant pollen type in the phrase, which cleared all 17 over-budget pollen rows.
- Thai wind bearings stay spelled out, because that’s the local convention and abbreviating them reads badly in a sentence.
- Indonesian air quality names don’t use the official band terms, because those don’t fit an 18pt subtitle. That trade is written down, so it doesn’t look like a mistake.
We can gate on the report later because the keeps sit in the same file as the findings. A row can stay over budget forever as long as someone decided that on purpose.
Results
- 1,620 rows measured at real rendered widths across 27 languages, with 483 over budget at the default type size. The first character-count run had 211 findings and caught two legend bugs in shipping charts
- 300+ server locale values shortened across 22 languages and deployed, with no app update for that half of the fix
- Server-string findings down from 140 to 74, and each of the 74 recorded with a reason
- The trade-off: the Mac font stands in for the device font, so rows in the
MARGINband still need a check on a device - Since then: the tool flipped to gate mode on 2026-07-31, with the floor moved from 375pt to 390pt, the narrowest iPhone still sold. In August 2026 the standalone tools moved into the unit-test target as
WidthReportTests, run throughbin/width-reports, and now measure with the real iOS font in the app-hosted test host. The recorded baselines matched the Mac results row for row across 1,755 rows, so theMARGINcaveat went away without ever having changed a verdict
Lessons Learned
- Ship the cheap stand-in first, then replace it. Character counts need no dependencies and found two real bugs. Real widths came second, once we knew what counting missed.
- Work budgets out from the layout formula, not by guessing. A hard-coded budget goes stale the moment someone touches the view. If the tool copies the grid arithmetic, a spacing change is a one-line edit.
- A format template has no width. Every placeholder needs its widest realistic value: the widest plural form, the widest clock string for the locale, the widest noun from the list, the longest real server value.
- Audit first, gate later, write down the keeps. A check that fails on day one with 211 findings gets turned off on day two. Exit 0 while there’s a backlog, record each row left over on purpose, then flip to gate mode in its own change.
- Where a string lives decides how its bugs get fixed. Putting level names and advisory phrases in the API instead of the app looked like a tidy-up. It meant a whole class of layout bugs could be fixed with a deploy.