Skip to content

kara-templater 源码分析

Published: at 19:29

断断续续学 Aegisub 和特效,一直想要弄懂的就是 kara-templater。文档的介绍部分看的头疼,还是得结合源码来看。这东西就相当于是笔记一样的存在吧(

这里提一句,Aegisub 的 Lua 版本是 Lua 5.1


ToC

入口

首先是入口,最后两行标记了入口函数:

1
aegisub.register_macro(tr"Apply karaoke template", tr"Applies karaoke effects from templates", macro_apply_templates, macro_can_template)
2
aegisub.register_filter(tr"Karaoke template", tr"Apply karaoke effect templates to the subtitles.\n\nSee the help file for information on how to use this.", 2000, filter_apply_templates)

这里的 tr 是调用了本地化的函数,我们暂且跳过;对于第一行,其功能对应的是自动化列表中的应用卡拉OK模板,因此有两个函数对应:前者对应实际的操作,后者对应能否应用操作。比如下图中,后者的返回就是 false

应用卡拉OK模板
应用卡拉OK模板

对于这一行,详细的类 TypeScript 声明如下:

1
type ProcessFunction = (subtitle: Subtitle, selected: lines[], active: number) => void;
2
declare function aegisub.register_macro(
3
name: string,
4
desc: string,
5
processing: ProcessFunction,
6
validation: ProcessFunction,
7
is_active: ProcessFunction
8
)

文中未给出定义的 Subtitle 对象的定义参见 Wiki

而第二行,其对应的是导出时的选项。其参数从左到右分别为名称,简介,优先级和实际操作。

导出
导出

对应的声明如下:

1
type ConfigPanelFunction = (subtitles: Subtitle, old_settings: Table<Setting>) => Table<Dialog>;
2
declare function aegisub.register_filter(
3
name: string,
4
description: string,
5
priority: number,
6
processing: ProcessFunction,
7
configuration_panel_provider
8
);

通过之后的阅读我们会知道,应用卡拉OK模板包含了导出中的功能因此我们只需要分析应用卡拉OK模板对应的函数就足够了。

macro_can_template

先从能否应用卡拉 OK 模板的 macro_can_template 函数开始吧:

1
function macro_can_template(subs)
2
-- 检测文件中是否有模板,当不存在模板时不允许宏运行
3
local num_dia = 0
4
for i = 1, #subs do
5
local l = subs[i]
6
if l.class == "dialogue" then
7
num_dia = num_dia + 1
8
-- 检测该行是否为 template 行
9
if (string.headtail(l.effect)):lower() == "template" then
10
return true
11
end
12
-- 不尝试匹配所有行以减短执行时间
13
if num_dia > 50 then
14
return false
15
end
16
end
17
end
18
return false
19
end

检测函数对字幕的各行进行循环,并尝试寻找以 template 作为特效栏第一个单词的行。当这样的行存在时,直接返回 true,表示可以应用。

这里值得注意的是,函数的执行限定了 dialogue 的前 50 行。这也是推荐将 template 放在整个字幕文件开头的原因。

macro_apply_templates

照例从入口开始:

1
function macro_apply_templates(subs, sel)
2
filter_apply_templates(subs, {ismacro=true, sel=sel})
3
aegisub.set_undo_point("apply karaoke template")
4
end

这个函数调用了 filter_apply_templates,这也就是上文中包含的来源。和 filter_apply_templates 相比,其只多了一个撤回以便应用内操作——导出是不需要撤回的。

这里有个问题:filter_apply_templates 函数的第二个参数和上文定义中不一致。不知道这是不是属于 bug,但由于实际的函数操作中并没有用到第二个参数,因此估计这是一个历史遗留问题。

filter_apply_templates

既然本体是这个,拿我们就直接来看好了。

1
-- 模板的主函数
2
function filter_apply_templates(subs, config)
3
aegisub.progress.task("Collecting header data...")
4
local meta, styles = karaskel.collect_head(subs, true)
5
6
aegisub.progress.task("Parsing templates...")
7
local templates = parse_templates(meta, styles, subs)
8
9
aegisub.progress.task("Applying templates...")
10
apply_templates(meta, styles, subs, templates)
11
end

可以看出有三个步骤:收集头部信息解析模板应用模板

karaskel.collect_head

本来这部分我想单独拉出一篇来看 karaskel,但后来想想还是算了。我们先进入 collect_head 函数:

初始化

1
-- 从字幕文件中收集样式和 metadata
2
function karaskel.collect_head(subs, generate_furigana)
3
local meta = {
4
-- X and Y script resolution
5
res_x = 0, res_y = 0,
6
-- 对视频和脚本分辨率不一致的修正因子
7
video_x_correct_factor = 1.0
8
}
9
local styles = { n = 0 }
10
local toinsert = {}
11
local first_style_line = nil
12
13
if not karaskel.furigana_scale then
14
karaskel.furigana_scale = 0.5
15
end

到这里为止的部分都是初始化。可以看到,要返回的 meta 中有 xy 分辨率和视频缩放因子 video_x_corrent_factor。下面这个 toinsert 看上去诡异,其实是 to_insert 没加下划线的锅。最后,其初始化了注音缩放因子0.5

步骤 1 - 收集样式与信息

1
-- First pass: collect all existing styles and get resolution info
2
for i = 1, #subs do
3
if aegisub.progress.is_cancelled() then error("User cancelled") end
4
local l = subs[i]
5
6
if l.class == "style" then
7
if not first_style_line then first_style_line = i end
8
-- Store styles into the style table
9
styles.n = styles.n + 1
10
styles[styles.n] = l
11
styles[l.name] = l
12
l.margin_v = l.margin_t -- convenience
13
14
-- And also generate furigana styles if wanted
15
if generate_furigana and not l.name:match("furigana") then
16
aegisub.debug.out(5, "Creating furigana style for style: " .. l.name .. "\n")
17
local fs = table.copy(l)
18
fs.fontsize = l.fontsize * karaskel.furigana_scale
19
fs.outline = l.outline * karaskel.furigana_scale
20
fs.shadow = l.shadow * karaskel.furigana_scale
21
fs.name = l.name .. "-furigana"
22
23
table.insert(toinsert, fs) -- queue to insert in file
24
end
25
26
elseif l.class == "info" then
27
-- Also look for script resolution
28
local k = l.key:lower()
29
meta[k] = l.value
30
end
31
end

接下来的步骤是收集所有的样式和 metadata。针对耗时较长的过程,我们需要允许用户随时打断操作,因此要提供随时可以中止的能力。在遍历过程中,这个步骤只对 style 行和 info 行操作。

值得注意的是,info 行是 Aegisub[Script Info] 的封装。其拥有 keyvalue 两个成员。

对于 style 行,其将样式存储在 styles 中(可通过索引和名称访问),并将 styles 中表示存储行数的变量(styles.n)自增。并且在这一步,增加了 margin_v 这以参数,对应 margin_t,方便之后的操作。

这一步中还存储了首行样式对应的行号以供下一步处理。(虽然我没看出具体用途)

同时,这一步还处理了注音样式。注音样式的判断依据是样式的名称中含有 furigana。对于所有非注音样式,都将生成一份副本。 副本的名称是 原名-furigana ,对应的字体边框阴影都被乘以了注音缩放因子。最后,其被加入 to_insert 中。

而对于 info 行,我们将其全部存入 meta 中。为了访问方便,我们将键值对中的全部转为其小写形式

步骤 2 - 加入新样式

1
-- Second pass: insert all toinsert styles that don't already exist
2
for i = 1, #toinsert do
3
if not styles[toinsert[i].name] then
4
-- 加入样式表
5
styles.n = styles.n + 1
6
styles[styles.n] = toinsert[i]
7
styles[toinsert[i].name] = toinsert[i]
8
-- 加入字幕文件
9
subs[-first_style_line] = toinsert[i]
10
end
11
end

这里的新样式就是之前生成的注音样式了。由于其全部存储在 to_insert 中,这一步的目的就是将其加入到 styles 里。当然了,前提是 styles 里不存在同名的样式。

行 9 意味不明,如果有知道的大佬欢迎在评论区指出。

步骤 3 - 修复分辨率

1
-- 修复分辨率
2
if meta.playresx then
3
meta.res_x = math.floor(meta.playresx)
4
end
5
if meta.playresy then
6
meta.res_y = math.floor(meta.playresy)
7
end
8
if meta.res_x == 0 and meta_res_y == 0 then
9
meta.res_x = 384
10
meta.res_y = 288
11
elseif meta.res_x == 0 then
12
-- This is braindead, but it's how TextSub does things...
13
if meta.res_y == 1024 then
14
meta.res_x = 1280
15
else
16
meta.res_x = meta.res_y / 3 * 4
17
end
18
elseif meta.res_y == 0 then
19
-- As if 1280x960 didn't exist
20
if meta.res_x == 1280 then
21
meta.res_y = 1024
22
else
23
meta.res_y = meta.res_x * 3 / 4
24
end
25
end

这里负责处理的就是分辨率方面的问题了。首先,通过向下取整,其将分辨率归约为整数;然后处理分辨率为 0 的不同情况。当 xy 同时为 0 时,其将分辨率定为 384*288;当只有 x 为 0 时,其根据 y 进行判断:当 y 为 1280 时,分辨率为 1024*1280(这一步是模拟 TextSub 的操作),否则就设置为 4:3 的对应分辨率;当只有 y 为 0 时,同样要么是 1280*1024,要么是 4:3

在字幕制作过程中,不应该出现任何未指定分辨率的情况。

步骤 4 - 视频尺寸,返回

1
local video_x, video_y = aegisub.video_size()
2
if video_y then
3
-- Correction factor for TextSub weirdness when render resolution does
4
-- not match script resolution. Text pixels are considered square in
5
-- render resolution rather than in script resolution, which is
6
-- logically inconsistent. Correct for that.
7
meta.video_x_correct_factor =
8
(video_y / video_x) / (meta.res_y / meta.res_x)
9
end
10
aegisub.debug.out(4, "Karaskel: Video X correction factor = %f\n\n", meta.video_x_correct_factor)
11
12
return meta, styles
13
end

最后处理的是视频,这一步同样也是为 TextSub 兼容而存在的。当视频分辨率与字幕分辨率不同时,其在 meta 中存入一个缩放因子,表示视频比例/字幕比例

到此,收集头部的工作就全部完成了。

parse_templates

在这一步中,我们将通过字幕文件生成对应的模板/代码。

1
-- Find and parse/prepare all karaoke template lines
2
function parse_templates(meta, styles, subs)
3
local templates = { once = {}, line = {}, syl = {}, char = {}, furi = {}, styles = {} }
4
local i = 1
5
while i <= #subs do
6
aegisub.progress.set((i-1) / #subs * 100)
7
local l = subs[i]
8
i = i + 1
9
if l.class == "dialogue" and l.comment then
10
local fx, mods = string.headtail(l.effect)
11
fx = fx:lower()
12
if fx == "code" then
13
parse_code(meta, styles, l, templates, mods)
14
elseif fx == "template" then
15
parse_template(meta, styles, l, templates, mods)
16
end
17
templates.styles[l.style] = true
18
elseif l.class == "dialogue" and l.effect == "fx" then
19
-- this is a previously generated effect line, remove it
20
i = i - 1
21
subs.delete(i)
22
end
23
end
24
aegisub.progress.set(100)
25
return templates
26
end

首先还是初始化,接下来便是遍历所有字幕行。这一次需要处理的是 dialogue 行,因此 class 限定成了 dialogue。对于 dialogue,我们还需要有所取舍:对于注释行,我们尝试将其解析为模板;对于特效为 fx,也就是标记为生成行的,我们需要将其直接删除

这里有一个辅助函数 string.headtail,其作用是以第一个空格为界限,将字符串分割为 headtail 两个部分。[reference]

于是,我们将特效栏的内容分割成了 fxmods 两块。接下来就是对不同的特效进行解析了:当 fxcode 时,对应代码行的处理方式;当 fxtemplate 时,对应模板行的处理方式。最后,模板行用到的样式被标记为 true

parse_code

这是复制过去忘改了吧(逃(下文已修正
这是复制过去忘改了吧(逃(下文已修正

1
function parse_code(meta, styles, line, templates, mods)
2
local template = {
3
code = line.text,
4
loops = 1,
5
style = line.style
6
}
7
local inserted = false
8
9
local rest = mods
10
while rest ~= "" do
11
local m, t = string.headtail(rest)
12
rest = t
13
m = m:lower()
14
if m == "once" then
15
aegisub.debug.out(5, "Found run-once code line: %s\n", line.text)
16
table.insert(templates.once, template)
17
inserted = true
18
elseif m == "line" then
19
aegisub.debug.out(5, "Found per-line code line: %s\n", line.text)
20
table.insert(templates.line, template)
21
inserted = true
22
elseif m == "syl" then
23
aegisub.debug.out(5, "Found per-syl code line: %s\n", line.text)
24
table.insert(templates.syl, template)
25
inserted = true
26
elseif m == "furi" then
27
aegisub.debug.out(5, "Found per-furi code line: %s\n", line.text)
28
table.insert(templates.furi, template)
29
inserted = true
30
elseif m == "all" then
31
template.style = nil
32
elseif m == "noblank" then
33
template.noblank = true
34
elseif m == "repeat" or m == "loop" then
35
local times, t = string.headtail(rest)
36
template.loops = tonumber(times)
37
if not template.loops then
38
aegisub.debug.out(3, "Failed reading this repeat-count to a number: %s\nIn template code line: %s\nEffect field: %s\n\n", times, line.text, line.effect)
39
template.loops = 1
40
else
41
rest = t
42
end
43
else
44
aegisub.debug.out(3, "Unknown modifier in code template: %s\nIn template code line: %s\nEffect field: %s\n\n", m, line.text, line.effect)
45
end
46
end
47
48
if not inserted then
49
aegisub.debug.out(5, "Found implicit run-once code line: %s\n", line.text)
50
table.insert(templates.once, template)
51
end
52
end

这里其实是针对不同的修饰语(modifier)进行的匹配。所有的修饰语可以同时使用,包括 oncelinesylfuriallnoblankrepeatalias = loop)。

oncelinesylfuri 对应的是四种执行模式,而 allnoblankrepeat 对应的是执行方式。没有指定执行模式的 code 行将会隐式地被归为 once

对于执行方式,其经过了如下处理:

代码行对应的内容就是该行 dialogue 的文本。

parse_template

模板的多样性比代码要多不少,但基本解析方式还是一样的。不多说,上源码:

1
-- List of reserved words that can't be used as "line" template identifiers
2
template_modifiers = {
3
"pre-line", "line", "syl", "furi", "char", "all", "repeat", "loop",
4
"notext", "keeptags", "noblank", "multi", "fx", "fxgroup"
5
}
6
7
function parse_template(meta, styles, line, templates, mods)
8
local template = {
9
t = "",
10
pre = "",
11
style = line.style,
12
loops = 1,
13
layer = line.layer,
14
addtext = true,
15
keeptags = false,
16
fxgroup = nil,
17
fx = nil,
18
multi = false,
19
isline = false,
20
perchar = false,
21
noblank = false
22
}
23
local inserted = false
24
25
local rest = mods
26
while rest ~= "" do
27
local m, t = string.headtail(rest)
28
rest = t
29
m = m:lower()
30
if (m == "pre-line" or m == "line") and not inserted then
31
aegisub.debug.out(5, "Found line template '%s'\n", line.text)
32
-- should really fail if already inserted
33
local id, t = string.headtail(rest)
34
id = id:lower()
35
-- check that it really is an identifier and not a keyword
36
for _, kw in pairs(template_modifiers) do
37
if id == kw then
38
id = nil
39
break
40
end
41
end
42
if id == "" then
43
id = nil
44
end
45
if id then
46
rest = t
47
end
48
-- get old template if there is one
49
if id and templates.line[id] then
50
template = templates.line[id]
51
elseif id then
52
template.id = id
53
templates.line[id] = template
54
else
55
table.insert(templates.line, template)
56
end
57
inserted = true
58
template.isline = true
59
-- apply text to correct string
60
if m == "line" then
61
template.t = template.t .. line.text
62
else -- must be pre-line
63
template.pre = template.pre .. line.text
64
end
65
elseif m == "syl" and not template.isline then
66
table.insert(templates.syl, template)
67
inserted = true
68
elseif m == "furi" and not template.isline then
69
table.insert(templates.furi, template)
70
inserted = true
71
elseif (m == "pre-line" or m == "line") and inserted then
72
aegisub.debug.out(2, "Unable to combine %s class templates with other template classes\n\n", m)
73
elseif (m == "syl" or m == "furi") and template.isline then
74
aegisub.debug.out(2, "Unable to combine %s class template lines with line or pre-line classes\n\n", m)
75
elseif m == "all" then
76
template.style = nil
77
elseif m == "repeat" or m == "loop" then
78
local times, t = string.headtail(rest)
79
template.loops = tonumber(times)
80
if not template.loops then
81
aegisub.debug.out(3, "Failed reading this repeat-count to a number: %s\nIn template line: %s\nEffect field: %s\n\n", times, line.text, line.effect)
82
template.loops = 1
83
else
84
rest = t
85
end
86
elseif m == "notext" then
87
template.addtext = false
88
elseif m == "keeptags" then
89
template.keeptags = true
90
elseif m == "multi" then
91
template.multi = true
92
elseif m == "char" then
93
template.perchar = true
94
elseif m == "noblank" then
95
template.noblank = true
96
elseif m == "fx" then
97
local fx, t = string.headtail(rest)
98
if fx ~= "" then
99
template.fx = fx
100
rest = t
101
else
102
aegisub.debug.out(3, "No fx name following fx modifier\nIn template line: %s\nEffect field: %s\n\n", line.text, line.effect)
103
template.fx = nil
104
end
105
elseif m == "fxgroup" then
106
local fx, t = string.headtail(rest)
107
if fx ~= "" then
108
template.fxgroup = fx
109
rest = t
110
else
111
aegisub.debug.out(3, "No fxgroup name following fxgroup modifier\nIn template linee: %s\nEffect field: %s\n\n", line.text, line.effect)
112
template.fxgroup = nil
113
end
114
else
115
aegisub.debug.out(3, "Unknown modifier in template: %s\nIn template line: %s\nEffect field: %s\n\n", m, line.text, line.effect)
116
end
117
end
118
119
if not inserted then
120
table.insert(templates.syl, template)
121
end
122
if not template.isline then
123
template.t = line.text
124
end
125
end

类似地,我们将修饰语归为执行模式执行方式两种。

执行模式有:pre-linelinesylfuri

执行方式allrepeatnotextkeeptagsmulticharnoblankfxfxgroup

这里与 code 最大的区别是执行模式之间互斥的。一行 template 只能有一种执行模式,当存在另一种是,其将会被跳过。

preline,line

我们顺着往下看吧。L32-L47 是负责处理 idrest 的。对于 id,唯一的规定就是不能与保留字相同。当与保留字相同时,根据代码逻辑,该名称将会被跳过,该行将会被当作未命名模板行处理。该保留字将在下一轮循环中再次解析。

接下来的 L48-L56 判断了处理方法。这里的 if idif id != nil 的缩写,因此第一个判断的成立条件是:存在 id 且 存在同名模板。这时候,我们将现有模板覆盖为原有模板。

第二个判断则是:存在 id不存在同名模板。这时候,将模板的 id 设定为当前获取的 id,并且将其加入 templates.line

否则,也就是不存在 id 的情况,此时将其以不指定名称的形式加入 template.line 中。

处理完上述步骤之后,L57-L64 就对应了模板合并的过程。首先设置 insertislinetrue 是为了避免重复解析(这在大 if 的各个分支中也有体现,这里不细讲),而下面的则是为了合并。对于 line,将其放到 template.t,而对 pre-line,将其放到 template.pre。这里如果对应的是上文的第一个判断成立的话,就是将新的内容 append 到旧内容之后,以达到模板合并的目的。

其他模式与方式

剩下的两种执行模式就很简单了,与 code 行的处理方式没有区别。而对于执行方式,fxfxgroup 之后需要一个 identifierrepeat 和上文一致,而剩下的都只是简单的 bool 赋值,这里就不再阐述了

最后,若没有指定任何一种处理模式,则缺省模式为 syl;若非 line 类型的模板行(即 sylfuri),则内容缺省为该行文本内容。

到此,解析模板的部分全部结束。

// TODO: 这一部分没有将大段代码拆开,之后需要修改

apply_templates

这部分就是核心了。对于 code 行而言,其需要一个脚本的执行环境,这部分内容也是在这里构建的。我们称脚本的运行环境为 tenv

tenv

初始化
1
-- the environment the templates will run in
2
local tenv = {
3
meta = meta,
4
-- put in some standard libs
5
string = string,
6
math = math,
7
_G = _G
8
}
9
tenv.tenv = tenv

这里初始化了 tenv 中可用的内容:字幕文件的 meta 信息、stringmath 和 对应全局作用域的 _G(即其他语言的 global)。最后,其将自身的引用存入 tenv.tenv 中。

retime

retime 函数是根据模式偏移量重新设置该行字幕起止时间的辅助函数。Wiki 有一张图解释了各种模式的具体细节。[reference]

初始化
1
tenv.retime = function(mode, addstart, addend)
2
local line, syl = tenv.line, tenv.syl
3
local newstart, newend = line.start_time, line.end_time
4
addstart = addstart or 0
5
addend = addend or 0

初始化,从 tenv 取出 linesyl,从 line 取出 start_timeend_time,并将开始结束时间的偏移量默认设置为 0。

音节(syl)
1
if mode == "syl" then
2
newstart = line.start_time + syl.start_time + addstart
3
newend = line.start_time + syl.end_time + addend

音节模式。起始时间是该音节的开始时间,结束时间是该音节的结束时间。

音节前(presyl)
1
elseif mode == "presyl" then
2
newstart = line.start_time + syl.start_time + addstart
3
newend = line.start_time + syl.start_time + addend

音节前模式。起止时间均为该音节的开始时间。

音节后(postsyl)
1
elseif mode == "postsyl" then
2
newstart = line.start_time + syl.end_time + addstart
3
newend = line.start_time + syl.end_time + addend

音节后模式。起止时间均为该音节的结束时间。

行(line)
1
elseif mode == "line" then
2
newstart = line.start_time + addstart
3
newend = line.end_time + addend

模式。起始时间为该行的起始时间,结束时间为该行的结束时间。

行前(preline)
1
elseif mode == "preline" then
2
newstart = line.start_time + addstart
3
newend = line.start_time + addend

行前模式。起止时间均为该行的起始时间。

行后(postline)
1
elseif mode == "postline" then
2
newstart = line.end_time + addstart
3
newend = line.end_time + addend

行后模式。起止时间均为该行的结束时间。

行前至音节(start2syl)
1
elseif mode == "start2syl" then
2
newstart = line.start_time + addstart
3
newend = line.start_time + syl.start_time + addend

行前至音节模式。起始时间为该行的起始时间,结束时间为该音节的起始时间。

音节至行后(syl2end)
1
elseif mode == "syl2end" then
2
newstart = line.start_time + syl.end_time + addstart
3
newend = line.end_time + addend

音节至行后模式。起始时间为该音节的结束时间,结束时间为该行的结束时间。

绝对时间(set/abs)
1
elseif mode == "set" or mode == "abs" then
2
newstart = addstart
3
newend = addend

绝对时间模式。直接设置起始时间,无任何相对关系。

百分比音节(sylpct)
1
elseif mode == "sylpct" then
2
newstart = line.start_time + syl.start_time + addstart*syl.duration/100
3
newend = line.start_time + syl.start_time + addend*syl.duration/100

百分比音节模式。相对该音节的持续时间计算偏移。

愿望单
1
-- wishlist: something for fade-over effects,
2
-- "time between previous line and this" and
3
-- "time between this line and next"
4
end

这功能还没加,估计是摸了。

回写
1
line.start_time = newstart
2
line.end_time = newend
3
line.duration = newend - newstart
4
return ""
5
end

设置该行的起止时间持续时间

fxgroup
1
tenv.fxgroup = {}

初始化 fxgroup

relayer
1
tenv.relayer = function(layer)
2
tenv.line.layer = layer
3
return ""
4
end

辅助函数 relayer,负责将 tenv 当前处理行layer 修改为指定值。

restyle
1
tenv.restyle = function(style)
2
tenv.line.style = style
3
tenv.line.styleref = styles[style]
4
return ""
5
end

辅助函数 restyle,负责将当前处理行样式设定为指定样式,并将指定样式的具体引用存储于 styleref 中。

maxloop(s)
1
tenv.maxloop = function(newmaxj)
2
tenv.maxj = newmaxj
3
return ""
4
end
5
tenv.maxloops = tenv.maxloop

辅助函数 maxloop 以及别名 maxloops,负责将 tenvmaxj 设定为指定值。

loopctl
1
tenv.loopctl = function(newj, newmaxj)
2
tenv.j = newj
3
tenv.maxj = newmaxj
4
return ""
5
end

辅助函数 loopctl,负责同时设置 jmaxj

recall

辅助函数 recall,和下文的诸多 remember 函数一起,属于持久存储部分的内容。通过将数据存储在 recall 本身的 table 中,以达到跨脚本共享的目的。

初始化
1
tenv.recall = {}
2
setmetatable(tenv.recall, {
3
decorators = {},

decorator 本身存储在 metatable 中是为了防止出现 namedecorator 的持久项使 decorator 被覆盖。

为了方便起见,后文提到对应元表(metatable)时会以 prototype 替换。

__call
1
__call = function(tab, name, default)
2
local decorator = getmetatable(tab).decorators[name]
3
if decorator then
4
name = decorator(tostring(name))
5
end
6
aegisub.debug.out(5, "Recalling '%s'\n", name)
7
return tab[name] or default
8
end,

__call 定义了调用时的操作。调用 recall 时,首先会尝试获取对应的真实名称修饰器decorators[name])并将执行结果覆盖 name,然后返回 tab[name],或不存在时返回 default

decorator_[a-z]+
1
decorator_line = function(name)
2
return string.format("_%s_%s", tostring(tenv.orgline), name)
3
end,
4
decorator_syl = function(name)
5
return string.format("_%s_%s", tostring(tenv.syl), name)
6
end,
7
decorator_basesyl = function(name)
8
return string.format("_%s_%s", tostring(tenv.basesyl), name)
9
end
10
})

这三个 decorator_[a-z]+ 是辅助函数,配合下文使用的。我们先往下看。

remember

辅助函数 remember,持久存储的存储部分。

获取真实名称
1
tenv.remember = function(name, value, decorator)
2
getmetatable(tenv.recall).decorators[name] = decorator
3
if decorator then
4
name = decorator(tostring(name))
5
end
6
aegisub.debug.out(5, "Remembering '%s' as '%s'\n", name, tostring(value))

给定 namevalue,其通过真实名称修饰器decorator)修饰后存入 tenv.recall,并将修饰器存入 tenv.recall.prototype.decorator

读取并返回
1
tenv.recall[name] = value
2
return value
3
end

函数返回存入的值。这个返回值一般用于内联代码以简化语句。如:

1
{\t(!remember("time", math.random(10,20)*10)!, !line.duration!, !recall.time!}

群里摸过来的,不要打我

remember_(line|(base)?syl)
1
tenv.remember_line = function(name, value)
2
return tenv.remember(name, value, getmetatable(tenv.recall).decorator_line)
3
end
4
tenv.remember_syl = function(name, value)
5
return tenv.remember(name, value, getmetatable(tenv.recall).decorator_syl)
6
end
7
tenv.remember_basesyl = function(name, value)
8
return tenv.remember(name, value, getmetatable(tenv.recall).decorator_basesyl)
9
end

辅助函数 remember_[a-z]+,对应上文的 tenv.recall.prototype.decorator_[a-z]+,是三个预定义了 decorator 函数的 remember 函数。某种意义上是相当于语法糖的存在。

remember_if

辅助函数 remember_if,负责在 condition 生效时执行对应的 remember 操作。

1
tenv.remember_if = function(name, value, condition, decorator)
2
if condition then
3
return tenv.remember(name, value, decorator)
4
end
5
return value
6
end

code once

在初始化好 tenv 之后,首先要运行的就是 template.once。在这一步,所有的 once 代码行全部执行完成。该循环如下所示:

1
-- 运行所有的 code once 代码片段
2
for k, t in pairs(templates.once) do
3
assert(t.code, "WTF, a 'once' template without code?")
4
run_code_template(t, tenv)
5
end

这里断言(assert)了所有代码行都有内容。具体的执行过程在 run_code_template 中,我们跳转过去看看。

run_code_template
加载代码
1
function run_code_template(template, tenv)
2
local f, err = loadstring(template.code, "template code")
3
if not f then
4
aegisub.debug.out(2, "Failed to parse Lua code: %s\nCode that failed to parse: %s\n\n", err, template.code)

通过 loadString 将对应文本转化为函数,传入的第二个参数设置了 chunk name

当代码存在语法错误或其他问题时,返回 err,执行失败。

设置环境、循环次数
1
else
2
local pcall = pcall
3
setfenv(f, tenv)
4
for j, maxj in template_loop(tenv, template.loops) do

这里将 pcall 引用存下供下文调用,并将 tenv 设置为函数 f 的执行环境。

值得注意的是,这里 for ... in 的是一个函数的返回值。我们跳往那个函数,也就是 template_loop 看看。

template_loop
1
-- 迭代器函数,返回一个以 tenv.j 和 tenv.maxj 控制的迭代器
2
function template_loop(tenv, initmaxj)
3
local oldmaxj = initmaxj
4
tenv.maxj = initmaxj
5
tenv.j = 0
6
local function itor()
7
if tenv.j >= tenv.maxj or aegisub.progress.is_cancelled() then
8
return nil
9
else
10
tenv.j = tenv.j + 1
11
if oldmaxj ~= tenv.maxj then
12
aegisub.debug.out(5, "Number of loop iterations changed from %d to %d\n", oldmaxj, tenv.maxj)
13
oldmaxj = tenv.maxj
14
end
15
return tenv.j, tenv.maxj
16
end
17
end
18
return itor
19
end

这个函数负责的是控制迭代次数。其在其执行开始时存储了传入的 initmaxj,并将循环次数设置为 0。之后,返回了一个迭代器:每次迭代执行时,其首先检查是否超出迭代次数或用户打断,此时,迭代中止;否则,迭代次数自加,且当 tenv.maxj 被手动设置时,以新的 tenv.maxj 覆盖旧的。

这里个人感觉有问题。如果这轮循环之前设置了 tenv.maxj,那在这个值还没有更新的时候第一个 if 是不成立的,而在更新后其就成立了,相当于多运行了一个循环,可能会导致不可知的问题。

个人建议修复:将 if oldmaxj ~= tenv.maxj 一段移至 local function itor() 开头。

正式调用
1
local res, err = pcall(f)
2
if not res then
3
aegisub.debug.out(2, "Runtime error in template code: %s\nCode producing error: %s\n\n", err, template.code)
4
end
5
end
6
end
7
end

通过 pcall 调用字符串生成的函数。

通过 pcall 调用的函数模式为保护模式,在这个模式下,函数执行出现问题时并不会直接 panic,而是返回错误,也就是第二个返回值 err

正式处理

由于正式处理的过程调用了大量函数,这里我们把其调用的函数和其本身同级显示,以保证目录的级数可用。

主循环
1
-- 开始处理行
2
local i, n = 0, #subs
3
while i < n do
4
aegisub.progress.set(i/n*100)
5
i = i + 1

首先初始化了 in。这里把 i 初始化为 0 是因为 lua 的下标从 1 开始,于后面的 i = i + 1 对应。

处理条件
1
local l = subs[i]
2
if l.class == "dialogue" and ((l.effect == "" and not l.comment) or l.effect:match("[Kk]araoke")) then

循环是针对字幕所有行的,对于当前循环行 l,只在其为 dialogue 的时候对其进行处理;只在该行的特效为空不为注释,或该行的特效为 [Kk]araoke 时对其进行处理。特效为空且不为注释的情况是未处理过的行,而特效未 [Kk]araoke 则是之前运行时处理过且被标记了的行。

处理前操作
1
l.i = i
2
l.comment = false
3
karaskel.preproc_line(subs, meta, styles, l)

在正式处理前,首先先记录下当前行的下标 i,并且将其注释关闭。接下来,运行 **karaskel.preproc_line**,处理字幕位置。

正式处理与处理后操作
1
if apply_line(meta, styles, subs, l, templates, tenv) then
2
-- 有样式应用到这行了 将其标记为 karaoke 行
3
l.comment = true
4
l.effect = "karaoke"
5
subs[i] = l
6
end
7
end
8
end
9
end

正式处理调用的是 **apply_line**。当返回有效时,表示样式应用成功。

应用成功后执行处理后操作:将当前行修改为注释行,将特效设置为 karaoke,覆盖回字幕文件。

karaskel.preproc_line

1
-- 预计算行的一些信息
2
-- 会修改 line 参数
3
function karaskel.preproc_line(subs, meta, styles, line)
4
-- subs 参数不会用到 并且可能永远不会用到
5
-- (哪一行突然改变 index 这件事情就不大对)
6
-- 随便传入,但小心直接调用 preproc_line_pos,函数签名可能会变
7
karaskel.preproc_line_pos(meta, styles, line)
8
end

可以看到,实际调用的是 preproc_line_pos

karaskel.preproc_line_pos

预处理:计算行尺寸
1
-- 布置行布局 包括注音布局
2
-- 会修改行对象
3
function karaskel.preproc_line_pos(meta, styles, line)
4
if not line.styleref then
5
karaskel.preproc_line_size(meta, styles, line)
6
end

当样式不存在时,我们先去生成/匹配样式。

这里调用的是 preproc_line_size 函数,其在计算整行大小时也将所有的样式应用到了各个音节。传送门

布局:注音布局
1
-- 注音布局必须在其他步骤之前进行,因为其可能会改变整行宽度
2
if line.furistyle then
3
karaskel.do_furigana_layout(meta, styles, line)

传送门

布局:基本布局
1
else
2
karaskel.do_basic_layout(meta, styles, line)
3
end

不考虑注音的超简单布局,我们直接来看对应函数。

karaskel.do_basic_layout
1
-- 进行基本音节布局(无注音)
2
function karaskel.do_basic_layout(meta, styles, line)
3
local curx = 0
4
for i = 0, line.kara.n do
5
local syl = line.kara[i]
6
syl.left = curx + syl.prespacewidth
7
syl.center = syl.left + syl.width / 2
8
syl.right = syl.left + syl.width
9
curx = curx + syl.prespacewidth + syl.width + syl.postspacewidth
10
end
11
end

对于一行中的每一个音节,将 leftcenterright 分别通过 width 计算出来即可。

外边距(margin)
1
-- 有效(实际)外边距
2
line.margin_v = line.margin_t
3
line.eff_margin_l = ((line.margin_l > 0) and line.margin_l) or line.styleref.margin_l
4
line.eff_margin_r = ((line.margin_r > 0) and line.margin_r) or line.styleref.margin_r
5
line.eff_margin_t = ((line.margin_t > 0) and line.margin_t) or line.styleref.margin_t
6
line.eff_margin_b = ((line.margin_b > 0) and line.margin_b) or line.styleref.margin_b
7
line.eff_margin_v = ((line.margin_v > 0) and line.margin_v) or line.styleref.margin_v

该行的实际外边距:line.margin_\w+ 存在时为 line.margin_\w+,不存在时为样式对应的外边距。

水平定位
左对齐
1
-- 以及定位
2
if line.styleref.align == 1 or line.styleref.align == 4 or line.styleref.align == 7 then
3
-- 左对齐
4
line.left = line.eff_margin_l
5
line.center = line.left + line.width / 2
6
line.right = line.left + line.width
7
line.x = line.left
8
line.halign = "left"

左对齐对应的是 align 为 1、4、7 的情况。此时,该行的 left 为左边距,center 为左边距+一半宽度,right 为左边距+宽度。由于是左对齐,xleft

halign 是该行对齐方式的文本表述,此时为 left

居中对齐
1
elseif line.styleref.align == 2 or line.styleref.align == 5 or line.styleref.align == 8 then
2
-- 居中对齐
3
line.left = (meta.res_x - line.eff_margin_l - line.eff_margin_r - line.width) / 2 + line.eff_margin_l
4
line.center = line.left + line.width / 2
5
line.right = line.left + line.width
6
line.x = line.center
7
line.halign = "center"

居中对齐对应的是 align 为2、5、8 的情况。此时,想要计算出一行的 left,必须考虑视频宽度、左右边距和行宽。视频宽度减去左右边距和行宽后,剩下的区域需要平分,再加上左边距,就是该行的 left 了。

同理计算出 centerright。由于是居中对齐,xcenter

右对齐
1
elseif line.styleref.align == 3 or line.styleref.align == 6 or line.styleref.align == 9 then
2
-- 右对齐
3
line.left = meta.res_x - line.eff_margin_r - line.width
4
line.center = line.left + line.width / 2
5
line.right = line.left + line.width
6
line.x = line.right
7
line.halign = "right"
8
end
9
line.hcenter = line.center

右对齐对应的是 align 为 3、6、9 的情况。与左对齐类似,此处不再阐述。

最后,记录下该行的 hcenter,记为垂直中心

垂直定位
1
if line.styleref.align >=1 and line.styleref.align <= 3 then
2
-- 底部对齐
3
line.bottom = meta.res_y - line.eff_margin_b
4
line.middle = line.bottom - line.height / 2
5
line.top = line.bottom - line.height
6
line.y = line.bottom
7
line.valign = "bottom"
8
elseif line.styleref.align >= 4 and line.styleref.align <= 6 then
9
-- 垂直居中
10
line.top = (meta.res_y - line.eff_margin_t - line.eff_margin_b - line.height) / 2 + line.eff_margin_t
11
line.middle = line.top + line.height / 2
12
line.bottom = line.top + line.height
13
line.y = line.middle
14
line.valign = "middle"
15
elseif line.styleref.align >= 7 and line.styleref.align <= 9 then
16
-- 顶部对齐
17
line.top = line.eff_margin_t
18
line.middle = line.top + line.height / 2
19
line.bottom = line.top + line.height
20
line.y = line.top
21
line.valign = "top"
22
end
23
line.vcenter = line.middle
24
end

垂直定位与水平定位基本一致,此处省略。最后记录下了该行的 vcenter,记为水平中心

karaskel.preproc_line_size

预处理:音节信息
1
-- 预计算指定行的大小信息,不作布局处理
2
-- 会修改行对象
3
function karaskel.preproc_line_size(meta, styles, line)
4
if not line.kara then
5
karaskel.preproc_line_text(meta, styles, line)
6
end

line.kara 不存在时,预处理该行的文本。传送门

简单介绍一下这个函数的作用吧,它将注音由 line 对象解析成了 line.kara,其中对应每个音节和注音都有了时间等关键信息。

存储样式引用
1
-- 增加样式信息
2
if styles[line.style] then
3
line.styleref = styles[line.style]
4
else
5
aegisub.debug.out(2, "WARNING: Style not found: " .. line.style .. "\n")
6
line.styleref = styles[1]
7
end

在这一步,对应行的样式被存储到了 line.styleref 中。当样式不存在时,会使用全局样式中的第一个。

计算整行大小
1
-- 计算整行大小
2
line.width, line.height, line.descent, line.extlead = aegisub.text_extents(line.styleref, line.text_stripped)
3
line.width = line.width * meta.video_x_correct_factor

通过 aegisub.text_extents 获取该行应用对应样式时的宽、高、descentextlead。该函数对应的 Wiki 在此:[reference]

最后,乘上 video_x_corrent_factor

计算音节大小
1
-- 计算音节大小
2
for s = 0, line.kara.n do
3
local syl = line.kara[s]
4
syl.style = line.styleref
5
syl.width, syl.height = aegisub.text_extents(syl.style, syl.text_spacestripped)
6
syl.width = syl.width * meta.video_x_correct_factor
7
syl.prespacewidth = aegisub.text_extents(syl.style, syl.prespace) * meta.video_x_correct_factor
8
syl.postspacewidth = aegisub.text_extents(syl.style, syl.postspace) * meta.video_x_correct_factor
9
end

循环获取每个音节、prespacepostspace 的宽和高,对宽乘上 video_x_corrent_factor

计算注音大小
1
-- 计算注音大小
2
if styles[line.style .. "-furigana"] then
3
line.furistyle = styles[line.style .. "-furigana"]
4
else
5
aegisub.debug.out(4, "No furigana style defined for style '%s'\n", line.style)
6
line.furistyle = false
7
end
8
if line.furistyle then
9
for f = 1, line.furi.n do
10
local furi = line.furi[f]
11
furi.style = line.furistyle
12
furi.width, furi.height = aegisub.text_extents(furi.style, furi.text)
13
furi.width = furi.width * meta.video_x_correct_factor
14
furi.prespacewidth = 0
15
furi.postspacewidth = 0
16
end
17
end
18
end

在计算之前,首先需要寻找注音样式($style-furigana)。当样式存在时,才进行进一步处理。

当样式存在时,遍历所有注音,计算其宽高并乘以 video_x_corrent_factor。注音的前后留白宽度为 0。

至此,全行的大小信息均计算完毕。传送回去

karaskel.preproc_line_text

这一部分负责处理的是预处理文本,包括注音等内容。该函数假定了所有输入行都满足 class=dialogue,使用时须注意。

由于其有用到 Aegisub 的 API,下面我们先来介绍 API 接口。

aegisub.parse_karaoke_data

这一部分是 Aegisub 自带的 API,因此本文不作源码解析,但这里给出定义:

1
type syl_base struct {
2
duration float64
3
start_time float64
4
end_time float64
5
tag string
6
text string // 原文本
7
text_stripped string // 移除了特效标签和绘图指令的文本
8
}
9
10
type parse_karaoke_data func(line Line) []syl_base
初始化
1
-- Pre-process line, determining stripped text, karaoke data and splitting off furigana data
2
-- 会修改 line 对象的内容
3
function karaskel.preproc_line_text(meta, styles, line)
4
-- 假定所有行都是 class=dialogue
5
local kara = aegisub.parse_karaoke_data(line)
6
line.kara = { n = 0 }
7
line.furi = { n = 0 }
8
9
line.text_stripped = ""
10
line.duration = line.end_time - line.start_time
11
12
local worksyl = { highlights = {n=0}, furi = {n=0} }
13
local cur_inline_fx = ""

首先仍然是初始化,这一步通过解析 line 获取了对应的 kara,并且将 linekarafuritext_stripped 设置为初始值。此外,还计算了 duration 的数值,并初始化了下面循环中要用到的 worksylcur_inline_fx(即 current_inline_fx)。

检测内联标签
1
for i = 0, #kara do
2
local syl = kara[i]
3
4
-- 检测内联标签
5
local inline_fx = syl.text:match("%{.*\\%-([^}\\]+)")
6
if inline_fx then
7
cur_inline_fx = inline_fx
8
end

这里开始了主循环,保存 syl,并开始检测内联标签。

这里用到的是 LuaPattern,不熟悉的读者可以参考这篇文章。这行 Pattern 对应的正则表达式为:{.*\\-([^}\]+)

当捕获到内容,即符合内联标签的 Pattern 时,将当前音节(syl)的内联标签设置为捕获到的 inline_fx

处理音节结束
1
-- 删除空格(只包括基本符号,不包含全角空格)
2
local prespace, syltext, postspace = syl.text_stripped:match("^([ \t]*)(.-)([ \t]*)$")
3
4
-- See if we've broken a (possible) multi-hl stretch
5
-- If we did it's time for a new worksyl (though never for the zero'th syllable)
6
local prefix = syltext:sub(1,unicode.charwidth(syltext,1))
7
if prefix ~= "#" and prefix ~= "#" and i > 0 then
8
line.kara[line.kara.n] = worksyl
9
line.kara.n = line.kara.n + 1
10
worksyl = { highlights = {n=0}, furi = {n=0} }
11
end

在处理之前,我们要先把文本前后的空符号去除。之后,我们处理音节结束的特殊情况,这里比较特殊的是需要判断注音

我们知道,注音的卡拉 OK 格式为:原文|内容。当原文不存在时,用 # 代替,代表原文和上一个音节的原文一致。

因此,当我们检测到该音节的第一个字符并非 # 时,代表上一个音节可以正常结束,这时,就将 worksyl 加入 kara 中,并将 kara 的长度标记 n 加一。

这里判断的 i > 0 是为了防止第一个音节的误判。第一个音节必定要包含原文,因此要防止遇到该音节时直接将空的 worksyl 加入 kara

存入当前音节
1
-- 增加高亮数据
2
local hl = {
3
start_time = syl.start_time,
4
end_time = syl.end_time,
5
duration = syl.duration
6
}
7
worksyl.highlights.n = worksyl.highlights.n + 1
8
worksyl.highlights[worksyl.highlights.n] = hl

这里需要解释的是 highlight 的概念。我们知道,卡拉 OK 特效一般的结果都是高亮。不论是文字意义上的高亮,还是特效元素意义上的高亮,高亮是指向当前文字位置的一个标识。

从传统的卡拉 OK 效果角度来说,对于注音,其高亮本身伴随的是原文的高亮。因此,可以说原文的高亮必须依托注音的高亮而生成。由此就产生了一个高亮队列,其中各个发音(也可以说是音节)从前往后依次高亮,同时这个过程也伴随着原文的高亮。这也就是这段代码中的 worksyl.highlights。而 hl,则是当前音节的起止时间数据。

既然前面已经处理好了音节的结束,这里就直接把当前音节的信息存入 worksylhighlights Table 里就可以了。

处理注音:初始化
1
-- 寻找注音(全角半角皆可)
2
-- 注音与音节分别存储
3
if syltext:find("|") or syltext:find("|") then
4
-- 全部替换成半角 | 全角正则不友好
5
syltext = syltext:gsub("|", "|")
6
-- 获取前后文本
7
local maintext, furitext = syltext:match("^(.-)|(.-)$")
8
syltext = maintext
9
10
local furi = { }
11
furi.syl = worksyl

首先是判断注音是否存在。正如上文描述的格式一般,分隔线隔开的两个部分分别为原文和注音。这里可以使用半角或全角的 |

等到了真正的处理流程,所有的全角分隔符都被替换成半角以方便处理。之后,通过文本匹配获得 | 前后的文本,并将音节文本修改为原文

最后,新建一个 furi Table,并将母音节的引用存入 furi.syl

处理注音:样式
1
-- 魔法来了
2
-- isbreak = 即使主文本相邻,也不在视觉上将注音相连
3
-- spillback = 允许注音偏移到主文本左侧
4
-- (注音是一定允许偏移到主文本右侧的)
5
local prefix = furitext:sub(1,unicode.charwidth(furitext,1))
6
if prefix == "!" or prefix == "!" then
7
furi.isbreak = true
8
furi.spillback = false
9
elseif prefix == "<" or prefix == "<" then
10
furi.isbreak = true
11
furi.spillback = true
12
else
13
furi.isbreak = false
14
furi.spillback = false
15
end
16
-- 移除特殊模式下的前缀字符
17
if furi.isbreak then
18
furitext = furitext:sub(unicode.charwidth(furitext,1)+1)
19
end

这里规定了 isbreakspillback 两种属性,默认全部关闭,根据用户配置选择性开启。

用户需要配置注音前缀以启用部分设置。对于 !,启用 isbreak;对于 <,启用 spillback。两种符号都支持全角。

三个模式的区别如下。

默认模式,注音从左到右:

{\k9}無|む{\k9}情|じょう{\k9}抱|か{\k9}#|か{\k9}え{\k9}唯|た{\k9}#|だ{\k9}立|た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る
{\k9}無|む{\k9}情|じょう{\k9}抱|か{\k9}#|か{\k9}え{\k9}唯|た{\k9}#|だ{\k9}立|た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る

中断模式,注音从左到右,但不侵占其他音节的空间:

{\k9}無|む{\k9}情|!じょう{\k9}抱|!か{\k9}#|か{\k9}え{\k9}唯|!た{\k9}#|だ{\k9}立|た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る
{\k9}無|む{\k9}情|!じょう{\k9}抱|!か{\k9}#|か{\k9}え{\k9}唯|!た{\k9}#|だ{\k9}立|た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る

中断左移模式,其所在音节与注音中点处于同一直线:

{\k9}無|む{\k9}情|<じょう{\k9}抱|<か{\k9}#|か{\k9}え{\k9}唯|<た{\k9}#|<だ{\k9}立|<た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る
{\k9}無|む{\k9}情|<じょう{\k9}抱|<か{\k9}#|か{\k9}え{\k9}唯|<た{\k9}#|<だ{\k9}立|<た{\k9}ち{\k9}竦|す{\k9}#|く{\k9}ん{\k9}で{\k9}い{\k9}る

[reference] (中国魂害人.jpg)

处理注音:其他属性
1
-- 有些看起来可能有点多余,但理想情况下注音应该和音节接口相同
2
furi.start_time = syl.start_time
3
furi.end_time = syl.end_time
4
furi.duration = syl.duration
5
furi.kdur = syl.duration / 10
6
furi.text = furitext
7
furi.text_stripped = furitext
8
furi.text_spacestripped = furitext
9
furi.line = line
10
furi.tag = syl.tag
11
furi.inline_fx = cur_inline_fx
12
furi.i = line.kara.n
13
furi.prespace = ""
14
furi.postspace = ""
15
furi.highlights = { n=1, [1]=hl }
16
furi.isfuri = true
17
18
line.furi.n = line.furi.n + 1
19
line.furi[line.furi.n] = furi
20
worksyl.furi.n = worksyl.furi.n + 1
21
worksyl.furi[worksyl.furi.n] = furi
22
end

为了保持音节和注音的接口一致性,这里增加了一些看似无用的内容。

重要的属性有:

最后,将注音加入该行和主音节的注音列表中。

处理主音节:无文本/有文本但前缀不为 #
1
-- 不属于多高亮的音节生成新音节
2
if not worksyl.text or (prefix ~= "#" and prefix ~= "#") then
3
-- 更新 text_stripped
4
line.text_stripped = line.text_stripped .. prespace .. syltext .. postspace
5
6
-- 从 syl 拷贝信息到 worksyl
7
worksyl.text = syl.text
8
worksyl.duration = syl.duration
9
worksyl.kdur = syl.duration / 10
10
worksyl.start_time = syl.start_time
11
worksyl.end_time = syl.end_time
12
worksyl.tag = syl.tag
13
worksyl.line = line
14
15
-- 增加新信息到 worksyl
16
worksyl.i = line.kara.n
17
worksyl.text_stripped = prespace .. syltext .. postspace -- be sure to include the spaces so the original line can be built from text_stripped
18
worksyl.inline_fx = cur_inline_fx
19
worksyl.text_spacestripped = syltext
20
worksyl.prespace = prespace
21
worksyl.postspace = postspace

这种情况一般出现于新的主音节开始之时。这时候 worksyl 基本为空,需要填充主音节的信息。

处理主音节:新高亮
1
else
2
-- This is just an extra highlight
3
worksyl.duration = worksyl.duration + syl.duration
4
worksyl.kdur = worksyl.kdur + syl.duration / 10
5
worksyl.end_time = syl.end_time
6
end
7
end

对应有文本或前缀为 # 的情况。这种情况下就将更新当前工作音节的时间信息。

最后写入,返回
1
-- Add the last syllable
2
line.kara[line.kara.n] = worksyl
3
-- But don't increment n here, n should be the highest syllable index! (The zero'th syllable doesn't count.)
4
end

将最后一个音节加入 line.kara 中,但不更新 n,因为 n 代表的是最后一个音节的下标。

至此,karaskel.preproc_line_text 分析完毕。传送回去

karaskel.do_furigana_layout

初始化
1
-- 执行高级(advanced)注音布局算法
2
function karaskel.do_furigana_layout(meta, styles, line)
3
-- 从构建布局组开始
4
-- 两个相邻的、带有注音的音节属于同一布局组
5
-- 强制打断会创建一个新的布局组(注:isbreak)
6
local lgroups = {}
7
-- 布局组哨兵
8
local lgsentinel = {basewidth=0, furiwidth=0, syls={}, furi={}, spillback=false, left=0, right=0}
9
table.insert(lgroups, lgsentinel)
10
-- 创建布局组
11
local last_had_furi = false
12
local lg = { basewidth=0, furiwidth=0, syls={}, furi={}, spillback=false }

总之是很普通的初始化过程。这里多了给 lgsentinel,只在开头和结尾存在,意义不明,后面的循环也将其跳过了。如果有知道含义的大佬欢迎指出。

布局组
处理上一组
1
for s = 0, line.kara.n do
2
local syl = line.kara[s]
3
-- 对无注音音节创建新布局组
4
-- 对 marked as split 的 furigana-endowed 注音创建新布局组
5
-- 但如果当前的布局组没有宽度就不创建新的
6
aegisub.debug.out(5, "syl.furi.n=%d, isbreak=%s, last_had_furi=%s, lg.basewidth=%d\n", syl.furi.n, syl.furi.n > 0 and syl.furi[1].isbreak and "y" or "n", last_had_furi and "y" or "n", lg.basewidth)
7
if (syl.furi.n == 0 or syl.furi[1].isbreak or not last_had_furi) and lg.basewidth > 0 then
8
aegisub.debug.out(5, "Inserting layout group, basewidth=%d, furiwidth=%d, isbreak=%s\n", lg.basewidth, lg.furiwidth, syl.furi.n > 0 and syl.furi[1].isbreak and "y" or "n")
9
table.insert(lgroups, lg)
10
lg = { basewidth=0, furiwidth=0, syls={}, furi={}, spillback=false }
11
last_had_furi = false
12
end

这里说的上一组有点遗留问题的感觉,这其实时循环末尾的内容提到了开头。如果当前音节没有注音,或者第一个注音就为中断,或者不是最后一个注音,且当前布局组的宽度(basewidth)大于 0,则将当前布局组加入布局组表中,并且重置 lglast_had_furi

加入当前音节
1
-- 将音节加入布局组
2
lg.basewidth = lg.basewidth + syl.prespacewidth + syl.width + syl.postspacewidth
3
table.insert(lg.syls, syl)
4
aegisub.debug.out(5, "\tAdding syllable to layout group: '%s', width=%d, isbreak=%s\n", syl.text_stripped, syl.width, syl.furi.n > 0 and syl.furi[1].isbreak and "y" or "n")

这里将布局组的宽度(basewidth)加上了音节的宽度,并将音节加入了布局组中。

加入当前音节注音
1
-- 将该音节的注音加入布局组
2
for f = 1, syl.furi.n do
3
local furi = syl.furi[f]
4
lg.furiwidth = lg.furiwidth + furi.width
5
lg.spillback = lg.spillback or furi.spillback
6
table.insert(lg.furi, furi)
7
aegisub.debug.out(5, "\tAdding furigana to layout group: %s (width=%d)\n", furi.text, furi.width)
8
last_had_furi = true
9
end
10
end

在加入注音时,同时加上注音的宽度(width),并且当其中一个注音允许 spillback 时,整个布局组允许溢出(即观感上的注音左移,文本右移)。

最后,标记 last_had_furitrue。至此,对每个音节的循环结束。

加入最后一组
1
-- 加入最后的布局组
2
aegisub.debug.out(5, "Inserting layout group, basewidth=%d, furiwidth=%d\n", lg.basewidth, lg.furiwidth)
3
table.insert(lgroups, lg)
4
-- 加入尾哨兵
5
table.insert(lgroups, lgsentinel)

最后,由于加入布局组表的操作在循环开头,因此这里我们要把最后一组没有轮到循环的布局组加入表中。

生成布局
初始化
1
aegisub.debug.out(5, "\nProducing layout from %d layout groups\n", #lgroups-1)
2
-- 在宏级别布置布局组
3
-- 在循环结束时跳过哨兵
4
local curx = 0
5
for i = 2, #lgroups-1 do
6
local lg = lgroups[i]
7
local prev = lgroups[i-1]
8
aegisub.debug.out(5, "Layout group, nsyls=%d, nfuri=%d, syl1text='%s', basewidth=%f furiwidth=%f, ", #lg.syls, #lg.furi, lg.syls[1] and lg.syls[1].text or "", lg.basewidth, lg.furiwidth)

这里从 2 开始循环到 lgroup.length - 1。循环体内定义了当前组(lg)和上一组(prev)。

情况一:无注音
1
-- 三种情况:无注音,注音比 base 小,注音比 base 大
2
if lg.furiwidth == 0 then
3
-- 直接放置 base 文本
4
lg.left = curx
5
lg.right = lg.left + lg.basewidth
6
-- 如果这里右前一组的溢出,将其放置于此
7
if prev.rightspill and prev.rightspill > 0 then
8
aegisub.debug.out(5, "eat rightspill=%f, ", prev.rightspill)
9
lg.leftspill = 0
10
lg.rightspill = prev.rightspill - lg.basewidth
11
prev.rightspill = 0
12
end
13
curx = curx + lg.basewidth

在无注音的情况下,当前布局组的 left 显然就是当前的 xright 也显然为当前的 left 加上 base 的宽度。

这种情况下唯一需要处理的特殊情况是由上一组带来的右溢。如果上一组存在右溢,则将上一组的右溢清空,减去本组的宽度,设置为本组的右溢。同时,这种情况下不存在左溢,故将左溢设置为 0。

最后,当前行的 xcurx)需加上当前布局组的宽度。

情况二:注音宽度小于(等于)布局组
1
elseif lg.furiwidth <= lg.basewidth then
2
-- 如果上一组存在右溢,我们必须知道(并处理)
3
if prev.rightspill and prev.rightspill > 0 then
4
aegisub.debug.out(5, "skip rightspill=%f, ", prev.rightspill)
5
curx = curx + prev.rightspill
6
prev.rightspill = 0
7
end
8
lg.left = curx
9
lg.right = lg.left + lg.basewidth
10
curx = curx + lg.basewidth
11
-- 负值左溢
12
lg.leftspill = (lg.furiwidth - lg.basewidth) / 2
13
lg.rightspill = lg.leftspill

在注音宽度小于等于布局组的情况下,我们首先处理右溢的问题。由于我们现在已经处于了一个全新的布局组,因此如果上一组存在右溢,那我们的 base 文本就不能在有注音的位置渲染。因此,主文本必须偏移上一组右溢的宽度值,也就是 L5 的内容。

最后,由于注音宽度比布局组小,因此需要将其居中放置。居中放置的方式就是设置当前文本的左溢为负值,数值为 (注音宽度-基宽度) / 2。(这里不大明白,可能有误。FIXME)

情况三:注音宽度大于布局组
1
else
2
-- 注音宽度比 base 大,必须在某个方向上产生溢出
3
if prev.rightspill and prev.rightspill > 0 then
4
aegisub.debug.out(5, "skip rightspill=%f, ", prev.rightspill)
5
curx = curx + prev.rightspill
6
prev.rightspill = 0
7
end

与上一种情况相同的右溢处理办法。

1
-- 是否只向右溢出
2
if lg.spillback then
3
-- 双向溢出
4
lg.leftspill = (lg.furiwidth - lg.basewidth) / 2
5
lg.rightspill = lg.leftspill
6
aegisub.debug.out(5, "spill left=%f right=%f, ", lg.leftspill, lg.rightspill)
7
-- 不能覆盖上一组的右溢
8
if prev.rightspill then
9
lg.left = curx + lg.leftspill
10
else
11
lg.left = curx
12
end

当溢出选项设置允许左溢时,此时左溢的宽度和右溢相等。

特殊地,检查上一组是否存在右溢。注意,这里只检查了存在性,而并没有规定其必须大于 0。因为我们知道,存在溢出的情况一定存在注音的情况,而当存在注音时,我们不应该覆盖上一组的任何文本

因此,当上一组存在右溢时,即代表上一组存在注音。此时,将当前布局组的 left 加上当前布局组的左溢,使得 base 文本居中。这样,上方注音的最左便是 curx,也就一定不会产生覆盖了。

当上一组不存在右溢时,表示上一组不存在注音,此时可以将左溢安心(?)地覆盖上去。

但其实也不大对,如果上一组的文本较短而本组的左溢过长,那就会覆盖到上上组,还是存在覆盖的问题。如下图所示:

看图说话
看图说话

1
else
2
-- 只右溢
3
lg.leftspill = 0
4
lg.rightspill = lg.furiwidth - lg.basewidth
5
aegisub.debug.out(5, "spill right=%f, ", lg.rightspill)
6
lg.left = curx
7
end

当不允许左溢时,只产生右溢。此时左溢设置为 0,右溢设置为溢出的宽度。

1
lg.right = lg.left + lg.basewidth
2
curx = lg.right
3
end
4
aegisub.debug.out(5, "left=%f, right=%f\n", lg.left, lg.right)
5
end

最后,更新 lg.rightlg.left 加上 base 文本宽度,并将 curx 设置为最右点。

写回布局

现在,布局组的计算已经完成了,接下来只要将位置信息写回各音节/注音即可。

初始化
1
-- 布局组已完成布局,将布局拆分到各个音节/注音
2
for i, lg in ipairs(lgroups) do
3
local basecenter = (lg.left + lg.right) / 2 -- centered furi is centered over this
4
local curx = lg.left -- base text is placed from here on

初始化过程中计算了 basecenter(然而并没有用到),并定义了当前 xcurx)。

写回音节
1
-- 写回音节
2
for s, syl in ipairs(lg.syls) do
3
syl.left = curx + syl.prespacewidth
4
syl.center = syl.left + syl.width/2
5
syl.right = syl.left + syl.width
6
curx = syl.right + syl.postspacewidth
7
end

写回音节遍历当前布局组中所有音节,并设置其左、中心、右的坐标。需要注意的是,音节可能有首位空字符,这里需要一并加入。

准备写回注音
1
if curx > line.width then line.width = curx end
2
-- 写回注音
3
if lg.furiwidth < lg.basewidth or lg.spillback then
4
-- 布局组居中
5
curx = lg.left + (lg.basewidth - lg.furiwidth) / 2
6
else
7
-- 左对齐
8
curx = lg.left
9
end

首先处理的时特殊情况:如果 curx 大于行宽了,那么更新行宽为 curx

接下来是准备写回注音的环节。当注音宽度小于 base 文本宽度,或设置允许左溢时,我们要将注音居中;否则,左对齐即可。

写回注音
1
for f, furi in ipairs(lg.furi) do
2
furi.left = curx
3
furi.center = furi.left + furi.width/2
4
furi.right = furi.left + furi.width
5
curx = furi.right
6
end
7
end
8
end

最后是写回注音,处理方式与写回音节相同,此处省略。

至此,注音布局完成。传送回去

apply_line

到了这一步,所有的计算之类的都已经完成,所有的音节和注音的位置都已经正确地存储在了内存中,接下来我们要做的,就是根据用户的输入,正确地生成卡拉 OK 特效了。

初始化
1
function apply_line(meta, styles, subs, line, templates, tenv)
2
-- Tell whether any templates were applied to this line, needed to know whether the original line should be removed from input
3
local applied_templates = false
4
5
-- General variable replacement context
6
local varctx = {
7
layer = line.layer,
8
lstart = line.start_time,
9
lend = line.end_time,
10
ldur = line.duration,
11
lmid = line.start_time + line.duration/2,
12
style = line.style,
13
actor = line.actor,
14
margin_l = ((line.margin_l > 0) and line.margin_l) or line.styleref.margin_l,
15
margin_r = ((line.margin_r > 0) and line.margin_r) or line.styleref.margin_r,
16
margin_t = ((line.margin_t > 0) and line.margin_t) or line.styleref.margin_t,
17
margin_b = ((line.margin_b > 0) and line.margin_b) or line.styleref.margin_b,
18
margin_v = ((line.margin_t > 0) and line.margin_t) or line.styleref.margin_t,
19
syln = line.kara.n,
20
li = line.i,
21
lleft = math.floor(line.left+0.5),
22
lcenter = math.floor(line.left + line.width/2 + 0.5),
23
lright = math.floor(line.left + line.width + 0.5),
24
lwidth = math.floor(line.width + 0.5),
25
ltop = math.floor(line.top + 0.5),
26
lmiddle = math.floor(line.middle + 0.5),
27
lbottom = math.floor(line.bottom + 0.5),
28
lheight = math.floor(line.height + 0.5),
29
lx = math.floor(line.x+0.5),
30
ly = math.floor(line.y+0.5)
31
}
32
33
tenv.orgline = line
34
tenv.line = nil
35
tenv.syl = nil
36
tenv.basesyl = nil

初始化过程中,我们定义了:

最后,设置了 tenvorgline,清空了 tenv 的行(line)、音节(syl)和 basesyl 信息。

待应用模板
1
-- 应用所有模板行
2
aegisub.debug.out(5, "Running line templates\n")
3
for t in matching_templates(templates.line, line, tenv) do

完成了基本内联变量的配置,接下来就要应用模板了。在应用之前,我们需要知道我们要应用哪些模板。判断哪些模板需要应用的是 matching_templates

matching_templates
1
-- 迭代器函数,返回所有要应用到给定行的模板
2
function matching_templates(templates, line, tenv)
3
local lastkey = nil
4
local function test_next()
5
local k, t = next(templates, lastkey)
6
lastkey = k
7
if not t then
8
return nil
9
elseif (t.style == line.style or not t.style) and
10
(not t.fxgroup or
11
(t.fxgroup and tenv.fxgroup[t.fxgroup] ~= false)) then
12
return t
13
else
14
return test_next()
15
end
16
end
17
return test_next
18
end

可以看出,这里判断符合的条件是:

  1. 模板与行样式相同或模板无样式
  2. 模板无特效组或 tenv 中模板特效组非 false // FIXME

上述条件须同时满足。

自适应变量
1
if aegisub.progress.is_cancelled() then break end
2
3
-- 为 pre-line 设置变量
4
varctx["start"] = varctx.lstart
5
varctx["end"] = varctx.lend
6
varctx.dur = varctx.ldur
7
varctx.kdur = math.floor(varctx.dur / 10)
8
varctx.mid = varctx.lmid
9
varctx.i = varctx.li
10
varctx.left = varctx.lleft
11
varctx.center = varctx.lcenter
12
varctx.right = varctx.lright
13
varctx.width = varctx.lwidth
14
varctx.top = varctx.ltop
15
varctx.middle = varctx.lmiddle
16
varctx.bottom = varctx.lbottom
17
varctx.height = varctx.lheight
18
varctx.x = varctx.lx
19
varctx.y = varctx.ly

这里的自适应变量处在行模式,其代表的内容均为行内容。之所以称其为自适应变量,是因为在之后的处理中,它会随着处理模式的不同发生意义的改变,而这种改变对于使用者而言是自适应的。

这里,我们定义了:

代码行
1
for j, maxj in template_loop(tenv, t.loops) do
2
if t.code then
3
aegisub.debug.out(5, "Code template, %s\n", t.code)
4
tenv.line = line
5
-- 尽管 run_code_template 也在循环内
6
-- 由于内层循环会改变 j 和 max_j
7
-- 外层循环在 run_code_template 结束后也不会继续执行
8
-- 因此只会执行一次
9
run_code_template(t, tenv)

执行代码行的代码。注意这里的代码只会执行一次,具体原因见注释。

set_ctx_syl

下一部分的处理会用到 set_ctx_syl,因此这里我们来看一下。

简单变量
1
function set_ctx_syl(varctx, line, syl)
2
varctx.sstart = syl.start_time
3
varctx.send = syl.end_time
4
varctx.sdur = syl.duration
5
varctx.skdur = syl.duration / 10
6
varctx.smid = syl.start_time + syl.duration / 2
7
varctx["start"] = varctx.sstart
8
varctx["end"] = varctx.send
9
varctx.dur = varctx.sdur
10
varctx.kdur = varctx.skdur
11
varctx.mid = varctx.smid
12
varctx.si = syl.i
13
varctx.i = varctx.si
14
varctx.sleft = math.floor(line.left + syl.left+0.5)
15
varctx.scenter = math.floor(line.left + syl.center+0.5)
16
varctx.sright = math.floor(line.left + syl.right+0.5)
17
varctx.swidth = math.floor(syl.width + 0.5)

这里根据音节的属性,设置音节对应的内联变量,具体为:

其实,中间还混入了几个自适应变量:startenddurkdurmidi。具体意义与行对应的相似,这里不再赘述。

注音/非注音的属性区别:竖直方向上的位置
1
if syl.isfuri then
2
varctx.sbottom = varctx.ltop
3
varctx.stop = math.floor(varctx.ltop - syl.height + 0.5)
4
varctx.smiddle = math.floor(varctx.ltop - syl.height/2 + 0.5)
5
else
6
varctx.stop = varctx.ltop
7
varctx.smiddle = varctx.lmiddle
8
varctx.sbottom = varctx.lbottom
9
end
10
varctx.sheight = syl.height

对于注音,我们将 sbottom 设置为该行的顶部;stop 根据 ltopheight 计算得出并四舍五入;同理计算出 smiddle

对于非注音,我们正常设置为行的对应属性即可。最后,设置音节的 sheight

水平对齐的属性区别
1
if line.halign == "left" then
2
varctx.sx = math.floor(line.left + syl.left + 0.5)
3
elseif line.halign == "center" then
4
varctx.sx = math.floor(line.left + syl.center + 0.5)
5
elseif line.halign == "right" then
6
varctx.sx = math.floor(line.left + syl.right + 0.5)
7
end

根据行对齐方式的不同,计算该音节的 sx,即 x 坐标。其中的主要区别是加上的竖直,分别对应音节的 leftcenterright 值,最后四舍五入。

垂直对齐的属性区别
1
if line.valign == "top" then
2
varctx.sy = varctx.stop
3
elseif line.valign == "middle" then
4
varctx.sy = varctx.smiddle
5
elseif line.valign == "bottom" then
6
varctx.sy = varctx.sbottom
7
end

同理,处理 sy,也就是音节的 y 值,但这里不需要参与计算。

剩余的自适应变量
1
varctx.left = varctx.sleft
2
varctx.center = varctx.scenter
3
varctx.right = varctx.sright
4
varctx.width = varctx.swidth
5
varctx.top = varctx.stop
6
varctx.middle = varctx.smiddle
7
varctx.bottom = varctx.sbottom
8
varctx.height = varctx.sheight
9
varctx.x = varctx.sx
10
varctx.y = varctx.sy
11
end

现在,所有的属性都已经计算完成了,将对应的自适应变量覆盖即可。

模板行
初始化
1
else
2
aegisub.debug.out(5, "Line template, pre = '%s', t = '%s'\n", t.pre, t.t)
3
applied_templates = true
4
local newline = table.copy(line)
5
tenv.line = newline
6
newline.layer = t.layer
7
newline.text = ""

如果待应用的是模板,此时拷贝当前行,并将 tenv.line 设置为拷贝后行。保留原 layer,清空文本。

pre-line
1
if t.pre ~= "" then
2
newline.text = newline.text .. run_text_template(t.pre, tenv, varctx)
3
end

当存在 pre-line,即 t.pre 非空时,新行的文本附加上解析后的 pre 文本。

run_text_template

这个函数的作用就是纯粹的模板替换了。由于我们已经有了解析好的模板(template),准备好的代码执行环境(tenv)和定义好的内敛变量(varctx),这时候就是用上它们的时候了。

1
function run_text_template(template, tenv, varctx)
2
local res = template
3
aegisub.debug.out(5, "Running text template '%s'\n", res)

简单的初始化,复制了一份 template 引用的拷贝到 res

1
-- 替换内联变量 (this is probably faster than using a custom function, but doesn't provide error reporting)
2
if varctx then
3
aegisub.debug.out(5, "Has varctx, replacing variables\n")
4
local function var_replacer(varname)
5
varname = string.lower(varname)
6
aegisub.debug.out(5, "Found variable named '%s', ", varname)
7
if varctx[varname] ~= nil then
8
aegisub.debug.out(5, "it exists, value is '%s'\n", varctx[varname])
9
return varctx[varname]
10
else
11
aegisub.debug.out(5, "doesn't exist\n")
12
aegisub.debug.out(2, "Unknown variable name: %s\nIn karaoke template: %s\n\n", varname, template)
13
return "$" .. varname
14
end
15
end
16
res = string.gsub(res, "$([%a_]+)", var_replacer)
17
aegisub.debug.out(5, "Done replacing variables, new template string is '%s'\n", res)
18
end

这里替换所有满足 \$([a-zA-Z_]+)(正则表达式)的字符串,并对其使用 var_replacer 函数。

var_replacer 函数接收一个参数,其就是匹配组的内容。在函数中,我们首先将其全部转化为小写,再到 varctx 中去尝试匹配。若匹配成功,则返回对应内容;否则就将原值返回,并且在前面补充上因为捕获而遗失的 $ 号。

1
-- 用以执行表达式的函数
2
local function expression_evaluator(expression)
3
f, err = loadstring(string.format("return (%s)", expression))
4
if (err) ~= nil then
5
aegisub.debug.out(2, "Error parsing expression: %s\nExpression producing error: %s\nTemplate with expression: %s\n\n", err, expression, template)
6
return "!" .. expression .. "!"
7
else
8
setfenv(f, tenv)
9
local res, val = pcall(f)
10
if res then
11
return val
12
else
13
aegisub.debug.out(2, "Runtime error in template expression: %s\nExpression producing error: %s\nTemplate with expression: %s\n\n", val, expression, template)
14
return "!" .. expression .. "!"
15
end
16
end
17
end

expression_evaluator 负责执行表达式并渲染其返回值。我们知道,表达式是以一对 ! 包裹的文本,我们将其加上 return 后作为代码执行的内容。这里要考虑两种情况:一种是表达式本身存在问题,另一种是表达式没有返回值。对于这两种情况,我们都不修改原文。对于正常可以执行且存在返回值的表达式,我们将替换成的文本设置为返回值。

1
-- 寻找并执行表达式
2
aegisub.debug.out(5, "Now evaluating expressions\n")
3
res = string.gsub(res , "!(.-)!", expression_evaluator)
4
aegisub.debug.out(5, "After evaluation: %s\nDone handling template\n\n", res)
5
6
return res
7
end

最后,执行并返回即可。

line
1
if t.t ~= "" then
2
for i = 1, line.kara.n do
3
local syl = line.kara[i]
4
tenv.syl = syl
5
tenv.basesyl = syl
6
set_ctx_syl(varctx, line, syl)
7
newline.text = newline.text .. run_text_template(t.t, tenv, varctx)
8
if t.addtext then
9
if t.keeptags then
10
newline.text = newline.text .. syl.text
11
else
12
newline.text = newline.text .. syl.text_stripped
13
end
14
end
15
end

当模板行非空时,对该行的每一个 kara 循环。

在循环过程中,将 tenv 的音节设置为该 kara 对应的音节,设置音节的 varctx,并执行模板。

最后,如果 t.addtext(即没有设置 notext),则根据 keeptags 情况设置文本。

空模板行
1
else
2
-- 该行无主模板,保持原文
3
if t.keeptags then
4
newline.text = newline.text .. line.text
5
else
6
newline.text = newline.text .. line.text_stripped
7
end
8
end

当模板行为空时,无法应用模板,故保持原文。

此时,如果模板设置中声明了 keeptags,则保留 tag,否则就删除 tag

收尾
1
newline.effect = "fx"
2
subs.append(newline)
3
end
4
end
5
end
6
aegisub.debug.out(5, "Done running line templates\n\n")

最后,给生成行的特效栏标注为 fx,并附加到字幕文件最后。

apply_syllable_templates
1
function apply_syllable_templates(syl, line, templates, tenv, varctx, subs)
2
local applied = 0
3
4
-- 循环匹配的所有模板
5
for t in matching_templates(templates, line, tenv) do
6
if aegisub.progress.is_cancelled() then break end
7
8
tenv.syl = syl
9
tenv.basesyl = syl
10
set_ctx_syl(varctx, line, syl)
11
12
applied = applied + apply_one_syllable_template(syl, line, t, tenv, varctx, subs, false, false)
13
end
14
15
return applied > 0
16
end

这个函数的功能是处理每音节的模板。对每一个匹配的模板,将其返回值附加到 applied,最后返回 applied,代表应用的模板数量。

在应用之前,我们需要将 tnv 中的音节设置为当前音节,并设置对应音节的内联变量。

最后,主要的调用是 apply_one_syllable_template,我们来看:

apply_one_syllable_template
初始化
1
function apply_one_syllable_template(syl, line, template, tenv, varctx, subs, skip_perchar, skip_multi)
2
if aegisub.progress.is_cancelled() then return 0 end
3
local t = template
4
local applied = 0
5
6
aegisub.debug.out(5, "Applying template to one syllable with text: %s\n", syl.text)

基本的初始化,可打断。这里初始化了 applied 是因为后面有递归。

检查:内联特效
1
-- 检查以确保内联特效正确
2
if t.fx and t.fx ~= syl.inline_fx then
3
aegisub.debug.out(5, "Syllable has wrong inline-fx (wanted '%s', got '%s'), skipping.\n", t.fx, syl.inline_fx)
4
return 0
5
end

这里检查了模板的内联特效是否与该音节的内联特效一致,不一致则不执行。

检查:空音节
1
if t.noblank and is_syl_blank(syl) then
2
aegisub.debug.out(5, "Syllable is blank, skipping.\n")
3
return 0
4
end

这里检查了模板是否跳过空音节以及音节是否为空。当上述二者均满足时,不执行音节模板替换。

处理:模板存在 char 修饰
1
-- 当需要时 递归每个字符
2
if not skip_perchar and t.perchar then
3
aegisub.debug.out(5, "Doing per-character effects...\n")
4
local charsyl = table.copy(syl)
5
tenv.syl = charsyl
6
7
local left, width = syl.left, 0
8
for c in unicode.chars(syl.text_stripped) do
9
charsyl.text = c
10
charsyl.text_stripped = c
11
charsyl.text_spacestripped = c
12
charsyl.prespace, charsyl.postspace = "", "" -- for whatever anyone might use these for
13
width = aegisub.text_extents(syl.style, c)
14
charsyl.left = left
15
charsyl.center = left + width/2
16
charsyl.right = left + width
17
charsyl.prespacewidth, charsyl.postspacewidth = 0, 0 -- whatever...
18
left = left + width
19
set_ctx_syl(varctx, line, charsyl)
20
21
applied = applied + apply_one_syllable_template(charsyl, line, t, tenv, varctx, subs, true, false)
22
end
23
24
return applied
25
end

对于 char 修饰,也就是上面判断条件中的第二个:perchar,此时要求我们以字符为单位进行模板应用,而不是音节。此时,我们针对每个字符进行处理。

首先,我们复制一份 syl 的副本,之后设置初始的 leftwidth 值。之后,对每个字符进行循环,将 charsyl 的内容替换为字符内容;将 prespacepostspace 置空; 通过调用 Aegisub 的 API 计算该字符的宽度并保存;设置对应的 leftcenterright;更新 left;更新 varctx;最后递归调用 apply_one_syllable_template,将这个只有一个字符的音节作为单独的音节进行运算。

这里没有设置 tenv,如果有知道原因的大佬请在评论区指出,此处提前拜谢(

处理:模板存在 multi 修饰
1
-- 递归多重高亮音节
2
if not skip_multi and t.multi then
3
aegisub.debug.out(5, "Doing multi-highlight effects...\n")
4
local hlsyl = table.copy(syl)
5
tenv.syl = hlsyl
6
7
for hl = 1, syl.highlights.n do
8
local hldata = syl.highlights[hl]
9
hlsyl.start_time = hldata.start_time
10
hlsyl.end_time = hldata.end_time
11
hlsyl.duration = hldata.duration
12
set_ctx_syl(varctx, line, hlsyl)
13
14
applied = applied + apply_one_syllable_template(hlsyl, line, t, tenv, varctx, subs, true, true)
15
end
16
17
return applied
18
end

对于 multi 修饰,与 char 类似,这里先偷个懒,之后有空再写(

常规处理:每音节代码行
1
-- 常规处理
2
if t.code then
3
aegisub.debug.out(5, "Running code line\n")
4
tenv.line = line
5
run_code_template(t, tenv)

对于代码行,常规的运行即可。

常规处理:每音节模板行
1
else
2
aegisub.debug.out(5, "Running %d effect loops\n", t.loops)
3
for j, maxj in template_loop(tenv, t.loops) do
4
local newline = table.copy(line)
5
newline.styleref = syl.style
6
newline.style = syl.style.name
7
newline.layer = t.layer
8
tenv.line = newline
9
newline.text = run_text_template(t.t, tenv, varctx)
10
if t.keeptags then
11
newline.text = newline.text .. syl.text
12
elseif t.addtext then
13
newline.text = newline.text .. syl.text_stripped
14
end
15
newline.effect = "fx"
16
aegisub.debug.out(5, "Generated line with text: %s\n", newline.text)
17
subs.append(newline)
18
applied = applied + 1
19
end
20
end
21
22
return applied
23
end

最后,对于模板行,我们需要的就是简单的替换,使用的是之前定义的 run_text_template 函数。

在执行这个函数之前,我们需要生成一个新行,并复制当前音节对应的基本信息。

执行完 run_text_template 之后,我们再判断修饰语的问题。这里我们考虑的是 keeptagsnotext。当 keeptags 时,我们将新行的文本附加上原文内容;当不存在 notext 时,我们将新行的文本附加上原文去除标签和绘画指令后的内容。

最后,我们将新行的特效栏设置为 fx,并将该行加入字幕文件中;将 applied 自加以表示应用的模板数增加了。

奇怪的模板增加了!

is_syl_blank

这个函数的主要功能是判断音节是否为空,配合 noblank 使用。

1
function is_syl_blank(syl)
2
if syl.duration <= 0 then
3
return true
4
end
5
6
-- try to remove common spacing characters
7
local t = syl.text_stripped
8
if t:len() <= 0 then return true end
9
t = t:gsub("[ \t\n\r]", "") -- regular ASCII space characters
10
t = t:gsub(" ", "") -- fullwidth space
11
return t:len() <= 0
12
end

当音节本身的持续时间就小于等于 0 时,自然是空的;否则,就要考虑文本内容是否为空了。先判断文本本身是否为空,在答案为否的情况下,我们尝试处理掉那些空字符再进行判断。这里处理掉的空字符有:

在将这些都删除之后再尝试判断,返回字符串长度 <= 0 的结果。

处理音节
1
-- 循环处理音节
2
for i = 0, line.kara.n do
3
if aegisub.progress.is_cancelled() then break end
4
local syl = line.kara[i]
5
6
aegisub.debug.out(5, "Applying templates to syllable: %s\n", syl.text)
7
if apply_syllable_templates(syl, line, templates.syl, tenv, varctx, subs) then
8
applied_templates = true
9
end
10
end

对每一个音节,我们循环并调用 apply_syllable_templates。当存在应用的模板时,将 applied_templates 设置为 true

处理注音
1
-- 循环处理注音
2
for i = 1, line.furi.n do
3
if aegisub.progress.is_cancelled() then break end
4
local furi = line.furi[i]
5
6
aegisub.debug.out(5, "Applying templates to furigana: %s\n", furi.text)
7
if apply_syllable_templates(furi, line, templates.furi, tenv, varctx, subs) then
8
applied_templates = true
9
end
10
end

对每一个注音,和处理音节的步骤一致。

返回
1
return applied_templates
2
end

最后,返回是否应用了模板。只要上述步骤中应用了任何一个模板,这里的返回值都是 true

至此,整个过程结束。kara-templater 执行完毕。


结语

断断续续写了一个多星期吧,期间也是 Windows Manjaro 来回切换。经历了一个大 ddl,一个课设的开坑,还有一大堆的签到。整个写作过程中最大的麻烦居然是 Wordpress 太卡了,这是我万万没有想到的,比如写现在这一段的时候就有将近 10 秒的延迟,下次我再也不写这么长的单篇了(笑)。

写了这么多,这是我万万没有想到的
写了这么多,这是我万万没有想到的

2020 年 4 月 2 日更新:都是憨批 CodeMirror Blocks 的锅,现在丝滑流畅了,wdnmd

限于笔者 Lua 水平只是玩过 OpenComputers 的水平,本文可能存在大量的问题,希望读者们能够在评论中帮忙指出。

嘛,总之就是这样。下一篇可能会讲一下 create_ap,也是之前读完的,大概两三千行左右吧,开咕!(逃