├── LICENSE ├── README.md ├── gplv3-with-text-136x68.png ├── pyproject.toml └── src └── ssfconv ├── __init__.py ├── cli.py ├── convert ├── CaseSensitiveConfigParser.py ├── __init__.py ├── image_operation.py ├── ini_ssf_operation.py └── out │ ├── Fcitx.py │ ├── Fcitx5.py │ └── __init__.py ├── extract ├── __init__.py └── ssf.py └── translation.py /LICENSE: -------------------------------------------------------------------------------- 1 | This package is free software; you can redistribute it and/or modify 2 | it under the terms of the GNU General Public License as published by 3 | the Free Software Foundation; either version 3 of the License, or 4 | (at your option) any later version. 5 | . 6 | This package is distributed in the hope that it will be useful, 7 | but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | GNU General Public License for more details. 10 | . 11 | You should have received a copy of the GNU General Public License 12 | along with this program. If not, see 13 | . 14 | On Debian systems, the complete text of the GNU General 15 | Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 什么?巨硬不给用github了?那我只好基于原则性的考虑,换个地方接着干 https://codeberg.org/RadND/ssfconv 2 | 3 | # 简介 4 | 5 | ![gplv3](./gplv3-with-text-136x68.png) 6 | 7 | 一个将皮肤从搜狗格式转换为 fcitx/fcitx5 格式的脚本,如果您在寻找“原生”的fcitx5皮肤,可以看[这里](https://github.com/topics/fcitx5-theme) 8 | 9 | 新开了仓库,因为作者的账号很久没有活动过,我所做的改动也很大: 10 | 11 | 1. 制成 python 包,用户不需要自己装依赖了 12 | 2. 重构,减少重复,将原先的脚本拆分为解包和转换两部分 13 | 3. 注册命令行程序、调整命令行参数、中文本地化 14 | 4. 由于使用了match case语法,python版本要求上升到3.10,如果想用旧版本python运行,请自行把相关代码改回长串 if elif 15 | 16 | 程序的目的没有变,所以原作者的[参考图像](https://www.fkxxyz.com/d/ssfconv)依然适用 17 | 18 | # 安装 19 | ## 手动 20 | 21 | clone或下载release 22 | 23 | ```shell 24 | python -m venv venv_name 25 | source venv_name/bin/activate 26 | # source venv_name/bin/activate.fish 27 | pip install . 28 | ``` 29 | 30 | ## pip 31 | 32 | [ ] 下次一定上传到 pypi 33 | 34 | ```shell 35 | pip install ssfconv 36 | ``` 37 | 38 | # 使用 39 | 40 | 本包提供的命令行程序加 `-h` 参数就可以查看帮助 41 | 42 | ## 获取皮肤 43 | 44 | 可以从[搜狗的皮肤站](https://pinyin.sogou.com/skins/)或bilibili搜索输入法皮肤获取自己喜欢的皮肤,得到ssf格式的文件,例如 【雨欣】蒲公英的思念.ssf 45 | 46 | ## 解包 ssf 皮肤 47 | 48 | ```shell 49 | ssfconv unpack 【雨欣】蒲公英的思念.ssf 50 | ``` 51 | 52 | 得到的文件夹可供 `ssfconv` 使用 53 | 54 | ## 转换为 fcitx4 皮肤 55 | 56 | ```shell 57 | ssfconv convert -t fcitx 【雨欣】蒲公英的思念 58 | ``` 59 | 60 | 复制到用户皮肤目录 61 | 62 | ```shell 63 | mkdir -p ~/.config/fcitx/skin/ 64 | cp -r 【雨欣】蒲公英的思念 ~/.config/fcitx/skin/ 65 | ``` 66 | 67 | 右键输入法托盘图表,选中皮肤,这款皮肤是不是出现在列表里了呢,尽情享用吧。 68 | 69 | ## 转换为 fcitx5 主题 70 | 71 | ```shell 72 | ssfconv convert 【雨欣】蒲公英的思念 73 | ``` 74 | 75 | 复制到用户主题目录 76 | 77 | ```shell 78 | mkdir -p ~/.local/share/fcitx5/themes/ 79 | cp -r 【雨欣】蒲公英的思念 ~/.local/share/fcitx5/themes/ 80 | ``` 81 | 82 | 打开 fcitx5 的配置,附加组件标签,经典用户界面,点配置,在主题的下拉列表里,选择这款皮肤。 83 | 84 | 或者你也可以直接修改配置文件 ~/.config/fcitx5/conf/classicui.conf,将 Theme 的值改成这个皮肤的名称即可。 85 | 86 | 查看该皮肤在配置文件中的名称: 87 | 88 | ```shell 89 | grep Name ~/.local/share/fcitx5/themes/【雨欣】蒲公英的思念/theme.conf 90 | ``` 91 | 92 | # 微调 93 | 94 | 转换得到的皮肤配置或多或少会有点瑕疵,其实调整它们并不困难,快去试试吧 95 | 96 | # 致谢 97 | 98 | 前两位作者的仓库分别在 99 | - https://github.com/fkxxyz/ssfconv 100 | - https://github.com/VOID001/ssf2fcitx 101 | 102 | 前两位作者的账号都不活动,也许已经对系统美化不感兴趣了。不管怎样,我把他们写在了 `pyproject.toml` 中,在此对他们表示感谢。( •ᴗ• ) 103 | 104 | 还要感谢 [`那些时光.ssf`](https://pinyin.sogou.com/d/skins/download.php?skin_id=344544) 的作者 szwjerry ,为了用上这个皮肤,我才接触到这个工具。 105 | 106 | # 贡献征集 107 | ## 方格示意 108 | 109 | 需要一个调试工具,能依据转换后的皮肤配置文件在图片上划线,用以表明这个皮肤是怎样被拉伸的 110 | 111 | 考虑到这和转换的过程无关,更像是一个独立的程序 112 | 113 | ## 更多整理 114 | 115 | 转换部分的主函数仍然非常长,需要拆分,有部分固定的配置可以抽离出来以模板文件的形式存在 116 | 117 | ## IBus 118 | 119 | ``` 120 | GNOME桌面和非GNOME桌面的IBus采用了两个不同的前端。非GNOME桌面的IBus是项目自己本身基于GTK写的一个很简陋的前端,其使用GTK主题,根据我之前的研究,要实现搜狗那样的皮肤效果应该不太可能。GNOME在他们的GJS代码库里给IBus重写了一个前端,他们的前端可定制性更强,主要是使用CSS文件指定样式 121 | @HollowMan6 122 | ``` 123 | 124 | 仅靠转换程序做不到在 IBus 上实现搜狗这种美观的皮肤效果,看样子最有希望的办法是通过 GNOME Shell 扩展修改输入法前端,先行提供背景图片等素材的显示和拉伸/压缩的能力 125 | 126 | readme有点长,其他不重要的内容看 [Wiki 页面](https://github.com/RadND/ssfconv/wiki) 127 | -------------------------------------------------------------------------------- /gplv3-with-text-136x68.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RadND/ssfconv/9fdbed9cb80907044917e53383216952675f1037/gplv3-with-text-136x68.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ssfconv" 7 | version = "1.2.1" 8 | dependencies = ["pycryptodomex", "pillow", "numpy"] 9 | requires-python = ">=3.10" 10 | authors = [ 11 | { name = "nihui" }, 12 | { name = "VOID001" }, 13 | { name = "fkxxyz" }, 14 | { name = "RadND" }, 15 | ] 16 | description = "Sogou input method skin file (.ssf file) converter" 17 | readme = "README.md" 18 | keywords = ["ssf", "fcitx", "fcitx5", "Fcitx5 Classic User interface"] 19 | classifiers = [ 20 | "Programming Language :: Python :: 3", 21 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 22 | "Operating System :: OS Independent", 23 | "Environment :: Console", 24 | "Intended Audience :: End Users/Desktop", 25 | "Natural Language :: Chinese (Simplified)", 26 | "Topic :: Adaptive Technologies", 27 | ] 28 | 29 | [project.urls] 30 | Repository = "https://github.com/radnd/ssfconv.git" 31 | Issues = "https://github.com/radnd/ssfconv/issues" 32 | 33 | [project.scripts] 34 | ssfconv = "ssfconv.cli:main" 35 | -------------------------------------------------------------------------------- /src/ssfconv/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ssfconv/cli.py: -------------------------------------------------------------------------------- 1 | import sys, shutil 2 | from pathlib import Path 3 | from .translation import _ 4 | import argparse 5 | from .extract.ssf import extract_ssf 6 | from .convert import convert 7 | 8 | 9 | def add_parser_unpack(subparsers: argparse._SubParsersAction): 10 | parser = subparsers.add_parser("unpack", help=_("Unpack skin")) 11 | parser.add_argument("src", type=Path, help=_("Skin file")) 12 | parser.add_argument( 13 | "dest", 14 | type=Path, 15 | help=_("Output folder name, default is same as Skin file name"), 16 | nargs="?", 17 | default=None, 18 | ) 19 | parser.add_argument( 20 | "-t", 21 | "--type", 22 | help=_( 23 | "Format of skin to be unpack", 24 | ), 25 | default="ssf", 26 | choices=["ssf"], 27 | ) 28 | parser.add_argument( 29 | "-f", 30 | "--force", 31 | help=_("Delete if output path already exist"), 32 | action="store_true", 33 | ) 34 | parser.set_defaults(func=unpack) 35 | 36 | 37 | def unpack(args): 38 | if args.dest == None: 39 | args.dest = args.src.with_suffix("") 40 | 41 | if not args.src.exists(): 42 | sys.stderr.write(_("Input path %s not exist\n") % args.src) 43 | return 1 44 | if not args.src.is_file(): 45 | sys.stderr.write(_("Input path %s is not file\n") % args.src) 46 | return 1 47 | if args.dest.exists(): 48 | if args.force: 49 | if args.dest.is_dir(): 50 | shutil.rmtree(args.dest) 51 | else: 52 | args.dest.unlink() 53 | else: 54 | sys.stderr.write(_("Output path %s already exist\n") % args.dest) 55 | return 1 56 | else: 57 | args.dest.mkdir() 58 | 59 | return extract_ssf(args.src, args.dest) 60 | 61 | 62 | def add_parser_convert(subparsers: argparse._SubParsersAction): 63 | parser = subparsers.add_parser("convert", help=_("Convert skin file")) 64 | parser.add_argument("src", type=Path, help=_("Input folder")) 65 | # parser.add_argument( 66 | # "dest", help="输出文件夹,默认与输入文件夹相同", nargs="?", default=None 67 | # ) 68 | parser.add_argument( 69 | "-t", 70 | "--type", 71 | help=_("Output skin type"), 72 | default="fcitx5", 73 | choices=["fcitx", "fcitx5"], 74 | ) 75 | # parser.add_argument( 76 | # "-f", "--force", help="强制覆盖输出文件夹的内容", action="store_true" 77 | # ) 78 | parser.add_argument( 79 | "-i", 80 | "--install", 81 | help=_("Install the convert result to it's default install location"), 82 | action="store_true", 83 | ) 84 | parser.set_defaults(func=conv) 85 | 86 | 87 | def conv(args): 88 | if not args.src.exists(): 89 | sys.stderr.write(_("Input path %s not exist\n") % args.src) 90 | return 1 91 | if not args.src.is_dir(): 92 | sys.stderr.write(_("Input path %s is not folder\n") % args.src) 93 | return 1 94 | 95 | args.dest = args.src 96 | if args.dest.exists(): 97 | # if not args.force: 98 | # sys.stderr.write("输出文件(夹) %s 已存在\n" % args.dest) 99 | # return 1 100 | pass 101 | else: 102 | args.dest.mkdir() 103 | 104 | err = convert(args) 105 | exit(err) 106 | 107 | 108 | def main(): 109 | parser = argparse.ArgumentParser(prog="ssfconv") 110 | # parser.add_argument("--foo", action="store_true", help="foo help") 111 | subparsers = parser.add_subparsers() 112 | 113 | add_parser_unpack(subparsers) 114 | add_parser_convert(subparsers) 115 | 116 | # print(parser.parse_args(["a", "12"])) 117 | args = parser.parse_args() 118 | args.func(args) 119 | -------------------------------------------------------------------------------- /src/ssfconv/convert/CaseSensitiveConfigParser.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | 3 | # 为了使其区分大小写,重载 ConfigParser 4 | class CaseSensitiveConfigParser(configparser.ConfigParser): 5 | def optionxform(self, optionstr): 6 | return optionstr -------------------------------------------------------------------------------- /src/ssfconv/convert/__init__.py: -------------------------------------------------------------------------------- 1 | from .out import ssf2fcitx, ssf2fcitx5 2 | from pathlib import Path 3 | import os, sys, shutil 4 | import logging 5 | 6 | 7 | def convert(args): 8 | install_dir = None 9 | # TODO refractor ssf2fcitx{5} get rid of os.path 10 | match args.type: 11 | case "fcitx5": 12 | err = ssf2fcitx5(args.src) 13 | case "fcitx": 14 | err = ssf2fcitx(args.src) 15 | case _: 16 | assert False 17 | if args.install: 18 | install(args) 19 | return err 20 | 21 | 22 | default_skins_dir = { 23 | "fcitx5": "fcitx5/themes/", 24 | "fcitx": "fcitx/skin/", 25 | } 26 | 27 | 28 | def install(args): 29 | match args.type: 30 | case "fcitx5": 31 | install_dir = os.getenv("XDG_DATA_HOME", Path("~/.local/share").expanduser()) 32 | case "fcitx": 33 | install_dir = os.getenv("XDG_CONFIG_HOME", Path("~/.config").expanduser()) 34 | case _: 35 | assert False 36 | install_dir = Path(install_dir) / default_skins_dir[args.type] 37 | shutil.move(args.dest, install_dir) 38 | -------------------------------------------------------------------------------- /src/ssfconv/convert/image_operation.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageDraw 2 | import numpy as np 3 | import logging 4 | from pathlib import Path 5 | 6 | 7 | def getImageAvg(image_path:Path, area=(0, 0, 0, 0)): 8 | """ 9 | 获取图片的像素平均值 10 | image_path 图片的路径 11 | aria 是需要求的平均值的区域,默认整幅图 12 | 格式 area = (x1,x2,y1,y2) 13 | 当 x2 或 y2 为零表示最大值直到边界 14 | 为负时表示距离最大边界多少的坐标 15 | 返回 (r,g,b) 三元组 16 | """ 17 | 18 | with Image.open(image_path) as file: 19 | size = file.size 20 | 21 | # 确定区域 22 | x1 = area[0] % size[0] 23 | x2 = area[1] % size[0] 24 | y1 = area[2] % size[1] 25 | y2 = area[3] % size[1] 26 | if x2 == 0: 27 | x2 = size[0] 28 | if y2 == 0: 29 | y2 = size[1] 30 | 31 | if x1 > x2: 32 | t = x1 33 | x1 = x2 34 | x2 = t 35 | if y1 > y2: 36 | t = y1 37 | y1 = y2 38 | y2 = t 39 | if x1 == x2: 40 | if x2 != size[0]: 41 | x2 += 1 42 | else: 43 | x1 -= 1 44 | if y1 == y2: 45 | if y2 != size[1]: 46 | y2 += 1 47 | else: 48 | y1 -= 1 49 | 50 | # 算出区域内所有像素点的平均值 51 | img = np.asarray(file) 52 | r = g = b = 0 53 | count = 0 54 | 55 | # 有没有透明度? 56 | if len(img.shape) < 3: 57 | pass 58 | elif img.shape[2] == 4: 59 | for y in range(y1, y2): 60 | for x in range(x1, x2): 61 | if img[y][x][3] > 0: 62 | r += img[y][x][0] 63 | g += img[y][x][1] 64 | b += img[y][x][2] 65 | count += 1 66 | else: 67 | for y in range(y1, y2): 68 | for x in range(x1, x2): 69 | r += img[y][x][0] 70 | g += img[y][x][1] 71 | b += img[y][x][2] 72 | count += 1 73 | 74 | if count == 0: 75 | count = 1 76 | # https://github.com/fkxxyz/ssfconv/issues/20 77 | r = int(r) 78 | g = int(g) 79 | b = int(b) 80 | 81 | r //= count 82 | g //= count 83 | b //= count 84 | return (r, g, b) 85 | 86 | 87 | # 获取图片大小的函数 88 | def getImageSize(image_file:str): 89 | with Image.open(image_file) as file: 90 | size = file.size 91 | assert size[0] > 0 and size[0] < 65536 and size[1] > 0 and size[1] < 65536 92 | return size 93 | 94 | 95 | # 保存一个多边形到文件 96 | def savePolygon(size, points, color, out_file:str): 97 | img = Image.new("RGBA", size) 98 | draw = ImageDraw.Draw(img) 99 | draw.polygon(points, fill=color) 100 | img.save(out_file) 101 | 102 | 103 | def rgbDistSqure(c1, c2): 104 | """ 105 | 简单的计算两个颜色之间的距离 106 | """ 107 | dr = c1[0] - c2[0] 108 | dg = c1[1] - c2[1] 109 | db = c1[2] - c2[2] 110 | return dr * dr + dg * dg + db * db 111 | 112 | 113 | def rgbDistMax(color, *colors): 114 | """ 115 | 求 colors 中与 color 的距离最大的颜色 116 | """ 117 | max_dist = 0 118 | max_dist_color = colors[0] 119 | for c in colors: 120 | # basic-colormath 的距离计算方法更精确,但这一切值得吗 121 | cur_dist = rgbDistSqure(color, c) 122 | if max_dist < cur_dist: 123 | max_dist = cur_dist 124 | max_dist_color = c 125 | return max_dist_color 126 | -------------------------------------------------------------------------------- /src/ssfconv/convert/ini_ssf_operation.py: -------------------------------------------------------------------------------- 1 | from .CaseSensitiveConfigParser import CaseSensitiveConfigParser 2 | from .image_operation import * 3 | import io 4 | from pathlib import Path 5 | import sys 6 | from ssfconv.translation import _ 7 | 8 | default_menu_img_bin = io.BytesIO( 9 | b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00<\x00\x00\x00<\x08\x06\x00\x00\x00:\xfc\xd9r\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\x00\x00\x00\tpHYs\x00\x00\x1b\xaf\x00\x00\x1b\xaf\x01^\x1a\x91\x1c\x00\x00\x00\x19tEXtSoftware\x00www.inkscape.org\x9b\xee<\x1a\x00\x00\x01\xdaIDATh\x81\xed\x9b\xc1N\xdb@\x10@\xdf\x80\x15R\x08R\x10\xa4\x12\xbd\x84\x037\xbe\xa0\x9f\xd2/\xec'\xf0\t|\x04\x1c\xc8\t\t\x88\x9a\x127AHdz\x98\xdd\xd6\nI$\xa4\x10\xd8a\x9e\xb4\xb2\xec\xb5\xady\x1e{}\x99\x11U\xa5\x89\x88\xec\x02-@(\x1b\x05\x9eTu\xd2<(Y8\x89\x1e\x02\xfb@\x1b\x1f\xc2\x8f\xc0\x18\x18f\xf1\n\xfe\xc9\x1e\x03_\x81\x03\xe0\x0b>\x84\xa7\xc0/\xa0%\"7\xaa:\xa9\xd2\xe4!&\xfb\r\xe8\x01\x1d`\xeb]\xc2\\\x1f3\xa0\xc6\x92\x07\xf0\x04L\xaa\x94\xdd},\xb3=L\xfa\x18\xf8\x0e\xf41\xf9\x92\xa8\x81\x01p\x01\xdc\xa4cS\xe0\xb7\x88\xecV\xd8\x02\xd5\xc6\x9eD\x07\x93\xfd\x01\xecm>\xd6\xb5\xd0\x01\xce\x80\x13\xe0'\xf0\x80\xb9\xb5\x81V\x85}\xabyla\x99\xdd\x03\xae\x80.\xf6j\xe8\xfc]?(\xd9a\x04\x9cb.\x974\x1c\xab\x05\x17\xf5\xd3\xb6\x0b[+\xde_\xdfNz_\xa1?jm\x00\x00\x00\x00IEND\xaeB`\x82" 10 | ) 11 | default_radio_img_bin = io.BytesIO( 12 | b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x18\x00\x00\x00\x18\x08\x06\x00\x00\x00\xe0w=\xf8\x00\x00\x00\tpHYs\x00\x00\r\xd7\x00\x00\r\xd7\x01B(\x9bx\x00\x00\x00\x19tEXtSoftware\x00www.inkscape.org\x9b\xee<\x1a\x00\x00\x00\x9dIDATH\x89\xed\x91\xc1\n\x830\x10Dg\x1a\xd0\x1f*\xbdfIoJ?\xb7-\xeda!G\xc1\x1fR\x88\xdb\x8b\xd2\x1e\x84\x08zk\xde1\xbb\x93\x07\xb3@\xa1P\xe0\xd6EUm\x9dsg\x00H)\xf5!\x84\xfba\x82\x18\xe3\xcb\xcc\x04@5?\r$\xd5{\xdf\xe4\xb2\xa7\xdc\x82\xaa\xb6f\xe6\x7f>\x07\x80\xda\xcc\xae1\xc6\xfd\x82\xb9\x96zeTM\xd3t\xd9-\xd8KV\x90R\xeaI\x8e+\xa3\x81d\x97\xcbo=\xf2\xc3\xcc\x02\xbeU\x8d\x00\xde"r;D0K\x9a\xa5s\x92\x9d\x88<\xb7f\x0b\x85\x7f\xe7\x03\xc2\x8b7\xa7\xab\xe8\x14\xb1\x00\x00\x00\x00IEND\xaeB`\x82' 13 | ) 14 | 15 | 16 | class SsfIniWrapper: 17 | def __init__(self, skin_dir: Path): 18 | self.ssf = None 19 | self.skin_dir: Path = skin_dir 20 | self.read_ssf_ini() 21 | 22 | def read_ssf_ini(self): 23 | """ 24 | 为了重复使用,不和init合并 25 | """ 26 | skin_ini = self.skin_dir / "skin.ini" 27 | if not skin_ini.is_file(): 28 | sys.stderr.write(_("Cant found skin.ini\n")) 29 | return 1 30 | 31 | try: 32 | ssf_ini = CaseSensitiveConfigParser(allow_no_value=True) 33 | ssf_ini.read(skin_ini, encoding="utf-16") 34 | except: 35 | sys.stderr.write(_("Failed to read skin.ini\n")) 36 | return 2 37 | self.ssf = ssf_ini 38 | return 39 | 40 | def get_image_config(self, section, key, index=0): 41 | """ 42 | 获取图片文件名的函数(获取失败则返回空字符串) 43 | """ 44 | image_name_list = self.ssf[section].get(key, "").split(",") 45 | if index < len(image_name_list): 46 | image_name = image_name_list[index] 47 | if (self.skin_dir / image_name).is_file(): 48 | return image_name 49 | else: 50 | return "" 51 | 52 | def try_get_value(self, section, key): 53 | return self.ssf[section].get(key, "").strip() 54 | 55 | def getRawIni(self): 56 | # FIXME 允许写这个变量是坏主意,考虑使用私有变量或私有方法来暗示 57 | return self.ssf 58 | 59 | def findBackgroundColorBy(self, key): 60 | # 排除键值不存在 61 | image_name = self.get_image_config(key[0], key[1]) 62 | if not image_name: 63 | return None 64 | 65 | # 排除区域不存在 66 | h_str = self.try_get_value(key[0], key[1][:-3] + "layout_horizontal") 67 | if not h_str: 68 | return None 69 | v_str = self.try_get_value(key[0], key[1][:-3] + "layout_vertical") 70 | if not v_str: 71 | return None 72 | 73 | # 得出区域 74 | h = h_str.split(",") 75 | v = v_str.split(",") 76 | if len(h) != 3 or len(v) != 3: 77 | return None 78 | 79 | # 排除平铺模式(筛选出是拉伸区域) 80 | # if int(h[0]) != 0 or int(v[0]) != 0: 81 | # return None 82 | 83 | return getImageAvg( 84 | self.skin_dir / image_name, 85 | (int(h[1]), -int(h[2]), int(v[1]), -int(v[2])), 86 | ) 87 | 88 | def findBackgroundColor(self): 89 | """ 90 | 根据里面所有的图片,根据所设置的拉伸区域确定合适的背景色 91 | 不知道原作者为什么按顺序取到任意一张就完成 92 | """ 93 | keys = ( 94 | ("Scheme_V1", "pic"), 95 | ("Scheme_V2", "pinyin_pic"), 96 | ("Scheme_V2", "zhongwen_pic"), 97 | ("Scheme_H1", "pic"), 98 | ("Scheme_H2", "pinyin_pic"), 99 | ("Scheme_H2", "zhongwen_pic"), 100 | ) 101 | for key in keys: 102 | avg_color = self.findBackgroundColorBy(key) 103 | if avg_color: 104 | return avg_color 105 | else: 106 | return (0, 0, 0) 107 | 108 | 109 | # 将 skin.ini 的颜色转换成 (r,g,b) 三元组 110 | def colorConv(ssf_color): 111 | color_int = int(ssf_color, 16) 112 | r = color_int % 256 113 | g = (color_int % 65536) // 256 114 | b = color_int // 65536 115 | return (r, g, b) 116 | -------------------------------------------------------------------------------- /src/ssfconv/convert/out/Fcitx.py: -------------------------------------------------------------------------------- 1 | from ..image_operation import * 2 | from ..ini_ssf_operation import * 3 | 4 | #TODO 用pathlib 替换 5 | import os 6 | 7 | # 创建符号链接的函数(若存在则覆盖) 8 | def symlinkF(src, dst): 9 | if os.path.isfile(dst): 10 | os.remove(dst) 11 | return os.symlink(src, dst) 12 | 13 | def makeConfFromSsf(ssf): 14 | skin = CaseSensitiveConfigParser(allow_no_value = True) 15 | 16 | skin['SkinInfo'] = { 17 | # 皮肤名称 18 | 'Name': ssf['General']['skin_name'], 19 | 20 | # 皮肤版本 21 | 'Version': ssf['General']['skin_version'], 22 | 23 | # 皮肤作者 24 | 'Author': ssf['General']['skin_author'], 25 | 26 | # 描述 27 | 'Desc': ssf['General']['skin_info'], 28 | } 29 | return skin 30 | 31 | 32 | def ssf2fcitx(skin_dir:str): 33 | """ 34 | 转换为 fcitx 格式 35 | 将解压后的 ssf 皮肤,在里面创建出 fcitx_skin.conf 36 | """ 37 | 38 | ssfw = SsfIniWrapper(Path(skin_dir)) 39 | ssf=ssfw.getRawIni() 40 | 41 | skin=makeConfFromSsf(ssf) 42 | 43 | # 输入框输入的拼音颜色 44 | input_color = colorConv(ssf['Display']['pinyin_color']) 45 | 46 | # 列表中第一个词的颜色 47 | first_color = colorConv(ssf['Display']['zhongwen_first_color']) 48 | 49 | # 列表中其他词的颜色 50 | other_color = colorConv(ssf['Display']['zhongwen_color']) 51 | 52 | # 根据里面所有的图片,根据所设置的拉伸区域确定合适的背景色 53 | back_color = ssfw.findBackgroundColor() 54 | 55 | # 字体大小(像素) 56 | font_size = int(ssf['Display']['font_size']) 57 | 58 | # 状态栏背景图 59 | static_bar_image = ssfw.get_image_config('StatusBar', 'pic') 60 | 61 | # 中/英文状态 62 | cn_status_image = ssfw.get_image_config('StatusBar', 'cn_en', 0) 63 | en_status_image = ssfw.get_image_config('StatusBar', 'cn_en', 1) 64 | 65 | # 全半角状态 66 | quan_status_image = ssfw.get_image_config('StatusBar', 'quan_ban', 0) 67 | ban_status_image = ssfw.get_image_config('StatusBar', 'quan_ban', 1) 68 | 69 | # 中/英文标点状态 70 | cn_p_status_image = ssfw.get_image_config('StatusBar', 'biaodian', 0) 71 | en_p_status_image = ssfw.get_image_config('StatusBar', 'biaodian', 1) 72 | 73 | # 繁/简状态 74 | simp_status_image = ssfw.get_image_config('StatusBar', 'fan_jian', 1) 75 | trad_status_image = ssfw.get_image_config('StatusBar', 'fan_jian', 0) 76 | 77 | # 虚拟键盘状态 78 | vk_inactive_status_image = ssfw.get_image_config('StatusBar', 'softkeyboard') 79 | for mouse_status in ('down','in','out','downing'): 80 | vk_active_status_image = ssfw.get_image_config('StatusBar', 'softkeyboard_' + mouse_status) 81 | if vk_active_status_image: 82 | break 83 | 84 | icons = (cn_status_image, simp_status_image, trad_status_image, 85 | quan_status_image, ban_status_image, 86 | cn_p_status_image, en_p_status_image, 87 | vk_inactive_status_image, vk_active_status_image) 88 | 89 | # 求图标的前景色(任意一个即可) 90 | for image in icons: 91 | if image: 92 | icon_color = getImageAvg(Path(skin_dir) / image) 93 | break 94 | else: 95 | icon_color = other_color 96 | 97 | skin['SkinFont'] = { 98 | # 字体大小 99 | 'FontSize': font_size, 100 | 101 | # 菜单字体大小 102 | 'MenuFontSize': 14, 103 | 104 | # 字体大小遵守dpi设置 105 | 'RespectDPI': 'False', 106 | 107 | # 提示信息颜色 108 | 'TipColor': '%d %d %d' % first_color, 109 | 110 | # 输入信息颜色 111 | 'InputColor': '%d %d %d' % other_color, 112 | 113 | # 候选词索引颜色 114 | 'IndexColor': '%d %d %d' % other_color, 115 | 116 | # 第一候选词颜色 117 | 'FirstCandColor': '%d %d %d' % first_color, 118 | 119 | # 用户词组颜色 120 | 'UserPhraseColor': '%d %d %d' % first_color, 121 | 122 | # 码表提示颜色 123 | 'CodeColor': '%d %d %d' % input_color, 124 | 125 | # 其他颜色 126 | 'OtherColor': '%d %d %d' % other_color, 127 | 128 | # 活动菜单项颜色 129 | 'ActiveMenuColor': '%d %d %d' % \ 130 | rgbDistMax(other_color, 131 | first_color, input_color, back_color, icon_color), 132 | 133 | # 非活动菜单项颜色+状态栏图标文字颜色 134 | 'InactiveMenuColor': '%d %d %d' % \ 135 | rgbDistMax(back_color, 136 | first_color, input_color, other_color, icon_color), 137 | } 138 | 139 | # 创建中文拼音状态图 pinyin.png 140 | if cn_status_image: 141 | symlinkF(cn_status_image, skin_dir + os.sep + 'pinyin.png') 142 | 143 | # 创建全/半角状态图 fullwidth_active.png / fullwidth_inactive.png 144 | if quan_status_image: 145 | symlinkF(quan_status_image, skin_dir + os.sep + 'fullwidth_active.png') 146 | if ban_status_image: 147 | symlinkF(ban_status_image, skin_dir + os.sep + 'fullwidth_inactive.png') 148 | 149 | # 创建中/英文标点状态图 punc_active.png / punc_inactive.png 150 | if cn_p_status_image: 151 | symlinkF(cn_p_status_image, skin_dir + os.sep + 'punc_active.png') 152 | if en_p_status_image: 153 | symlinkF(en_p_status_image, skin_dir + os.sep + 'punc_inactive.png') 154 | 155 | # 创建繁/简状态图 chttrans_inactive.png / chttrans_active.png 156 | if simp_status_image: 157 | symlinkF(simp_status_image, skin_dir + os.sep + 'chttrans_inactive.png') 158 | if trad_status_image: 159 | symlinkF(trad_status_image, skin_dir + os.sep + 'chttrans_active.png') 160 | 161 | # 创建虚拟键盘状态图 vk_inactive.png / vk_active.png 162 | if vk_inactive_status_image: 163 | symlinkF(vk_inactive_status_image, skin_dir + os.sep + 'vk_inactive.png') 164 | if vk_active_status_image: 165 | symlinkF(vk_active_status_image, skin_dir + os.sep + 'vk_active.png') 166 | 167 | # 求搜狗状态栏上几个按钮的坐标的最值 168 | x_min = y_min = 65536 169 | x_max = y_max = 0 170 | for button in ('cn_en', 171 | 'biaodian', 172 | 'quan_ban', 173 | 'quan_shuang', 174 | 'fan_jian', 175 | 'softkeyboard', 176 | 'menu', 177 | 'sogousearch', 178 | 'passport', 179 | 'skinmanager'): 180 | display = ssfw.try_get_value('StatusBar', button + '_display') 181 | if display != '1': continue 182 | pos = ssfw.try_get_value('StatusBar', button + '_pos').split(',') 183 | if len(pos) != 2: continue 184 | 185 | # 取最值 186 | if int(pos[0]) < x_min: x_min = int(pos[0]) 187 | if int(pos[1]) < y_min: y_min = int(pos[1]) 188 | 189 | # 得到图标尺寸 190 | icon_image = ssfw.get_image_config('StatusBar', button, 0) 191 | if not icon_image: continue 192 | size = getImageSize(skin_dir + os.sep + icon_image) 193 | 194 | # 取最右值 195 | x = int(pos[0]) + size[0] 196 | if x > x_max: x_max = x 197 | 198 | y = int(pos[1]) + size[1] 199 | if y > y_max: y_max = y 200 | 201 | # 得出合适的右边距和下边距 202 | if static_bar_image: 203 | size = getImageSize(skin_dir + os.sep + static_bar_image) 204 | MarginRight = size[0] - x_max + 4 205 | MarginBottom = size[1] - y_max + 4 206 | else: 207 | MarginRight = 4 208 | MarginBottom = 4 209 | 210 | skin['SkinMainBar'] = { 211 | # 背景图片 212 | 'BackImg': static_bar_image, 213 | 214 | # Logo图标 215 | 'Logo': '', 216 | 217 | # 英文模式图标 218 | 'Eng': en_status_image, 219 | 220 | # 激活状态输入法图标 221 | 'Active': cn_status_image, 222 | 223 | # 左边距 224 | 'MarginLeft': x_min+4, 225 | 226 | # 右边距 227 | 'MarginRight': MarginRight, 228 | 229 | # 上边距 230 | 'MarginTop': y_min+4, 231 | 232 | # 下边距 233 | 'MarginBottom': MarginBottom, 234 | 235 | # 可点击区域的左边距 236 | #ClickMarginLeft=0 237 | # 可点击区域的右边距 238 | #ClickMarginRight=0 239 | # 可点击区域的上边距 240 | #ClickMarginTop=0 241 | # 可点击区域的下边距 242 | #ClickMarginBottom=0 243 | # 覆盖图片 244 | #Overlay= 245 | # 覆盖图片停靠位置 246 | # Available Value: 247 | # TopLeft 248 | # TopCenter 249 | # TopRight 250 | # CenterLeft 251 | # Center 252 | # CenterRight 253 | # BottomLeft 254 | # BottomCenter 255 | # BottomRight 256 | #OverlayDock=TopLeft 257 | # 覆盖图片 X 偏移 258 | #OverlayOffsetX=0 259 | # 覆盖图片 Y 偏移 260 | #OverlayOffsetY=0 261 | # 纵向填充规则 262 | # Available Value: 263 | # Copy 264 | # Resize 265 | #FillVertical=Resize 266 | # 横向填充规则 267 | # Available Value: 268 | # Copy 269 | # Resize 270 | #FillHorizontal=Resize 271 | # 使用自定的文本图标颜色 272 | # Available Value: 273 | # True False 274 | #UseCustomTextIconColor=True 275 | # 活动的文本图标颜色 276 | #ActiveTextIconColor=101 153 209 277 | # 非活动的文本图标颜色 278 | #InactiveTextIconColor=101 153 209 279 | # 特殊图标位置 280 | #Placement= 281 | } 282 | 283 | 284 | # 输入框背景图 285 | input_bar_image = ssfw.get_image_config('Scheme_H1', 'pic') 286 | input_bar_image_size = getImageSize(skin_dir + os.sep + input_bar_image) 287 | 288 | # 绘制 prev.png 和 next.png 颜色为 '%d %d %d' % other_color 289 | savePolygon((6,12), ((0,0),(6,6),(0,12)), other_color, skin_dir + os.sep + 'next.png') 290 | savePolygon((6,12), ((0,6),(6,0),(6,12)), other_color, skin_dir + os.sep + 'prev.png') 291 | 292 | # 水平边距 293 | lh = ssfw.try_get_value('Scheme_H1', 'layout_horizontal') 294 | if lh: 295 | lh = tuple(map(lambda s:int(s), ssf['Scheme_H1']['layout_horizontal'].split(','))) 296 | else: 297 | lh = (0, 0, 0) 298 | 299 | # 竖直边距 300 | pinyin_marge = ssfw.try_get_value('Scheme_H1', 'pinyin_marge') 301 | if pinyin_marge: 302 | pinyin_marge = tuple(map(lambda s:int(s), pinyin_marge.split(','))) 303 | else: 304 | assert False 305 | zhongwen_marge = ssfw.try_get_value('Scheme_H1', 'zhongwen_marge') 306 | if zhongwen_marge: 307 | zhongwen_marge = tuple(map(lambda s:int(s), zhongwen_marge.split(','))) 308 | else: 309 | assert False 310 | separator = ssfw.try_get_value('Scheme_H1', 'separator') 311 | sep = 1 if separator else 0 312 | InputPos = pinyin_marge[0] + font_size 313 | OutputPos = pinyin_marge[0] + pinyin_marge[1] + font_size + \ 314 | sep + zhongwen_marge[0] + font_size 315 | MarginBottom = input_bar_image_size[1] - OutputPos 316 | if lh[1] - pinyin_marge[2] > 32: 317 | MarginLeft = pinyin_marge[2] 318 | else: 319 | MarginLeft = lh[1] 320 | 321 | skin['SkinInputBar'] = { 322 | # 背景图片 323 | 'BackImg': input_bar_image, 324 | 325 | # 左边距 326 | 'MarginLeft': MarginLeft, 327 | 328 | # 右边距 329 | 'MarginRight': lh[2], 330 | 331 | # 上边距 332 | 'MarginTop': 0, 333 | 334 | # 下边距 335 | 'MarginBottom': MarginBottom, 336 | 337 | # 可点击区域的左边距 338 | #ClickMarginLeft=0 339 | # 可点击区域的右边距 340 | #ClickMarginRight=0 341 | # 可点击区域的上边距 342 | #ClickMarginTop=0 343 | # 可点击区域的下边距 344 | #ClickMarginBottom=0 345 | # 覆盖图片 346 | #Overlay=hangul.png 347 | # 覆盖图片停靠位置 348 | # Available Value: 349 | # TopLeft 350 | # TopCenter 351 | # TopRight 352 | # CenterLeft 353 | # Center 354 | # CenterRight 355 | # BottomLeft 356 | # BottomCenter 357 | # BottomRight 358 | #OverlayDock=TopRight 359 | # 覆盖图片 X 偏移 360 | #OverlayOffsetX=-26 361 | # 覆盖图片 Y 偏移 362 | #OverlayOffsetY=2 363 | 364 | # 光标颜色 365 | 'CursorColor': '%d %d %d' % first_color, 366 | 367 | # 预编辑文本的位置或偏移 368 | 'InputPos': InputPos, 369 | 370 | # 候选词表的位置或偏移 371 | 'OutputPos': OutputPos, 372 | 373 | # 上一页图标 374 | 'BackArrow': 'prev.png', 375 | 376 | # 下一页图标 377 | 'ForwardArrow': 'next.png', 378 | 379 | # 上一页图标的横坐标 380 | 'BackArrowX': lh[2] - lh[1] + 10, 381 | 382 | # 上一页图标的纵坐标 383 | 'BackArrowY': pinyin_marge[0], 384 | 385 | # 下一页图标的横坐标 386 | 'ForwardArrowX': lh[2] - lh[1], 387 | 388 | # 下一页图标的纵坐标 389 | 'ForwardArrowY': pinyin_marge[0], 390 | 391 | # 纵向填充规则 392 | # Available Value: 393 | # Copy 394 | # Resize 395 | #FillVertical=Resize 396 | # 横向填充规则 397 | # Available Value: 398 | # Copy 399 | # Resize 400 | #FillHorizontal=Resize 401 | } 402 | 403 | # 使用系统默认的 active.png 和 inactive.png 404 | symlinkF('/usr/share/fcitx/skin/default/active.png', 405 | skin_dir + os.sep + 'active.png') 406 | symlinkF('/usr/share/fcitx/skin/default/inactive.png', 407 | skin_dir + os.sep + 'inactive.png') 408 | 409 | skin['SkinTrayIcon'] = { 410 | # 活动输入法图标 411 | 'Active': 'active.png', 412 | 413 | # 非活动输入法图标 414 | 'Inactive': 'inactive.png', 415 | } 416 | 417 | # 用纯背景色构建出本主题的 menu.png 418 | img = Image.open(default_menu_img_bin) 419 | a = np.array(img) 420 | for i in range(len(a)): 421 | for j in range(len(a[0])): 422 | if a[i][j][3]: 423 | a[i][j][0] = back_color[0] 424 | a[i][j][1] = back_color[1] 425 | a[i][j][2] = back_color[2] 426 | img = Image.fromarray(a) 427 | img.save(skin_dir + os.sep + 'menu.png') 428 | 429 | skin['SkinMenu'] = { 430 | # 背景图片 431 | 'BackImg': 'menu.png', 432 | 433 | # 上边距 434 | 'MarginTop': 8, 435 | 436 | # 下边距 437 | 'MarginBottom': 8, 438 | 439 | # 左边距 440 | 'MarginLeft': 8, 441 | 442 | # 右边距 443 | 'MarginRight': 8, 444 | 445 | # 活动菜单项颜色 446 | 'ActiveColor': '%d %d %d' % other_color, 447 | 448 | # 分隔线颜色 449 | 'LineColor': '%d %d %d' % other_color, 450 | } 451 | 452 | skin['SkinKeyboard'] = { 453 | # 虚拟键盘图片 454 | #BackImg=keyboard.png 455 | 456 | # 软键盘按键文字颜色 457 | #'KeyColor': '%d %d %d' % first_color, 458 | } 459 | 460 | skin.write(open(skin_dir + os.sep + 'fcitx_skin.conf', 'w', encoding="utf-8"), False) 461 | return 0 -------------------------------------------------------------------------------- /src/ssfconv/convert/out/Fcitx5.py: -------------------------------------------------------------------------------- 1 | from ..ini_ssf_operation import * 2 | 3 | #TODO 用pathlib 替换 4 | import os 5 | 6 | def makeConfFromSsf(ssf): 7 | skin = CaseSensitiveConfigParser(allow_no_value = True) 8 | 9 | skin['Metadata'] = { 10 | # 皮肤名称 11 | 'Name': ssf['General']['skin_name'], 12 | 13 | # 皮肤版本 14 | 'Version': ssf['General']['skin_version'], 15 | 16 | # 皮肤作者 17 | 'Author': ssf['General']['skin_author'], 18 | 19 | # 描述 20 | 'Description': ssf['General']['skin_info'], 21 | 22 | # 用 DPI 缩放 23 | 'ScaleWithDPI': 'False', 24 | } 25 | return skin 26 | 27 | 28 | def ssf2fcitx5(skin_dir:str): 29 | """ 30 | 转换为 fcitx5 格式 31 | 将解压后的 ssf 皮肤,在里面创建出 theme.conf 32 | """ 33 | 34 | ssfw = SsfIniWrapper(Path(skin_dir)) 35 | ssf=ssfw.getRawIni() 36 | 37 | skin=makeConfFromSsf(ssf) 38 | 39 | # 输入框(pre_edit)输入的拼音颜色 40 | input_color = colorConv(ssf['Display']['pinyin_color']) 41 | 42 | # 列表中第一个词的颜色 43 | first_color = colorConv(ssf['Display']['zhongwen_first_color']) 44 | 45 | # 列表中其他词的颜色 46 | other_color = colorConv(ssf['Display']['zhongwen_color']) 47 | 48 | # 根据里面所有的图片,根据所设置的拉伸区域确定合适的背景色 49 | back_color = ssfw.findBackgroundColor() 50 | 51 | # 字体大小(像素) 52 | font_size = int(ssf['Display']['font_size']) 53 | 54 | skin['InputPanel'] = { 55 | # 字体及其大小 56 | 'Font': 'Sans %d' % font_size, 57 | 58 | # 非选中候选字颜色 59 | 'NormalColor': '#%02x%02x%02x' % other_color, 60 | 61 | # 选中候选字颜色 62 | 'HighlightCandidateColor': '#%02x%02x%02x' % first_color, 63 | 64 | # 高亮前景颜色(back_color输入字符颜色) 65 | 'HighlightColor': '#%02x%02x%02x' % input_color, 66 | 67 | # 输入字符背景颜色 68 | 'HighlightBackgroundColor': '#%02x%02x%02x' % back_color, 69 | 70 | # 71 | 'Spacing': 3, 72 | } 73 | 74 | # 输入框背景图 75 | input_bar_image = ssfw.get_image_config('Scheme_H1', 'pic') 76 | input_bar_image_size = getImageSize(skin_dir + os.sep + input_bar_image) 77 | 78 | # 水平拉升区域 79 | lh = ssfw.try_get_value('Scheme_H1', 'layout_horizontal') 80 | if lh: 81 | lh = tuple(map(lambda s:int(s), lh.split(','))) 82 | else: 83 | lh = (0, 2, 2) 84 | 85 | # 垂直拉升区域 86 | lv = ssfw.try_get_value('Scheme_H1', 'layout_vertical') 87 | if lv: 88 | lv = tuple(map(lambda s:int(s), lv.split(','))) 89 | else: 90 | lv = (0, 2, 2) 91 | 92 | # 拼音边距 93 | pinyin_marge = ssfw.try_get_value('Scheme_H1', 'pinyin_marge') 94 | if pinyin_marge: 95 | pinyin_marge = tuple(map(lambda s:int(s), pinyin_marge.split(','))) 96 | else: 97 | assert False 98 | 99 | # 候选词边距 100 | zhongwen_marge = ssfw.try_get_value('Scheme_H1', 'zhongwen_marge') 101 | if zhongwen_marge: 102 | zhongwen_marge = tuple(map(lambda s:int(s), zhongwen_marge.split(','))) 103 | else: 104 | assert False 105 | 106 | # 分隔符长度 107 | sep = 1 if ssfw.try_get_value('Scheme_H1', 'separator') else 0 108 | 109 | # 恒等式: 110 | # 输入的拼音下方到候选词上方的距离: 111 | # pinyin_marge[1] + sep + zhongwen_marge[0] = TextMargin.Bottom + TextMargin.Top 112 | # 输入的拼音上方到上方边界的距离: 113 | # pinyin_marge[0] = ContentMargin.Top + TextMargin.Top 114 | # 候选词下方到下方边界的距离: 115 | # zhongwen_marge[1] = ContentMargin.Bottom + TextMargin.Bottom 116 | # 117 | # 118 | # 这是四元一次方程组,由于只有三个方程,那么随便确定其中一个即可解得其它未知数。 119 | # 增加的方程: 120 | # TextMargin.Bottom = (pinyin_marge[1] + sep + zhongwen_marge[0]) // 2 121 | 122 | distant_pinyin_zhongwen = pinyin_marge[1] + sep + zhongwen_marge[0] 123 | 124 | # 解得: 125 | TextMargin_Bottom = distant_pinyin_zhongwen // 2 126 | TextMargin_Top = distant_pinyin_zhongwen - TextMargin_Bottom 127 | ContentMargin_Top = pinyin_marge[0] - TextMargin_Top 128 | #ContentMargin_Bottom = zhongwen_marge[1] - TextMargin_Bottom 129 | ContentMargin_Bottom = input_bar_image_size[1] - \ 130 | ContentMargin_Top - TextMargin_Top - font_size - TextMargin_Bottom - \ 131 | TextMargin_Top - font_size - TextMargin_Bottom 132 | 133 | TextMargin_Top_Left = 5 134 | TextMargin_Top_Right = 5 135 | 136 | # 文字边距 137 | skin['InputPanel/TextMargin'] = { 138 | 'Left': TextMargin_Top_Left, 139 | 'Right': TextMargin_Top_Right, 140 | 'Top': TextMargin_Top, 141 | 'Bottom': TextMargin_Bottom, 142 | } 143 | 144 | # 输入框内容边距 145 | skin['InputPanel/ContentMargin'] = { 146 | 'Left': max(pinyin_marge[2], zhongwen_marge[2]) - TextMargin_Top_Left, 147 | 'Right': max(pinyin_marge[3], zhongwen_marge[3]) - TextMargin_Top_Right, 148 | 'Top': ContentMargin_Top, 149 | 'Bottom': ContentMargin_Bottom, 150 | } 151 | 152 | # 输入框背景图 153 | skin['InputPanel/Background'] = { 154 | 'Image': input_bar_image, 155 | } 156 | 157 | # 输入框背景图的拉升区域 158 | skin['InputPanel/Background/Margin'] = { 159 | 'Left': lh[1], 160 | 'Right': lh[2], 161 | 'Top': lv[1], 162 | 'Bottom': lv[2], 163 | } 164 | 165 | 166 | # 绘制高亮的纯色图片 167 | # menu_highlight_color = rgbDistMax(first_color, input_color, other_color, back_color) 168 | Image.new('RGBA', (38,23), (0,0,0,0)).save(skin_dir + os.sep + 'highlight.png') 169 | 170 | # 高亮背景 171 | skin['InputPanel/Highlight'] = { 172 | 'Image': 'highlight.png', 173 | } 174 | # 高亮背景边距 175 | skin['InputPanel/Highlight/Margin'] = { 176 | 'Left': 5, 177 | 'Right': 5, 178 | 'Top': 5, 179 | 'Bottom': 5, 180 | } 181 | 182 | 183 | # 绘制 prev.png 和 next.png 颜色为 '%d %d %d' % other_color 184 | savePolygon((16,24), ((5,6),(5,18),(11,12)), other_color, skin_dir + os.sep + 'next.png') 185 | savePolygon((16,24), ((11,6),(11,18),(5,12)), other_color, skin_dir + os.sep + 'prev.png') 186 | 187 | 188 | # 前一页的箭头 189 | skin['InputPanel/PrevPage'] = { 190 | 'Image': 'prev.png', 191 | } 192 | skin['InputPanel/PrevPage/ClickMargin'] = { 193 | 'Left': 5, 194 | 'Right': 5, 195 | 'Top': 4, 196 | 'Bottom': 4, 197 | } 198 | # 后一页的箭头 199 | skin['InputPanel/NextPage'] = { 200 | 'Image': 'next.png', 201 | } 202 | skin['InputPanel/NextPage/ClickMargin'] = { 203 | 'Left': 5, 204 | 'Right': 5, 205 | 'Top': 4, 206 | 'Bottom': 4, 207 | } 208 | 209 | # 竖排合窗口设置 210 | Scheme_V1_pic = ssfw.try_get_value('Scheme_V1', 'pic') 211 | 212 | # 水平拉升区域 213 | lh = ssfw.try_get_value('Scheme_V1', 'layout_horizontal') 214 | if lh: 215 | lh = tuple(map(lambda s:int(s), lh.split(','))) 216 | else: 217 | lh = None 218 | 219 | # 垂直拉升区域 220 | lv = ssfw.try_get_value('Scheme_V1', 'layout_vertical') 221 | if lv: 222 | lv = tuple(map(lambda s:int(s), lv.split(','))) 223 | else: 224 | lv = None 225 | 226 | # 拼音边距 227 | pinyin_marge = ssfw.try_get_value('Scheme_V1', 'pinyin_marge') 228 | if pinyin_marge: 229 | pinyin_marge = tuple(map(lambda s:int(s), pinyin_marge.split(','))) 230 | else: 231 | pinyin_marge = None 232 | 233 | # 候选词边距 234 | zhongwen_marge = ssfw.try_get_value('Scheme_V1', 'zhongwen_marge') 235 | if zhongwen_marge: 236 | zhongwen_marge = tuple(map(lambda s:int(s), zhongwen_marge.split(','))) 237 | else: 238 | zhongwen_marge = None 239 | 240 | if Scheme_V1_pic and lh and lv and pinyin_marge and zhongwen_marge: 241 | # 背景图片 242 | skin['Menu/Background'] = { 243 | 'Image': Scheme_V1_pic, 244 | } 245 | 246 | # 背景图片拉升边距 247 | skin['Menu/Background/Margin'] = { 248 | 'Left': lh[1], 249 | 'Right': lh[2], 250 | 'Top': lv[1], 251 | 'Bottom': lv[2], 252 | } 253 | 254 | sep = 1 if ssfw.try_get_value('Scheme_V1', 'separator') else 0 255 | 256 | # 背景图片内容边距 257 | horizontal_margin = min(zhongwen_marge[2], zhongwen_marge[3]) 258 | skin['Menu/ContentMargin'] = { 259 | # 左边距 260 | 'Left': horizontal_margin, 261 | 262 | # 右边距 263 | 'Right': horizontal_margin, 264 | 265 | # 上边距 266 | 'Top': pinyin_marge[0] + pinyin_marge[1] + sep + zhongwen_marge[0], 267 | 268 | # 下边距 269 | 'Bottom': zhongwen_marge[1], 270 | } 271 | else: 272 | # 构建纯色背景 273 | 274 | # 用纯背景色构建出本主题的 menu.png 275 | img = Image.open(default_menu_img_bin) 276 | a = np.array(img) 277 | for i in range(len(a)): 278 | for j in range(len(a[0])): 279 | if a[i][j][3]: 280 | a[i][j][0] = back_color[0] 281 | a[i][j][1] = back_color[1] 282 | a[i][j][2] = back_color[2] 283 | img = Image.fromarray(a) 284 | img.save(skin_dir + os.sep + 'menu.png') 285 | 286 | # 背景图片 287 | skin['Menu/Background'] = { 288 | 'Image': 'menu.png', 289 | } 290 | 291 | # 背景图片拉升边距 292 | skin['Menu/Background/Margin'] = { 293 | 'Left': 20, 294 | 'Right': 20, 295 | 'Top': 20, 296 | 'Bottom': 20, 297 | } 298 | 299 | # 背景图片内容边距 300 | skin['Menu/ContentMargin'] = { 301 | # 左边距 302 | 'Left': 8, 303 | 304 | # 右边距 305 | 'Right': 8, 306 | 307 | # 上边距 308 | 'Top': 8, 309 | 310 | # 下边距 311 | 'Bottom': 8, 312 | } 313 | 314 | # 绘制高亮的透明图片 315 | #menu_highlight_color = rgbDistMax((255,255,255), back_color, input_color, first_color, other_color) 316 | Image.new('RGBA', (38,23), (0,0,0,0)).save(skin_dir + os.sep + 'menu_highlight.png') 317 | 318 | # 高亮背景 319 | skin['Menu/Highlight'] = { 320 | 'Image': 'menu_highlight.png', 321 | } 322 | # 高亮背景边距 323 | skin['Menu/Highlight/Margin'] = { 324 | 'Left': 10, 325 | 'Right': 10, 326 | 'Top': 5, 327 | 'Bottom': 5, 328 | } 329 | 330 | # 分隔符颜色 331 | skin['Menu/Separator'] = { 332 | 'Color': '#%02x%02x%02x' % other_color, 333 | } 334 | 335 | # 用纯背景色构建出本主题的 radio.png 336 | img = Image.open(default_radio_img_bin) 337 | a = np.array(img) 338 | for i in range(len(a)): 339 | for j in range(len(a[0])): 340 | if a[i][j][3]: 341 | a[i][j][0] = other_color[0] 342 | a[i][j][1] = other_color[1] 343 | a[i][j][2] = other_color[2] 344 | img = Image.fromarray(a) 345 | img.save(skin_dir + os.sep + 'radio.png') 346 | 347 | # 复选框图片 348 | skin['Menu/CheckBox'] = { 349 | 'Image': 'radio.png', 350 | } 351 | 352 | # 绘制箭头图片 353 | savePolygon((6,12), ((0,0),(6,6),(0,12)), other_color, skin_dir + os.sep + 'arrow.png') 354 | 355 | # 箭头图片 356 | skin['Menu/SubMenu'] = { 357 | 'Image': 'arrow.png', 358 | } 359 | 360 | # 菜单文字项边距 361 | skin['Menu/TextMargin'] = { 362 | # 左边距 363 | 'Left': 5, 364 | 365 | # 右边距 366 | 'Right': 5, 367 | 368 | # 上边距 369 | 'Top': 5, 370 | 371 | # 下边距 372 | 'Bottom': 5, 373 | } 374 | 375 | skin.write(open(skin_dir + os.sep + 'theme.conf', 'w', encoding="utf-8"), False) 376 | return 0 377 | -------------------------------------------------------------------------------- /src/ssfconv/convert/out/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["Fcitx", "Fcitx5"] 2 | 3 | from .Fcitx import ssf2fcitx 4 | from .Fcitx5 import ssf2fcitx5 5 | -------------------------------------------------------------------------------- /src/ssfconv/extract/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/ssfconv/extract/ssf.py: -------------------------------------------------------------------------------- 1 | from Cryptodome.Cipher import AES 2 | import zlib, struct 3 | from pathlib import Path 4 | import zipfile 5 | 6 | # 这部分最早可以追溯到 https://github.com/KDE/kimtoy/blob/master/kssf.cpp 7 | 8 | def extract_ssf(file_path:Path, dest_dir:Path): 9 | """ 10 | 解压ssf文件到指定文件夹,文件夹不存在会自动创建 11 | ssf 文件格式目前有两种,一种是加密过后,一种未加密的zip 12 | """ 13 | def __decrypt(bin): 14 | # AES 解密内容 15 | aesKey = b'\x52\x36\x46\x1A\xD3\x85\x03\x66' + \ 16 | b'\x90\x45\x16\x28\x79\x03\x36\x23' + \ 17 | b'\xDD\xBE\x6F\x03\xFF\x04\xE3\xCA' + \ 18 | b'\xD5\x7F\xFC\xA3\x50\xE4\x9E\xD9' 19 | iv = b'\xE0\x7A\xAD\x35\xE0\x90\xAA\x03' + \ 20 | b'\x8A\x51\xFD\x05\xDF\x8C\x5D\x0F' 21 | ssfAES = AES.new(aesKey, AES.MODE_CBC, iv) 22 | plain_bin = ssfAES.decrypt(bin[8:]) 23 | 24 | # zlib 解压内容 25 | data = zlib.decompress(plain_bin[4:]) # 注意要跳过头四字节 26 | 27 | def readUint(offset): 28 | return struct.unpack('I', data[offset:offset+4])[0] 29 | 30 | # 整个内容的大小 31 | size = readUint(0) 32 | 33 | # 得到若干个偏移量 34 | offsets_size = readUint(4) 35 | offsets = struct.unpack('I'*(offsets_size//4),data[8:8+offsets_size]) 36 | 37 | for offset in offsets: 38 | # 得到文件名 39 | name_len = readUint(offset) 40 | filename = data[offset+4:offset+4+name_len].decode('utf-16') 41 | # 得到文件内容 42 | content_len = readUint(offset+4+name_len) 43 | content = data[offset+8+name_len:offset+8+name_len+content_len] 44 | 45 | (dest_dir / filename).write_bytes(content) 46 | 47 | return 48 | 49 | ssf_bin = file_path.read_bytes() 50 | 51 | if ssf_bin[:4] == b'Skin': # 通过头四字节判断是否被加密 52 | __decrypt(ssf_bin) 53 | else: 54 | # 直接 zip 解压 55 | with zipfile.ZipFile(file_path) as zf: 56 | zf.extractall(dest_dir) -------------------------------------------------------------------------------- /src/ssfconv/translation.py: -------------------------------------------------------------------------------- 1 | # import gettext, locale 2 | 3 | # localedir = Path() / "data" / "locales" 4 | 5 | # l10n = gettext.translation( 6 | # "ssfconv", 7 | # localedir=localedir, 8 | # fallback=True, 9 | # ) 10 | # l10n.install() 11 | # _ = l10n.gettext 12 | 13 | #BUG https://github.com/breezy-team/setuptools-gettext/issues/94 14 | # I dont want to use deprecated setup.py so i18n attempt get stuck 15 | _ = lambda x:x #gettext placeholder --------------------------------------------------------------------------------