File indexing completed on 2024-05-05 04:01:50

0001 #!/usr/bin/env python3
0002 # SPDX-FileCopyrightText: 2023 Jonathan Poelen <jonathan.poelen@gmail.com>
0003 # SPDX-License-Identifier: MIT
0004 
0005 import argparse
0006 import json
0007 import sys
0008 
0009 parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
0010                                  description='''Contrast checker for themes
0011 
0012 Allows you to view all the colors and backgrounds applied to a theme and rate the contrast based on the APCA (Accessible Perceptual Contrast Algorithm) used in WCAG 3. A very low score is a sign of poor contrast, and can make reading difficult or impossible.
0013 
0014 However, color perception depends on the individual, hardware or software configurations (night/blue light filter), lighting or simply surrounding colors. For example, low contrast may remain legible in the editor when not surrounded by bright color.
0015 
0016 There are 3 options for modifying the contract result:
0017 
0018   -a / --add-luminance and -p / --add-percent-luminance to directly modify the output value. For example, -p -14 -a 15 increases the constrast a little when it's low and very little when it's high.
0019 
0020   -C / --color-space Okl selects a color space with different properties, mainly on the color red.
0021 ''')
0022 
0023 parser.add_argument('-f', '--bg', metavar='BACKGROUND', action='append',
0024                     help='show only the specified background color styles')
0025 parser.add_argument('-l', '--language', metavar='LANGUAGE', action='append',
0026                     help='show only the specified language')
0027 
0028 parser.add_argument('-c', '--no-custom-styles', action='store_true',
0029                     help='do not display custom languages')
0030 parser.add_argument('-s', '--no-standard-styles', action='store_true',
0031                     help='do not display standard colors')
0032 parser.add_argument('-b', '--no-borders', action='store_true',
0033                     help='do not display border colors')
0034 parser.add_argument('-H', '--no-legend', action='store_true',
0035                     help='do not display legend')
0036 
0037 parser.add_argument('-M', '--min-luminance', metavar='LUMINANCE', type=float, default=0,
0038                     help='only displays colors with a lower luminance')
0039 parser.add_argument('-L', '--max-luminance', metavar='LUMINANCE', type=float, default=110.0,
0040                     help='only displays colors with a lower luminance')
0041 
0042 parser.add_argument('-a', '--add-luminance', metavar='LUMINANCE', type=float, default=0,
0043                     help='add fixed value for luminance')
0044 parser.add_argument('-p', '--add-percent-luminance', metavar='LUMINANCE', type=float, default=0,
0045                     help='add percent luminance. Apply before --add-luminance')
0046 
0047 # sRGB is W3 in APCA
0048 parser.add_argument('-C', '--color-space', default='sRGB',
0049                     choices=['sRGB', 'DisplayP3', 'AdobeRGB', 'Rec2020', 'Okl'],
0050                     help='select a color space ; Okl is a color space that increases the contrast of red with black or blue background and decreases it with white or green background')
0051 
0052 parser.add_argument('-d', '--compute-diff', action='store_true',
0053                     help='compute luminance between 2 colors or more ; the first color represents the background, the others the foreground')
0054 
0055 parser.add_argument('-F', '--output-format', default='ansi', choices=['ansi', 'html'])
0056 parser.add_argument('-T', '--html-title', help='title of html page when --output-format=html')
0057 
0058 parser.add_argument('themes_or_colors', metavar='THEME_OR_COLOR', nargs='+',
0059                     help='a .theme file or a color (#rgb, #rrggbb, #argb, #aarrggbb) when -d / --compute-diff is used')
0060 
0061 args = parser.parse_intermixed_args()
0062 
0063 
0064 RGBColor = tuple[int, int, int]
0065 
0066 def parse_rgb_color(color: str, bg: RGBColor) -> RGBColor:
0067     n = len(color)
0068 
0069     if not color.startswith('#') or n not in (7, 9, 4, 5):
0070         raise Exception(f'Invalid argb format: {color}')
0071 
0072     try:
0073         argb = int(color[1:], 16)
0074     except ValueError:
0075         raise Exception(f'Invalid argb format: {color}')
0076 
0077     # format: #rrggbb or #aarrggbb
0078     if n == 7 or n == 9:
0079         result = (
0080             (argb >> 16) & 0xff,
0081             (argb >>  8) & 0xff,
0082             (argb      ) & 0xff,
0083         )
0084         if n == 7:
0085             return result
0086         alpha = argb >> 24
0087 
0088     # format: #rgb or #argb
0089     else:
0090         (r, g, b) = (
0091             (argb >> 8) & 0xf,
0092             (argb >> 4) & 0xf,
0093             (argb     ) & 0xf,
0094         )
0095         result = ((r << 4 | r), (g << 4 | g), (b << 4 | b))
0096         if n == 4:
0097             return result
0098         alpha = argb >> 12
0099         alpha |= alpha << 4
0100 
0101     # alpha blend
0102     return (
0103         (alpha * result[0] + (255 - alpha) * bg[0]) // 255,
0104         (alpha * result[1] + (255 - alpha) * bg[1]) // 255,
0105         (alpha * result[2] + (255 - alpha) * bg[2]) // 255,
0106     )
0107 
0108 
0109 # based on https://drafts.csswg.org/css-color/#color-conversion-code (CSS 4)
0110 # 17. Sample code for Color Conversions
0111 
0112 def lin_sRGB(c: int) -> float:
0113     # [0, 255] to [0, 1]
0114     v = c / 255.0
0115     if v <= 0.04045:
0116         return v / 12.92
0117     return ((v + 0.055) / 1.055) ** 2.4
0118 
0119 sRGB_to_Y_mat = (
0120     0.21263900587151036,
0121     0.71516867876775592,
0122     0.072192315360733714,
0123 )
0124 
0125 DisplayP3_to_Y_mat = (
0126     0.22897456406974884,
0127     0.69173852183650619,
0128     0.079286914093744998,
0129 )
0130 
0131 # not in CSS
0132 Okl_to_Y_mat = (
0133     # These values are the formula which calculates `l` in the XYZ to Okalab transformation.
0134     # (https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab)
0135     # Specifically taking these values doesn't really make sense, but compared to sRGB
0136     # - the luminance between red and green will be very greatly decreased
0137     # - the luminance between red and white will be decreased
0138     # - the luminance between red and blue / black color will be greatly increased
0139     # - the luminance between blue and green will be decreased
0140     0.4122214708,
0141     0.5363325363,
0142     0.0514459929,
0143 )
0144 
0145 def lin_AdobeRGB(c: int) -> float:
0146     # [0, 255] to [0, 1]
0147     return (c / 255.0) ** 2.19921875
0148 
0149 AdobeRGB_to_Y_mat = (
0150     0.29734497525053616,
0151     0.62736356625546597,
0152     0.07529145849399789,
0153 )
0154 
0155 def lin_Rec2020(c: int) -> float:
0156     # [0, 255] to [0, 1]
0157     v = c / 255.0
0158 
0159     if v < 0.08124285829863151:
0160         return v / 4.5
0161 
0162     return ((v + 0.09929682680944) / 1.09929682680944) ** (1 / 0.45)
0163 
0164 Rec2020_to_Y_mat = (
0165     0.26270021201126703,
0166     0.67799807151887104,
0167     0.059301716469861945,
0168 )
0169 
0170 def make_to_Y(lin, mat):
0171     def to_Y(rgb: RGBColor) -> float:
0172         return (
0173             mat[0] * lin(rgb[0]) +
0174             mat[1] * lin(rgb[1]) +
0175             mat[2] * lin(rgb[2])
0176         )
0177     return to_Y
0178 
0179 
0180 if args.color_space == 'sRGB':
0181     rgb_to_Y = make_to_Y(lin_sRGB, sRGB_to_Y_mat)
0182 elif args.color_space == 'Okl':
0183     rgb_to_Y = make_to_Y(lin_sRGB, Okl_to_Y_mat)
0184 elif args.color_space == 'DisplayP3':
0185     rgb_to_Y = make_to_Y(lin_sRGB, DisplayP3_to_Y_mat)
0186 elif args.color_space == 'AdobeRGB':
0187     rgb_to_Y = make_to_Y(lin_AdobeRGB, AdobeRGB_to_Y_mat)
0188 else:  # Rec2020
0189     rgb_to_Y = make_to_Y(lin_Rec2020, Rec2020_to_Y_mat)
0190 
0191 
0192 # https://github.com/Myndex/apca-w3
0193 
0194 # G-4g constants for use with 2.4 exponent
0195 normBG = 0.56
0196 normTXT = 0.57
0197 revTXT = 0.62
0198 revBG = 0.65
0199 
0200 # G-4g Clamps and Scalers
0201 blkThrs = 0.022
0202 blkClmp = 1.414
0203 scaleBoW = 1.14
0204 scaleWoB = 1.14
0205 loBoWoffset = 0.027
0206 loWoBoffset = 0.027
0207 deltaYmin = 0.0005
0208 loClip = 0.0  # originally 0.1, but this limits the contrast to 7.5
0209 
0210 def APCA_contrast(txtY: float, bgY: float) -> float:
0211     ## BLACK SOFT CLAMP
0212 
0213     # Soft clamps Y for either color if it is near black.
0214 
0215     if txtY <= blkThrs:
0216         txtY += (blkThrs - txtY) ** blkClmp
0217 
0218     if bgY <= blkThrs:
0219         bgY += (blkThrs - bgY) ** blkClmp
0220 
0221     # Return 0 Early for extremely low ∆Y
0222     if abs(bgY - txtY) < deltaYmin:
0223         return 0.0
0224 
0225     ## APCA/SAPC CONTRAST - LOW CLIP (W3 LICENSE)
0226 
0227     # For normal polarity, black text on white (BoW)
0228     # Calculate the SAPC contrast value and scale
0229     if bgY > txtY:
0230         SAPC = (bgY ** normBG - txtY ** normTXT) * scaleBoW
0231 
0232         # Low Contrast smooth rollout to prevent polarity reversal
0233         # and also a low-clip for very low contrasts
0234         outputContrast = 0.0 if SAPC < loClip else SAPC - loBoWoffset
0235 
0236     # For reverse polarity, light text on dark (WoB)
0237     # WoB should always return negative value.
0238     else:
0239         SAPC = (bgY ** revBG - txtY ** revTXT) * scaleWoB
0240 
0241         outputContrast = 0.0 if SAPC > -loClip else SAPC + loWoBoffset
0242 
0243     return outputContrast * 100.0
0244 
0245 
0246 class ColorInfo:
0247     text: str = '#000'
0248     color: RGBColor = (0, 0, 0)
0249     Y: float = 0
0250 
0251     def __init__(self, rgb: str, bg: RGBColor):
0252         self.text = rgb
0253         self.color = parse_rgb_color(rgb, bg)
0254         self.Y = rgb_to_Y(self.color)
0255 
0256     def __str__(self) -> str:
0257         return self.text
0258 
0259 
0260 NORMAL_LUMINANCE     = (90, 75, 60, 45, 35)
0261 BOLD_LUMINANCE       = (80, 65, 50, 38, 30)
0262 SPELL_LUMINANCE      = (70, 55, 40, 30, 25)
0263 DECORATION_LUMINANCE = (60, 45, 30, 15, 10)
0264 
0265 BOLD_TEXT = (BOLD_LUMINANCE, False, 'Text. ▐')
0266 NORMAL_TEXT = (NORMAL_LUMINANCE, True, 'Text. ▐')
0267 DECORATION_TEXT = (DECORATION_LUMINANCE, False, 'Text. ▐')
0268 HEADER = (
0269     '\x1b[35m'
0270     ' Background                             |'
0271     ' Foreground                                  |'
0272     f' {(args.add_luminance or args.add_percent_luminance) and "   Luminance  " or " Lum "} |'
0273     ' Score\x1b[m'
0274 )
0275 
0276 RANK1 = '\x1b[32m'
0277 RANK2 = '\x1b[32m'
0278 RANK3 = '\x1b[32m'
0279 RANK4 = '\x1b[33m'
0280 RANK5 = '\x1b[31m'
0281 RANK6 = '\x1b[31;1m'
0282 
0283 def ffloat(x: float) -> str:
0284     s = f'{x:>5.1f}\x1b[m'
0285     return s.replace('.', '\x1b[37m.')
0286 
0287 def flum(luminance: float,
0288          add_luminance: float,
0289          add_percent_luminance: float,
0290          bg: ColorInfo,
0291          fg: ColorInfo,
0292          luminance_values: tuple[int, int, int, int, int],
0293          is_bold: bool,
0294          sample_text: str
0295          ) -> str:
0296     """
0297     Compute and formate a score
0298     """
0299     luminance = abs(luminance)
0300     adjusted_luminance = luminance
0301 
0302     adjusted_text = ''
0303     if add_luminance or add_percent_luminance:
0304         adjusted_luminance += adjusted_luminance * add_percent_luminance + add_luminance
0305         adjusted_luminance = max(0, min(108, adjusted_luminance))
0306         adjusted_text = f' \x1b[35m->\x1b[m {ffloat(adjusted_luminance)}'
0307 
0308     (AAAA, AAA, AA, BAD, VERY_BAD) = luminance_values
0309     if adjusted_luminance >= AAAA:
0310         score = f'{RANK1}AAAA'
0311     elif adjusted_luminance >= AAA:
0312         score = f'{RANK2}AAA '
0313     elif adjusted_luminance >= AA:
0314         score = f'{RANK3}AA  '
0315     elif adjusted_luminance >= BAD:
0316         score = f'{RANK4}A   '
0317     elif adjusted_luminance >= VERY_BAD:
0318         score = f'{RANK5}FAIL'
0319     else:
0320         score = f'{RANK6}FAIL'
0321 
0322     (r1, g1, b1) = fg.color
0323     (r2, g2, b2) = bg.color
0324     bold = ';1' if is_bold else ''
0325     return f'{ffloat(luminance)}{adjusted_text} | {score}\x1b[m  ' \
0326            f'\x1b[38;2;{r1};{g1};{b1};48;2;{r2};{g2};{b2}{bold}m {sample_text} \x1b[m'
0327 
0328 def color2ansi(rgb: RGBColor) -> str:
0329     return f'{rgb[0]};{rgb[1]};{rgb[2]}'
0330 
0331 spaces = '                                                                    '
0332 def fcol_impl(name: str, rgb: str, n: int) -> str:
0333     w = spaces[0: n - (len(name) + len(rgb) + 3)]
0334     return f'{name} \x1b[37m({rgb})\x1b[m{w}'
0335 
0336 def fcol1(name: str, rgb: str) -> str:
0337     return fcol_impl(name, rgb, 38)
0338 
0339 def fcol2(name: str, rgb: str) -> str:
0340     return fcol_impl(name, rgb, 43)
0341 
0342 def create_tab_from_text_styles(min_luminance: float,
0343                                 max_luminance: float,
0344                                 add_luminance: float,
0345                                 add_percent_luminance: float,
0346                                 col1: str,
0347                                 kstyle: str,
0348                                 bg: ColorInfo,
0349                                 text_styles: dict[str, dict[str, str | bool]]
0350                                 ) -> str:
0351     lines = []
0352     for name, defs in sorted(text_styles.items()):
0353         if style := defs.get(kstyle):
0354             fg = ColorInfo(style, bg.color)
0355             lum = APCA_contrast(fg.Y, bg.Y)
0356             if min_luminance <= abs(lum) <= max_luminance:
0357                 bold = defs.get('bold', False)
0358                 result = flum(lum, add_luminance, add_percent_luminance,
0359                               bg, fg, *(BOLD_TEXT if bold else NORMAL_TEXT))
0360                 lines.append(f' {col1} | {fcol2(name, fg.text)} | {result}')
0361     return '\n'.join(lines)
0362 
0363 
0364 output = []
0365 
0366 def run_borders(
0367     min_luminance: float,
0368     max_luminance: float,
0369     add_luminance: float,
0370     add_percent_luminance: float,
0371     editor_colors: dict[str, str],
0372     bg_editor: RGBColor
0373 ) -> None:
0374     output.append('\n\x1b[34mIcon Border\x1b[m:\n')
0375 
0376     bg_icon = ColorInfo(editor_colors['IconBorder'], (0, 0, 0))
0377     bg_current_line = ColorInfo(editor_colors['CurrentLine'], bg_editor.color)
0378     bg_current_line_border = ColorInfo(editor_colors['CurrentLine'], bg_icon.color)
0379 
0380     fg_line = ColorInfo(editor_colors['LineNumbers'], bg_icon.color)
0381     fg_current = ColorInfo(editor_colors['CurrentLineNumber'], bg_icon.color)
0382     fg_separator = ColorInfo(editor_colors['Separator'], bg_icon.color)
0383     fg_modified = ColorInfo(editor_colors['ModifiedLines'], bg_icon.color)
0384     fg_saved = ColorInfo(editor_colors['SavedLines'], bg_icon.color)
0385 
0386     xbg = color2ansi(bg_icon.color)
0387     xborder = f'\x1b[38;2;{color2ansi(fg_line.color)};48;2;{xbg}m'
0388     xsaved = f'\x1b[48;2;{color2ansi(fg_saved.color)};38;2;{xbg}m▋{xborder}'
0389     xmodified = f'\x1b[48;2;{color2ansi(fg_modified.color)};38;2;{xbg}m▋{xborder}'
0390     xeditor = f'\x1b[38;2;{color2ansi(fg_separator.color)}m▕\x1b[m'\
0391               f'\x1b[48;2;{color2ansi(bg_editor.color)}m        \x1b[m'
0392 
0393     cbg = color2ansi(bg_current_line_border.color)
0394     cborder = f'\x1b[38;2;{color2ansi(fg_current.color)};48;2;{cbg}m'
0395     csaved = f'\x1b[48;2;{color2ansi(fg_saved.color)};38;2;{cbg}m▋{cborder}'
0396     cmodified = f'\x1b[48;2;{color2ansi(fg_modified.color)};38;2;{cbg}m▋{cborder}'
0397     ceditor = f'\x1b[38;2;{color2ansi(fg_separator.color)}m▕\x1b[m'\
0398               f'\x1b[48;2;{color2ansi(bg_current_line.color)}m        \x1b[m'
0399 
0400     # imitates the border of Kate editor
0401     output.append(f'          LineNumbers: {xborder} 42 {xeditor} bg:  IconBorder | BackgroundColor\n'
0402           f'    CurrentLineNumber: {cborder} 43 {ceditor} bg: CurrentLine | CurrentLine\n'
0403           f'          LineNumbers: {xborder} 44{xmodified}{xeditor} (ModifiedLines)\n'
0404           f'    CurrentLineNumber: {cborder} 45{cmodified}{ceditor}\n'
0405           f'          LineNumbers: {xborder} 46{xsaved}{xeditor} (SavedLines)\n'
0406           f'    CurrentLineNumber: {cborder} 47{csaved}{ceditor}\n'
0407           f'                            ⧹ Separator\n\n{HEADER}\n')
0408 
0409     color_line_number = ('LineNumbers', fg_line, NORMAL_TEXT)
0410     colors = (
0411         ('CurrentLineNumber', fg_current, NORMAL_TEXT),
0412         ('ModifiedLines', fg_modified, DECORATION_TEXT),
0413         ('SavedLines', fg_saved, DECORATION_TEXT),
0414     )
0415 
0416     for name, bg, colors in (
0417         ('IconBorder', bg_icon, (color_line_number, *colors)),
0418         ('CurrentLine', bg_current_line_border, colors),
0419     ):
0420         col = fcol1(name, bg.text)
0421         lines = []
0422         for k, fg, text_data in colors:
0423             lum = APCA_contrast(fg.Y, bg.Y)
0424             if min_luminance <= abs(lum) <= max_luminance:
0425                 result = flum(lum, add_luminance, add_percent_luminance,
0426                               bg, fg, *text_data)
0427                 lines.append(f' {col} | {fcol2(k, fg.text)} | {result}')
0428 
0429         if lines:
0430             output.append('\n'.join(lines))
0431             output.append('\n\n')
0432 
0433     # table for Separator color
0434     for name, bg in (
0435         ('IconBorder', bg_icon),
0436         ('CurrentLine', bg_current_line_border),
0437         ('BackgroundColor', bg_editor),
0438     ):
0439         lum = APCA_contrast(fg_separator.Y, bg.Y)
0440         if min_luminance <= abs(lum) <= max_luminance:
0441             col = fcol1(name, bg.text)
0442             result = flum(lum, add_luminance, add_percent_luminance,
0443                           bg, fg_separator, DECORATION_LUMINANCE, False, NORMAL_TEXT[2])
0444             output.append(f' {col} | {fcol2("Separator", fg_separator.text)} | {result}\n')
0445 
0446 
0447 def run(d: dict[str, str | dict[str, bool | str | dict[str, bool | str]]],
0448         min_luminance: float,
0449         max_luminance: float,
0450         add_luminance: float,
0451         add_percent_luminance: float,
0452         show_borders: bool,
0453         show_custom_styles: bool,
0454         show_standard_styles: bool,
0455         accepted_backgrounds: set[str] | None,
0456         accepted_languages: set[str] | None
0457         ) -> None:
0458     editor_colors = d['editor-colors']
0459 
0460     bg_editor = ColorInfo(editor_colors['BackgroundColor'], (0, 0, 0))
0461 
0462     output.append(f'\x1b[34;1mTheme\x1b[m: {d["metadata"]["name"]}\n')
0463 
0464     if show_borders:
0465         run_borders(min_luminance, max_luminance,
0466                     add_luminance, add_percent_luminance,
0467                     editor_colors, bg_editor)
0468 
0469     #
0470     # Editor
0471     #
0472 
0473     output.append('\n\x1b[34mText Area\x1b[m:\n')
0474 
0475     editor_bg_colors = {
0476         k: (ColorInfo(editor_colors[k], bg_editor.color), 'text-color')
0477         for k in (
0478             'TemplateReadOnlyPlaceholder',
0479             'TemplatePlaceholder',
0480             'TemplateFocusedPlaceholder',
0481             'TemplateBackground',
0482             'MarkBookmark',
0483             'CodeFolding',
0484             'ReplaceHighlight',
0485             'SearchHighlight',
0486             'BracketMatching',
0487         )
0488         if not accepted_backgrounds or k in accepted_backgrounds
0489     }
0490     if not accepted_backgrounds or 'TextSelection' in accepted_backgrounds:
0491         editor_bg_colors['TextSelection'] = (
0492             ColorInfo(editor_colors['TextSelection'], bg_editor.color),
0493             'selected-text-color'
0494         )
0495     if not accepted_backgrounds or 'BackgroundColor' in accepted_backgrounds:
0496         editor_bg_colors['BackgroundColor'] = (bg_editor, 'text-color')
0497 
0498     text_styles = d['text-styles']
0499     custom_styles = d.get('custom-styles', {}) if show_custom_styles else {}
0500 
0501     for name, (bg, kstyle) in editor_bg_colors.items():
0502         col = fcol1(name, bg.text)
0503 
0504         if show_standard_styles:
0505             tab = create_tab_from_text_styles(
0506                 min_luminance, max_luminance,
0507                 add_luminance, add_percent_luminance,
0508                 col, kstyle, bg, text_styles
0509             )
0510 
0511             #
0512             # Spell decoration
0513             #
0514             name = 'SpellChecking'
0515             fg = ColorInfo(editor_colors[name], bg_editor.color)
0516             lum = APCA_contrast(fg.Y, bg.Y)
0517             if min_luminance <= abs(lum) <= max_luminance:
0518                 result = flum(lum, add_luminance, add_percent_luminance,
0519                               bg, fg, SPELL_LUMINANCE, False, '~~~~~~~')
0520                 spell_line = f' {col} | {fcol2(name, fg.text)} | {result}'
0521                 tab = f'{tab}\n{spell_line}' if tab else spell_line
0522 
0523             if tab:
0524                 output.append(f'\n{HEADER}\n{tab}\n')
0525 
0526         # table by language for custom styles
0527         for language, defs in sorted(custom_styles.items()):
0528             if accepted_languages and language not in accepted_languages:
0529                 continue
0530             if tab := create_tab_from_text_styles(
0531                 min_luminance, max_luminance,
0532                 add_luminance, add_percent_luminance,
0533                 col, kstyle, bg, defs
0534             ):
0535                 output.append(f'\n\x1b[36mLanguage: "{language}"\x1b[m:\n{tab}\n')
0536 
0537     # ignored:
0538     # - WordWrapMarker
0539     # - TabMarker
0540     # - IndentationLine
0541     # - MarkBreakpointActive
0542     # - MarkBreakpointReached
0543     # - MarkBreakpointDisabled
0544     # - MarkExecution
0545     # - MarkWarning
0546     # - MarkError
0547 
0548 def result_legend(AAAA: float, AAA: float, AA: float, BAD: float, VERY_BAD: float) -> str:
0549     return (
0550         f'{RANK1}AAAA\x1b[m (>={AAAA}) ; '
0551         f'{RANK2}AAA\x1b[m (>={AAA}) ; '
0552         f'{RANK3}AA\x1b[m (>={AA}) ; '
0553         f'{RANK4}A\x1b[m (>={BAD}) ; '
0554         f'{RANK5}FAIL\x1b[m (>={VERY_BAD}) ; '
0555         f'{RANK6}FAIL\x1b[m (<{VERY_BAD})'
0556     )
0557 
0558 
0559 if not args.no_legend:
0560     output.append(f'''Luminance legend:
0561 - Range for light theme: [0; 106]
0562 - Range for  dark theme: [0; 108]
0563 - Result for normal text:    {result_legend(*NORMAL_LUMINANCE)}
0564 - Result for bold text:      {result_legend(*BOLD_LUMINANCE)}
0565 - Result for spelling error: {result_legend(*SPELL_LUMINANCE)}
0566 - Result for decoration:     {result_legend(*DECORATION_LUMINANCE)}
0567 
0568 Luminance adjustement: {args.add_percent_luminance:+}% {args.add_luminance:+}  (see -p and -a)
0569 
0570 ''')
0571 
0572 add_luminance = args.add_luminance
0573 add_percent_luminance = args.add_percent_luminance / 100
0574 
0575 if args.compute_diff:
0576     bg = ColorInfo(args.themes_or_colors[0], (0,0,0))
0577     output.append('Background | Foreground\n')
0578 
0579     # compares the background with all foreground colors in normal and bold
0580     for color in args.themes_or_colors[1:]:
0581         fg = ColorInfo(color, bg.color)
0582         lum = APCA_contrast(fg.Y, bg.Y)
0583         col = f'{bg.text:^10} | {fg.text:^10} | '
0584         output.append(col)
0585         output.append(flum(lum, add_luminance, add_percent_luminance, bg, fg, *NORMAL_TEXT))
0586         output.append('\n')
0587         output.append(col)
0588         output.append(flum(lum, add_luminance, add_percent_luminance, bg, fg, *BOLD_TEXT))
0589         output.append('\n')
0590 else:
0591     add_new_line = False
0592     for theme in args.themes_or_colors:
0593         if add_new_line:
0594             output.append('\n\n')
0595         add_new_line = True
0596 
0597         # read json theme file
0598         try:
0599             if theme == '-':
0600                 data = json.load(sys.stdin)
0601             else:
0602                 with open(theme) as f:
0603                     data = json.load(f)
0604         except OSError as e:
0605             print(f'\x1b[31m{e}\x1b[m', file=sys.stderr)
0606             continue
0607 
0608         run(data,
0609             args.min_luminance,
0610             args.max_luminance,
0611             add_luminance,
0612             add_percent_luminance,
0613             not args.no_borders,
0614             not args.no_custom_styles,
0615             not args.no_standard_styles,
0616             args.bg and set(args.bg),
0617             args.language and set(args.language),
0618         )
0619 
0620 is_html = args.output_format == 'html' and not args.compute_diff
0621 
0622 output = ''.join(output)
0623 
0624 if is_html:
0625     import re
0626 
0627     ansi_to_html = {
0628         '1': 'bold',
0629         '31': 'red',
0630         '32': 'green',
0631         '33': 'orange',
0632         '34': 'blue',
0633         '35': 'purple',
0634         '36': 'cyan',
0635         '37': 'gray',
0636     }
0637 
0638     extract_color = re.compile(r'([34])8;2;(\d+);(\d+);(\d+)')
0639     extract_effect = re.compile(r'\d+')
0640 
0641     depth = 0
0642     def replace_styles(m) -> str:
0643         global depth
0644 
0645         effects = m[1]
0646         if not effects:
0647             ret = '</span>' * depth
0648             depth = 0
0649             return ret
0650 
0651         depth += 1
0652         colors = []
0653         def rgb(m) -> str:
0654            prop = 'color' if m[1] == '3' else 'background'
0655            colors.append(f'{prop}:rgb({m[2]},{m[3]},{m[4]})')
0656            return ''
0657         effects = extract_color.sub(rgb, effects)
0658         if colors:
0659             styles = ';'.join(colors)
0660             styles = f' style="{styles}"'
0661         else:
0662             styles = ''
0663 
0664         classes = ' '.join(map(lambda s: ansi_to_html[s], extract_effect.findall(effects)))
0665         if classes:
0666             classes = f' class="{classes}"'
0667 
0668         return f'<span{styles}{classes}>'
0669 
0670     output = re.sub(r'\x1b\[([^m]*)m', replace_styles, output)
0671 
0672 try:
0673     if is_html:
0674         bg = data['editor-colors']['BackgroundColor'];
0675         rgb = parse_rgb_color(bg, (0,0,0))
0676         if (rgb[0] < 127) + (rgb[0] < 127) + (rgb[0] < 127) >= 2:
0677             tmode1 = '#light:target'
0678             tmode2 = ''
0679             mode1 = 'dark'
0680             mode2 = 'light'
0681         else:
0682             tmode1 = ''
0683             tmode2 = '#dark:target'
0684             mode1 = 'light'
0685             mode2 = 'dark'
0686         title = args.html_title or data["metadata"]["name"]
0687         sys.stdout.write(f'''<!DOCTYPE html>
0688 <html><head><title>{title}</title><style>
0689 html, body, #mode {{
0690   padding: 0;
0691   margin: 0;
0692 }}
0693 
0694 body {{
0695   padding: .5em;
0696 }}
0697 
0698 pre {{
0699   font-family: "JetBrains Mono", "Liberation Mono", Firacode, "DejaVu Sans Mono", Inconsolata, monospace;
0700 }}
0701 
0702 .bold {{ font-weight: bold }}
0703 
0704 /* light theme */
0705 
0706 body{tmode1} {{
0707   background: #ddd;
0708   color: #000;
0709 }}
0710 
0711 {tmode1} .red {{ color: #A02222 }}
0712 {tmode1} .green {{ color: #229022 }}
0713 {tmode1} .orange {{ color: #909022 }}
0714 {tmode1} .blue {{ color: #2222A0 }}
0715 {tmode1} .purple {{ color: #A022A0 }}
0716 {tmode1} .cyan {{ color: #22A0A0 }}
0717 {tmode1} .gray {{ color: Gray }}
0718 
0719 /* dark theme */
0720 
0721 body{tmode2} {{
0722   background: #222;
0723   color: #eee;
0724 }}
0725 {tmode2} .red {{ color: #D95555 }}
0726 {tmode2} .green {{ color: #55D055 }}
0727 {tmode2} .orange {{ color: #D0D055 }}
0728 {tmode2} .blue {{ color: #68A0E8 }}
0729 {tmode2} .purple {{ color: #D077D0 }}
0730 {tmode2} .cyan {{ color: Turquoise }}
0731 {tmode2} .gray {{ color: Gray }}
0732 
0733 div {{
0734   position: absolute;
0735   top: -1px;
0736   left: 0;
0737 }}
0738 
0739 #mode a {{
0740   padding: .5rem 1rem;
0741 }}
0742 
0743 #light-mode {{
0744   color: #2222A0;
0745   background: #ddd;
0746 }}
0747 #light-mode:hover, #light-mode:focus {{ background: #ccc; }}
0748 
0749 #dark-mode {{
0750   color: #68a0E8;
0751   background: #222;
0752 }}
0753 #dark-mode:hover, #dark-mode:focus {{ background: #333; }}
0754 
0755 #{mode1}-mode {{ display: none }}
0756 #{mode2}-mode {{ display: inline-block }}
0757 #{mode2}:target #{mode2}-mode {{ display: none }}
0758 #{mode2}:target #{mode1}-mode {{ display: inline-block }}
0759 
0760 </style></head><body id="{mode2}">
0761 <p id="mode"><a id="{mode2}-mode" href="#{mode2}">Switch to {mode2} mode</a><a id="{mode1}-mode" href="#{mode1}">Switch to {mode1} mode</a></p>
0762 <pre>''')
0763         sys.stdout.write(output)
0764         sys.stdout.write('</pre></body></html>')
0765     else:
0766         sys.stdout.write(output)
0767 
0768     # flush output here to force SIGPIPE to be triggered
0769     sys.stdout.flush()
0770 # open in `less` then closing can cause this error
0771 except BrokenPipeError:
0772     # Python flushes standard streams on exit; redirect remaining output
0773     # to devnull to avoid another BrokenPipeError at shutdown
0774     import os
0775     devnull = os.open(os.devnull, os.O_WRONLY)
0776     os.dup2(devnull, sys.stdout.fileno())