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