●MAX — Rork Max generates native Swift instead of React Native, covering iPhone/iPad/Watch/TV/Vision Pro/iMessage (May)●NATIVE — Rork Max unlocks native Apple features: AR/LiDAR, Metal 3D, Dynamic Island, HealthKit, NFC, and Core ML (May)●SIM — A browser-based cloud iOS simulator lets you test on a real Apple environment without Xcode or a Mac (May)●PUBLISH — One- and two-click App Store publishing automates builds, certificates, and submission (May)●FUNDING — Rork has raised funding from a16z and others, with monthly visits topping 743k and steady growth (May)●EXPO — Standard Rork builds cross-platform iOS and Android apps on Expo (React Native) from a description (May)●MAX — Rork Max generates native Swift instead of React Native, covering iPhone/iPad/Watch/TV/Vision Pro/iMessage (May)●NATIVE — Rork Max unlocks native Apple features: AR/LiDAR, Metal 3D, Dynamic Island, HealthKit, NFC, and Core ML (May)●SIM — A browser-based cloud iOS simulator lets you test on a real Apple environment without Xcode or a Mac (May)●PUBLISH — One- and two-click App Store publishing automates builds, certificates, and submission (May)●FUNDING — Rork has raised funding from a16z and others, with monthly visits topping 743k and steady growth (May)●EXPO — Standard Rork builds cross-platform iOS and Android apps on Expo (React Native) from a description (May)
How I Localized My Play Store Screenshots into 16 Languages — Swapping PSD Text with Python and Standardizing on Noto Fonts
An implementation log of localizing an Android wallpaper app's store screenshots into 16 languages: swapping PSD text layers per language with Python, auto-shrinking overflow, and assigning the right font per script — with the actual code and a font cheat sheet.
"Make sixteen languages of store screenshots." It's one sentence to say, but the moment you pour translated copy into a finished design, you hit a dull wall: the layout breaks. German runs about 1.8× longer than Japanese and overflows the button. Arabic flows right-to-left and flips everything. Korean and Thai don't even have glyphs in the font you have on hand. I've been building iOS and Android wallpaper apps on my own since 2014 — they've grown past 50 million cumulative downloads — and this "make the final look right" stage is still what eats the most time.
I recently rebuilt the store screenshots for the Android app Beautiful Wallpapers (Beautiful Wallpapers on Google Play) into 16 languages: English, Spanish, French, German, and the rest. Instead of retyping 16 languages × 5 frames by hand in Photoshop, I switched to swapping the PSD's text layers per language with Python, and the job got noticeably lighter. This article is that implementation log. Not clean theory — the code I actually ran, the places I tripped, and the cheat sheet of which font I assigned to which language.
Why swap the text instead of baking it into pixels
Let me start with the approach. When people think "automate multilingual screenshots," the first idea is usually to render each language's text as an image and composite it onto the background (a transparent overlay PNG). I built that too. But it has a weakness: you can't change the font afterward.
With the overlay approach, the text is frozen into pixels the moment you generate it. If you later decide "the Korean heading should be a touch bolder," your only option is to re-run the script. As an indie dev, I want to do the final visual tweaks in Photoshop, by hand, to the very end.
So the approach I committed to was swapping only the string, per language, without rasterizing the text layer. I duplicate the original Japanese type layers, replace the string inside with the translation, and produce an "editable text PSD." Drag a language group onto the master and it lands in the exact same spot, with the font and size still fully editable in Photoshop. In practice, that single property was the biggest win.
For the record: I use none of this automation on the artwork itself (the wallpapers). The automation is strictly for the store-listing side — operations and promotion. I keep that line fixed.
Understand the source structure first
The source PSD was 1440 × 13333px with five artboards stacked vertically, each 1440 × 2560px (9:16). There are about 40 text layers total — title, subtitle, feature bullets, number badges — distributed across the artboards.
The first job is to make those text layers machine-pickable. With psd-tools you can recurse into groups and pull out only the layers where kind == 'type'.
from psd_tools import PSDImagedef find_types(layers): """Recurse through groups and yield only text layers.""" for layer in layers: if layer.kind == 'type': yield layer if layer.is_group(): yield from find_types(layer)src = PSDImage.open("screenshot.psd")for t in find_types(src): # layer.text is the visible string, layer.bbox is the coordinates print(repr(t.text), t.bbox)
layer.text gives the visible string and layer.bbox gives (left, top, right, bottom). By checking which artboard's range a layer's top falls into, you can mechanically decide "this line is the heading of screenshot 5." Keep a translation dictionary keyed on the original Japanese string (TR[japanese] = {"en": "...", "de": "...", ...}) and the layer matches its translation cleanly through layer.text.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been stuck where the translation is fine but the layout breaks every single time, you'll get a repeatable workflow that swaps PSD text per language and auto-shrinks overflow
✦You'll learn the real psd-tools code that edits text layers without rasterizing, plus a per-language cheat sheet of which Noto font to assign for Cyrillic, Hangul, Thai, and Arabic
✦You'll be able to mass-produce 16 languages of screenshots with a script instead of by hand, and apply the same flow to your own app's store presence
Secure payment via Stripe · Cancel anytime
Rewrite the text layer's contents (the no-rasterize core)
This is the crux. A Photoshop text layer keeps its visible string inside a proprietary structure called EngineData. Assigning to layer.text alone does nothing. You have to rewrite Text inside EngineData and, critically, rebuild the style run arrays (StyleRun / ParagraphRun) to match the new character count. If you don't, the text garbles when opened in Photoshop, or color/size flips partway through.
from psd_tools.constants import Tagfrom psd_tools.psd.engine_data import Integerdef set_text(layer, newtext): """Replace a text layer's string without rasterizing it.""" tt = layer._record.tagged_blocks.get_data(Tag.TYPE_TOOL_OBJECT_SETTING) eng = tt.text_data[b'EngineData'].value ed = eng.get('EngineDict') editor = ed.get('Editor') txt = editor.get('Text') # Line breaks are \r, not \n. Preserve the original trailing break. orig = txt.value trail = "\r" if orig.endswith("\r") else "" full = newtext.replace("\n", "\r") + trail txt.value = full # Collapse StyleRun / ParagraphRun to "one style x full length" for run in ('StyleRun', 'ParagraphRun'): r = ed.get(run) if r is None: continue ra = r.get('RunArray') rla = r.get('RunLengthArray') if ra is None or rla is None: continue ra._items = [ra._items[0]] # keep only the first style rla._items = [Integer(len(full))] # its length = full char count return full, ed
Layers with mixed styles (say, a number and its unit using different fonts, like "100K+") get collapsed to a single style here. It's a compromise, but for store screenshots it made later adjustment easier — fix only the spots you care about in Photoshop.
One more thing that matters: build into a blank PSD, not a copy of the original. The source contained smart objects and adjustment layers, and a re-serialized version of that wouldn't open — Photoshop reported it as "incompatible." Create a fresh blank document and stack only the duplicated text layers into it, and it opens reliably.
W, H = src.width, src.heightdoc = PSDImage.new('RGB', (W, H)) # build from a blank doc, not the original# then append: language group -> per-artboard subgroup -> text layers
Auto-shrink the overflow
German and French run longer than Japanese. Left alone, they exceed the canvas width and get clipped, so I added a step that auto-shrinks the font size only for layers whose translation overflows. Font size lives in EngineData's FontSize; the layer's vertical/horizontal scale comes from the TypeTool's transform matrix. If the measured text width exceeds the available width, multiply the size down to a ratio that fits. I capped the floor at 50% of the original and refuse to crush it further.
CANVAS_W = 1440MARGIN = 28MIN_SCALE = 0.5def fit_fontsize(font_size, text_width, avail): """Return the shrink ratio for the overflow (floor of 50%).""" if text_width <= avail: return 1.0 # fits, so 1:1 return max(MIN_SCALE, avail / text_width)
In the real code, the formula for "available width" changes with text justification (center / right / left) and with right-to-left (RTL) layouts like Arabic. Centered text is bounded by the margins on either side of the center; RTL is the right edge minus the margin. If you don't split this per language, your shrink still clips at a weird position. For long lines that don't fit even at 50%, I keep the full translation in the layer name and handle it by hand in Photoshop — splitting it into two lines, for instance. "80% automatic, 20% by hand" was the right balance of quality and effort for me.
A small trick: put the translation in the layer name
It's mundane but it paid off: make each text layer's name the translation itself. The instant you open the Layers panel in Photoshop, you can see which layer is which language's which line.
def set_name(layer, pretty): # ASCII-safe name internally; Unicode full translation as the display name layer._record.name = pretty.encode('ascii', 'replace').decode()[:200] or 'layer' try: layer._record.tagged_blocks.set_data(Tag.UNICODE_LAYER_NAME, pretty) except Exception: pass
At 16 languages × 5 frames × 8 layers, the Layers panel runs to hundreds of rows. If everything is named "Layer 1 copy 2" it's a nightmare; with the translation in the name you can even search, and hand-tweaking gets far faster.
Per-language font cheat sheet — the practical heart of this article
Automation builds the shape, but the final look is decided by font choice. The original Japanese is set in fonts like Kozuka Gothic; Latin renders cleanly as-is, but Cyrillic, Hangul, Thai, and Arabic have no glyphs and must be swapped.
In the end I standardized everything on the Noto family. It's free, covers every script, and ships matching weights, which makes multilingual consistency easy. It's the safest choice. Here is what I actually assigned, mapped to the original design roles.
Replacement policy by role:
Headings (title / features): originally a heavy gothic (Bold 700-ish). Use Noto Sans Bold.
Subheadings: originally Medium 500-ish. Use Noto Sans Medium.
Body: Regular 400-ish. Use Noto Sans Regular.
Secondary body: Light 300-ish. Use Noto Sans Light.
Tagline (mincho / serif): use Noto Serif Medium.
Brand Latin (Beautiful 4K / Collection / Quality, etc.): leave as Latin, shared across all languages. For polish, a thin titling face like Cinzel.
Number badge (100K+, script style): leave as-is. If you change it, a script face like Allura.
Sans-serif to assign for body and headings, by language:
English, Spanish, French, German, Italian, Portuguese (BR), Catalan, Polish: Noto Sans (Latin, so the original gothic is fine too).
Russian (ru): Noto Sans (Cyrillic coverage; swap required).
Turkish (tr): Noto Sans (handles dotted/dotless İ / ı).
Korean (ko): Noto Sans KR (= Noto Sans CJK KR; swap required).
Simplified Chinese (zh-Hans): Noto Sans SC.
Traditional Chinese (zh-Hant): Noto Sans TC.
Thai (th): Noto Sans Thai (swap required; widen line spacing slightly).
Arabic (ar): Noto Sans Arabic or Noto Kufi Arabic (swap required; right-align).
Japanese (original): Kozuka Gothic / Noto Sans JP / IPAex Gothic.
The actual files worth keeping on disk, at minimum, are this lineup: NotoSans-Regular.ttf, NotoSansJP, NotoSansKR, NotoSansSC, NotoSansThai, NotoSansArabic, and NotoSerifJP for the serif tagline. I also keep ipaexg.ttf (IPAex Gothic) as an option when I want to add character to Japanese. Rather than carrying separate files per weight, keeping one variable-font (VariableFont) build made file management easier.
A few working tips: select all layers in a group and change the font in one go; when a translation is too long to fit, drop the size a touch or tighten the tracking. Right-align Arabic, and give Thai slightly more leading — those two alone tighten things up considerably.
Export sizes — Google Play and the App Store differ
After hand-polishing, you export to each store's spec. Get this wrong and review rejects you, so here are the numbers I actually used.
Google Play (phone):
Recommended 1080 × 1920px (9:16). Each side 320–3840px; aspect ratio within 16:9 to 9:16.
Format: JPEG or 24-bit PNG (no alpha), max 8MB each, 2–8 images per listing.
The original 1440 × 2560 is already 9:16 and within spec, but downscaling to 1080 × 1920 for export was lighter and more reliable.
App Store Connect (iPhone):
6.9-inch: 1320 × 2868px (1290×2796 / 1260×2736 also valid).
6.5-inch: 1242 × 2688px (1284×2778 also valid).
Format: PNG or JPEG, RGB, no alpha. Dimensions must match exactly — off by a single pixel and it's rejected.
Note: iPhone is 19.5:9 (≈0.4615), taller than 9:16 (0.5625). A plain resize won't match the ratio, so you must extend the canvas with top/bottom padding or re-layout on a separate iOS canvas. I keep dedicated iOS assets and re-lay-out there.
A premium: true aside: when I got stuck on Android density-split drawables and nodpi handling, I split that into Android density-split drawables and nodpi pitfalls. It's a separate layer from screenshots, but it's the same genre of being killed by fine print on the way to store submission.
What happens end to end (the pipeline)
Run the script once and the flow is:
Pull every text layer from the source PSD and bin them into artboards by top coordinate.
For each language, open a copy of the source and swap each text layer's string for the translation (no rasterizing).
Auto-shrink overflowing layers to a fitting ratio (floor of 50%).
Put the full translation in the layer name, and organize into language group → per-artboard subgroup.
Stack all languages into a blank document and save as an editable text PSD.
Reopen it and emit layer counts and per-language sample strings to a verification log.
Don't skip that final check. I once had the accident of "it saved, but opened in Photoshop still showing JP." The cause was just the text engine drawing a stale cache; reselecting the layer redraws it and fixes it. Adding the small step of reopening and asserting the strings after export helps you catch this "saved but looks old" class of trap early.
Only after all this did I escape the state of "retype 16 languages from scratch every time." When I next update the same app's design, I just fix the translation dictionary and font assignments and rebuild. When you want to reach users worldwide as an indie developer, the language barrier is something you thin out one sheet at a time, through exactly this kind of unglamorous plumbing. Maybe I'm tracing, through my own apps, the feeling I had in 1997 when the internet first connected me across borders.
I hope this helps anyone trying to mass-produce multilingual store assets the same way. Start by opening your own app's source PSD and checking whether the text layers can be pulled as EngineData.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.