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())