@linto-ai/transcript-ui-plugin-subtitle 0.9.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 +661 -0
- package/README.md +29 -0
- package/package.json +48 -0
- package/src/SubtitleBanner.vue +81 -0
- package/src/SubtitleFullscreen.vue +148 -0
- package/src/SubtitleScroller.ts +198 -0
- package/src/SubtitleWatermark.vue +71 -0
- package/src/index.ts +89 -0
- package/src/useSubtitleScroller.ts +66 -0
- package/src/useWatermarkCycle.ts +59 -0
- package/src/utils/parseWatermark.ts +39 -0
- package/src/utils/splitPartialSubtitles.ts +200 -0
- package/src/utils/test/parseWatermark.test.ts +104 -0
- package/src/utils/test/splitPartialSubtitles.test.ts +180 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, watch, type ComputedRef, type Ref } from "vue"
|
|
2
|
+
import { useCore } from "@linto-ai/transcript-ui-core"
|
|
3
|
+
import { SubtitleScroller } from "./SubtitleScroller"
|
|
4
|
+
|
|
5
|
+
interface UseSubtitleScrollerOptions {
|
|
6
|
+
canvasRef: Readonly<Ref<HTMLCanvasElement | null>>
|
|
7
|
+
fontSize: Ref<number> | ComputedRef<number>
|
|
8
|
+
lineHeight: Ref<number> | ComputedRef<number>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wires a SubtitleScroller to editor events (live partials, turn adds, sync resets).
|
|
13
|
+
* Handles creation on mount and full cleanup on unmount.
|
|
14
|
+
*/
|
|
15
|
+
export function useSubtitleScroller(options: UseSubtitleScrollerOptions) {
|
|
16
|
+
const core = useCore()
|
|
17
|
+
let scroller: SubtitleScroller | null = null
|
|
18
|
+
|
|
19
|
+
onMounted(() => {
|
|
20
|
+
if (!options.canvasRef.value) return
|
|
21
|
+
scroller = new SubtitleScroller(options.canvasRef.value, {
|
|
22
|
+
fontSize: options.fontSize.value,
|
|
23
|
+
lineHeight: options.lineHeight.value,
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
watch([options.fontSize, options.lineHeight], ([fs, lh]) => {
|
|
28
|
+
if (!scroller) return
|
|
29
|
+
scroller.setFontSize(fs, lh)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
watch(
|
|
33
|
+
() => core.live?.partial.value,
|
|
34
|
+
(text) => {
|
|
35
|
+
if (text && scroller) scroller.newPartial(text)
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const unsubTurnAdd = core.onActiveTranslation("turn:add", ({ turn }) => {
|
|
40
|
+
if (!scroller) return
|
|
41
|
+
const text =
|
|
42
|
+
turn.words.length > 0
|
|
43
|
+
? turn.words.map((w) => w.text).join(" ")
|
|
44
|
+
: (turn.text ?? "")
|
|
45
|
+
if (text) scroller.newFinal(text)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
function resetScroller(): void {
|
|
49
|
+
if (!scroller) return
|
|
50
|
+
scroller.resetDrawing()
|
|
51
|
+
scroller.resetAll()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unsubTranslationChange = core.on("translation:change", resetScroller)
|
|
55
|
+
const unsubTranslationSync = core.on("translation:sync", resetScroller)
|
|
56
|
+
const unsubChannelSync = core.on("channel:sync", resetScroller)
|
|
57
|
+
|
|
58
|
+
onUnmounted(() => {
|
|
59
|
+
unsubTurnAdd()
|
|
60
|
+
unsubTranslationChange()
|
|
61
|
+
unsubTranslationSync()
|
|
62
|
+
unsubChannelSync()
|
|
63
|
+
scroller?.dispose()
|
|
64
|
+
scroller = null
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
2
|
+
import type { WatermarkPluginApi } from "@linto-ai/transcript-ui-core"
|
|
3
|
+
|
|
4
|
+
export function useWatermarkCycle(api: WatermarkPluginApi | undefined) {
|
|
5
|
+
const visible = ref(false)
|
|
6
|
+
let showTimer: ReturnType<typeof setTimeout> | null = null
|
|
7
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
|
8
|
+
|
|
9
|
+
function clearTimers() {
|
|
10
|
+
if (showTimer) {
|
|
11
|
+
clearTimeout(showTimer)
|
|
12
|
+
showTimer = null
|
|
13
|
+
}
|
|
14
|
+
if (hideTimer) {
|
|
15
|
+
clearTimeout(hideTimer)
|
|
16
|
+
hideTimer = null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function show() {
|
|
21
|
+
if (!api || !api.display.value) return
|
|
22
|
+
visible.value = true
|
|
23
|
+
if (!api.pinned.value) {
|
|
24
|
+
hideTimer = setTimeout(hide, api.duration.value * 1000)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hide() {
|
|
29
|
+
visible.value = false
|
|
30
|
+
if (!api || !api.display.value || api.pinned.value) return
|
|
31
|
+
showTimer = setTimeout(show, api.frequency.value * 1000)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function restart() {
|
|
35
|
+
clearTimers()
|
|
36
|
+
if (!api || !api.display.value) {
|
|
37
|
+
visible.value = false
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
if (api.pinned.value) {
|
|
41
|
+
visible.value = true
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
visible.value = false
|
|
45
|
+
showTimer = setTimeout(show, api.frequency.value * 1000)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (api) {
|
|
49
|
+
watch(
|
|
50
|
+
[api.display, api.pinned, api.frequency, api.duration],
|
|
51
|
+
restart,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(restart)
|
|
56
|
+
onBeforeUnmount(clearTimers)
|
|
57
|
+
|
|
58
|
+
return { visible }
|
|
59
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { WatermarkToken } from "@linto-ai/transcript-ui-core"
|
|
2
|
+
|
|
3
|
+
export type WatermarkPart =
|
|
4
|
+
| { type: "text"; value: string }
|
|
5
|
+
| { type: "token"; src: string; alt: string }
|
|
6
|
+
|
|
7
|
+
const TOKEN_RE = /\$(\w+)/g
|
|
8
|
+
|
|
9
|
+
export function parseWatermark(
|
|
10
|
+
content: string,
|
|
11
|
+
tokens: Record<string, WatermarkToken>,
|
|
12
|
+
): WatermarkPart[] {
|
|
13
|
+
const parts: WatermarkPart[] = []
|
|
14
|
+
let lastIndex = 0
|
|
15
|
+
let match: RegExpExecArray | null
|
|
16
|
+
|
|
17
|
+
TOKEN_RE.lastIndex = 0
|
|
18
|
+
while ((match = TOKEN_RE.exec(content)) !== null) {
|
|
19
|
+
if (match.index > lastIndex) {
|
|
20
|
+
parts.push({ type: "text", value: content.slice(lastIndex, match.index) })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const name = match[1] ?? ""
|
|
24
|
+
const token = name ? tokens[name] : undefined
|
|
25
|
+
if (token) {
|
|
26
|
+
parts.push({ type: "token", src: token.src, alt: token.alt ?? name })
|
|
27
|
+
} else {
|
|
28
|
+
parts.push({ type: "text", value: match[0] })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lastIndex = match.index + match[0].length
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (lastIndex < content.length) {
|
|
35
|
+
parts.push({ type: "text", value: content.slice(lastIndex) })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return parts
|
|
39
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { diffArrays } from "diff"
|
|
2
|
+
|
|
3
|
+
interface SubtitleState {
|
|
4
|
+
previousText: string
|
|
5
|
+
previousIndexes: number[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface DiffEntry {
|
|
9
|
+
value: string[]
|
|
10
|
+
added?: boolean
|
|
11
|
+
removed?: boolean
|
|
12
|
+
count: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReplacementEntry {
|
|
16
|
+
replaced: true
|
|
17
|
+
removed: boolean
|
|
18
|
+
added: boolean
|
|
19
|
+
countRemoved: number
|
|
20
|
+
countAdded: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type DiffWithReplacements = (DiffEntry | ReplacementEntry)[]
|
|
24
|
+
|
|
25
|
+
export default function splitPartialSubtitles(
|
|
26
|
+
{ previousText, previousIndexes: oldCutPositions }: SubtitleState,
|
|
27
|
+
newText: string,
|
|
28
|
+
computeIfTextIsTooLong: (text: string) => boolean,
|
|
29
|
+
computeIfTextOverflows?: (text: string) => boolean,
|
|
30
|
+
): SubtitleState {
|
|
31
|
+
if (!newText) {
|
|
32
|
+
return { previousText, previousIndexes: oldCutPositions }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const previousTextSplitBySpace = previousText.split(" ")
|
|
36
|
+
const newTextSplitBySpace = newText.split(" ")
|
|
37
|
+
const diffList = diffArrays(previousTextSplitBySpace, newTextSplitBySpace, {
|
|
38
|
+
comparator: isSameWord,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const diffListWithReplacements = detectReplacements(diffList)
|
|
42
|
+
|
|
43
|
+
const copyCutPositions = [...oldCutPositions]
|
|
44
|
+
let newCutPositions = [...oldCutPositions]
|
|
45
|
+
|
|
46
|
+
let wordIndex = 0
|
|
47
|
+
for (const diff of diffListWithReplacements) {
|
|
48
|
+
do {
|
|
49
|
+
if (wordIndex < copyCutPositions[0]!) break
|
|
50
|
+
} while (copyCutPositions.shift() !== undefined)
|
|
51
|
+
|
|
52
|
+
if (copyCutPositions.length === 0) break
|
|
53
|
+
|
|
54
|
+
if ("replaced" in diff && diff.replaced) {
|
|
55
|
+
newCutPositions = incrementIndexes(
|
|
56
|
+
newCutPositions,
|
|
57
|
+
copyCutPositions[0]!,
|
|
58
|
+
diff.countAdded - diff.countRemoved,
|
|
59
|
+
)
|
|
60
|
+
wordIndex += diff.countRemoved
|
|
61
|
+
} else if ("removed" in diff && diff.removed) {
|
|
62
|
+
const d = diff as DiffEntry
|
|
63
|
+
wordIndex += d.count
|
|
64
|
+
newCutPositions = incrementIndexes(
|
|
65
|
+
newCutPositions,
|
|
66
|
+
copyCutPositions[0]!,
|
|
67
|
+
-d.count,
|
|
68
|
+
)
|
|
69
|
+
} else if ("added" in diff && diff.added) {
|
|
70
|
+
const d = diff as DiffEntry
|
|
71
|
+
newCutPositions = incrementIndexes(
|
|
72
|
+
newCutPositions,
|
|
73
|
+
copyCutPositions[0]!,
|
|
74
|
+
d.count,
|
|
75
|
+
)
|
|
76
|
+
} else {
|
|
77
|
+
const d = diff as DiffEntry
|
|
78
|
+
wordIndex += d.count
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The scroller only ever displays the last two lines, so we only keep those
|
|
83
|
+
// two inside the canvas.
|
|
84
|
+
const lineText = (from: number, to?: number): string =>
|
|
85
|
+
newTextSplitBySpace.slice(from, to).join(" ")
|
|
86
|
+
|
|
87
|
+
// Step 1 — keep the second-to-last (first visible) line inside the canvas. If a
|
|
88
|
+
// widened word pushed it off the edge, move the last cut earlier so the
|
|
89
|
+
// overflowing tail flows down into the last line; the head stays in place. We
|
|
90
|
+
// trigger on the overflow threshold but cut at the normal one, leaving a margin
|
|
91
|
+
// before it can overflow again (hysteresis, avoids re-cutting on every partial).
|
|
92
|
+
if (computeIfTextOverflows && newCutPositions.length > 0) {
|
|
93
|
+
const lastIndex = newCutPositions.length - 1
|
|
94
|
+
const lineStart = lastIndex > 0 ? newCutPositions[lastIndex - 1]! : 0
|
|
95
|
+
const secondToLastLine = lineText(lineStart, newCutPositions[lastIndex]!)
|
|
96
|
+
|
|
97
|
+
if (computeIfTextOverflows(secondToLastLine)) {
|
|
98
|
+
const [headCut] = getIndexesWhereToCutText(
|
|
99
|
+
secondToLastLine,
|
|
100
|
+
computeIfTextIsTooLong,
|
|
101
|
+
)
|
|
102
|
+
// headCut is undefined for a single unsplittable word: leave it as is.
|
|
103
|
+
if (headCut !== undefined) newCutPositions[lastIndex] = lineStart + headCut
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Step 2 — cut the last line if it is too long, including the words step 1 may
|
|
108
|
+
// have just pushed down. This is the normal live-growth cut.
|
|
109
|
+
const lastLineStart =
|
|
110
|
+
newCutPositions.length > 0 ? newCutPositions[newCutPositions.length - 1]! : 0
|
|
111
|
+
const lastLine = lineText(lastLineStart)
|
|
112
|
+
if (computeIfTextIsTooLong(lastLine)) {
|
|
113
|
+
const cuts = getIndexesWhereToCutText(lastLine, computeIfTextIsTooLong)
|
|
114
|
+
newCutPositions = newCutPositions.concat(cuts.map((i) => i + lastLineStart))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
previousIndexes: newCutPositions,
|
|
119
|
+
previousText: newText,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function detectReplacements(diffList: DiffEntry[]): DiffWithReplacements {
|
|
124
|
+
const result: DiffWithReplacements = []
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < diffList.length; i++) {
|
|
127
|
+
const currentDiff = diffList[i]!
|
|
128
|
+
const nextDiff = diffList[i + 1]
|
|
129
|
+
|
|
130
|
+
// A replacement is a removed block immediately followed by an added one.
|
|
131
|
+
const isReplacement = currentDiff.removed && nextDiff?.added
|
|
132
|
+
|
|
133
|
+
if (isReplacement) {
|
|
134
|
+
result.push({
|
|
135
|
+
replaced: true,
|
|
136
|
+
removed: true,
|
|
137
|
+
added: true,
|
|
138
|
+
countRemoved: currentDiff.count,
|
|
139
|
+
countAdded: nextDiff!.count,
|
|
140
|
+
})
|
|
141
|
+
i++ // skip the added block, already merged into this replacement
|
|
142
|
+
} else {
|
|
143
|
+
result.push(currentDiff)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function incrementIndexes(indexes: number[], from: number, increment: number): number[] {
|
|
151
|
+
return indexes.map((index) => (index >= from ? index + increment : index))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getIndexesWhereToCutText(
|
|
155
|
+
text: string,
|
|
156
|
+
computeIfTextIsTooLong: (text: string) => boolean,
|
|
157
|
+
): number[] {
|
|
158
|
+
const splitText = text.split(" ")
|
|
159
|
+
if (!computeIfTextIsTooLong(text) || splitText.length <= 1) {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let i: number
|
|
164
|
+
for (i = 0; i < splitText.length; i++) {
|
|
165
|
+
const currentText = splitText.slice(0, i).join(" ")
|
|
166
|
+
if (computeIfTextIsTooLong(currentText)) break
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [i - 1].concat(
|
|
170
|
+
incrementIndexes(
|
|
171
|
+
getIndexesWhereToCutText(
|
|
172
|
+
splitText.slice(i - 1).join(" "),
|
|
173
|
+
computeIfTextIsTooLong,
|
|
174
|
+
),
|
|
175
|
+
0,
|
|
176
|
+
i - 1,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isSameWord(word1: string, word2: string): boolean {
|
|
182
|
+
const w1Normalized = word1
|
|
183
|
+
.toLowerCase()
|
|
184
|
+
.normalize("NFD")
|
|
185
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
186
|
+
const w2Normalized = word2
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.normalize("NFD")
|
|
189
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
190
|
+
|
|
191
|
+
const shortestLength = Math.min(w1Normalized.length, w2Normalized.length)
|
|
192
|
+
|
|
193
|
+
let numberOfSameChars = 0
|
|
194
|
+
for (let i = 0; i < shortestLength; i++) {
|
|
195
|
+
if (w1Normalized[i] === w2Normalized[i]) numberOfSameChars++
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const similarity = numberOfSameChars / w1Normalized.length
|
|
199
|
+
return similarity > 0.8
|
|
200
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseWatermark } from "../parseWatermark"
|
|
3
|
+
|
|
4
|
+
const tokens = {
|
|
5
|
+
linto: { src: "/img/linto.svg", alt: "LinTO" },
|
|
6
|
+
logo: { src: "/img/logo.png" },
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("parseWatermark", () => {
|
|
10
|
+
it("returns a single text part for plain content", () => {
|
|
11
|
+
expect(parseWatermark("Hello world", tokens)).toEqual([
|
|
12
|
+
{ type: "text", value: "Hello world" },
|
|
13
|
+
])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("returns an empty array for empty content", () => {
|
|
17
|
+
expect(parseWatermark("", tokens)).toEqual([])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("replaces a known token with an image part", () => {
|
|
21
|
+
expect(parseWatermark("$linto", tokens)).toEqual([
|
|
22
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("uses the token name as alt when alt is not provided", () => {
|
|
27
|
+
expect(parseWatermark("$logo", tokens)).toEqual([
|
|
28
|
+
{ type: "token", src: "/img/logo.png", alt: "logo" },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("keeps unknown tokens as literal text", () => {
|
|
33
|
+
expect(parseWatermark("Hi $unknown", tokens)).toEqual([
|
|
34
|
+
{ type: "text", value: "Hi " },
|
|
35
|
+
{ type: "text", value: "$unknown" },
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("handles text around and between tokens", () => {
|
|
40
|
+
expect(
|
|
41
|
+
parseWatermark("Powered by $linto and $logo - thanks", tokens),
|
|
42
|
+
).toEqual([
|
|
43
|
+
{ type: "text", value: "Powered by " },
|
|
44
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
45
|
+
{ type: "text", value: " and " },
|
|
46
|
+
{ type: "token", src: "/img/logo.png", alt: "logo" },
|
|
47
|
+
{ type: "text", value: " - thanks" },
|
|
48
|
+
])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("replaces every occurrence of the same token", () => {
|
|
52
|
+
expect(parseWatermark("$linto vs $linto", tokens)).toEqual([
|
|
53
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
54
|
+
{ type: "text", value: " vs " },
|
|
55
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("stops the token name at the first non-word character", () => {
|
|
60
|
+
expect(parseWatermark("$linto.svg", tokens)).toEqual([
|
|
61
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
62
|
+
{ type: "text", value: ".svg" },
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("handles adjacent tokens", () => {
|
|
67
|
+
expect(parseWatermark("$linto$logo", tokens)).toEqual([
|
|
68
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
69
|
+
{ type: "token", src: "/img/logo.png", alt: "logo" },
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("keeps a lone $ as literal text", () => {
|
|
74
|
+
expect(parseWatermark("price: $ 20", tokens)).toEqual([
|
|
75
|
+
{ type: "text", value: "price: $ 20" },
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("keeps numeric-only tokens as literal when unknown", () => {
|
|
80
|
+
expect(parseWatermark("$20", tokens)).toEqual([
|
|
81
|
+
{ type: "text", value: "$20" },
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("does not run HTML: angle brackets are kept as-is (rendering escapes them)", () => {
|
|
86
|
+
expect(parseWatermark("<script>$linto", tokens)).toEqual([
|
|
87
|
+
{ type: "text", value: "<script>" },
|
|
88
|
+
{ type: "token", src: "/img/linto.svg", alt: "LinTO" },
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("works with no tokens registered", () => {
|
|
93
|
+
expect(parseWatermark("$linto hi", {})).toEqual([
|
|
94
|
+
{ type: "text", value: "$linto" },
|
|
95
|
+
{ type: "text", value: " hi" },
|
|
96
|
+
])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("is stateless across consecutive calls (no regex lastIndex leak)", () => {
|
|
100
|
+
const first = parseWatermark("$linto", tokens)
|
|
101
|
+
const second = parseWatermark("$linto", tokens)
|
|
102
|
+
expect(first).toEqual(second)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import splitPartialSubtitles, {
|
|
3
|
+
getIndexesWhereToCutText,
|
|
4
|
+
} from "../splitPartialSubtitles"
|
|
5
|
+
|
|
6
|
+
// A line is "too long" once it exceeds `maxWords` words. Word-based so the cut
|
|
7
|
+
// positions stay easy to reason about: one cut roughly every `maxWords` words.
|
|
8
|
+
const tooLongAfter = (maxWords: number) => (text: string) =>
|
|
9
|
+
text.split(" ").length > maxWords
|
|
10
|
+
|
|
11
|
+
describe("getIndexesWhereToCutText", () => {
|
|
12
|
+
it("returns no cut when the text fits", () => {
|
|
13
|
+
expect(getIndexesWhereToCutText("a b c", tooLongAfter(3))).toEqual([])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("returns no cut for a single word, even when it is too long", () => {
|
|
17
|
+
expect(getIndexesWhereToCutText("word", (t) => t.length > 1)).toEqual([])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("cuts once when the text is one line too long", () => {
|
|
21
|
+
expect(getIndexesWhereToCutText("a b c d e", tooLongAfter(3))).toEqual([3])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("cuts repeatedly for a very long text", () => {
|
|
25
|
+
expect(getIndexesWhereToCutText("a b c d e f g h", tooLongAfter(3))).toEqual([
|
|
26
|
+
3, 6,
|
|
27
|
+
])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe("splitPartialSubtitles", () => {
|
|
32
|
+
const emptyState = { previousText: "", previousIndexes: [] as number[] }
|
|
33
|
+
|
|
34
|
+
it("returns the previous state untouched for empty text", () => {
|
|
35
|
+
const state = { previousText: "a b", previousIndexes: [1] }
|
|
36
|
+
expect(splitPartialSubtitles(state, "", tooLongAfter(3))).toEqual(state)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("produces no cut for a short fresh text", () => {
|
|
40
|
+
expect(splitPartialSubtitles(emptyState, "a b", tooLongAfter(3))).toEqual({
|
|
41
|
+
previousText: "a b",
|
|
42
|
+
previousIndexes: [],
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("cuts a long fresh text", () => {
|
|
47
|
+
expect(splitPartialSubtitles(emptyState, "a b c d e", tooLongAfter(3))).toEqual(
|
|
48
|
+
{ previousText: "a b c d e", previousIndexes: [3] },
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("keeps the earlier cut stable when the text grows", () => {
|
|
53
|
+
const state = { previousText: "a b c d e", previousIndexes: [3] }
|
|
54
|
+
expect(
|
|
55
|
+
splitPartialSubtitles(state, "a b c d e f g", tooLongAfter(3)),
|
|
56
|
+
).toEqual({ previousText: "a b c d e f g", previousIndexes: [3, 6] })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("does not cut when a word is replaced without overflowing", () => {
|
|
60
|
+
const state = { previousText: "hello world", previousIndexes: [] }
|
|
61
|
+
expect(splitPartialSubtitles(state, "hi world", tooLongAfter(3))).toEqual({
|
|
62
|
+
previousText: "hi world",
|
|
63
|
+
previousIndexes: [],
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Two stable lines of three words each, cut at index 3:
|
|
69
|
+
// "mot1 mot2 mot3 | mot4 mot5 mot6"
|
|
70
|
+
// These pin how an edit shifts (or keeps) that cut.
|
|
71
|
+
describe("splitPartialSubtitles — cut shifting on edits", () => {
|
|
72
|
+
const twoLines = {
|
|
73
|
+
previousText: "mot1 mot2 mot3 mot4 mot5 mot6",
|
|
74
|
+
previousIndexes: [3],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const indexesAfter = (next: string): number[] =>
|
|
78
|
+
splitPartialSubtitles(twoLines, next, tooLongAfter(3)).previousIndexes
|
|
79
|
+
|
|
80
|
+
it("keeps the cut when line 1 is replaced word-for-word (1 word -> 2, 1 deleted)", () => {
|
|
81
|
+
// mot2 mot3 -> mot2.1 mot2.2 (still 2 words), line 2 stays 3 words
|
|
82
|
+
expect(indexesAfter("mot1 mot2.1 mot2.2 mot4 mot6 mot7")).toEqual([3])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("keeps the cut on a 1:1 replacement plus a tail delete/add", () => {
|
|
86
|
+
expect(indexesAfter("mot1 mot2.1 mot3 mot5 mot6 mot7")).toEqual([3])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("keeps the cut when a word is duplicated across both lines", () => {
|
|
90
|
+
// mot2 -> mot4, so "mot4" now appears in both lines
|
|
91
|
+
expect(indexesAfter("mot1 mot4 mot3 mot4 mot5 mot6")).toEqual([3])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("shifts the cut right when a word is inserted in line 1", () => {
|
|
95
|
+
// KNOWN OVERFLOW BUG: line 1 ends up with 4 words and is NOT re-cut.
|
|
96
|
+
// Expected to change once the two-threshold fix re-validates earlier lines.
|
|
97
|
+
expect(indexesAfter("mot1 inserted mot2 mot3 mot4 mot5 mot6")).toEqual([4])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("shifts the cut left when a word is deleted in line 1", () => {
|
|
101
|
+
expect(indexesAfter("mot1 mot3 mot4 mot5 mot6")).toEqual([2])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("re-cuts the last line when it grows too long", () => {
|
|
105
|
+
expect(indexesAfter("mot1 mot2 mot3 mot4 mot5 mot6 mot7 mot8")).toEqual([
|
|
106
|
+
3, 6,
|
|
107
|
+
])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("re-cuts the last line when a word is inserted into line 2", () => {
|
|
111
|
+
expect(indexesAfter("mot1 mot2 mot3 mot4 inserted mot5 mot6")).toEqual([
|
|
112
|
+
3, 6,
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// The overflow predicate is char-based here, standing in for pixel width:
|
|
118
|
+
// tooLong = normal cut threshold (canvas.width - 2 * paddingInline)
|
|
119
|
+
// overflows = canvas edge / overflow (canvas.width - paddingInline)
|
|
120
|
+
// The gap between 11 and 15 is the inline margin a line may spill into.
|
|
121
|
+
describe("splitPartialSubtitles — overflow on the second-to-last line", () => {
|
|
122
|
+
const tooLong = (t: string) => t.length > 11
|
|
123
|
+
const overflows = (t: string) => t.length > 15
|
|
124
|
+
|
|
125
|
+
const indexesAfter = (
|
|
126
|
+
prev: string,
|
|
127
|
+
prevIndexes: number[],
|
|
128
|
+
next: string,
|
|
129
|
+
): number[] =>
|
|
130
|
+
splitPartialSubtitles(
|
|
131
|
+
{ previousText: prev, previousIndexes: prevIndexes },
|
|
132
|
+
next,
|
|
133
|
+
tooLong,
|
|
134
|
+
overflows,
|
|
135
|
+
).previousIndexes
|
|
136
|
+
|
|
137
|
+
it("pushes the overflowing tail down and keeps the head when the last line has room", () => {
|
|
138
|
+
// "ddd" -> "dddd" makes line 1 leave the canvas; its tail flows down and the
|
|
139
|
+
// head ("aaa bbb ccc") stays in place.
|
|
140
|
+
expect(indexesAfter("aaa bbb ccc ddd z", [4], "aaa bbb ccc dddd z")).toEqual([
|
|
141
|
+
3,
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("tolerates a line that only spills into the inline margin (no re-cut)", () => {
|
|
146
|
+
// line 1 is 13 chars: past the soft limit (11) but within the hard one (15).
|
|
147
|
+
expect(indexesAfter("aaaa bbbb ccc z", [3], "aaaa bbbb ccc z")).toEqual([3])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("scrolls when the last line is full and cannot absorb the pushed-down word", () => {
|
|
151
|
+
expect(
|
|
152
|
+
indexesAfter("aaa bbb ccc ddd xxx yyy", [4], "aaa bbb ccc dddd xxx yyy"),
|
|
153
|
+
).toEqual([3, 5])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("leaves an unsplittable wide word in place", () => {
|
|
157
|
+
expect(
|
|
158
|
+
indexesAfter("wordwordwordword z", [1], "wordwordwordword z"),
|
|
159
|
+
).toEqual([1])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("does nothing when no line overflows", () => {
|
|
163
|
+
expect(indexesAfter("aa bb cc z", [3], "aa bb cc z")).toEqual([3])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("worked example: pushes the head down then re-cuts the rest", () => {
|
|
167
|
+
// "mot2" widens to "mot2222" and "mot7" is appended:
|
|
168
|
+
// mot1 mot2 mot3 | mot4 mot5 mot6
|
|
169
|
+
// -> mot1 mot2222 | mot3 mot4 mot5 | mot6 mot7
|
|
170
|
+
const soft = (t: string) => t.length > 14
|
|
171
|
+
const hard = (t: string) => t.length > 16
|
|
172
|
+
const res = splitPartialSubtitles(
|
|
173
|
+
{ previousText: "mot1 mot2 mot3 mot4 mot5 mot6", previousIndexes: [3] },
|
|
174
|
+
"mot1 mot2222 mot3 mot4 mot5 mot6 mot7",
|
|
175
|
+
soft,
|
|
176
|
+
hard,
|
|
177
|
+
)
|
|
178
|
+
expect(res.previousIndexes).toEqual([2, 5])
|
|
179
|
+
})
|
|
180
|
+
})
|