rust字体子集化

在使用jellyfin播放某些字幕组资源时,出现某些字幕无法渲染,或者出现方块的情况

排查

jellyfin播放原理是使用ffmpeg提取相关资源,ass字幕文件,可能存在的字体附件。然后再用提取的资源文件转码成MP4,发送到前端播放。

所以首先看ass字幕文件,其中[V4+ Styles]中定义了使用的字体

1
2
3
4
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Source Han Sans SC Medium,58,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,0,0,10,1
Style: Default_Top,Source Han Sans SC Medium,50,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,0,0,0,1

Source Han Sans SC Medium就是使用的字体名
这个字体系统里不存在,解决方案有二,一是设置jellyfin的回退字体,但是不合我意;二是嵌入一个字体文件。


我在其他能正常播放的视频文件里提取了相应的附件,然后修改ass文件,指定新的字体名后,再重新封装视频,把新字幕和字体都封装进去

顺便一提,ubuntu包管理器安装的ffmpeg默认是不支持字体文件的

所以我是去jellyfin中重新安装的ffmpeg

结果是成功了一半,有些字正常,有些字不正常。

再使用了FontViewOk比对字体渲染后,确定是字体不支持这些字。一通查找下知道了字体子集化这个概念,就是只打包要渲染的文字,这样可以有效减小字体文件大小,特别适合视频字幕这种文字已知的场景

解决

所以解决方案就是自己实现字体子集化。

查到的方案有使用python的fonttool库,有使用别的gui软件,有使用java的,都不合适。我想用rust实现

首先是使用‌harfbuzz‌,这是个c++库,rust上有多个绑定,我使用的是这个

功能实现上没有问题,问题出在使用musl静态编译上,需要一个musl-g++库,但是我找不到这个库,其他支持musl编译的镜像也不行,只能认为rust和c++库不兼容静态编译。

反复查找后,找到了allsorts,这是一个纯rust实现的字体相关的库,虽然目前还处于开发阶段,但是已经够用了。

以下是实现代码,注意,这里用的版本是0.15.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
use allsorts::{
error::ParseError,
font_data::{self, FontData},
gsub::{GlyphOrigin, RawGlyph, RawGlyphFlags},
tables::{FontTableProvider, NameTable, OffsetTable, OpenTypeData},
tag,
unicode::VariationSelector,
};
pub type BoxError = Box<dyn std::error::Error>;

fn subset_text<F: FontTableProvider>(font_provider: &F, text: &str, output_path: &str) {
// Work out the glyphs we want to keep from the text
let mut glyphs = chars_to_glyphs(font_provider, text).unwrap();
let notdef = RawGlyph {
unicodes: allsorts::tinyvec::tiny_vec![],
glyph_index: 0,
liga_component_pos: 0,
glyph_origin: GlyphOrigin::Direct,
flags: RawGlyphFlags::empty(),
variation: None,
extra_data: (),
};
glyphs.insert(0, Some(notdef));

let mut glyphs: Vec<RawGlyph<()>> = glyphs.into_iter().flatten().collect();
glyphs.sort_by(|a, b| a.glyph_index.cmp(&b.glyph_index));
let mut glyph_ids = glyphs
.iter()
.map(|glyph| glyph.glyph_index)
.collect::<Vec<_>>();
glyph_ids.dedup();
if glyph_ids.is_empty() {
panic!("no glyphs left in font");
}

println!("Number of glyphs in new font: {}", glyph_ids.len());

// Subset
let new_font = allsorts::subset::subset(font_provider, &glyph_ids).unwrap();

// Write out the new font
let mut output = std::fs::File::create(output_path).unwrap();
output.write_all(&new_font).unwrap();
}

fn chars_to_glyphs<F: FontTableProvider>(
font_provider: &F,
text: &str,
) -> Result<Vec<Option<RawGlyph<()>>>, BoxError> {
let cmap_data = font_provider.read_table_data(allsorts::tag::CMAP)?;
let cmap = allsorts::binary::read::ReadScope::new(&cmap_data)
.read::<allsorts::tables::cmap::Cmap>()?;
let (_, cmap_subtable) = allsorts::font::read_cmap_subtable(&cmap)?.ok_or("fail")?;

let glyphs = text
.chars()
.map(|ch| map(&cmap_subtable, ch, None))
.collect::<Result<Vec<_>, _>>()?;

Ok(glyphs)
}
pub(crate) fn map(
cmap_subtable: &allsorts::tables::cmap::CmapSubtable,
ch: char,
variation: Option<VariationSelector>,
) -> Result<Option<RawGlyph<()>>, allsorts::error::ParseError> {
if let Some(glyph_index) = cmap_subtable.map_glyph(ch as u32)? {
let glyph = make(ch, glyph_index, variation);
Ok(Some(glyph))
} else {
Ok(None)
}
}
pub(crate) fn make(
ch: char,
glyph_index: u16,
variation: Option<VariationSelector>,
) -> RawGlyph<()> {
RawGlyph {
unicodes: allsorts::tinyvec::tiny_vec![[char; 1] => ch],
glyph_index,
liga_component_pos: 0,
glyph_origin: GlyphOrigin::Char(ch),
flags: RawGlyphFlags::empty(),
variation,
extra_data: (),
}
}

测试使用musl静态编译也完全没有问题。

至此,功能上可算是完成了。

补充

以上代码生成的字体文件,具体的字体名仍然是原字体名,这是合理的。

之前参考的视频文件里的字体名也做了重命名,原因是避免和系统已有字体出现冲突。

研究了一下解析字体文件过程,字体名等信息被放在某一块叫NameTable的地方,理论上只需要修改这一块的字节,就能把名字改过来。

首先allsorts不支持修改字体名,至少我没找到。而且也不提供NameTable的字节偏移量,也就无法定位到字体名。所以需要修改源码

只需要修改 binary/read.rs#31 ,把base属性暴露出来

1
2
3
4
pub struct ReadScope<'a> {
pub base: usize,
data: &'a [u8],
}

只需要使用base加上原有的部分offset,就能得到在文件里的整体偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
fn dump_name_table(
name_table: &allsorts::tables::NameTable,
) -> Result<(), allsorts::error::ParseError> {
use encoding_rs::{MACINTOSH, UTF_16BE};
for name_record in &name_table.name_records {
let platform = name_record.platform_id;
let encoding = name_record.encoding_id;
let language = name_record.language_id;
let offset = usize::from(name_record.offset);
let length = usize::from(name_record.length);
let name_data = name_table
.string_storage
.offset_length(offset, length)?
.data();
println!("offset={}, length = {length},{:?}",name_table.string_storage.base + offset ,name_data);
let name = match (platform, encoding, language) {
(0, _, _) => decode(UTF_16BE, name_data),
(1, 0, _) => decode(MACINTOSH, name_data),
(3, 0, _) => decode(UTF_16BE, name_data),
(3, 1, _) => decode(UTF_16BE, name_data),
(3, 10, _) => decode(UTF_16BE, name_data),
_ => format!(
"(unknown platform={} encoding={} language={})",
platform, encoding, language
),
};
match get_name_meaning(name_record.name_id) {
Some(meaning) => println!("{}", meaning,),
None => println!("name {}", name_record.name_id,),
}
println!("{:?}", name);
println!();
}
}
fn get_name_meaning(name_id: u16) -> Option<&'static str> {
match name_id {
NameTable::COPYRIGHT_NOTICE => Some("Copyright"),
NameTable::FONT_FAMILY_NAME => Some("Font Family"),
NameTable::FONT_SUBFAMILY_NAME => Some("Font Subfamily"),
NameTable::UNIQUE_FONT_IDENTIFIER => Some("Unique Identifier"),
NameTable::FULL_FONT_NAME => Some("Full Font Name"),
NameTable::VERSION_STRING => Some("Version"),
NameTable::POSTSCRIPT_NAME => Some("PostScript Name"),
NameTable::TRADEMARK => Some("Trademark"),
NameTable::MANUFACTURER_NAME => Some("Manufacturer"),
NameTable::DESIGNER => Some("Designer"),
NameTable::DESCRIPTION => Some("Description"),
NameTable::URL_VENDOR => Some("URL Vendor"),
NameTable::URL_DESIGNER => Some("URL Designer"),
NameTable::LICENSE_DESCRIPTION => Some("License Description"),
NameTable::LICENSE_INFO_URL => Some("License Info URL"),
NameTable::TYPOGRAPHIC_FAMILY_NAME => Some("Typographic Family"),
NameTable::TYPOGRAPHIC_SUBFAMILY_NAME => Some("Typographic Subfamily"),
NameTable::COMPATIBLE_FULL => Some("Compatible Full"),
NameTable::SAMPLE_TEXT => Some("Sample Text"),
NameTable::POSTSCRIPT_CID_FINDFONT_NAME => Some("PostScript CID findfont"),
NameTable::WWS_FAMILY_NAME => Some("WWS Family Name"),
NameTable::WWS_SUBFAMILY_NAME => Some("WWS Subfamily Name"),
NameTable::LIGHT_BACKGROUND_PALETTE => Some("Light Background Palette"),
NameTable::DARK_BACKGROUND_PALETTE => Some("Dark Background Palette"),
NameTable::VARIATIONS_POSTSCRIPT_NAME_PREFIX => Some("Variations PostScript Name Prefix"),
_ => None,
}
}
pub(crate) fn decode(encoding: &'static encoding_rs::Encoding, data: &[u8]) -> String {
let mut decoder = encoding.new_decoder();
if let Some(size) = decoder.max_utf8_buffer_length(data.len()) {
let mut s = String::with_capacity(size);
let (_res, _read, _repl) = decoder.decode_to_string(data, &mut s, true);
s
} else {
String::new() // can only happen if buffer is enormous
}
}

在指定偏移位置覆盖原有长度的字节,应该就是实现重命名,只是我没有去实现


rust字体子集化
http://blog.inkroom.cn/2025/10/27/16VK9N4.html
作者
inkbox
发布于
2025年10月27日
许可协议