コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。Text Rendering と Text Editing はまったく別のシステムです。
紙に「こんにちは 👨👩👧👦」と書いて写真を撮り、元の紙と写真の両方を子どもへ渡します。どちらにも文字が見えます。子どもに「ん」と「に」の間へキャレットを置く、「に」を選択する、日本語入力で読みを漢字へ変換する、読み上げ機能で文章を読ませる、という操作を頼んでみましょう。
先に予想してみてください。写真でできるのはどれでしょうか。プログラムが keydown のたびに文字列の末尾へ1文字足したら、中国語のピンイン変換中には何が起きるでしょうか。Backspace を1回押して家族の Emoji を削除するとき、一人だけ消すべきでしょうか、それとも家族の図柄全体でしょうか。
- 普段は写真を見る高速で、絵と一緒に動く
- 直すときは元の紙に替えるキャレット、選択、言語入力
- 元の紙を正確に重ねる位置、拡縮、回転
- 書き終えたら新しい写真を撮るcommit または cancel
Canvas の fillText() は文字を写真へ焼き付けるようなものです。textarea や contenteditable はブラウザが理解する元の紙です。編集中でない Text Shape は Renderer が描き、Editing State に入ったら DOM Overlay が引き受けます。ユーザーが Commit すると Document を更新して Renderer が再描画し、Cancel すれば Overlay を捨てて元の値を保ちます。
おもちゃを Canvas に置き換える
| おもちゃの世界 | Text システム | 担当 |
|---|---|---|
| 文字の写真 | Canvas Preview | 高速表示と Scene に合わせた描画 |
| 書き込める元の紙 | textarea / contenteditable | Caret、Selection、Clipboard、IME、Spellcheck |
| 活字の箱 | FontFace / document.fonts | Font の読み込みと準備完了通知 |
| 文字の定規 | TextMetrics / DOM Measurement | 幅、baseline、実 bounds、折り返し |
| 一つの「見える文字」 | Grapheme Cluster | Emoji と結合文字に対するユーザー操作単位 |
| 右から左へ読む本 | Bidi / RTL | テキストアルゴリズムが視覚順と論理順を処理 |
| 入力候補カード | Composition Events | 変換中の値は最終 Document 値ではない |
| 紙を重ねる透明テープ | DOM Overlay Transform | Shape/Camera に合わせ Zoom を補償 |
| 書き終える/破棄する | Commit / Cancel | History Transaction が一つ、または書き込みゼロ |
この比喩には限界があります。DOM Overlay と Canvas は同じ組版エンジンではありません。同一の font string でも、折り返し、fallback、hinting、baseline に数ピクセルの差が出ます。Grapheme はユーザーが認識する単位ですが、言語学上の文字や単語とは限りません。contenteditable は豊富なブラウザ機能を提供しますが、安全な Rich Text データモデルではありません。貼り付けた HTML には引き続き sanitize と normalize が必要です。
まず誤った直感を捨てる
- 「Canvas で文字を描けるなら、ついでにキャレットも描けばよい。」 Selection、IME、Bidi、Clipboard、仮想キーボード、支援技術、プラットフォーム慣習も再実装することになり、コストはキャレットの比ではありません。
- 「
keydownのevent.keyを文字列へ足すのが一番制御しやすい。」 Composition は中間キーを発行し、Emoji は複数 code point からなり、Selection が末尾にあるとも限りません。 - 「
text.lengthが文字数だ。」 UTF-16 code unit、Unicode code point、Grapheme Cluster は異なります。家族 Emoji の length は 1 よりはるかに大きくなります。 - 「Font URL の読み込みが終われば測定できる。」 Font が decode 前かもしれず、fallback がすでに layout に参加しているかもしれません。FontFaceSet を待ち、font 変更後に invalidate します。
- 「Canvas の
measureTextが自動で折り返す。」 渡された inline text を測るだけです。段落 layout または DOM measurement が折り返しを担当します。 - 「textarea を Shape の x/y に置けばそろう。」 Parent、World、Camera の各行列、rotation、transform origin、padding、line-height、Zoom 補償がまだ必要です。
- 「Rich Text は
innerHTMLを保存すればよい。」 信頼できない HTML、ブラウザ私有マークアップ、業務モデルが混ざります。
Production backpack
前提となる契約
第4章は Shape Local→World→Screen 行列を提供します。第6章には対称な cleanup を持つ DOMOverlayLayer Portal があります。第9章には editing State と Commit/Cancel があります。第10章の Selection と Resize は Text Editing 中に入力を奪いません。Document の Text Shape は text、width、style token、direction を保存し、DOM node は保存しません。
正式な知識
Font Loading はファイルを一つダウンロードするだけではありません。FontFace は読み込み可能な font を表し、document.fonts は FontFaceSet です。layout や測定の前に await document.fonts.load('16px "Canvas Sans"', sample) を呼べます。loadingdone 後は invalidate します。失敗時は明示的な Font Fallback chain を使い、失敗を記録しても編集は止めません。fallback が対象 font に置き換わると、Text Metrics、折り返し、Shape height、Connector anchor がすべて変わり得ます。Document 内容を密かに変えず、派生 layout を再計算します。
Canvas の measureText() は TextMetrics を返し、width のほか一部ブラウザでは actual bounding box、font bounding box、baseline 関連値を提供します。textBaseline は描画の基準線であって CSS line box ではありません。Primitive Text Rendering は font、direction、textAlign、baseline を明示し、layout 済みの各行へ fillText() を呼びます。Canvas に段落折り返し API はありません。単純な Text Shape なら決定的な幅で改行できます。本番 Rich Text は hidden DOM の authoritative measurement または成熟した layout engine が適し、measurement version を cache key に含めます。
Line Breaking は空白で split することではありません。CJK には空白がなく、禁則処理、soft hyphen、長い単語にも規則が必要です。Wrapping は hard break \n と soft wrap を区別します。Intl.Segmenter(...,{granularity:'grapheme'}) は多くの Grapheme Cluster の途中分割を防ぎます。対象 Browser Matrix にない場合は Unicode fixture で検証した segmentation library を同梱します。Array.from(string) は code point へ分けるだけで Grapheme fallback にはなりません。完全な Unicode line breaking には、対応言語向けの改行 library または DOM 組版も必要です。Emoji、結合アクセント、肌色 modifier、ZWJ sequence をユーザーが認識するまとまりとしてテストします。
Bidi アルゴリズムは LTR/RTL 混在文の視覚配置を決めます。Document は論理文字列を保存し、画面順に文字を並べ替えません。Shape は direction: auto|ltr|rtl を持ち、Overlay は dir と CSS direction、Canvas は context direction を設定します。アラビア語を手動反転してはいけません。Font Fallback は Grapheme ごとに起きるため、測定では描画と同じ CSS font shorthand を使います。
IME は compositionstart/update/end と beforeinput/input を協調させます。変換中に DOM 値は変わりますが、中間段階ごとに History を作ってはいけません。compositionend だけが Commit のタイミングとも限りません。編集 transaction 終了時に Overlay の現在値を Commit します。keydown は Escape や明示的 shortcut など制御意図だけを扱い、変換中は event.isComposing を確認します。Caret、Selection、ローカル undo stack、Clipboard は native control に任せます。Text Shape の Undo と control 内 Undo の境界も必要です。たとえば Editing 中の Ctrl/Cmd+Z は control へ渡し、終了後に Document History へ渡します。
textarea はプレーンテキスト向けで、安定し Selection、IME、モバイルキーボードを標準対応します。contenteditable は Rich Text 向けですが、正規化モデル、Paste Sanitization、Selection mapping、ブラウザマトリックステストが必要です。spellcheck、autocorrect、autocomplete はプロダクトとプライバシー要件で選び、ユーザー文字列を独自 telemetry へ送ってはいけません。Screen Reader には accessible name、編集状態、フォーカスが必要です。通常時にも DOM Inspector または Fallback Semantics を提供し、「Canvas」とだけ読ませないようにします。
Textarea Overlay を開くときは元値を固定し、DOM を作り、value/dir/lang/spellcheck を設定し、完全な screen matrix を適用し、フォーカスして Selection を復元します。Camera または Parent Transform が変わるたび matrix を更新し、構築時だけ合わせてはいけません。Zoom-Compensated Editor は screen scale に応じて DOM font size を増やす方法も、外側へ matrix transform を適用する方法も使えます。どちらでも DOM wrap width と Canvas layout のワールド幅を一致させます。Rotation は位置だけでなく CSS matrix で適用します。Commit は control 値を読み、最大長と Rich Text schema を検証し、一つの UpdateText Command を発行します。Document Schema が明記しユーザーへ説明しない限り、ここで NFC/NFKC normalization を密かに行ってはいけません。正規化は実 code point 列も変えるためです。Cancel は Command を発行しません。Exit は listener と node を除去し、フォーカスを Canvas/Inspector へ戻します。
根拠と互換性(確認日:2026-08-29)
- MDN CSS Font Loading API、
FontFace、Document.fontsが読み込みと準備完了の境界です。 - MDN TextMetrics、
measureText()、textBaselineは Canvas primitive measurement を説明し、段落編集は提供しません。 - MDN
Intl.Segmenterは Grapheme、word、sentence の segmentation に使えますが、完全な Rich Text layout engine ではありません。 - MDN CompositionEvent、
InputEvent.isComposing、UI Events の composition 節 が IME ライフサイクルを定義します。keydown から文字列を組み立ててはいけません。 - MDN
<textarea>、contenteditable、dir、spellcheckが native editing、Bidi、スペルチェックの根拠です。 - WHATWG HTML の editing APIs と the
textareaelement が仕様境界です。機能とモバイルキーボードは実ブラウザマトリックスで検証します。
この章で積み上げる実装
開始地点: Text Label は Canvas fillText() だけを使い、Caret、IME、Clipboard がありません。完了地点: 非編集中は Canvas Preview、編集中は DOM Overlay が Text Shape を正確に覆います。Commit は Document を一度更新し、Cancel は書き込みゼロです。
次のファイルを追加します。
src/engine/text/TextLayout.ts:font cache、Grapheme-safe wrapping、baseline。src/engine/text/FontManager.ts:FontFaceSet readiness と invalidation。src/ui/overlays/TextEditorOverlay.ts:textarea ライフサイクルと行列。src/engine/shapes/TextShapeRenderer.ts:layout lines から Preview を描画。src/engine/text/__tests__/layout.test.ts:CJK/Emoji/RTL/fallback。tests/browser/text-ime.spec.ts:composition、selection、clipboard、rotation、Zoom。
まず、テスト可能な primitive layout を完全な純粋関数で作ります。hard break、Grapheme、最大幅を尊重し、split('') で Emoji を壊しません。
export type Measure = (text: string) => number;
export type TextLine = Readonly<{ text: string; width: number }>;
export function wrapGraphemes(
value: string,
maxWidth: number,
measure: Measure,
locale = 'und',
): readonly TextLine[] {
if (!(maxWidth > 0)) throw new Error('TEXT_WIDTH_MUST_BE_POSITIVE');
const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
const output: TextLine[] = [];
for (const paragraph of value.split('\n')) {
const graphemes = Array.from(segmenter.segment(paragraph), (part) => part.segment);
if (graphemes.length === 0) {
output.push({ text: '', width: 0 });
continue;
}
let line = '',
width = 0;
for (const grapheme of graphemes) {
const candidate = line + grapheme;
const candidateWidth = measure(candidate);
if (line && candidateWidth > maxWidth) {
output.push({ text: line, width });
line = grapheme;
width = measure(grapheme);
} else {
line = candidate;
width = candidateWidth;
}
}
output.push({ text: line, width });
}
return output;
}
export async function layoutCanvasText(
context: CanvasRenderingContext2D,
value: string,
cssFont: string,
width: number,
locale: string,
onFontFailure: (reason: unknown) => void = () => undefined,
): Promise<readonly TextLine[]> {
try {
const faces = await document.fonts.load(cssFont, value || 'M');
if (faces.length === 0) onFontFailure(new Error(`no face matched ${cssFont}`));
} catch (cause) {
onFontFailure(cause);
}
context.save();
try {
context.font = cssFont;
return wrapGraphemes(value, width, (text) => context.measureText(text).width, locale);
} finally {
context.restore();
}
}
以下は完全なプレーンテキスト Overlay です。通常文字の keydown を監視せず、composition 中は Document を書かず、Shape→Screen matrix をそのまま CSS へ渡します。
export type TextShape = Readonly<{
id: string;
text: string;
width: number;
minHeight: number;
font: string;
fontSize: number;
lineHeight: number;
color: string;
direction: 'auto' | 'ltr' | 'rtl';
language: string;
}>;
export type Matrix2D = Readonly<{
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}>;
export interface TextEditPort {
commit(id: string, before: string, after: string): void;
focusCanvas(): void;
}
export class TextEditorOverlay {
private readonly textarea: HTMLTextAreaElement;
private composing = false;
private blurPending = false;
private closed = false;
constructor(
host: HTMLElement,
private readonly shape: TextShape,
screenMatrix: Matrix2D,
private readonly port: TextEditPort,
) {
const area = document.createElement('textarea');
this.textarea = area;
area.value = shape.text;
area.dir = shape.direction;
area.lang = shape.language;
area.spellcheck = true;
area.setAttribute('aria-label', 'Canvas のテキストを編集');
area.style.position = 'absolute';
area.style.left = '0';
area.style.top = '0';
area.style.width = `${shape.width}px`;
area.style.minHeight = `${shape.minHeight}px`;
area.style.margin = '0';
area.style.padding = '0';
area.style.border = '1px solid currentColor';
area.style.background = 'Canvas';
area.style.color = shape.color;
area.style.font = shape.font;
area.style.lineHeight = String(shape.lineHeight);
area.style.resize = 'none';
area.style.transformOrigin = '0 0';
this.updateScreenMatrix(screenMatrix);
area.addEventListener('compositionstart', this.onCompositionStart);
area.addEventListener('compositionend', this.onCompositionEnd);
area.addEventListener('keydown', this.onKeyDown);
area.addEventListener('blur', this.onBlur);
host.append(area);
area.focus({ preventScroll: true });
area.setSelectionRange(area.value.length, area.value.length);
}
private onCompositionStart = () => {
this.composing = true;
};
private onCompositionEnd = () => {
this.composing = false;
if (this.blurPending) this.commit();
};
private onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || this.composing) return;
if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
this.commit();
}
};
private onBlur = () => {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.commit();
};
updateScreenMatrix(matrix: Matrix2D) {
if (this.closed) return;
this.textarea.style.transform = `matrix(${matrix.a},${matrix.b},${matrix.c},${matrix.d},${matrix.e},${matrix.f})`;
}
commit() {
if (this.closed) return;
if (this.composing) {
this.blurPending = true;
return;
}
this.blurPending = false;
const after = this.textarea.value;
if (new TextEncoder().encode(after).byteLength > 100_000) {
this.textarea.setCustomValidity('テキストが長すぎます');
this.textarea.reportValidity();
this.textarea.focus({ preventScroll: true });
return;
}
this.textarea.setCustomValidity('');
this.closed = true;
this.cleanup();
if (after !== this.shape.text) this.port.commit(this.shape.id, this.shape.text, after);
this.port.focusCanvas();
}
cancel() {
if (this.closed) return;
this.closed = true;
this.cleanup();
this.port.focusCanvas();
}
private cleanup() {
this.textarea.removeEventListener('compositionstart', this.onCompositionStart);
this.textarea.removeEventListener('compositionend', this.onCompositionEnd);
this.textarea.removeEventListener('keydown', this.onKeyDown);
this.textarea.removeEventListener('blur', this.onBlur);
this.textarea.remove();
}
}
Canvas Preview は同じ font/lineHeight とキャッシュ済み lines を使い、baseline を明示します。Canvas の direction は ltr/rtl/inherit しか受けないため、direction:auto を直接代入できません。authoritative な DOM measurement/layout が段落の resolvedDirection を先に解決し、Preview は同じ結果を使います。fontSize も独立した検証済み数値でなければなりません。"600 16px Canvas Sans" に parseFloat() を使うと、font size 16 ではなく font weight 600 を読み取ってしまいます。
export function renderTextPreview(
context: CanvasRenderingContext2D,
shape: TextShape,
lines: readonly TextLine[],
resolvedDirection: 'ltr' | 'rtl',
) {
context.save();
try {
context.font = shape.font;
context.fillStyle = shape.color;
context.textBaseline = 'alphabetic';
context.direction = resolvedDirection;
const fontSize = shape.fontSize;
if (!(fontSize > 0) || !Number.isFinite(fontSize)) throw new Error('INVALID_FONT_SIZE');
lines.forEach((line, index) =>
context.fillText(line.text, 0, fontSize + index * fontSize * shape.lineHeight),
);
} finally {
context.restore();
}
}
単体テストは Grapheme が ZWJ sequence の途中で切れないことを証明します。IME の最終証拠は実ブラウザテストで取得します。
import { describe, expect, it } from 'vitest';
import { wrapGraphemes } from '../TextLayout';
describe('text layout', () => {
it('家族 emoji を一つの折り返し単位として扱う', () => {
const family = '👨👩👧👦';
const lines = wrapGraphemes(
`A${family}B`,
2,
(text) =>
Array.from(new Intl.Segmenter('und', { granularity: 'grapheme' }).segment(text)).length,
);
expect(lines.map((line) => line.text)).toEqual([`A${family}`, 'B']);
expect(lines.flatMap((line) => [...line.text]).join('')).toBe(`A${family}B`);
});
it('空行を含む hard line break を保持する', () => {
expect(wrapGraphemes('甲\n\n乙', 10, (text) => text.length).map((line) => line.text)).toEqual([
'甲',
'',
'乙',
]);
});
});
pnpm vitest run src/engine/text を実行し、CJK、Emoji、空行、幅境界、font cache のテストが通ることを確認します。pnpm playwright test tests/browser/text-ime.spec.ts --project=chromium を実行し、composition 中は History revision が変わらず、終了して Commit したあと 1 だけ増えることを確認します。続けて Safari/WebKit と Firefox で Clipboard、Selection、RTL を実行します。モバイルでは仮想キーボードによって Overlay が Viewport 外へ永久に押し出されないことを実機確認します。
写真のゲームへ戻りましょう。Renderer は新しい写真を表示し、DOM control は一時的に重ねる元の紙、FontManager は活字の箱、Composition はまだ選ばれていない候補カードです。子どもが「完了」と言ったときだけ新しい写真を撮り、台帳を書き換えます。Cancel なら元の紙を取り除くだけです。
わざと壊してみる
| 注入する障害 | 症状 | 証拠 | 修正 | 回帰テスト | 復旧 |
|---|---|---|---|---|---|
| 中国語ピンイン composition | 候補が重複し、キーごとに Undo | composition/input/History trace | native control が中間値を保持し、終了時に一度 Commit | Chromium IME event sequence | Overlay を Cancel、Document 不変 |
| 日本語変換 | Enter で早すぎる編集終了 | isComposing=true の keydown | composition 中は commit shortcut を無視 | WebKit/Chromium fixture | Selection を復元 |
| Family Emoji | Backspace が壊れた記号を残す、折り返しが分断 | grapheme segments | Intl.Segmenter または DOM layout | ZWJ/肌色/国旗 fixture | layout cache 再構築 |
| アラビア語 RTL | 文字が反転し Caret がずれる | logical value と visual screenshot | 文字列を反転せず dir/direction を設定 | RTL 混在数字テスト | 論理テキストを復元 |
| Font 読み込み遅延 | テキストが跳ね Connector anchor が誤る | fonts.status と前後 metrics | loadingdone で invalidate | delayed-font route | layout cache を消す |
| fallback が対象 font に置換 | 行数が変わるのに Bounds 不変 | line count/metrics revision | 派生 height と anchors を再計算 | font-swap test | 一度の deterministic redraw |
| 400% Zoom + Rotation | Overlay と文字が離れる | CSS/Shape screen matrix 比較 | 完全 matrix と transform-origin | 0/37/90° の複数 Zoom screenshot | Overlay 再構築 |
| Rich HTML を Paste | Script/私有 style がモデルへ入る | clipboard MIME/schema ログ | plain-text 方針または sanitizer + typed rich model | 悪意ある HTML fixture | Paste を原子的に拒否 |
| Mobile Virtual Keyboard | 入力欄が隠れフォーカス喪失 | VisualViewport/focus trace | 可視領域 scroll と focus 方針 | iOS/Android 実機 | 編集 session を保って再配置 |
証拠をもって合格する
| 証明すること | 自動化された証拠 | 必須の手動・実機証拠 |
|---|---|---|
| keydown 文字組み立てが IME を壊さない | event replay と一つの Command | 中国語・日本語の実 IME |
| Grapheme を分断しない | Segmenter fixture | システム Emoji キーボードで削除 |
| RTL/Bidi が論理値を保つ | value snapshot | Screen Reader と Caret の進行方向 |
| Font 変更から復旧 | 遅延読み込みと cache invalidation | OS ごとの fallback |
| Overlay がそろう | 複数 Zoom/rotation screenshot diff | 400% Zoom で手動選択 |
| Commit/Cancel 境界 | revision 1/0 assertion | blur、Escape、仮想キーボード |
- FontFace、
document.fonts、fallback、loading failure、再測定にプロトコルがある。 - Text Metrics、Line Breaking/Wrapping、Baseline、Canvas Preview が明示的な layout data を使う。
- Grapheme、Emoji、Bidi、RTL、IME、Composition、Caret、Selection を正しい層が担当する。
- textarea/contenteditable の選択に ADR があり、Rich Text、Spellcheck、Paste に安全方針がある。
- DOM Measurement と Overlay が Zoom、Rotation、Parent Transform 後もそろう。
- Commit は一つの Command、Cancel は Document 書き込みゼロ。
- 通常テキストを
keydownから手作業で組み立てず、IME、Selection、Clipboard、Composition、Screen Reader を壊さない。
5歳児に説明する
「DOM」「Canvas」「入力メソッド」「書記素」「双方向テキスト」という言葉を使わずに答えてください。
- 文字の写真は読めるのに、なぜ2文字の間へ小さな縦線を置けないのでしょうか。
- 子どもがまだ候補文字を選んでいる途中に、なぜキーを押すたび共有台帳を変えてはいけないのでしょうか。
- 家族の小さな絵は部品が多いのに、なぜ一つのまとまりとして扱うのでしょうか。
- 新しい問題です。活字の箱が1分遅れて届き、1行だった文字が2行になりました。何を測り直しますか。