blob: 43db36617ef4e966e644319c0ff0512646a6b85d [file] [log] [blame]
Yu-Ping Wud71b4452020-06-16 11:00:26 +08001#!/usr/bin/env python
Hung-Te Lin707e2ef2013-08-06 10:20:04 +08002# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +08005"""Script to generate bitmaps for firmware screens."""
Hung-Te Lin707e2ef2013-08-06 10:20:04 +08006
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +08007import argparse
8from collections import defaultdict, namedtuple
9import copy
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080010import glob
Jes Klinke1687a992020-06-16 13:47:17 -070011import json
Hung-Te Lin04addcc2015-03-23 18:43:30 +080012import multiprocessing
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080013import os
14import re
Jes Klinke1687a992020-06-16 13:47:17 -070015import shutil
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080016import signal
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080017import subprocess
18import sys
Jes Klinke1687a992020-06-16 13:47:17 -070019import tempfile
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080020from xml.etree import ElementTree
Hung-Te Lin04addcc2015-03-23 18:43:30 +080021
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080022import yaml
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080023from PIL import Image
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080024
25SCRIPT_BASE = os.path.dirname(os.path.abspath(__file__))
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080026
27STRINGS_GRD_FILE = 'firmware_strings.grd'
28STRINGS_JSON_FILE_TMPL = '{}.json'
29FORMAT_FILE = 'format.yaml'
30BOARDS_CONFIG_FILE = 'boards.yaml'
31
32TXT_TO_PNG_SVG = os.path.join(SCRIPT_BASE, 'text_to_png_svg')
33STRINGS_DIR = os.path.join(SCRIPT_BASE, 'strings')
34LOCALE_DIR = os.path.join(STRINGS_DIR, 'locale')
35OUTPUT_DIR = os.getenv('OUTPUT', os.path.join(SCRIPT_BASE, 'build'))
36STAGE_DIR = os.path.join(OUTPUT_DIR, '.stage')
37STAGE_LOCALE_DIR = os.path.join(STAGE_DIR, 'locale')
38STAGE_FONT_DIR = os.path.join(STAGE_DIR, 'font')
39
40ONE_LINE_DIR = 'one_line'
41SVG_FILES = '*.svg'
42PNG_FILES = '*.png'
43
44# String format YAML key names.
Yu-Ping Wu10cf2892020-08-10 17:20:11 +080045DEFAULT_NAME = '_DEFAULT_'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080046KEY_LOCALES = 'locales'
Yu-Ping Wu338f0832020-10-23 16:14:40 +080047KEY_GENERIC_FILES = 'generic_files'
48KEY_LOCALIZED_FILES = 'localized_files'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080049KEY_FONTS = 'fonts'
50KEY_STYLES = 'styles'
Matt Delco4c5580d2019-03-07 14:00:28 -080051DIAGNOSTIC_FILES = 'diagnostic_files'
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080052
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080053# Board config YAML key names.
54SCREEN_KEY = 'screen'
55PANEL_KEY = 'panel'
56SDCARD_KEY = 'sdcard'
57BAD_USB3_KEY = 'bad_usb3'
58LOCALES_KEY = 'locales'
59RTL_KEY = 'rtl'
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080060TEXT_COLORS_KEY = 'text_colors'
61RW_OVERRIDE_KEY = 'rw_override'
62
63BMP_HEADER_OFFSET_NUM_LINES = 6
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080064
Jes Klinke1687a992020-06-16 13:47:17 -070065# Regular expressions used to eliminate spurious spaces and newlines in
66# translation strings.
67NEWLINE_PATTERN = re.compile(r'([^\n])\n([^\n])')
68NEWLINE_REPLACEMENT = r'\1 \2'
69CRLF_PATTERN = re.compile(r'\r\n')
70MULTIBLANK_PATTERN = re.compile(r' *')
71
Yu-Ping Wu11027f02020-10-14 17:35:42 +080072GLYPH_FONT = 'Noto Sans Mono'
73
Yu-Ping Wuabb9afb2020-10-27 17:15:22 +080074LocaleInfo = namedtuple('LocaleInfo', ['code', 'rtl'])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080075
Yu-Ping Wu6b282c52020-03-19 12:54:15 +080076
Hung-Te Lin707e2ef2013-08-06 10:20:04 +080077class DataError(Exception):
78 pass
79
80
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +080081class BuildImageError(Exception):
82 """The exception class for all errors generated during build image process."""
83
84
Hsuan Ting Chenf2917582020-10-21 13:50:08 +080085# Some of the numbers below are from depthcharge:
86# - 1000: UI_SCALE
87# - 50: UI_MARGIN_H
88# - 228: UI_REC_QR_SIZE
89# - 24: UI_REC_QR_MARGIN_H
90# - 24: UI_DESC_TEXT_HEIGHT
91# - 128: UI_FOOTER_HEIGHT
92# - 20: UI_FOOTER_COL1_MARGIN_RIGHT
93# - 282: width of rec_url.bmp
94# - 40: UI_FOOTER_COL2_MARGIN_RIGHT
95# - 40: UI_FOOTER_COL3_MARGIN_LEFT
96# - 20: UI_FOOTER_TEXT_HEIGHT
97
98DEFAULT_MAX_WIDTH = 1000 - 50 * 2
99
100MAX_WIDTH_LOOKUPS = [
101 (re.compile(r'rec_phone_step2_desc'), DEFAULT_MAX_WIDTH - 228 - 24 * 2, 24),
102 (re.compile(r'navigate\w*'), DEFAULT_MAX_WIDTH - 128 - 20 - 282 - 40 - 40,
103 20),
104 (re.compile(r'\w+_desc\w*'), DEFAULT_MAX_WIDTH, 24),
105]
106
107
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800108def convert_text_to_png(locale, input_file, font, margin, output_dir,
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800109 **options):
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800110 """Converts text files into PNG image files.
111
112 Args:
113 locale: Locale (language) to select implicit rendering options. None for
114 locale-independent strings.
115 input_file: Path of input text file.
116 font: Font spec.
117 margin: CSS-style margin.
118 output_dir: Directory to generate image files.
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800119 **options: Other options to be added.
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800120 """
121 name, _ = os.path.splitext(os.path.basename(input_file))
122 command = [TXT_TO_PNG_SVG, '--outdir=%s' % output_dir]
123 if locale:
124 command.append('--lan=%s' % locale)
125 if font:
126 command.append("--font='%s'" % font)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800127 font_size = os.getenv('FONT_SIZE')
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800128 if font_size:
129 command.append('--point=%r' % font_size)
130 if margin:
131 command.append('--margin="%s"' % margin)
132 # TODO(b/159399377): Set different widths for titles and descriptions.
Hsuan Ting Chenf2917582020-10-21 13:50:08 +0800133 # Currently only wrap lines for descriptions and navigation instructions in
134 # the footer.
135 # Without the --width option set, the minimum height of the output SVG
136 # image is roughly 22px (for locale 'en'). With --width=WIDTH passed to
137 # pango-view, the width of the output seems to always be (WIDTH * 4 / 3),
138 # regardless of the font being used. Therefore, set the max_width in
139 # points as follows to prevent drawing from exceeding canvas boundary in
140 # depthcharge runtime.
141
142 for pattern, max_width, text_height in MAX_WIDTH_LOOKUPS:
143 if pattern.fullmatch(name):
144 max_width_pt = int(22 * max_width / text_height / (4 / 3))
145 command.append('--width=%d' % max_width_pt)
146 break
147
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800148 for k, v in options.items():
149 command.append('--%s="%s"' % (k, v))
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800150 command.append(input_file)
151
152 return subprocess.call(' '.join(command), shell=True,
153 stdout=subprocess.PIPE) == 0
154
155
156def convert_glyphs():
157 """Converts glyphs of ascii characters."""
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800158 os.makedirs(STAGE_FONT_DIR, exist_ok=True)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800159 # Remove the extra whitespace at the top/bottom within the glyphs
160 margin = '-3 0 -1 0'
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800161 options = {
162 'bgcolor': '#000000',
163 'color': '#FFFFFF',
164 }
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800165 for c in range(ord(' '), ord('~') + 1):
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800166 txt_file = os.path.join(STAGE_FONT_DIR, f'idx{c:03d}_{c:02x}.txt')
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800167 with open(txt_file, 'w', encoding='ascii') as f:
168 f.write(chr(c))
169 f.write('\n')
170 # TODO(b/163109632): Parallelize the conversion of glyphs
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800171 convert_text_to_png(None, txt_file, GLYPH_FONT, margin, STAGE_FONT_DIR,
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800172 **options)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800173
174
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800175def _load_locale_json_file(locale, json_dir):
Jes Klinke1687a992020-06-16 13:47:17 -0700176 result = {}
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800177 filename = os.path.join(json_dir, STRINGS_JSON_FILE_TMPL.format(locale))
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800178 with open(filename, encoding='utf-8-sig') as input_file:
Jes Klinke1687a992020-06-16 13:47:17 -0700179 for tag, msgdict in json.load(input_file).items():
180 msgtext = msgdict['message']
181 msgtext = re.sub(CRLF_PATTERN, '\n', msgtext)
182 msgtext = re.sub(NEWLINE_PATTERN, NEWLINE_REPLACEMENT, msgtext)
183 msgtext = re.sub(MULTIBLANK_PATTERN, ' ', msgtext)
184 # Strip any trailing whitespace. A trailing newline appears to make
185 # Pango report a larger layout size than what's actually visible.
186 msgtext = msgtext.strip()
187 result[tag] = msgtext
188 return result
189
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800190
191def parse_locale_json_file(locale, json_dir):
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800192 """Parses given firmware string json file.
Mathew King89d48c62019-02-15 10:08:39 -0700193
194 Args:
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800195 locale: The name of the locale, e.g. "da" or "pt-BR".
Jes Klinke1687a992020-06-16 13:47:17 -0700196 json_dir: Directory containing json output from grit.
Mathew King89d48c62019-02-15 10:08:39 -0700197
198 Returns:
199 A dictionary for mapping of "name to content" for files to be generated.
200 """
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800201 result = _load_locale_json_file(locale, json_dir)
202 original = _load_locale_json_file('en', json_dir)
203 for tag in original:
204 if tag not in result:
205 # Use original English text, in case translation is not yet available
206 print('WARNING: locale "%s", missing entry %s' % (locale, tag))
207 result[tag] = original[tag]
Mathew King89d48c62019-02-15 10:08:39 -0700208
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800209 return result
Mathew King89d48c62019-02-15 10:08:39 -0700210
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800211
212def parse_locale_input_files(locale, json_dir):
213 """Parses all firmware string files for the given locale.
214
215 Args:
216 locale: The name of the locale, e.g. "da" or "pt-BR".
217 json_dir: Directory containing json output from grit.
218
219 Returns:
220 A dictionary for mapping of "name to content" for files to be generated.
221 """
222 result = parse_locale_json_file(locale, json_dir)
223
224 # Walk locale directory to add pre-generated texts such as language names.
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800225 for input_file in glob.glob(os.path.join(LOCALE_DIR, locale, "*.txt")):
Mathew King89d48c62019-02-15 10:08:39 -0700226 name, _ = os.path.splitext(os.path.basename(input_file))
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800227 with open(input_file, 'r', encoding='utf-8-sig') as f:
Mathew King89d48c62019-02-15 10:08:39 -0700228 result[name] = f.read().strip()
Shelley Chen2f616ac2017-05-22 13:19:40 -0700229
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800230 return result
231
232
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800233def build_text_files(inputs, files, output_dir):
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800234 """Builds text files from given input data.
235
236 Args:
237 inputs: Dictionary of contents for given file name.
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800238 files: List of files.
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800239 output_dir: Directory to generate text files.
240 """
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800241 for name in files:
242 file_name = os.path.join(output_dir, name + '.txt')
243 with open(file_name, 'w', encoding='utf-8-sig') as f:
244 f.write(inputs[name] + '\n')
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800245
246
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800247def convert_localized_strings(formats):
248 """Converts localized strings."""
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800249 # Make a copy of formats to avoid modifying it
250 formats = copy.deepcopy(formats)
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800251
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800252 env_locales = os.getenv('LOCALES')
253 if env_locales:
254 locales = env_locales.split()
255 else:
256 locales = formats[KEY_LOCALES]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800257
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800258 files = formats[KEY_LOCALIZED_FILES]
259 if os.getenv('DIAGNOSTIC_UI') == '1' and DIAGNOSTIC_FILES in formats:
260 files.update(formats[DIAGNOSTIC_FILES])
261
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800262 styles = formats[KEY_STYLES]
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800263 default_style = styles[DEFAULT_NAME]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800264 fonts = formats[KEY_FONTS]
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800265 default_font = fonts[DEFAULT_NAME]
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800266
Yu-Ping Wu51940352020-09-17 08:48:55 +0800267 # Sources are one .grd file with identifiers chosen by engineers and
268 # corresponding English texts, as well as a set of .xlt files (one for each
269 # language other than US english) with a mapping from hash to translation.
270 # Because the keys in the xlt files are a hash of the English source text,
271 # rather than our identifiers, such as "btn_cancel", we use the "grit"
272 # command line tool to process the .grd and .xlt files, producing a set of
273 # .json files mapping our identifier to the translated string, one for every
274 # language including US English.
Jes Klinke1687a992020-06-16 13:47:17 -0700275
Yu-Ping Wu51940352020-09-17 08:48:55 +0800276 # Create a temporary directory to place the translation output from grit in.
277 json_dir = tempfile.mkdtemp()
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800278
Yu-Ping Wu51940352020-09-17 08:48:55 +0800279 # This invokes the grit build command to generate JSON files from the XTB
280 # files containing translations. The results are placed in `json_dir` as
281 # specified in firmware_strings.grd, i.e. one JSON file per locale.
282 subprocess.check_call([
283 'grit',
Yu-Ping Wu8f633b82020-09-22 14:27:57 +0800284 '-i', os.path.join(LOCALE_DIR, STRINGS_GRD_FILE),
Yu-Ping Wu51940352020-09-17 08:48:55 +0800285 'build',
286 '-o', os.path.join(json_dir)
287 ])
Jes Klinke1687a992020-06-16 13:47:17 -0700288
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800289 # Ignore SIGINT in child processes
290 sigint_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800291 pool = multiprocessing.Pool(multiprocessing.cpu_count())
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800292 signal.signal(signal.SIGINT, sigint_handler)
293
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800294 results = []
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800295 for locale in locales:
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800296 print(locale, end=' ', flush=True)
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800297 inputs = parse_locale_input_files(locale, json_dir)
298 output_dir = os.path.normpath(os.path.join(STAGE_DIR, 'locale', locale))
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800299 if not os.path.exists(output_dir):
300 os.makedirs(output_dir)
Matt Delco4c5580d2019-03-07 14:00:28 -0800301
Yu-Ping Wuae79af62020-09-23 16:48:06 +0800302 build_text_files(inputs, files, output_dir)
Shelley Chen2f616ac2017-05-22 13:19:40 -0700303
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800304 for name, category in files.items():
305 style_options = styles.get(category, default_style)
306 res = pool.apply_async(convert_text_to_png,
307 (locale,
308 os.path.join(output_dir, '%s.txt' % name),
309 fonts.get(locale, default_font),
310 '0',
311 output_dir),
312 style_options)
313 results.append(res)
Hung-Te Lin04addcc2015-03-23 18:43:30 +0800314 pool.close()
Jes Klinke1687a992020-06-16 13:47:17 -0700315 if json_dir is not None:
316 shutil.rmtree(json_dir)
Yu-Ping Wud71b4452020-06-16 11:00:26 +0800317 print()
Yu-Ping Wuc90a22f2020-04-24 11:17:15 +0800318
319 try:
320 success = [r.get() for r in results]
321 except KeyboardInterrupt:
322 pool.terminate()
323 pool.join()
324 exit('Aborted by user')
325 else:
326 pool.join()
327 if not all(success):
328 exit('Failed to render some locales')
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800329
330
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800331def build_strings():
332 """Builds text strings."""
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800333 with open(FORMAT_FILE, encoding='utf-8') as f:
334 formats = yaml.load(f)
335
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800336 # Convert glyphs
337 print('Converting glyphs...')
338 convert_glyphs()
339
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800340 # Convert generic (locale-independent) strings
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800341 files = formats[KEY_GENERIC_FILES]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800342 styles = formats[KEY_STYLES]
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800343 default_style = styles[DEFAULT_NAME]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800344 fonts = formats[KEY_FONTS]
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800345 default_font = fonts[DEFAULT_NAME]
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800346
347 for input_file in glob.glob(os.path.join(STRINGS_DIR, '*.txt')):
348 name, _ = os.path.splitext(os.path.basename(input_file))
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800349 category = files[name]
350 style_options = styles.get(category, default_style)
Yu-Ping Wu11027f02020-10-14 17:35:42 +0800351 if not convert_text_to_png(None, input_file, default_font, '0',
Yu-Ping Wu338f0832020-10-23 16:14:40 +0800352 STAGE_DIR, **style_options):
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800353 exit('Failed to convert text %s' % input_file)
354
355 # Convert localized strings
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800356 convert_localized_strings(formats)
357
358
359def load_boards_config(filename):
360 """Loads the configuration of all boards from `filename`.
361
362 Args:
363 filename: File name of a YAML config file.
364
365 Returns:
366 A dictionary mapping each board name to its config.
367 """
368 with open(filename, 'rb') as file:
369 raw = yaml.load(file)
370
371 configs = {}
372 default = raw[DEFAULT_NAME]
373 if not default:
374 raise BuildImageError('Default configuration is not found')
375 for boards, params in raw.items():
376 if boards == DEFAULT_NAME:
377 continue
378 config = copy.deepcopy(default)
379 if params:
380 config.update(params)
381 for board in boards.replace(',', ' ').split():
382 configs[board] = config
383
384 return configs
385
386
387class Converter(object):
388 """Converter from assets, texts, URLs, and fonts to bitmap images.
389
390 Attributes:
391 ASSET_DIR (str): Directory of image assets.
392 DEFAULT_OUTPUT_EXT (str): Default output file extension.
393 DEFAULT_REPLACE_MAP (dict): Default mapping of file replacement. For
394 {'a': 'b'}, "a.*" will be converted to "b.*".
395 SCALE_BASE (int): See ASSET_SCALES below.
396 DEFAULT_FONT_SCALE (tuple): Scale (width and height) of the font images.
397 ASSET_SCALES (dict): Scale of each image asset. Key is the image name and
398 value is a tuple (w, h), which are the width and height relative to the
399 screen resolution. For example, if SCALE_BASE is 1000, (500, 100) means
400 the image will be scaled to 50% of the screen width and 10% of the screen
401 height.
402 TEXT_SCALES (dict): Scale of each localized text image. The meaning is
403 similar to ASSET_SCALES.
404 ASSET_MAX_COLORS (int): Maximum colors to use for converting image assets
405 to bitmaps.
406 DEFAULT_BACKGROUND (tuple): Default background color.
407 BACKGROUND_COLORS (dict): Background color of each image. Key is the image
408 name and value is a tuple of RGB values.
409 """
410
411 ASSET_DIR = 'assets'
412 DEFAULT_OUTPUT_EXT = '.bmp'
413
414 DEFAULT_REPLACE_MAP = {
415 'rec_sel_desc1_no_sd': '',
416 'rec_sel_desc1_no_phone_no_sd': '',
417 'rec_disk_step1_desc0_no_sd': '',
418 'rec_to_dev_desc1_phyrec': '',
419 'rec_to_dev_desc1_power': '',
420 'navigate0_tablet': '',
421 'navigate1_tablet': '',
422 'nav-button_power': '',
423 'nav-button_volume_up': '',
424 'nav-button_volume_down': '',
425 'broken_desc_phyrec': '',
426 'broken_desc_detach': '',
427 }
428
429 # scales
430 SCALE_BASE = 1000 # 100.0%
431
432 # These are supposed to be kept in sync with the numbers set in depthcharge
433 # to avoid runtime scaling, which makes images blurry.
434 DEFAULT_ASSET_SCALE = (0, 30)
435 DEFAULT_TEXT_SCALE = (0, 24)
436 DEFAULT_FONT_SCALE = (0, 20)
437 LANG_SCALE = (0, 26)
438 ICON_SCALE = (0, 45)
439 STEP_ICON_SCALE = (0, 28)
440 TITLE_SCALE = (0, 42)
441 BUTTON_SCALE = (0, 20)
442 BUTTON_ICON_SCALE = (0, 24)
443 BUTTON_ARROW_SCALE = (0, 20)
Yu-Ping Wufca6af92020-11-04 15:59:49 +0800444 QR_FOOTER_SCALE = (0, 128)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800445 QR_DESC_SCALE = (0, 228)
446 FOOTER_TEXT_SCALE = (0, 20)
447
448 ASSET_SCALES = {
449 'separator': None,
450 'ic_globe': (0, 20),
451 'ic_dropdown': (0, 24),
452 'ic_info': ICON_SCALE,
453 'ic_warning': ICON_SCALE,
454 'ic_error': ICON_SCALE,
455 'ic_dev_mode': ICON_SCALE,
456 'ic_restart': ICON_SCALE,
457 'ic_1': STEP_ICON_SCALE,
458 'ic_1-done': STEP_ICON_SCALE,
459 'ic_2': STEP_ICON_SCALE,
460 'ic_2-done': STEP_ICON_SCALE,
461 'ic_3': STEP_ICON_SCALE,
462 'ic_3-done': STEP_ICON_SCALE,
463 'ic_done': STEP_ICON_SCALE,
464 'ic_search': BUTTON_ICON_SCALE,
465 'ic_search_focus': BUTTON_ICON_SCALE,
466 'ic_settings': BUTTON_ICON_SCALE,
467 'ic_settings_focus': BUTTON_ICON_SCALE,
468 'ic_power': BUTTON_ICON_SCALE,
469 'ic_power_focus': BUTTON_ICON_SCALE,
470 'ic_dropleft': BUTTON_ARROW_SCALE,
471 'ic_dropleft_focus': BUTTON_ARROW_SCALE,
472 'ic_dropright': BUTTON_ARROW_SCALE,
473 'ic_dropright_focus': BUTTON_ARROW_SCALE,
474 'qr_rec': QR_FOOTER_SCALE,
475 'qr_rec_phone': QR_DESC_SCALE,
476 }
477
478 TEXT_SCALES = {
479 'language': LANG_SCALE,
480 'firmware_sync_title': TITLE_SCALE,
481 'broken_title': TITLE_SCALE,
482 'adv_options_title': TITLE_SCALE,
483 'debug_info_title': TITLE_SCALE,
484 'firmware_log_title': TITLE_SCALE,
485 'rec_sel_title': TITLE_SCALE,
486 'rec_step1_title': TITLE_SCALE,
487 'rec_phone_step2_title': TITLE_SCALE,
488 'rec_disk_step2_title': TITLE_SCALE,
489 'rec_disk_step3_title': TITLE_SCALE,
490 'rec_invalid_title': TITLE_SCALE,
491 'rec_to_dev_title': TITLE_SCALE,
492 'dev_title': TITLE_SCALE,
493 'dev_to_norm_title': TITLE_SCALE,
494 'dev_boot_ext_title': TITLE_SCALE,
495 'dev_invalid_disk_title': TITLE_SCALE,
496 'dev_select_bootloader_title': TITLE_SCALE,
497 'diag_menu_title': TITLE_SCALE,
498 'diag_storage_title': TITLE_SCALE,
499 'diag_memory_quick_title': TITLE_SCALE,
500 'diag_memory_full_title': TITLE_SCALE,
501 'btn_dev_mode': BUTTON_SCALE,
502 'btn_debug_info': BUTTON_SCALE,
503 'btn_firmware_log': BUTTON_SCALE,
504 'btn_page_up': BUTTON_SCALE,
505 'btn_page_down': BUTTON_SCALE,
506 'btn_rec_by_phone': BUTTON_SCALE,
507 'btn_rec_by_disk': BUTTON_SCALE,
508 'btn_adv_options': BUTTON_SCALE,
509 'btn_secure_mode': BUTTON_SCALE,
510 'btn_int_disk': BUTTON_SCALE,
511 'btn_ext_disk': BUTTON_SCALE,
512 'btn_alt_bootloader': BUTTON_SCALE,
513 'btn_launch_diag': BUTTON_SCALE,
514 'btn_diag_storage': BUTTON_SCALE,
515 'btn_diag_memory_quick': BUTTON_SCALE,
516 'btn_diag_memory_full': BUTTON_SCALE,
517 'btn_diag_cancel': BUTTON_SCALE,
518 'btn_next': BUTTON_SCALE,
519 'btn_back': BUTTON_SCALE,
520 'btn_confirm': BUTTON_SCALE,
521 'btn_cancel': BUTTON_SCALE,
522 'model': FOOTER_TEXT_SCALE,
523 'help_center': FOOTER_TEXT_SCALE,
524 'rec_url': FOOTER_TEXT_SCALE,
525 'navigate0': FOOTER_TEXT_SCALE,
526 'navigate1': FOOTER_TEXT_SCALE,
527 'navigate0_tablet': FOOTER_TEXT_SCALE,
528 'navigate1_tablet': FOOTER_TEXT_SCALE,
529 }
530
531 # background colors
532 DEFAULT_BACKGROUND = (0x20, 0x21, 0x24)
533 LANG_HEADER_BACKGROUND = (0x16, 0x17, 0x19)
534 LINK_SELECTED_BACKGROUND = (0x2a, 0x2f, 0x39)
535 ASSET_MAX_COLORS = 128
536
537 BACKGROUND_COLORS = {
538 'ic_dropdown': LANG_HEADER_BACKGROUND,
539 'ic_dropleft_focus': LINK_SELECTED_BACKGROUND,
540 'ic_dropright_focus': LINK_SELECTED_BACKGROUND,
541 'ic_globe': LANG_HEADER_BACKGROUND,
542 'ic_search_focus': LINK_SELECTED_BACKGROUND,
543 'ic_settings_focus': LINK_SELECTED_BACKGROUND,
544 'ic_power_focus': LINK_SELECTED_BACKGROUND,
545 }
546
547 def __init__(self, board, config, output):
548 """Inits converter.
549
550 Args:
551 board: Board name.
552 config: A dictionary of configuration parameters.
553 output: Output directory.
554 """
555 self.board = board
556 self.config = config
557 self.set_dirs(output)
558 self.set_screen()
559 self.set_replace_map()
560 self.set_locales()
561 self.text_max_colors = self.config[TEXT_COLORS_KEY]
562
563 def set_dirs(self, output):
564 """Sets board output directory and stage directory.
565
566 Args:
567 output: Output directory.
568 """
569 self.output_dir = os.path.join(output, self.board)
570 self.output_ro_dir = os.path.join(self.output_dir, 'locale', 'ro')
571 self.output_rw_dir = os.path.join(self.output_dir, 'locale', 'rw')
572 self.stage_dir = os.path.join(output, '.stage')
573 self.temp_dir = os.path.join(self.stage_dir, 'tmp')
574
575 def set_screen(self):
576 """Sets screen width and height."""
577 self.screen_width, self.screen_height = self.config[SCREEN_KEY]
578
579 self.stretch = (1, 1)
580 if self.config[PANEL_KEY]:
581 # Calculate 'stretch'. It's used to shrink images horizontally so that
582 # resulting images will look proportional to the original image on the
583 # stretched display. If the display is not stretched, meaning aspect
584 # ratio is same as the screen where images were rendered (1366x766),
585 # no shrinking is performed.
586 panel_width, panel_height = self.config[PANEL_KEY]
587 self.stretch = (self.screen_width * panel_height,
588 self.screen_height * panel_width)
589
590 if self.stretch[0] > self.stretch[1]:
591 raise BuildImageError('Panel aspect ratio (%f) is smaller than screen '
592 'aspect ratio (%f). It indicates screen will be '
593 'shrunk horizontally. It is currently unsupported.'
594 % (panel_width / panel_height,
595 self.screen_width / self.screen_height))
596
597 # Set up square drawing area
598 self.canvas_px = min(self.screen_width, self.screen_height)
599
600 def set_replace_map(self):
601 """Sets a map replacing images.
602
603 For each (key, value), image 'key' will be replaced by image 'value'.
604 """
605 replace_map = self.DEFAULT_REPLACE_MAP.copy()
606
607 if os.getenv('DETACHABLE') == '1':
608 replace_map.update({
609 'nav-key_enter': 'nav-button_power',
610 'nav-key_up': 'nav-button_volume_up',
611 'nav-key_down': 'nav-button_volume_down',
612 'navigate0': 'navigate0_tablet',
613 'navigate1': 'navigate1_tablet',
614 'broken_desc': 'broken_desc_detach',
615 })
616
617 physical_presence = os.getenv('PHYSICAL_PRESENCE')
618 if physical_presence == 'recovery':
619 replace_map['rec_to_dev_desc1'] = 'rec_to_dev_desc1_phyrec'
620 replace_map['broken_desc'] = 'broken_desc_phyrec'
621 elif physical_presence == 'power':
622 replace_map['rec_to_dev_desc1'] = 'rec_to_dev_desc1_power'
623 elif physical_presence != 'keyboard':
624 raise BuildImageError('Invalid physical presence setting %s for board %s'
625 % (physical_presence, self.board))
626
627 if not self.config[SDCARD_KEY]:
628 replace_map.update({
629 'rec_sel_desc1': 'rec_sel_desc1_no_sd',
630 'rec_sel_desc1_no_phone': 'rec_sel_desc1_no_phone_no_sd',
631 'rec_disk_step1_desc0': 'rec_disk_step1_desc0_no_sd',
632 })
633
634 self.replace_map = replace_map
635
636 def set_locales(self):
637 """Sets a list of locales for which localized images are converted."""
638 # LOCALES environment variable can overwrite boards.yaml
639 env_locales = os.getenv('LOCALES')
640 rtl_locales = set(self.config[RTL_KEY])
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800641 if env_locales:
642 locales = env_locales.split()
643 else:
644 locales = self.config[LOCALES_KEY]
645 # Check rtl_locales are contained in locales.
646 unknown_rtl_locales = rtl_locales - set(locales)
647 if unknown_rtl_locales:
648 raise BuildImageError('Unknown locales %s in %s' %
649 (list(unknown_rtl_locales), RTL_KEY))
Yu-Ping Wuabb9afb2020-10-27 17:15:22 +0800650 self.locales = [LocaleInfo(code, code in rtl_locales)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800651 for code in locales]
652
653 def calculate_dimension(self, original, scale, num_lines):
654 """Calculates scaled width and height.
655
656 This imitates the function of Depthcharge with the same name.
657
658 Args:
659 original: (width, height) of the original image.
660 scale: (x, y) scale parameter relative to the canvas size using
661 SCALE_BASE as a base.
662 num_lines: multiplication factor for the y-dimension.
663
664 Returns:
665 (width, height) of the scaled image.
666 """
667 dim_width, dim_height = (0, 0)
668 scale_x, scale_y = scale
669 org_width, org_height = original
670
671 if scale_x == 0 and scale_y == 0:
672 raise BuildImageError('Invalid scale parameter: %s' % (scale))
673 if scale_x > 0:
674 dim_width = int(self.canvas_px * scale_x / self.SCALE_BASE)
675 if scale_y > 0:
676 dim_height = int(self.canvas_px * scale_y / self.SCALE_BASE) * num_lines
677 if scale_x == 0:
678 dim_width = org_width * dim_height // org_height
679 if scale_y == 0:
680 dim_height = org_height * dim_width // org_width
681
682 dim_width = int(dim_width * self.stretch[0] / self.stretch[1])
683
684 return dim_width, dim_height
685
686 def _get_svg_height(self, svg_file):
687 tree = ElementTree.parse(svg_file)
688 height = tree.getroot().attrib['height']
689 m = re.match('([0-9]+)pt', height)
690 if not m:
691 raise BuildImageError('Cannot get height from %s' % svg_file)
692 return int(m.group(1))
693
694 def get_num_lines(self, file, one_line_dir):
695 """Gets the number of lines of text in `file`."""
696 name, _ = os.path.splitext(os.path.basename(file))
697 svg_name = name + '.svg'
698 multi_line_file = os.path.join(os.path.dirname(file), svg_name)
699 one_line_file = os.path.join(one_line_dir, svg_name)
700 # The number of lines id determined by comparing the height of
701 # `multi_line_file` with `one_line_file`, where the latter is generated
702 # without the '--width' option passed to pango-view.
703 height = self._get_svg_height(multi_line_file)
704 line_height = self._get_svg_height(one_line_file)
705 return int(round(height / line_height))
706
707 def convert_svg_to_png(self, svg_file, png_file, scale, num_lines,
708 background):
709 """Converts .svg file to .png file."""
710 background_hex = ''.join(format(x, '02x') for x in background)
711 # If the width/height of the SVG file is specified in points, the
712 # rsvg-convert command with default 90DPI will potentially cause the pixels
713 # at the right/bottom border of the output image to be transparent (or
714 # filled with the specified background color). This seems like an
715 # rsvg-convert issue regarding image scaling. Therefore, use 72DPI here
716 # to avoid the scaling.
717 command = ['rsvg-convert',
718 '--background-color', "'#%s'" % background_hex,
719 '--dpi-x', '72',
720 '--dpi-y', '72',
721 '-o', png_file]
722 if scale:
723 width = int(self.canvas_px * scale[0] / self.SCALE_BASE)
724 height = int(self.canvas_px * scale[1] / self.SCALE_BASE) * num_lines
725 if width:
726 command.extend(['--width', '%d' % width])
727 if height:
728 command.extend(['--height', '%d' % height])
729 command.append(svg_file)
730 subprocess.check_call(' '.join(command), shell=True)
731
732 def convert_to_bitmap(self, input_file, scale, num_lines, background, output,
733 max_colors):
734 """Converts an image file `input_file` to a BMP file `output`."""
735 image = Image.open(input_file)
736
737 # Process alpha channel and transparency.
738 if image.mode == 'RGBA':
739 target = Image.new('RGB', image.size, background)
740 image.load() # required for image.split()
741 mask = image.split()[-1]
742 target.paste(image, mask=mask)
743 elif (image.mode == 'P') and ('transparency' in image.info):
744 exit('Sorry, PNG with RGBA palette is not supported.')
745 elif image.mode != 'RGB':
746 target = image.convert('RGB')
747 else:
748 target = image
749
750 # Process scaling
751 if scale:
752 new_size = self.calculate_dimension(image.size, scale, num_lines)
753 if new_size[0] == 0 or new_size[1] == 0:
754 print('Scaling', input_file)
755 print('Warning: width or height is 0 after resizing: '
756 'scale=%s size=%s stretch=%s new_size=%s' %
757 (scale, image.size, self.stretch, new_size))
758 return
759 target = target.resize(new_size, Image.BICUBIC)
760
761 # Export and downsample color space.
762 target.convert('P', dither=None, colors=max_colors, palette=Image.ADAPTIVE
763 ).save(output)
764
765 with open(output, 'rb+') as f:
766 f.seek(BMP_HEADER_OFFSET_NUM_LINES)
767 f.write(bytearray([num_lines]))
768
769 def convert(self, files, output_dir, scales, max_colors, one_line_dir=None):
770 """Converts file(s) to bitmap format."""
771 if not files:
772 raise BuildImageError('Unable to find file(s) to convert')
773
774 for file in files:
775 name, ext = os.path.splitext(os.path.basename(file))
776 output = os.path.join(output_dir, name + self.DEFAULT_OUTPUT_EXT)
777
778 background = self.BACKGROUND_COLORS.get(name, self.DEFAULT_BACKGROUND)
779 scale = scales[name]
780
781 if name in self.replace_map:
782 name = self.replace_map[name]
783 if not name:
784 continue
785 print('Replace: %s => %s' % (file, name))
786 file = os.path.join(os.path.dirname(file), name + ext)
787
788 # Determine num_lines in order to scale the image
789 # TODO(b/159399377): Wrap lines for texts other than descriptions.
Hsuan Ting Chenf2917582020-10-21 13:50:08 +0800790 if one_line_dir and ('_desc' in name or name.startswith('navigate')):
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800791 num_lines = self.get_num_lines(file, one_line_dir)
792 else:
793 num_lines = 1
794
795 if ext == '.svg':
796 png_file = os.path.join(self.temp_dir, name + '.png')
797 self.convert_svg_to_png(file, png_file, scale, num_lines, background)
798 file = png_file
799
800 self.convert_to_bitmap(
801 file, scale, num_lines, background, output, max_colors)
802
803 def convert_assets(self):
804 """Converts images in assets folder."""
805 files = []
806 files.extend(glob.glob(os.path.join(self.ASSET_DIR, SVG_FILES)))
807 files.extend(glob.glob(os.path.join(self.ASSET_DIR, PNG_FILES)))
808 scales = defaultdict(lambda: self.DEFAULT_ASSET_SCALE)
809 scales.update(self.ASSET_SCALES)
810 self.convert(files, self.output_dir, scales, self.ASSET_MAX_COLORS)
811
812 def convert_generic_strings(self):
813 """Converts generic (locale-independent) strings."""
814 scales = self.TEXT_SCALES.copy()
815 files = glob.glob(os.path.join(self.stage_dir, SVG_FILES))
816 self.convert(files, self.output_dir, scales, self.text_max_colors)
817
818 def convert_localized_strings(self):
819 """Converts localized strings."""
820 # Using stderr to report progress synchronously
821 print(' processing:', end='', file=sys.stderr, flush=True)
822 for locale_info in self.locales:
823 locale = locale_info.code
824 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
825 stage_locale_dir = os.path.join(STAGE_LOCALE_DIR, locale)
Yu-Ping Wuabb9afb2020-10-27 17:15:22 +0800826 scales = defaultdict(lambda: self.DEFAULT_TEXT_SCALE)
827 scales.update(self.TEXT_SCALES)
828 print(' ' + locale, end='', file=sys.stderr, flush=True)
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800829 os.makedirs(ro_locale_dir)
830 self.convert(
831 glob.glob(os.path.join(stage_locale_dir, SVG_FILES)),
832 ro_locale_dir, scales, self.text_max_colors,
833 one_line_dir=os.path.join(stage_locale_dir, ONE_LINE_DIR))
834 print(file=sys.stderr)
835
836 def move_language_images(self):
837 """Renames language bitmaps and move to self.output_dir.
838
839 The directory self.output_dir contains locale-independent images, and is
840 used for creating vbgfx.bin by archive_images.py.
841 """
842 for locale_info in self.locales:
843 locale = locale_info.code
844 ro_locale_dir = os.path.join(self.output_ro_dir, locale)
845 old_file = os.path.join(ro_locale_dir, 'language.bmp')
846 new_file = os.path.join(self.output_dir, 'language_%s.bmp' % locale)
847 if os.path.exists(new_file):
848 raise BuildImageError('File already exists: %s' % new_file)
849 shutil.move(old_file, new_file)
850
851 def convert_fonts(self):
852 """Converts font images"""
853 scales = defaultdict(lambda: self.DEFAULT_FONT_SCALE)
854 files = glob.glob(os.path.join(STAGE_FONT_DIR, SVG_FILES))
855 font_output_dir = os.path.join(self.output_dir, 'font')
856 os.makedirs(font_output_dir)
857 self.convert(files, font_output_dir, scales, self.text_max_colors)
858
859 def copy_images_to_rw(self):
860 """Copies localized images specified in boards.yaml for RW override."""
861 if not self.config[RW_OVERRIDE_KEY]:
862 print(' No localized images are specified for RW, skipping')
863 return
864
865 for locale_info in self.locales:
866 locale = locale_info.code
867 rw_locale_dir = os.path.join(self.output_ro_dir, locale)
868 ro_locale_dir = os.path.join(self.output_rw_dir, locale)
869 os.makedirs(rw_locale_dir)
870
871 for name in self.config[RW_OVERRIDE_KEY]:
872 ro_src = os.path.join(ro_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
873 rw_dst = os.path.join(rw_locale_dir, name + self.DEFAULT_OUTPUT_EXT)
874 shutil.copyfile(ro_src, rw_dst)
875
876 def create_locale_list(self):
877 """Creates locale list as a CSV file.
878
879 Each line in the file is of format "code,rtl", where
880 - "code": language code of the locale
881 - "rtl": "1" for right-to-left language, "0" otherwise
882 """
883 with open(os.path.join(self.output_dir, 'locales'), 'w') as f:
884 for locale_info in self.locales:
885 f.write('{},{}\n'.format(locale_info.code,
886 int(locale_info.rtl)))
887
888 def build(self):
889 """Builds all images required by a board."""
890 # Clean up output directory
891 if os.path.exists(self.output_dir):
892 shutil.rmtree(self.output_dir)
893 os.makedirs(self.output_dir)
894
895 if not os.path.exists(self.stage_dir):
896 raise BuildImageError('Missing stage folder. Run make in strings dir.')
897
898 # Clean up temp directory
899 if os.path.exists(self.temp_dir):
900 shutil.rmtree(self.temp_dir)
901 os.makedirs(self.temp_dir)
902
903 print('Converting asset images...')
904 self.convert_assets()
905
906 print('Converting generic strings...')
907 self.convert_generic_strings()
908
909 print('Converting localized strings...')
910 self.convert_localized_strings()
911
912 print('Moving language images to locale-independent directory...')
913 self.move_language_images()
914
915 print('Creating locale list file...')
916 self.create_locale_list()
917
918 print('Converting fonts...')
919 self.convert_fonts()
920
921 print('Copying specified images to RW packing directory...')
922 self.copy_images_to_rw()
923
924
925def build_images(board):
926 """Builds images for `board`."""
927 configs = load_boards_config(BOARDS_CONFIG_FILE)
928 print('Building for ' + board)
929 converter = Converter(board, configs[board], OUTPUT_DIR)
930 converter.build()
931
932
933def main():
934 """Builds bitmaps for firmware screens."""
935 parser = argparse.ArgumentParser()
936 parser.add_argument('board', help='Target board')
937 args = parser.parse_args()
938 build_strings()
939 build_images(args.board)
Yu-Ping Wu7f6639a2020-09-28 15:31:35 +0800940
941
Hung-Te Lin707e2ef2013-08-06 10:20:04 +0800942if __name__ == '__main__':
Yu-Ping Wu6e4d3892020-10-19 14:09:37 +0800943 main()