Two files, no build step and no dependencies. The script is a classic script, so it also loads from a page opened straight from disk.
两个文件,没有构建步骤,没有依赖。脚本是普通脚本,所以从磁盘直接打开的页面也能加载。
ファイルは二つ、ビルドも依存関係もありません。クラシックスクリプトなので、ディスクから直接開いたページでも読み込めます。
From npm, where the package is named tsubu-js, pinned to an exact version (below). Or copy tsubu.js and tsubu.css next to your page: that is all a page needs. tsubu-export.js (subtitles out) and tsubu-import.js (other formats in) are optional.
从 npm 安装,包名是 tsubu-js,固定到精确版本(见下)。也可以把 tsubu.js 和 tsubu.css 复制到页面旁边,页面只需要这两个文件。tsubu-export.js(导出字幕)和 tsubu-import.js(导入其他格式)是可选的。
npm からインストールできます。パッケージ名は tsubu-js で、バージョンを固定します(下記)。または tsubu.js と tsubu.css をページの隣に置くだけで動きます。tsubu-export.js(字幕の書き出し)と tsubu-import.js(他の形式の読み込み)は省略できます。
npm install --save-exact tsubu-js
<link rel="stylesheet" href="tsubu.css">
<script src="tsubu.js"></script> // defines window.Tsubu
<script src="tsubu-export.js"></script> // optional: window.TsubuExport
<script src="tsubu-import.js"></script> // optional: window.TsubuImport
In Node, require() returns the same object; stateAt, htmlAt, contrast, the exporters and the importers work there without a browser. Each script also has an ES module.
在 Node 里 require() 返回同一个对象;stateAt、htmlAt、contrast、导出和导入函数不需要浏览器就能用。每个脚本也都有 ES 模块。
Node では require() が同じオブジェクトを返します。stateAt、htmlAt、contrast、書き出しと読み込みの関数はブラウザなしで動きます。各スクリプトには ES モジュールもあります。
const Tsubu = require("tsubu-js"); // or require("./tsubu.js")
const TsubuExport = require("tsubu-js/export");
const TsubuImport = require("tsubu-js/import");
import Tsubu, { sentence } from "tsubu-js"; // ES modules and bundlers
The sentence arrives already cut into segments, each a list of parts; a part with ruby becomes <ruby>. Times are in seconds on your own clock. sentence() gives HTML; apply(root, t) draws the state at t.
句子传进来时已经切好了段,每段是一组片段;带 ruby 的片段变成 <ruby>。时间以秒为单位,用你自己的时钟。sentence() 给出 HTML;apply(root, t) 画出 t 时刻的状态。
文は文節に分けて渡し、各文節はパーツの並びです。ruby を持つパーツは <ruby> になります。時刻は秒単位で、呼び出し側の時計をそのまま使います。sentence() が HTML を返し、apply(root, t) が時刻 t の状態を描きます。
<link rel="stylesheet" href="tsubu.css">
<div id="line" style="font-size: 40px"></div>
<script src="tsubu.js"></script>
<script>
const s = Tsubu.sentence({
segments: [
{ parts: [{ text: "雨", ruby: "あめ" }, { text: "の" }, { text: "日", ruby: "ひ" }, { text: "は" }], start: 0.0, end: 0.7 },
{ parts: [{ text: "、" }], kind: "punct" },
{ parts: [{ text: "家", ruby: "いえ" }, { text: "で" }], start: 0.9, end: 1.3 },
{ parts: [{ text: "お" }, { text: "茶", ruby: "ちゃ" }, { text: "を" }], start: 1.3, end: 1.8 },
{ parts: [{ text: "飲", ruby: "の" }, { text: "みます" }], start: 1.85, end: 2.6 },
{ parts: [{ text: "。" }], kind: "punct" },
],
});
const line = document.getElementById("line");
line.innerHTML = s.html;
const root = line.querySelector(".tsubu");
// Drive it from any clock: an <audio> element, a video timeline, a frame counter. tsubu
// keeps no timers; the loop is yours. Read the time on every frame: "timeupdate" fires only
// every 15 to 250 ms, so a highlight driven by it lags behind the voice.
const audio = document.querySelector("audio");
const frame = () => { s.apply(root, audio.currentTime); if (!audio.paused) requestAnimationFrame(frame); };
audio?.addEventListener("play", frame);
audio?.addEventListener("seeked", frame);
s.apply(root, 1.0); // the frame at t = 1.0 s: 家で is highlighted
Tsubu.setRuby(root, false); // hide the furigana; nothing moves
</script>
The same input, drawn here with the light theme. Tick the furigana off and on: no character moves.
同样的输入,这里用 light 主题画出来。把注音关掉再打开:没有一个字会动。
同じ入力を、ここでは light テーマで描いています。ふりがなを切ってまた付けてみてください。文字は一つも動きません。
Change the text, the readings or the times below; the sentence is redrawn as you type. tsubu refuses input it would have to guess at (a segment without a time while others have one, overlapping times, punctuation with a time) and the error is shown under the box.
改一改下面的文字、读音或时间,句子会随输入重画。需要猜测的输入 tsubu 会拒绝(别的段有时间而这一段没有、时间重叠、标点带时间),错误显示在框下面。
下のテキスト、読み、時刻を書き換えると、入力に合わせて文が描き直されます。推測が必要な入力(時刻のある文節とない文節が混在、時刻の重なり、句読点への時刻)は tsubu が拒否し、エラーが枠の下に出ます。
An agent that writes pages or templates with tsubu needs the contract, not the whole guide. The block below is that contract, in the guide's words; paste it into the agent's instructions. The API page and Tsubu.MOTIONS (the motion catalogue as data: each preset's purpose, what it needs, its parameters) are the references it can read on its own.
用 tsubu 写页面或模板的 agent 需要的是契约,不是整份指南。下面这段就是契约,措辞与指南一致;把它贴进 agent 的指令里。API 页和 Tsubu.MOTIONS(动效目录的数据形式:每个预设的用途、需要什么、参数)是它可以自己读的参考。
tsubu でページやテンプレートを書くエージェントに必要なのは、ガイド全体ではなく契約です。下のブロックがその契約で、文言はガイドのままです。エージェントの指示に貼り付けてください。API ページと Tsubu.MOTIONS(モーションカタログのデータ版:各プリセットの目的、必要なもの、パラメータ)は、エージェントが自分で読める参照先です。
tsubu draws ONE sentence, already cut into segments, for read-along use.
- Input: { segments: [{ parts: [{ text, ruby?, origin? }], kind?: "punct", start?, end?, id?, tags?, romaji? }], start?, end?, lang? }.
Times are seconds on the caller's clock. tsubu never segments text, never estimates times and never plays audio.
- Timing: every spoken segment timed → "segments" (one segment lights at a time); only the sentence timed → "sentence"
(the whole sentence lights; the span is NOT divided among segments); no times → "none" (static; stateAt/apply throw).
Partly timed, overlapping or punctuation times are refused with an Error whose message starts with "tsubu:".
- Draw: s = Tsubu.sentence(input, options); el.innerHTML = s.html; root = el.querySelector(".tsubu"); s.apply(root, t).
The frame at t depends on t alone: no timers, no transitions. For video, call apply(root, t) at each frame's time,
or render s.htmlAt(t) without a page. Never add CSS transitions to make it "smooth".
- Furigana: Tsubu.setRuby(root, on) switches them without moving any character. Keep that: never set display: none on rt.
- Romaji: the caller's own, per segment (segments[].romaji). options.over and options.under each name "ruby", "romaji"
or "none" (default: furigana over, nothing under). Tsubu.setAnnotation(root, "over" | "under", on) switches a side.
- Loanword origins: the caller's own, per part (parts[].origin = { text, lang, wasei? }), never guessed. With
options.origin "over" they are drawn over the text (not with over "romaji"); a part with ruby shows its furigana
instead. Tsubu.setAnnotation(root, "origin", on) switches them. JMdict's lsource data requires attribution.
- Themes: options.theme "light" | "dark" | "media"; speakers "a" | "b". Verify colours with Tsubu.checkContrast(root):
4.5 for text and furigana, 3 for the underline and the highlight. The unthemed defaults are not a preset and fail.
- Motion: choose from Tsubu.MOTIONS only, to show what is being read, never to decorate. Plain read-along: none.
Teaching line by line: "appear" (or "underline-draw" to keep the sentence readable). Listening and dictation:
"mask" or "blur", revealed by time or by Tsubu.setRevealed(root, [i…]) on the learner's events.
Do not combine "appear" with "mask"/"blur", nor "mask" with "underline-draw". Do not dim read text with opacity.
- Lines: breaks only between segments and by Japanese rules, balanced. Fluid size: wrap in class "tsubu-fluid"
with a definite width. Paging: --tsubu-max-lines; call apply after document.fonts.ready and after resizes.
- Vertical: options.writing "vertical"; give the container a height.
- Subtitles: TsubuExport.toWebVTT(input) / toASS(input) / toSRT(input) from the same input (times required).
- Other formats in: TsubuImport.fromWebVTT(text) / fromSRT(text) / fromCaptions(remotionCaptions) / fromKuroshiro(html).
Segments come only from what the format marks, times only from the file.
- Tags: segment id and tags are written as data-id / data-tags for the caller to style; colours and lines only.