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