slidev-addon-subtitle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/assets/logo.svg +72 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.mjs +302 -0
- package/package.json +53 -0
- package/setup/components/SubtitleDisplay.vue +101 -0
- package/setup/main.ts +6 -0
- package/setup/preparser.ts +3 -0
- package/setup/shims-vue.d.ts +5 -0
- package/setup/transformers.ts +22 -0
- package/setup/vite-plugins.ts +3 -0
- package/style.css +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lee Dogeon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/logo.svg" width="115" height="115" alt="slidev-addon-subtitle" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">slidev-addon-subtitle</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Automatic click-synchronized subtitles for <a href="https://sli.dev">Slidev</a> presentations
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://moreal.github.io/slidev-addon-subtitle/">Documentation</a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
## Development workflow
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
mise trust
|
|
19
|
+
mise install
|
|
20
|
+
mise x -- bazelisk build //:ci_all
|
|
21
|
+
mise x -- bazelisk build //docs:site_archive
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To build docs into `docs/.vitepress/dist` for local preview:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
yarn docs:build
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
[MIT](./LICENSE)
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<svg width="115" height="115" viewBox="0 0 115 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<defs>
|
|
3
|
+
<filter id="shadow" x="-20" y="-20" width="200" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
4
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
5
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
|
6
|
+
<feOffset dy="3"/>
|
|
7
|
+
<feGaussianBlur stdDeviation="4"/>
|
|
8
|
+
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.18 0"/>
|
|
9
|
+
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
|
10
|
+
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
|
11
|
+
</filter>
|
|
12
|
+
|
|
13
|
+
<filter id="shadow_caption" x="-20" y="-20" width="200" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
14
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
15
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
|
16
|
+
<feOffset dy="2"/>
|
|
17
|
+
<feGaussianBlur stdDeviation="3"/>
|
|
18
|
+
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.20 0"/>
|
|
19
|
+
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
|
20
|
+
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
|
21
|
+
</filter>
|
|
22
|
+
|
|
23
|
+
<clipPath id="clip">
|
|
24
|
+
<rect width="115" height="115" rx="20" fill="white"/>
|
|
25
|
+
</clipPath>
|
|
26
|
+
</defs>
|
|
27
|
+
|
|
28
|
+
<g clip-path="url(#clip)">
|
|
29
|
+
<!-- light backdrop -->
|
|
30
|
+
<rect width="115" height="115" fill="#F6F8FA"/>
|
|
31
|
+
|
|
32
|
+
<!-- screen frame -->
|
|
33
|
+
<g filter="url(#shadow)">
|
|
34
|
+
<rect x="14" y="16" width="87" height="62" rx="12" fill="#111827" fill-opacity="0.92"/>
|
|
35
|
+
<rect x="18" y="20" width="79" height="54" rx="10" fill="white"/>
|
|
36
|
+
<rect x="18" y="20" width="79" height="54" rx="10" stroke="#E5E7EB" stroke-width="1"/>
|
|
37
|
+
</g>
|
|
38
|
+
|
|
39
|
+
<!-- slide title: ●■▲ as a lead mark (no top menu bar) -->
|
|
40
|
+
<g>
|
|
41
|
+
<circle cx="28" cy="28" r="3.2" fill="#3ACBD4"/>
|
|
42
|
+
<rect x="33.5" y="24.8" width="6.4" height="6.4" rx="1.6" fill="#2B95B5"/>
|
|
43
|
+
<path d="M46.5 24.8 L50.1 31.2 H42.9 L46.5 24.8 Z" fill="#FFBB13"/>
|
|
44
|
+
|
|
45
|
+
<!-- title line -->
|
|
46
|
+
<rect x="54" y="26" width="33" height="4" rx="2" fill="#CBD5E1"/>
|
|
47
|
+
</g>
|
|
48
|
+
|
|
49
|
+
<!-- slide content -->
|
|
50
|
+
<g>
|
|
51
|
+
<rect x="26" y="36" width="56" height="3" rx="1.5" fill="#E2E8F0"/>
|
|
52
|
+
<rect x="26" y="42" width="50" height="3" rx="1.5" fill="#E2E8F0"/>
|
|
53
|
+
<rect x="26" y="48" width="54" height="3" rx="1.5" fill="#E2E8F0"/>
|
|
54
|
+
<rect x="26" y="54" width="40" height="3" rx="1.5" fill="#E2E8F0"/>
|
|
55
|
+
</g>
|
|
56
|
+
|
|
57
|
+
<!-- caption attached to screen bottom -->
|
|
58
|
+
<g filter="url(#shadow_caption)">
|
|
59
|
+
<rect x="22" y="64" width="71" height="16" rx="7" fill="#2E2E2E" fill-opacity="0.82"/>
|
|
60
|
+
</g>
|
|
61
|
+
<g>
|
|
62
|
+
<rect x="31" y="68.5" width="53" height="3" rx="1.5" fill="white" fill-opacity="0.92"/>
|
|
63
|
+
<rect x="38" y="74" width="39" height="3" rx="1.5" fill="white" fill-opacity="0.92"/>
|
|
64
|
+
</g>
|
|
65
|
+
|
|
66
|
+
<!-- stand (moved slightly down to avoid caption overlap) -->
|
|
67
|
+
<g>
|
|
68
|
+
<rect x="56" y="82" width="3" height="14" rx="1.5" fill="#9CA3AF"/>
|
|
69
|
+
<path d="M40 98 L57.5 86 L75 98" stroke="#9CA3AF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
70
|
+
</g>
|
|
71
|
+
</g>
|
|
72
|
+
</svg>
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface SubtitleOptions {
|
|
3
|
+
enabledModes: ("dev" | "build" | "export")[];
|
|
4
|
+
chunkMode: "sentence" | "line";
|
|
5
|
+
sentenceDelimiters: string[];
|
|
6
|
+
maxDisplayWidth: number;
|
|
7
|
+
}
|
|
8
|
+
interface SubtitleEntry {
|
|
9
|
+
start: number;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
declare const defaultOptions: SubtitleOptions;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/chunking.d.ts
|
|
15
|
+
declare function parseNoteToSubtitleTimeline(note: string | undefined, options?: Partial<SubtitleOptions>): SubtitleEntry[];
|
|
16
|
+
//#endregion
|
|
17
|
+
export { type SubtitleEntry, type SubtitleOptions, defaultOptions, parseNoteToSubtitleTimeline };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
//#region src/types.ts
|
|
2
|
+
const defaultOptions = {
|
|
3
|
+
enabledModes: ["export"],
|
|
4
|
+
chunkMode: "sentence",
|
|
5
|
+
sentenceDelimiters: [
|
|
6
|
+
".",
|
|
7
|
+
"!",
|
|
8
|
+
"?",
|
|
9
|
+
"。",
|
|
10
|
+
"!",
|
|
11
|
+
"?",
|
|
12
|
+
"…",
|
|
13
|
+
"\n"
|
|
14
|
+
],
|
|
15
|
+
maxDisplayWidth: 80
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/chunking.ts
|
|
20
|
+
const CLICK_MARKER_RE = /^\[click(?::(\d+))?\]$/i;
|
|
21
|
+
const SUBTITLE_PAUSE_RE = /^\[subtitle:pause\]$/i;
|
|
22
|
+
const COMBINING_MARK_RE = /\p{Mark}/u;
|
|
23
|
+
function parseNoteToSubtitleTimeline(note, options = {}) {
|
|
24
|
+
const opts = resolveOptions(options);
|
|
25
|
+
if (note === void 0 || note === null) return [];
|
|
26
|
+
const normalized = note.replace(/\r\n/g, "\n").trim();
|
|
27
|
+
if (normalized === "") return [];
|
|
28
|
+
return buildTimeline(normalized, opts);
|
|
29
|
+
}
|
|
30
|
+
function buildTimeline(note, options) {
|
|
31
|
+
const entries = [];
|
|
32
|
+
const lines = note.replace(/\[subtitle:pause\]/gi, "\n[subtitle:pause]\n").split("\n");
|
|
33
|
+
let cursor = 0;
|
|
34
|
+
let current = [];
|
|
35
|
+
const flush = () => {
|
|
36
|
+
const text = current.join("\n").trim();
|
|
37
|
+
current = [];
|
|
38
|
+
if (text === "") return 0;
|
|
39
|
+
const chunks = splitGroupText(text, options);
|
|
40
|
+
let emitted = 0;
|
|
41
|
+
for (const chunk of chunks) {
|
|
42
|
+
const trimmed = chunk.trim();
|
|
43
|
+
if (trimmed === "") continue;
|
|
44
|
+
entries.push({
|
|
45
|
+
start: cursor,
|
|
46
|
+
text: trimmed
|
|
47
|
+
});
|
|
48
|
+
cursor += 1;
|
|
49
|
+
emitted += 1;
|
|
50
|
+
}
|
|
51
|
+
return emitted;
|
|
52
|
+
};
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
if (SUBTITLE_PAUSE_RE.test(trimmed)) {
|
|
56
|
+
flush();
|
|
57
|
+
entries.push({
|
|
58
|
+
start: cursor,
|
|
59
|
+
text: ""
|
|
60
|
+
});
|
|
61
|
+
cursor += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const match = trimmed.match(CLICK_MARKER_RE);
|
|
65
|
+
if (!match) {
|
|
66
|
+
current.push(line);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const emitted = flush();
|
|
70
|
+
if (match[1] !== void 0) {
|
|
71
|
+
const parsed = Number(match[1]);
|
|
72
|
+
if (Number.isFinite(parsed)) cursor = Math.max(cursor, Math.max(0, Math.trunc(parsed)));
|
|
73
|
+
else cursor += 1;
|
|
74
|
+
} else if (emitted === 0) cursor += 1;
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
function splitGroupText(text, options) {
|
|
80
|
+
return wrapSegmentsByDisplayWidth(options.chunkMode === "line" ? text.split("\n").map((line) => line.trim()).filter((line) => line !== "") : splitSentences(text, new Set(options.sentenceDelimiters.filter((value) => value !== "\n"))), options.maxDisplayWidth);
|
|
81
|
+
}
|
|
82
|
+
function splitSentences(text, delimiters) {
|
|
83
|
+
const result = [];
|
|
84
|
+
for (const rawLine of text.split("\n")) {
|
|
85
|
+
const line = rawLine.trim();
|
|
86
|
+
if (line === "") continue;
|
|
87
|
+
let current = "";
|
|
88
|
+
const pushCurrent = () => {
|
|
89
|
+
const trimmed = current.trim();
|
|
90
|
+
if (trimmed !== "") result.push(trimmed);
|
|
91
|
+
current = "";
|
|
92
|
+
};
|
|
93
|
+
for (let i = 0; i < line.length; i++) {
|
|
94
|
+
const char = line[i];
|
|
95
|
+
current += char;
|
|
96
|
+
if (!isSentenceBoundary(line, i, char, delimiters)) continue;
|
|
97
|
+
pushCurrent();
|
|
98
|
+
}
|
|
99
|
+
pushCurrent();
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
function isSentenceBoundary(line, index, char, delimiters) {
|
|
104
|
+
if (!delimiters.has(char)) return false;
|
|
105
|
+
if (char === ".") {
|
|
106
|
+
const prev = line[index - 1] ?? "";
|
|
107
|
+
const next = line[index + 1] ?? "";
|
|
108
|
+
if (isDigit(prev) && isDigit(next)) return false;
|
|
109
|
+
if (next === ".") return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
function isDigit(value) {
|
|
114
|
+
return value >= "0" && value <= "9";
|
|
115
|
+
}
|
|
116
|
+
function wrapSegmentsByDisplayWidth(segments, maxDisplayWidth) {
|
|
117
|
+
if (!Number.isFinite(maxDisplayWidth) || maxDisplayWidth <= 0) return segments;
|
|
118
|
+
const wrapped = [];
|
|
119
|
+
for (const segment of segments) wrapped.push(...wrapByWords(segment, maxDisplayWidth));
|
|
120
|
+
return wrapped;
|
|
121
|
+
}
|
|
122
|
+
function wrapByWords(text, maxDisplayWidth) {
|
|
123
|
+
const words = text.trim().split(/\s+/).filter((word) => word !== "");
|
|
124
|
+
if (words.length === 0) return [];
|
|
125
|
+
if (words.length === 1) return [words[0]];
|
|
126
|
+
const wordWidths = words.map((word) => displayWidth(word));
|
|
127
|
+
const baseRanges = greedyLineRanges(wordWidths, maxDisplayWidth);
|
|
128
|
+
if (baseRanges.length <= 1) return [words.join(" ")];
|
|
129
|
+
const baseLineCount = baseRanges.length;
|
|
130
|
+
const strictCandidate = optimizeLineRanges(wordWidths, maxDisplayWidth, baseLineCount, 0);
|
|
131
|
+
const candidates = [];
|
|
132
|
+
if (strictCandidate) candidates.push({
|
|
133
|
+
ranges: strictCandidate.ranges,
|
|
134
|
+
score: strictCandidate.score,
|
|
135
|
+
overflow: strictCandidate.overflow
|
|
136
|
+
});
|
|
137
|
+
else candidates.push({
|
|
138
|
+
ranges: baseRanges,
|
|
139
|
+
score: Number.POSITIVE_INFINITY,
|
|
140
|
+
overflow: 0
|
|
141
|
+
});
|
|
142
|
+
if (baseLineCount > 1) {
|
|
143
|
+
const maxOverflow = Math.max(2, Math.floor(maxDisplayWidth * .15));
|
|
144
|
+
const compactCandidate = optimizeLineRanges(wordWidths, maxDisplayWidth, baseLineCount - 1, maxOverflow);
|
|
145
|
+
if (compactCandidate) {
|
|
146
|
+
const droppedLinePenalty = Math.pow(maxDisplayWidth * .45, 2);
|
|
147
|
+
candidates.push({
|
|
148
|
+
ranges: compactCandidate.ranges,
|
|
149
|
+
score: compactCandidate.score + droppedLinePenalty,
|
|
150
|
+
overflow: compactCandidate.overflow
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
candidates.sort((a, b) => {
|
|
155
|
+
if (a.score !== b.score) return a.score - b.score;
|
|
156
|
+
if (a.overflow !== b.overflow) return a.overflow - b.overflow;
|
|
157
|
+
return a.ranges.length - b.ranges.length;
|
|
158
|
+
});
|
|
159
|
+
return mergeTinyTrailingWordRange((candidates[0] ?? { ranges: baseRanges }).ranges, wordWidths, maxDisplayWidth).map(([start, end]) => words.slice(start, end).join(" "));
|
|
160
|
+
}
|
|
161
|
+
function mergeTinyTrailingWordRange(ranges, wordWidths, maxDisplayWidth) {
|
|
162
|
+
if (ranges.length < 2) return ranges;
|
|
163
|
+
const last = ranges[ranges.length - 1];
|
|
164
|
+
const prev = ranges[ranges.length - 2];
|
|
165
|
+
if (last[1] - last[0] !== 1) return ranges;
|
|
166
|
+
const prefix = buildPrefixWidths(wordWidths);
|
|
167
|
+
const tailWidth = lineWidthFromPrefix(prefix, last[0], last[1]);
|
|
168
|
+
if (tailWidth > Math.max(2, Math.floor(maxDisplayWidth * .45))) return ranges;
|
|
169
|
+
if (lineWidthFromPrefix(prefix, prev[0], prev[1]) + 1 + tailWidth > maxDisplayWidth + Math.max(2, Math.floor(maxDisplayWidth * .15))) return ranges;
|
|
170
|
+
const merged = ranges.slice(0, -2);
|
|
171
|
+
merged.push([prev[0], last[1]]);
|
|
172
|
+
return merged;
|
|
173
|
+
}
|
|
174
|
+
function greedyLineRanges(wordWidths, maxDisplayWidth) {
|
|
175
|
+
if (wordWidths.length === 0) return [];
|
|
176
|
+
const ranges = [];
|
|
177
|
+
let start = 0;
|
|
178
|
+
let currentWidth = wordWidths[0];
|
|
179
|
+
for (let i = 1; i < wordWidths.length; i++) {
|
|
180
|
+
const nextWidth = currentWidth + 1 + wordWidths[i];
|
|
181
|
+
if (nextWidth <= maxDisplayWidth) {
|
|
182
|
+
currentWidth = nextWidth;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
ranges.push([start, i]);
|
|
186
|
+
start = i;
|
|
187
|
+
currentWidth = wordWidths[i];
|
|
188
|
+
}
|
|
189
|
+
ranges.push([start, wordWidths.length]);
|
|
190
|
+
return ranges;
|
|
191
|
+
}
|
|
192
|
+
function optimizeLineRanges(wordWidths, maxDisplayWidth, totalLines, maxOverflow) {
|
|
193
|
+
const totalWords = wordWidths.length;
|
|
194
|
+
if (totalWords === 0 || totalLines <= 0) return {
|
|
195
|
+
ranges: [],
|
|
196
|
+
score: 0,
|
|
197
|
+
overflow: 0
|
|
198
|
+
};
|
|
199
|
+
if (totalLines >= totalWords) return {
|
|
200
|
+
ranges: wordWidths.map((_, index) => [index, index + 1]),
|
|
201
|
+
score: 0,
|
|
202
|
+
overflow: 0
|
|
203
|
+
};
|
|
204
|
+
const prefix = buildPrefixWidths(wordWidths);
|
|
205
|
+
const lineWidth = (start, end) => {
|
|
206
|
+
return lineWidthFromPrefix(prefix, start, end);
|
|
207
|
+
};
|
|
208
|
+
const isValidLine = (start, end) => {
|
|
209
|
+
if (lineWidth(start, end) <= maxDisplayWidth + maxOverflow) return true;
|
|
210
|
+
return end - start === 1;
|
|
211
|
+
};
|
|
212
|
+
const targetWidth = lineWidth(0, totalWords) / totalLines;
|
|
213
|
+
const dp = Array.from({ length: totalLines + 1 }, () => Array(totalWords + 1).fill(Number.POSITIVE_INFINITY));
|
|
214
|
+
const prev = Array.from({ length: totalLines + 1 }, () => Array(totalWords + 1).fill(-1));
|
|
215
|
+
dp[0][0] = 0;
|
|
216
|
+
for (let lineIndex = 1; lineIndex <= totalLines; lineIndex++) {
|
|
217
|
+
const minEnd = lineIndex;
|
|
218
|
+
const maxEnd = totalWords - (totalLines - lineIndex);
|
|
219
|
+
for (let end = minEnd; end <= maxEnd; end++) for (let start = lineIndex - 1; start < end; start++) {
|
|
220
|
+
if (!Number.isFinite(dp[lineIndex - 1][start])) continue;
|
|
221
|
+
if (!isValidLine(start, end)) continue;
|
|
222
|
+
const cost = lineCost(lineWidth(start, end), targetWidth, end - start, lineIndex, totalLines, maxDisplayWidth);
|
|
223
|
+
const nextCost = dp[lineIndex - 1][start] + cost;
|
|
224
|
+
if (nextCost < dp[lineIndex][end]) {
|
|
225
|
+
dp[lineIndex][end] = nextCost;
|
|
226
|
+
prev[lineIndex][end] = start;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!Number.isFinite(dp[totalLines][totalWords])) return null;
|
|
231
|
+
const ranges = [];
|
|
232
|
+
let overflow = 0;
|
|
233
|
+
let end = totalWords;
|
|
234
|
+
for (let lineIndex = totalLines; lineIndex >= 1; lineIndex--) {
|
|
235
|
+
const start = prev[lineIndex][end];
|
|
236
|
+
if (start < 0) return null;
|
|
237
|
+
ranges.push([start, end]);
|
|
238
|
+
const width = lineWidth(start, end);
|
|
239
|
+
overflow += Math.max(0, width - maxDisplayWidth);
|
|
240
|
+
end = start;
|
|
241
|
+
}
|
|
242
|
+
ranges.reverse();
|
|
243
|
+
return {
|
|
244
|
+
ranges,
|
|
245
|
+
score: dp[totalLines][totalWords],
|
|
246
|
+
overflow
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function lineCost(width, targetWidth, wordsInLine, lineIndex, totalLines, maxDisplayWidth) {
|
|
250
|
+
const variance = Math.pow(width - targetWidth, 2);
|
|
251
|
+
const overflow = Math.max(0, width - maxDisplayWidth);
|
|
252
|
+
let penalty = 0;
|
|
253
|
+
if (overflow > 0) penalty += Math.pow(overflow, 2) * 12;
|
|
254
|
+
if (wordsInLine === 1 && totalLines > 1) {
|
|
255
|
+
const ratio = targetWidth > 0 ? width / targetWidth : 1;
|
|
256
|
+
if (lineIndex === totalLines && ratio < .95) penalty += Math.pow(targetWidth * (.95 - ratio), 2) + targetWidth * 4;
|
|
257
|
+
else if (ratio < .6) penalty += Math.pow(targetWidth * (.6 - ratio), 2);
|
|
258
|
+
}
|
|
259
|
+
if (lineIndex === totalLines) {
|
|
260
|
+
const ratio = targetWidth > 0 ? width / targetWidth : 1;
|
|
261
|
+
if (ratio < .55) penalty += Math.pow(targetWidth * (.55 - ratio), 2) * 2.5;
|
|
262
|
+
}
|
|
263
|
+
return variance + penalty;
|
|
264
|
+
}
|
|
265
|
+
function buildPrefixWidths(wordWidths) {
|
|
266
|
+
const prefix = Array(wordWidths.length + 1).fill(0);
|
|
267
|
+
for (let i = 0; i < wordWidths.length; i++) prefix[i + 1] = prefix[i] + wordWidths[i];
|
|
268
|
+
return prefix;
|
|
269
|
+
}
|
|
270
|
+
function lineWidthFromPrefix(prefix, start, end) {
|
|
271
|
+
return prefix[end] - prefix[start] + Math.max(0, end - start - 1);
|
|
272
|
+
}
|
|
273
|
+
function displayWidth(value) {
|
|
274
|
+
let width = 0;
|
|
275
|
+
for (const char of value) width += charDisplayWidth(char);
|
|
276
|
+
return width;
|
|
277
|
+
}
|
|
278
|
+
function charDisplayWidth(char) {
|
|
279
|
+
const codePoint = char.codePointAt(0);
|
|
280
|
+
if (codePoint === void 0) return 0;
|
|
281
|
+
if (codePoint <= 31 || codePoint >= 127 && codePoint <= 159) return 0;
|
|
282
|
+
if (COMBINING_MARK_RE.test(char)) return 0;
|
|
283
|
+
if (isFullWidthCodePoint(codePoint)) return 2;
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
function isFullWidthCodePoint(codePoint) {
|
|
287
|
+
if (codePoint < 4352) return false;
|
|
288
|
+
return codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 12871 && codePoint !== 12351 || codePoint >= 12880 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 42182 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65131 || codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 110592 && codePoint <= 110593 || codePoint >= 127488 && codePoint <= 127569 || codePoint >= 131072 && codePoint <= 262141;
|
|
289
|
+
}
|
|
290
|
+
function resolveOptions(options) {
|
|
291
|
+
const rawMaxDisplayWidth = options.maxDisplayWidth ?? defaultOptions.maxDisplayWidth;
|
|
292
|
+
const maxDisplayWidth = Number.isFinite(rawMaxDisplayWidth) && rawMaxDisplayWidth > 0 ? Math.trunc(rawMaxDisplayWidth) : Number.POSITIVE_INFINITY;
|
|
293
|
+
return {
|
|
294
|
+
enabledModes: options.enabledModes ? [...options.enabledModes] : [...defaultOptions.enabledModes],
|
|
295
|
+
chunkMode: options.chunkMode ?? defaultOptions.chunkMode,
|
|
296
|
+
sentenceDelimiters: options.sentenceDelimiters ? [...options.sentenceDelimiters] : [...defaultOptions.sentenceDelimiters],
|
|
297
|
+
maxDisplayWidth
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
//#endregion
|
|
302
|
+
export { defaultOptions, parseNoteToSubtitleTimeline };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slidev-addon-subtitle",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"slidev",
|
|
6
|
+
"slidev-addon"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"workspaces": [
|
|
10
|
+
"examples/*",
|
|
11
|
+
"docs"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"setup",
|
|
16
|
+
"style.css",
|
|
17
|
+
"assets/logo.svg"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.mts",
|
|
23
|
+
"import": "./dist/index.mjs"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"lint": "oxlint",
|
|
31
|
+
"fmt": "oxfmt --write .",
|
|
32
|
+
"fmt:check": "oxfmt --check .",
|
|
33
|
+
"check": "tsc --noEmit",
|
|
34
|
+
"docs:dev": "yarn workspace slidev-addon-subtitle-docs run dev",
|
|
35
|
+
"docs:build": "mise x -- bazelisk build //docs:site_archive && rm -rf docs/.vitepress/dist && mkdir -p docs/.vitepress/dist && tar -xf bazel-bin/docs/site.tar -C docs/.vitepress/dist",
|
|
36
|
+
"docs:preview": "yarn workspace slidev-addon-subtitle-docs run preview",
|
|
37
|
+
"bazel:check": "mise x -- bazelisk build //:ci_checks",
|
|
38
|
+
"bazel:build": "mise x -- bazelisk build //:lib_build",
|
|
39
|
+
"bazel:docs": "mise x -- bazelisk build //docs:site_archive",
|
|
40
|
+
"ci": "mise x -- bazelisk build //:ci_all",
|
|
41
|
+
"prepare": "lefthook install"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22",
|
|
45
|
+
"lefthook": "^1",
|
|
46
|
+
"oxfmt": "latest",
|
|
47
|
+
"oxlint": "^1",
|
|
48
|
+
"tsdown": "^0.20.3",
|
|
49
|
+
"typescript": "^5",
|
|
50
|
+
"vitest": "^3"
|
|
51
|
+
},
|
|
52
|
+
"packageManager": "yarn@4.12.0"
|
|
53
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useSlideContext } from "@slidev/client";
|
|
3
|
+
import {
|
|
4
|
+
defaultOptions,
|
|
5
|
+
parseNoteToSubtitleTimeline,
|
|
6
|
+
type SubtitleEntry,
|
|
7
|
+
} from "slidev-addon-subtitle";
|
|
8
|
+
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
9
|
+
|
|
10
|
+
const { $clicks, $clicksContext: clicks, $renderContext, $route } = useSlideContext();
|
|
11
|
+
|
|
12
|
+
const registrationId = `slidev-addon-subtitle-${Math.random().toString(36).slice(2, 10)}`;
|
|
13
|
+
const baseOffset = ref(0);
|
|
14
|
+
let stopWatch: (() => void) | undefined;
|
|
15
|
+
|
|
16
|
+
const note = computed(() => $route?.meta.slide?.note ?? "");
|
|
17
|
+
|
|
18
|
+
const timeline = computed<SubtitleEntry[]>(() => {
|
|
19
|
+
return parseNoteToSubtitleTimeline(note.value, defaultOptions);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function registerTimeline() {
|
|
23
|
+
clicks.unregister(registrationId);
|
|
24
|
+
|
|
25
|
+
baseOffset.value = clicks.currentOffset;
|
|
26
|
+
|
|
27
|
+
const items = timeline.value;
|
|
28
|
+
const lastStart = items.length > 0 ? items[items.length - 1].start : -1;
|
|
29
|
+
if (lastStart > 0) {
|
|
30
|
+
clicks.register(registrationId, {
|
|
31
|
+
max: baseOffset.value + lastStart,
|
|
32
|
+
delta: 0,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onMounted(() => {
|
|
38
|
+
registerTimeline();
|
|
39
|
+
stopWatch = watch(
|
|
40
|
+
timeline,
|
|
41
|
+
() => {
|
|
42
|
+
registerTimeline();
|
|
43
|
+
},
|
|
44
|
+
{ deep: true },
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
onUnmounted(() => {
|
|
49
|
+
if (stopWatch) stopWatch();
|
|
50
|
+
clicks.unregister(registrationId);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const localClicks = computed(() => $clicks.value - baseOffset.value);
|
|
54
|
+
|
|
55
|
+
const activeSubtitle = computed(() => {
|
|
56
|
+
const items = timeline.value;
|
|
57
|
+
if (items.length === 0) return "";
|
|
58
|
+
|
|
59
|
+
const click = localClicks.value;
|
|
60
|
+
for (let i = 0; i < items.length; i++) {
|
|
61
|
+
const current = items[i];
|
|
62
|
+
const next = items[i + 1];
|
|
63
|
+
const endExclusive = next ? next.start : current.start + 1;
|
|
64
|
+
|
|
65
|
+
if (click >= current.start && click < endExclusive) {
|
|
66
|
+
return current.text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return "";
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const shouldRender = computed(() => {
|
|
74
|
+
if (!activeSubtitle.value) return false;
|
|
75
|
+
return $renderContext.value === "slide";
|
|
76
|
+
});
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<Transition name="subtitle-fade">
|
|
81
|
+
<div v-if="shouldRender" class="pdf-subtitle">
|
|
82
|
+
{{ activeSubtitle }}
|
|
83
|
+
</div>
|
|
84
|
+
</Transition>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style scoped>
|
|
88
|
+
.subtitle-fade-enter-active,
|
|
89
|
+
.subtitle-fade-leave-active {
|
|
90
|
+
transition: opacity 0.18s ease;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.subtitle-fade-enter-from,
|
|
94
|
+
.subtitle-fade-leave-to {
|
|
95
|
+
opacity: 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.pdf-subtitle {
|
|
99
|
+
white-space: pre-line;
|
|
100
|
+
}
|
|
101
|
+
</style>
|
package/setup/main.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineTransformersSetup, type MarkdownTransformContext } from "@slidev/types";
|
|
2
|
+
|
|
3
|
+
const SUBTITLE_COMPONENT_TAG = "<SubtitleDisplay />";
|
|
4
|
+
|
|
5
|
+
function injectSubtitleDisplay(ctx: MarkdownTransformContext): void {
|
|
6
|
+
if (ctx.options.mode !== "export") return;
|
|
7
|
+
|
|
8
|
+
const note = ctx.slide.note?.trim();
|
|
9
|
+
if (!note) return;
|
|
10
|
+
|
|
11
|
+
const source = ctx.s.toString();
|
|
12
|
+
if (source.includes("<SubtitleDisplay")) return;
|
|
13
|
+
|
|
14
|
+
const prefix = source.startsWith("\n")
|
|
15
|
+
? `${SUBTITLE_COMPONENT_TAG}\n`
|
|
16
|
+
: `${SUBTITLE_COMPONENT_TAG}\n\n`;
|
|
17
|
+
ctx.s.prepend(prefix);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default defineTransformersSetup(() => ({
|
|
21
|
+
pre: [injectSubtitleDisplay],
|
|
22
|
+
}));
|
package/style.css
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.pdf-subtitle {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 48px;
|
|
4
|
+
left: 0;
|
|
5
|
+
right: 0;
|
|
6
|
+
width: fit-content;
|
|
7
|
+
margin-left: auto;
|
|
8
|
+
margin-right: auto;
|
|
9
|
+
padding: 8px 24px;
|
|
10
|
+
background: rgba(0, 0, 0, 0.75);
|
|
11
|
+
border-radius: 8px;
|
|
12
|
+
color: #ffffff;
|
|
13
|
+
font-size: 1.25rem;
|
|
14
|
+
line-height: 1.6;
|
|
15
|
+
text-align: center;
|
|
16
|
+
z-index: 100;
|
|
17
|
+
pointer-events: none;
|
|
18
|
+
}
|