Skip to content

Latest commit

 

History

History
1982 lines (1530 loc) · 111 KB

DESIGN.md

File metadata and controls

1982 lines (1530 loc) · 111 KB

makeitaquote 蚭蚈曞

最終曎新: 2026-08-30察象バヌゞョン: makeitaquote@12.0.0 たでの蚭蚈刀断を环積した蚘録 リポゞトリ: https://github.com/otnc/makeitaquote  リポゞトリ構成のベヌス: oto-lab/npm-biome-ts

この文曞に぀いお: §1〜§13 は 2026-07-31 に曞いた v9 時点の詳现蚭蚈曞で、圓時の刀断をそのたた残しおいるv8 ずの埌方互換を持たない、ずいう § 1.3 の決定を含め、事実ずしお過去に確定した内容だから。v9 以降の倉曎は §14 以降に版ごずの節ずしお远蚘しおおり、本䜓の蚘述ず矛盟する堎合は番号の倧きい節が優先する。今の姿を知りたいだけなら本䜓は読み飛ばし、䞋の䞀芧から該圓する節に盎接飛んでよい。

版 節 䜕が倉わったか
v9.1 §14 瞊長レむアりト・反転・倪字
v9.2 §15 フォント名解決の API 経由化、Misskey カスタム絵文字
v9.3 §16 折り返しの実バグ修正、色システム、サむズ、字圢フォヌルバック
v10 — フォントTwemoji キャッシュがプロゞェクトロヌカルにREADME/MIGRATING 参照、本曞に節なし
v11 — 䌚話・ツむヌト取り蟌みなどREADME 参照、本曞に節なし
v12 §19 makeitaquote/api サブパスを廃止し @makeitaquote/voids ぞ分離
v12 §20 markdown オプション远加 — 倪字/斜䜓/䞋線/取り消し線を陀去せず描画できるように
v12 §21 watermark の画像察応、MiQConversation を MiQChain返信/匕甚チェヌン画像に眮き換え

v10・v11 で本曞に節がないのは、圓時ここに远蚘する運甚がただ無かったため。README・MIGRATING.md の蚘茉が䞀次情報になる。


1. 目的ずスコヌプ

1.1 背景

珟行 v8 は「Voids API に POST しお URL か Buffer を受け取るだけ」の薄い Wrapper であり、以䞋の問題を抱えおいる。

問題 内容
倖郚 API 党面䟝存 api.voids.top が萜ちるずパッケヌゞが機胜停止する
゚ンドポむントの意味づけの誀り generate() / generateBeta() を「本番ずフォヌルバック」ずしお README に曞いおいるが、実際には機胜が異なる 2 ぀の゚ンドポむントである§2.1
型ず実装の乖離 index.d.ts が手曞きで、実装ず同期する保蚌がない
責務の混圚 Discord 䟝存setFromMessage・敎圢displus・HTTPaxiosが 1 ファむルに同居
拡匵䜙地の欠劂 画像サむズ・配色・フォントが API 偎に固定され、利甚者が䞀切制埡できない

1.2 v9 のゎヌル

  1. API 非䟝存のロヌカル画像生成を第䞀玚の機胜ずしお実装するmakeitaquote。
  2. Voids API 䟝存の実装は別サブパスに隔離するmakeitaquote/api。
  3. TypeScript で曞き盎し、型定矩を実装から自動生成する。
  4. Discord カスタム絵文字・Twemoji・日本語テキストを正しく描画する。
  5. 芋た目サむズ・配色・フォント・レむアりトを利甚者が制埡できるようにする。
  6. フォント未蚭定でも日本語が描画できるようにする§8。
  7. リポゞトリ構成・CI・リリヌスフロヌを oto-lab/npm-biome-ts に揃える§5。

1.3 非スコヌプ

  • v8 API ずの埌方互換䞍芁ず決定枈み
  • ブラりザ環境での動䜜Node.js 専甚。@napi-rs/canvas がネむティブバむンディングのため
  • 動画・GIF アニメヌションの出力アニメ絵文字も静止フレヌムずしお描画する

2. 珟行実装の分析

2.1 generate() ず generateBeta() は機胜が異なる

珟行コヌドを粟読した結果、この 2 ぀は「同じ機胜の本番ベヌタ」ではない。返すものが根本的に違う。

// index.js:171-172  —— /fakequote
const response = await axios.post(API_URL, this.format, { responseType: 'json' })
return response.data.url                        // ← JSON { url } を返す

// index.js:166-169  —— /fakequote + returnRawImage
const response = (await axios.post(API_URL, this.format)).data
const imageBuffer = await axios.get(response.url, { responseType: 'arraybuffer' })
return Buffer.from(imageBuffer.data, 'binary')  // ← URL を取っおから “もう䞀床” GET

// index.js:197-198  —— /fakequotebeta
const response = (await axios.post(BETA_API_URL, this.format, { responseType: 'arraybuffer' })).data
return Buffer.from(response)                    // ← 画像バむナリを盎接返す
/fakequote /fakequotebeta
レスポンス application/json — { url: "..." } 画像バむナリ
画像の所圚 Voids 偎にアップロヌドされ、ホスト URL が発行される どこにも保存されない。郜床生成しお返すだけ
URL の取埗 できる できない原理的に
Buffer 取埗時の埀埩数 2 埀埩POST → JSON → GET 1 埀埩
Discord での䜿い方 URL 文字列をそのたた reply() できるexamples/miq-with-discordjs.md:15 MessageAttachment での添付が必芁examples/beta-with-discordjs.md:15

この差が v9 の API 蚭蚈を決める。

  • 「URL が欲しい」→ /fakequote しか遞択肢がない
  • 「Buffer だけ欲しい」→ /fakequotebeta のほうが 1 埀埩で速く、しかも画像が第䞉者のサヌバヌに残らない

したがっお v8 のように「メ゜ッド名で゚ンドポむントを遞ばせる」のではなく、欲しいものを蚀えば適切な゚ンドポむントが自動的に遞ばれる蚭蚈にする§6.4。README の「generate() が䜿えないずきは generateBeta()」ずいう蚘述は誀解を招くため、v9 では䞡者を察等な別機胜ずしお説明し盎す。

生成される画像デザむンが䞡゚ンドポむントで同䞀かどうかはコヌドからは刀定できない。フェヌズ 3 の実装時に実出力を比范し、差があれば README の察応衚に泚蚘する。

2.2 廃止する䟝存・蚭定

察象 理由
axios ky に眮換§3.5
displus setText(text, formatText) の Markdown 陀去のためだけに䜿われおいた。敎圢は本パッケヌゞの責務ではないため機胜ごず廃止
.github/dependabot.yml 廃止。ベヌステンプレヌトに存圚せず、ネむティブバむナリを含む本パッケヌゞでは自動 PR の怜蚌コストに芋合わない。䟝存曎新は手動で行う

v12 で䞀郚䞊曞き: 「敎圢は本パッケヌゞの責務ではない」ずいう䞊蚘の刀断は、陀去機胜displus の圹割に぀いおのものであり、v12 の markdown オプションは陀去に加えお描画ずいう新しい皮類の機胜を远加するもの。圓時 displus を切った理由Markdown 陀去のためだけの薄い䟝存を増やしたくないは今回も螏襲しおおり、新芏䟝存は远加しおいない。経緯は §20 を参照。


3. 技術遞定

3.1 決定サマリ

領域 採甚 バヌゞョン 区分
描画゚ンゞン @napi-rs/canvas ^1.0.3 dependencies
絵文字パヌス @twemoji/parser ^17.0.2 dependencies
日本語改行 budoux ^0.8.4 dependencies
HTTP クラむアント ky ^2.0.2 dependencies
蚀語 TypeScript ^6.0.3 devDependencies
バンドラ tsdown ^0.22.14 devDependencies
テスト vitest + @vitest/coverage-v8 ^4.1.10 devDependencies
Lint / Format @biomejs/biome ^2.5.6 devDependencies
ランタむム Node.js >=22 engines

すべお 2026-07-31 時点の最新を確認枈み。バヌゞョンはベヌステンプレヌト oto-lab/npm-biome-ts ず䞀臎しおいる。

3.2 描画゚ンゞン: @napi-rs/canvas

採甚理由

  • Skia ベヌスChrome ず同じ描画゚ンゞンで、テキストレンダリング品質が高い。

  • 党䞻芁プラットフォヌムのプリビルドバむナリを同梱v1.0.3 の optionalDependencies で確認。

    darwin-x64 / darwin-arm64 / android-arm64
    linux-x64-gnu / linux-x64-musl
    linux-arm64-gnu / linux-arm64-musl
    linux-arm-gnueabihf / linux-riscv64-gnu
    win32-x64-msvc / win32-arm64-msvc
    

    特に muslAlpineバむナリがある点が node-canvas ずの決定的な差。Discord Bot は node:*-alpine で動かされるこずが倚く、node-canvas だず Cairo・Pango・libjpeg 等を apk add する必芁がある。

  • Canvas 2D API 準拠なので、グラデヌション・クリップ・フィルタ・行送りを自前で现かく組める。

  • 掻発にメンテナンスされおいるv1.0.3 が 2026-07-28 リリヌス。

䞍採甚にした遞択肢

候補 䞍採甚理由
canvasnode-canvas v3.2.3 でもプリビルド察象が狭く musl 非察応。ビルド倱敗が最頻出のサポヌト案件になる
skia-canvas 描画品質は良いが Alpine/musl 非察応、DL 数も䞀桁少ない
satori + resvg-js アバタヌのグレヌスケヌルグラデヌション合成が CSS 衚珟の制玄を受ける。フォントを毎回 Buffer で枡す必芁がある。ネむティブ䟝存が結局 2 ぀になる

泚意点

  • GlobalFonts.register(buffer, name) は枡された Buffer のメモリを参照するコピヌしない。登録に䜿った Buffer を解攟・再利甚しおはならないBrooooooklyn/canvas#1006。FontRegistry が Buffer ぞの参照を保持し続けるこずで察凊する。
  • WOFF2 の読み蟌み可吊がバヌゞョンにより䞍安定。自動取埗するフォントは TTF に限定する§8.2。

3.3 絵文字描画: 自前実装既存ラむブラリはアルゎリズム参考のみ

候補ずしお挙がった 2 ラむブラリの゜ヌスを粟読した結果、䟝存ずしおは採甚せず、アルゎリズムを参考に自前実装する。

ラむブラリ 版 䟝存 刀定
node-canvas-with-twemoji-and-discord-emojiflazepe 1.2.2 (2025-02) canvas@^3.1.0, @twemoji/parser@^15.1.1 参考実装
@miq4d/canvas-with-discord-content 2.1.2 (2026-06) canvas@^2.11.2, twemoji@^14.0.2 参考実装

䟝存ずしお採甚しない理由

  1. どちらも require("canvas") を内郚で盎接呌んでいるutils/loadTwemojiImageByUrl.js:2。@napi-rs/canvas ず䜵甚できず、採甚するず描画゚ンゞンが node-canvas に固定される。
  2. 行折り返しに非察応。drawTextWithTwemoji.js:38-40 の maxWidth は ctx.setTransform(scale, 0, 0, 1, 0, 0) による暪方向の圧瞮であっお word wrap ではない。MiQ は本文の折り返しが䞭栞機胜なので、どちらを䜿っおもレむアりト局は自前で曞くこずになる。
  3. 画像キャッシュが jsDelivr URL のみ察象・䞊限なしの Map。垞駐 Bot でメモリが単調増加する。
  4. 描画ルヌプ内で逐次 await loadImage(url) しおいる。絵文字 20 個で 20 埀埩の盎列 HTTP になる。
  5. @miq4d 版は canvas@^2 + twemoji@14@twemoji/parser の前身ず䟝存が叀い。

継承する蚭蚈

  • @twemoji/parser の parse(text, { assetType }) でテキストを走査し、暙準絵文字の indices ず CDN URL を埗る。
  • Discord カスタム絵文字は正芏衚珟 /<(a)?:(\w{2,32}):(\d{17,20})>/ で怜出し、https://cdn.discordapp.com/emojis/{id}.{png|gif} に解決する。
  • 絵文字は fontSize 四方の drawImage ずしお、テキストのベヌスラむン基準で配眮する。

远加で実装するもの

  • 行折り返しBudouX + グラフェム境界 + 犁則、§7.3
  • LRU キャッシュ䞊限・TTL・in-flight 合流・負のキャッシュ、§9.2
  • アニメ絵文字<a:...>の 1 フレヌム目描画
  • 取埗倱敗時のフォヌルバック
  • 絵文字取埗の䞊列プリフェッチ

3.4 日本語の改行: BudouXkuromoji ではない

改行䜍眮の決定に圢態玠解析を䜿いたいずいう芁望に察し、kuromoji / kuroshiro を調査した結果、budoux を採甚する。

候補 版 / 曎新 展開サむズ 刀定
kuromoji 0.1.2 / 2022-06 41.3 MB 䞍採甚
kuroshiro 1.2.0 / 2022-06 – 䞍採甚
kuroshiro-analyzer-kuromoji 1.1.0 / 2022-05 – 䞍採甚
kuromojinkuromoji ラッパ 3.0.1 / 2025-04 kuromoji 䟝存 䞍採甚
budoux 0.8.4 / 2026-05 2.6 MB党蚀語。日本語モデルのみなら数十 KB 採甚

kuromoji 系を採らない理由

  1. 展開サむズ 41.3 MB。IPADic 蟞曞を䞞ごず含む。画像生成ラむブラリの䟝存ずしおは過倧で、@napi-rs/canvas のバむナリず合わせるずむンストヌルサむズが 50MB を超える。
  2. 蟞曞のロヌドが非同期か぀数秒かかる。Bot の初回リク゚ストが目に芋えお遅くなる。
  3. 2022 幎から曎新が止たっおいる。kuroshiro 系も同様。
  4. そもそも甚途に察しお過剰。必芁なのは「品詞・読み・原圢」ではなく「ここで改行しおよいか」の䞀点だけ。圢態玠解析は目的ではなく手段であり、より盎接的な手段が存圚する。

BudouX を採る理由

  1. 改行䜍眮の決定そのものを目的に Google が䜜ったラむブラリ。Chrome の CSS word-break: auto-phraseChrome 119+の実装に䜿われおいるものず同じ AdaBoost モデル。

  2. 同期 API。蟞曞ロヌド埅ちがない。

    import { jaModel, Parser } from 'budoux'
    const parser = new Parser(jaModel)
    parser.parse('今日は倩気です。') // → ['今日は', '倩気です。']

    loadDefaultJapaneseParser() ではなく Parser + jaModel を盎接䜿うこずで、HTMLProcessor を含たない最小構成になる。

  3. 日本語モデルのみを import できるため、実際にバンドルされるのは数十 KB。

  4. 䞭囜語簡䜓・繁䜓・タむ語のモデルも同梱されおおり、将来の倚蚀語察応の䜙地がある。

  5. 2026-05 曎新ず珟圹。

䜍眮づけ: BudouX は「文節境界を返す」だけであり、折り返しアルゎリズム党䜓を眮き換えるものではない。§7.3 の候補䜍眮決定においお「CJK は字単䜍で折る」ずいう玠朎な芏則の代わりに文節境界を優先候補ずしお䜿う、ずいう組み蟌み方をする。BudouX が倱敗しおも字単䜍フォヌルバックで必ず折れるので、品質向䞊の䞊乗せずしお機胜する。

3.5 HTTP クラむアント: ky

  • 䟝存れロ、Fetch API ベヌス、~15KB。
  • リトラむ・タむムアりト・フックが組み蟌み。絵文字 CDN の䞀時的倱敗に匷い。

ky は ESM-onlyky@2.0.2 の package.json は "type": "module"、exports に require 条件なし。

Node 22.12+ では require(ESM) が動くが、22.0–22.11 では動かない。engines が >=22 である以䞊そこに䟝存できないため、tsdown の deps.alwaysBundle で CJS 出力にバンドルしお埋め蟌む§5.5。ky は䟝存れロなので副䜜甚がない。

3.6 Discord 型の扱い

setFromMessage() のために discord.js ぞ䟝存しない。**構造的型duck typing**で受ける。

export interface MessageLike {
  content: string
  author: {
    username: string
    globalName?: string | null
    global_name?: string | null
    discriminator?: string | null
    displayAvatarURL?: (options?: unknown) => string
  }
  member?: {
    displayName?: string
    nickname?: string | null
    displayAvatarURL?: (options?: unknown) => string
  } | null
}

discord.js v13 / v14 / discord.js-selfbot-v13 のいずれの Message もこの圢を満たすため、peerDependencies すら䞍芁。テストでもモックが容易になる。


4. パッケヌゞ構成

4.1 方針

v12 で廃止: makeitaquote/api サブパスは削陀し、倖郚 API クラむアントは別パッケヌゞ @makeitaquote/voids ぞ切り出した。以䞋は v9〜v11 の蚭蚈蚘録ずしお残す。経緯ず珟行構成は §19 を参照。

単䞀 npm パッケヌゞ + subpath exports。npm 䞊のパッケヌゞ名は makeitaquote 1 ぀。

require('makeitaquote')      →  ロヌカル画像生成API 非䟝存
require('makeitaquote/api')  →  Voids API クラむアント

サブパス名は voids ではなく api ずする。「倖郚 API を叩く偎」ずいう圹割を衚し、将来 Voids 以倖の API 実装を足しおも名前が砎綻しない。

4.2 䟝存の分離

makeitaquote/api は ky しか䜿わない。@napi-rs/canvasバむナリ ~10MBや budoux を匕かせたくない。

@napi-rs/canvas は optionalDependencies ではなく通垞の dependencies に眮く。本䜓こそが v9 の䞻機胜であり、optional にするず「入っおいない」状態に陥りやすいため。

その代わり src/api/* は @napi-rs/canvas ず budoux を䞀切 import しないようにビルド境界を切り、makeitaquote/api を import しただけではネむティブモゞュヌルがロヌドされない状態を保蚌するbuild 怜蚌で担保、§5.8。

4.3 ディレクトリ構成

ベヌステンプレヌトに埓い、テストは゜ヌスに䜵眮src/**/*.test.tsする。test/ ディレクトリは䜜らない。

makeitaquote/
├── .editorconfig
├── .gitattributes
├── .gitignore
├── biome.json
├── package.json
├── tsconfig.json
├── tsdown.config.ts
├── vitest.config.ts
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── .github/
│   ├── FUNDING.yml                     # 既存を維持
│   ├── PULL_REQUEST_TEMPLATE.md
│   ├── ISSUE_TEMPLATE/
│   │   ├── 1-bug-report.yml
│   │   ├── 2-feature-request.yml
│   │   └── config.yml
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
│   └── dependabot.yml は削陀
├── scripts/
│   └── check-build.js                  # ビルド成果物の怜蚌§5.8
└── src/
    ├── index.ts                        # ルヌト゚ントリre-export のみ
    ├── core/
    │   ├── MiQ.ts
    │   ├── MiQ.test.ts
    │   ├── quote.ts
    │   ├── source.ts                   # MessageLike → QuoteData
    │   ├── source.test.ts
    │   ├── errors.ts
    │   └── types.ts
    ├── theme/
    │   ├── presets.ts                  # dark / light / color
    │   ├── resolve.ts
    │   ├── resolve.test.ts
    │   └── types.ts
    ├── render/
    │   ├── pipeline.ts
    │   ├── pipeline.test.ts            # smoke test実際に PNG を生成
    │   ├── background.ts
    │   ├── avatar.ts
    │   ├── quoteText.ts
    │   ├── attribution.ts
    │   └── canvasFactory.ts            # @napi-rs/canvas の唯䞀の import 地点
    ├── text/
    │   ├── segment.ts
    │   ├── segment.test.ts
    │   ├── measure.ts
    │   ├── breakpoint.ts               # BudouX + グラフェム + 犁則
    │   ├── breakpoint.test.ts
    │   ├── wrap.ts
    │   ├── wrap.test.ts
    │   ├── fit.ts
    │   ├── fit.test.ts
    │   └── draw.ts
    ├── emoji/
    │   ├── resolve.ts
    │   ├── resolve.test.ts
    │   ├── loader.ts
    │   └── cache.ts
    ├── font/
    │   ├── registry.ts
    │   ├── autoload.ts
    │   ├── autoload.test.ts
    │   ├── diskCache.ts
    │   └── sources.ts
    ├── output/
    │   └── encode.ts
    ├── http/
    │   └── client.ts
    ├── util/
    │   ├── lru.ts
    │   ├── lru.test.ts
    │   ├── grapheme.ts
    │   └── assert.ts
    ├── __fixtures__/                   # テスト甚の PNG / メッセヌゞモック
    │   ├── emoji-1f600.png
    │   ├── avatar.png
    │   └── messages.ts
    └── api/
        ├── index.ts                    # サブパス゚ントリ
        ├── client.ts                   # VoidsMiQ
        ├── client.test.ts
        ├── endpoints.ts
        └── types.ts

__fixtures__/ は tsdown の entry に含めないためビルド成果物に入らない。files: ["dist"] なので npm 配垃物にも含たれない。


5. リポゞトリ構成oto-lab/npm-biome-ts 準拠

ベヌステンプレヌトの各ファむルをそのたた採甚し、本パッケヌゞ固有の事情による差分のみを加える。差分は §5.9 に䞀芧する。

5.1 package.json

テンプレヌトのフィヌルド順・スクリプト名を螏襲する。exports にサブパス ./api を远加した点が䞻な差分。

{
  "name": "makeitaquote",
  "version": "9.0.0",
  "description": "Generate \"Make it a Quote\" images locally, or via an external API.",
  "keywords": [
    "img", "image", "gen", "generate", "miq",
    "makeitaquote", "quote", "discord", "canvas", "api"
  ],
  "homepage": "https://github.com/otnc/makeitaquote#readme",
  "bugs": { "url": "https://github.com/otnc/makeitaquote/issues" },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/otnc/makeitaquote.git"
  },
  "license": "ISC",
  "author": "otoneko.",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.mjs",
  "types": "dist/index.d.mts",
  "exports": {
    ".": {
      "types": {
        "import": "./dist/index.d.mts",
        "require": "./dist/index.d.cts"
      },
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./api": {
      "types": {
        "import": "./dist/api/index.d.mts",
        "require": "./dist/api/index.d.cts"
      },
      "import": "./dist/api/index.mjs",
      "require": "./dist/api/index.cjs"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "engines": { "node": ">=22" },
  "scripts": {
    "build": "tsdown",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "typecheck": "tsc --noEmit",
    "format": "biome format --write",
    "format:check": "biome format",
    "lint": "biome lint",
    "lint:fix": "biome lint --write",
    "check": "biome check --write",
    "ci": "biome ci",
    "check:build": "node scripts/check-build.js",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "@napi-rs/canvas": "^1.0.3",
    "@twemoji/parser": "^17.0.2",
    "budoux": "^0.8.4",
    "ky": "^2.0.2"
  },
  "devDependencies": {
    "@biomejs/biome": "^2.5.6",
    "@types/node": "^22.20.1",
    "@vitest/coverage-v8": "^4.1.10",
    "tsdown": "^0.22.14",
    "typescript": "^6.0.3",
    "vitest": "^4.1.10"
  }
}

2026-08-01 での蚂正: "license" はその埌 ISC → MIT に倉曎された。姉効パッケヌゞのラむセンス方針に合わせたもので、v9 系がこの倉曎の起点。䞊のスナップショットは蚭蚈時点の蚘録ずしおそのたた残すが、珟圚の package.json / LICENSE / README / THIRD-PARTY-NOTICES.md はすべお MIT を指しおいる。

  • テンプレヌトの setup スクリプトず scripts/setup.js は採甚しない。既存リポゞトリでありプレヌスホルダの眮換が䞍芁なため。
  • check:build を远加§5.8。
  • @types/node は engines に合わせお ^22テンプレヌト準拠。ランタむムが Node 22 なので 25 系の型を入れるず存圚しない API が型䞊通っおしたう。

5.2 biome.json

テンプレヌトのものをそのたた䜿い、files.includes に examples/** を足すだけ。

{
  "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
  "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
  "files": {
    "ignoreUnknown": true,
    "includes": ["src/**", "scripts/**", "examples/**", "*.ts", "*.json"]
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },
  "linter": {
    "enabled": true,
    "rules": { "preset": "recommended" }
  },
  "assist": {
    "enabled": true,
    "actions": { "source": { "organizeImports": "on" } }
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "asNeeded",
      "trailingCommas": "all"
    }
  }
}

コヌドスタむルテンプレヌト由来。本蚭蚈曞のコヌド䟋もこれに埓っおいる

  • セミコロンなしsemicolons: "asNeeded"
  • シングルクォヌト
  • 末尟カンマあり
  • むンデント 2 スペヌス、行幅 100
  • 改行 LF

console.warn / console.info をフォント関連§8.3で䜿うが、Biome の recommended プリセットに noConsole は含たれないため远加蚭定は䞍芁。

5.3 tsconfig.json

テンプレヌトのものをそのたた䜿う。テストは src 配䞋にあるため include の远加は䞍芁。

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2024"],
    "strict": true,
    "verbatimModuleSyntax": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "declaration": true,
    "isolatedModules": true,
    "resolveJsonModule": true
  },
  "include": ["src", "tsdown.config.ts", "vitest.config.ts"]
}

noUncheckedIndexedAccess / exactOptionalPropertyTypes はテンプレヌトに含たれおいないため远加しない。テンプレヌトずの差異を増やさないこずを優先する。必芁になった時点で別途議論する。

5.4 .gitattributes / .editorconfig

テンプレヌトの .gitattributes は 1 行のみ。バむナリは Git が NUL バむトで自動刀定するため、src/__fixtures__/*.png を眮いおも問題は起きない。テンプレヌトのたた採甚する。

# Normalize all text files to LF, regardless of the platform or the
# contributor's core.autocrlf setting, so Biome's formatter (which writes LF)
# never disagrees with what's checked out on Windows.
* text=auto eol=lf

.editorconfig はテンプレヌトに存圚しないが、゚ディタ偎でも LF を担保するため远加するBiome・Git ず合わせお 3 重。

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

本リポゞトリは Windows で開発されおいるため、.gitattributes 導入時に䞀床だけ既存ファむルの正芏化が必芁。 git add --renormalize . && git commit -m "Normalize line endings to LF"

5.5 .gitignore

テンプレヌトのものをそのたた採甚する.private/ が既に含たれおいる。珟行の tests ゚ントリは䞍芁になる。

.private/
node_modules/
dist/
coverage/
*.log
.DS_Store
.env
.env.*
!.env.example

5.6 tsdown.config.ts

テンプレヌトの構成に、゚ントリ远加ず䟝存の倖郚化蚭定を加える。

import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: ['src/index.ts', 'src/api/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  // Node >= 22 supports ES2024
  target: 'node22',
  platform: 'node',
  deps: {
    // ky is ESM-only; inline it so the CJS build doesn't `require()` an ESM package.
    alwaysBundle: ['ky'],
    // Native bindings must stay as imports — a bundled .node file won't load.
    neverBundle: ['@napi-rs/canvas', '@twemoji/parser', 'budoux'],
  },
})

実装時の蚂正: tsdown の移行ガむドには deps: { external, noExternal } ずあるが、0.22.14 の実際の DepsConfig は neverBundle / alwaysBundle に改名されおいる。旧名を曞くず型゚ラヌになる。

src/api/index.ts を゚ントリに远加するず、dist/api/index.{mjs,cjs,d.mts,d.cts} が出力され、§5.1 の exports ず察応する。

tsup からの䞻な差分移行時の泚意

tsup tsdown
external / noExternal がトップレベル deps: { neverBundle, alwaysBundle } に改名0.22 で名称倉曎
CJS が既定 ESM が既定。format を明瀺する
cjsInterop cjsDefault

ESM / CJS 䞡察応の泚意点

項目 扱い
ky deps.alwaysBundle でバンドル。CJS からの require('ky') を回避
@napi-rs/canvas external。.node バむナリを含むためバンドル䞍可
@twemoji/parser / budoux CJS/ESM 䞡察応枈み。external のたたで良い
import.meta CJS 出力で壊れるため src/ 内で䜿甚犁止。パス解決は node:os / node:path で組むscripts/ 内は ESM 専甚なので䜿甚可
Top-level await 同䞊、src/ 内で䜿甚犁止

5.7 vitest.config.ts

テンプレヌトに coverage 蚭定を远加する。

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/__fixtures__/**'],
    },
  },
})

5.8 scripts/check-build.js

ビルド成果物に察する怜蚌。vitest の include が src/** に限定されおおり、たた dist が必芁なため、独立したスクリプトずしお CI の build ゞョブで実行する。

怜蚌項目:

  1. require('./dist/index.cjs') が成功し、MiQ を export しおいる
  2. import('./dist/index.mjs') が成功し、MiQ を export しおいる
  3. require('./dist/api/index.cjs') / import('./dist/api/index.mjs') が成功し、VoidsMiQ を export しおいる
  4. dist/api/index.{cjs,mjs} の゜ヌス文字列に @napi-rs/canvas ず budoux が珟れない§4.2 の䟝存分離の担保
  5. dist/** に require("ky") が珟れないdeps.alwaysBundle が効いおいるこずの確認
  6. dist/** に CRLF が混入しおいない
  7. dist/index.d.mts / dist/api/index.d.cts などの型定矩が生成されおいる

䟝存を増やさないため node:assert ず node:fs のみで曞く。Biome の files.includes に scripts/** が含たれおいるので lint/format 察象になる。

5.9 テンプレヌトからの差分䞀芧

項目 テンプレヌト 本パッケヌゞ 理由
exports . のみ . + ./api + ./package.json サブパス分割§4.1
tsdown.entry 1 ゚ントリ 2 ゚ントリ 同䞊
tsdown.deps なし alwaysBundle: ['ky'], neverBundle: ['@napi-rs/canvas', 
] ESM-only 䟝存ずネむティブバむナリ§5.6
scripts/setup.js あり 削陀 既存リポゞトリでプレヌスホルダ眮換が䞍芁
scripts/check-build.js なし 远加 䟝存分離ずデュアルフォヌマットの怜蚌§5.8
biome.files.includes src, scripts, *.ts, *.json + examples/** サンプルコヌドも lint 察象にする
vitest.coverage なし 远加 test:coverage スクリプトが存圚するため蚭定を明瀺
.editorconfig なし 远加 ゚ディタ偎でも LF を担保
CI マトリクス ubuntu × Node 22/24 + windows / macos / alpine ネむティブバむナリを含むため§5.10
.github/dependabot.yml なし 削陀珟行から 指瀺どおり廃止§2.2
.github/FUNDING.yml なし 維持珟行のたた 既存の蚭定を壊さない

5.10 .github/workflows/ci.yml

テンプレヌトの <setup-steps> / <pm> を npm 向けに展開したうえで、プラットフォヌムマトリクスを拡匵する。

@napi-rs/canvas はプラットフォヌムごずに別バむナリを読み蟌むため、ubuntu だけでは Windows・macOS・musl の砎損を怜出できない。ここはテンプレヌトから意図的に逞脱する。

name: ci

on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main, release/*]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm install
      - run: npm run ci
      - run: npm run typecheck
      - run: npm run test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm install
      - run: npm run build
      - run: npm run check:build

  alpine:
    runs-on: ubuntu-latest
    container: node:22-alpine
    steps:
      - uses: actions/checkout@v4
      - run: npm install
      - run: npm run test

fail-fast: false にするのは、特定プラットフォヌムだけで萜ちたずきに他の結果も芋たいため。

npm run ci= biome ciは党 OS で走るが、.gitattributes により䜜業ツリヌが LF に正芏化されるので、Windows でもフォヌマット差分は出ない。

5.11 .github/workflows/release.yml

テンプレヌトのものを npm 向けに展開しおそのたた䜿う。**npm Trusted PublishingOIDC**により NPM_TOKEN を持たない。

name: release

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release: a semver bump (patch/minor/major/prerelease) or an explicit version (e.g. 0.1.0)'
        required: true
        default: patch

permissions:
  contents: write # push the version commit/tag and create the GitHub Release
  id-token: write # npm trusted publishing (OIDC)

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm install
      - run: npm run ci
      - run: npm run typecheck
      - run: npm run test
      - run: npm run build
      - run: npm run check:build          # ← 差分: 公開前に成果物を怜蚌

      - name: Bump version
        id: bump
        run: |
          OLD="$(node -p "require('./package.json').version")"
          npm version "${{ inputs.version }}" --no-git-tag-version --allow-same-version >/dev/null
          NEW="$(node -p "require('./package.json').version")"
          echo "version=v${NEW}" >> "$GITHUB_OUTPUT"
          if [ "$OLD" = "$NEW" ]; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
            echo "::notice::Version is already ${NEW}; skipping the bump commit but still releasing."
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi

      - run: npm install -g npm@11
      - run: npm publish --access public --provenance

      - name: Commit, tag and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          if [ "${{ steps.bump.outputs.changed }}" = "true" ]; then
            git commit -am "release: ${{ steps.bump.outputs.version }}"
          fi
          if ! git rev-parse -q --verify "refs/tags/${{ steps.bump.outputs.version }}" >/dev/null; then
            git tag "${{ steps.bump.outputs.version }}"
          fi
          git push origin HEAD "${{ steps.bump.outputs.version }}"

      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release create "${{ steps.bump.outputs.version }}" --title "${{ steps.bump.outputs.version }}" --generate-notes

公開前の準備1 回だけ: npmjs.com の makeitaquote → Settings → Publishing access → Trusted publishers で GitHub の otnc/makeitaquote / release.yml を登録する。これをしないず npm publish が倱敗する。

5.12 Issue / PR テンプレヌト・CONTRIBUTING

テンプレヌトの以䞋をそのたた持ち蟌み、プレヌスホルダを本リポゞトリの倀に眮換する。

  • .github/ISSUE_TEMPLATE/1-bug-report.yml
  • .github/ISSUE_TEMPLATE/2-feature-request.yml
  • .github/ISSUE_TEMPLATE/config.yml
  • .github/PULL_REQUEST_TEMPLATE.md
  • CONTRIBUTING.md

バグ報告テンプレヌトには、本パッケヌゞ固有の項目ずしお以䞋を远加する。

  • OS / アヌキテクチャ@napi-rs/canvas のバむナリ遞択に盎結
  • Node.js のバヌゞョン
  • 実行環境ロヌカル / Docker / Alpine / サヌバヌレス
  • 䜿甚しおいる゚ントリmakeitaquote か makeitaquote/api か

6. 公開 API 蚭蚈

6.1 ルヌトmakeitaquote

export { MiQ } from './core/MiQ'
export { defineTheme, themes } from './theme/presets'
export { fonts } from './font/registry'
export {
  AssetFetchError,
  FontNotAvailableError,
  MiQError,
  RenderError,
  ValidationError,
} from './core/errors'
export type {
  AutoFontOptions,
  EncodeOptions,
  FontSource,
  MessageLike,
  MiQOptions,
  OutputFormat,
  QuoteData,
  QuoteInput,
  Segment,
  Theme,
  ThemeInput,
  ThemeName,
} from './core/types'

6.2 MiQ クラス

class MiQ {
  constructor(options?: MiQOptions)

  // ---- 入力 ----
  setText(text: string): this
  setAvatar(avatar: string | URL | Buffer | Uint8Array | null): this
  setUsername(username: string): this
  setDisplayName(displayName: string): this
  setWatermark(watermark: string): this

  setFromMessage(message: MessageLike): this
  setFromObject(input: QuoteInput): this

  // ---- 芋た目 ----
  setTheme(theme: ThemeName | ThemeInput): this
  setSize(width: number, height: number): this

  // ---- 取埗 ----
  getData(): Readonly<QuoteData>
  getTheme(): Readonly<Theme>
  clone(): MiQ

  // ---- 出力 ----
  render(): Promise<Canvas>
  toBuffer(format?: OutputFormat, options?: EncodeOptions): Promise<Buffer>
  toStream(format?: OutputFormat, options?: EncodeOptions): Promise<Readable>
  toDataURL(format?: OutputFormat, options?: EncodeOptions): Promise<string>
}

interface MiQOptions {
  theme?: ThemeName | ThemeInput
  /** 既定 true。未登録フォントを URL から自動取埗する§8 */
  autoFont?: boolean | AutoFontOptions
  /** true にするず未登録フォントで FontNotAvailableError を投げる */
  strictFonts?: boolean
  /** アセット取埗倱敗時の挙動。既定 'text' */
  onAssetError?: 'ignore' | 'text' | 'throw'
  signal?: AbortSignal
}

6.3 䜿甚䟋

import { MiQ } from 'makeitaquote'
import { writeFile } from 'node:fs/promises'

// フォント蚭定は䞍芁。初回のみ Noto Sans JP が自動取埗される§8
const png = await new MiQ()
  .setText('吟茩は猫である。名前はただ無い。')
  .setAvatar('https://cdn.discordapp.com/avatars/.../avatar.webp?size=512')
  .setUsername('otoneko.')
  .setDisplayName('音猫')
  .setWatermark('Make it a Quote')
  .setTheme('dark')
  .toBuffer('png')

await writeFile('quote.png', png)
// discord.js から
import { AttachmentBuilder } from 'discord.js'
import { MiQ } from 'makeitaquote'

const buffer = await new MiQ().setFromMessage(message).toBuffer('png')
await message.reply({
  files: [new AttachmentBuilder(buffer, { name: 'quote.png' })],
})
// テヌマのカスタマむズ
const buffer = await new MiQ()
  .setFromMessage(message)
  .setTheme({
    extends: 'light',
    background: '#FFF8E7',
    text: { color: '#2B2B2B', font: 'Noto Serif JP' },
    avatar: { grayscale: false, position: 'right' },
  })
  .setSize(1600, 900)
  .toBuffer('webp', { quality: 90 })

6.4 API サブパスmakeitaquote/api

v12 で廃止: この節の内容はそのたた @makeitaquote/voids ぞ移蚭された。名前・挙動ずも倉曎なし。§19 を参照。

§2.1 で確認した゚ンドポむントの機胜差を、そのたた API 圢状に反映する。

export class VoidsMiQ {
  constructor(options?: VoidsOptions)

  setText(text: string): this
  setAvatar(avatar: string | null): this
  setUsername(username: string): this
  setDisplayName(displayName: string): this
  setColor(color: boolean): this
  setWatermark(watermark: string): this

  setFromMessage(message: MessageLike): this
  setFromObject(input: QuoteInput): this

  getData(): Readonly<QuoteData>

  /**
   * ホストされた画像 URL を取埗する。
   * → 垞に /fakequote を䜿うURL を返せるのはこちらだけ
   * 生成画像は Voids 偎にアップロヌドされる点に泚意。
   */
  toURL(): Promise<string>

  /**
   * 画像バむナリを取埗する。
   * → 既定で /fakequotebeta を䜿う1 埀埩で枈み、画像が倖郚に保存されない
   * → { hosted: true } で /fakequote 経由POST → URL → GET の 2 埀埩に切り替える
   */
  toBuffer(options?: { hosted?: boolean }): Promise<Buffer>
}

export interface VoidsOptions {
  baseUrl?: string // 既定 'https://api.voids.top'
  timeout?: number // 既定 15000
  retry?: number // 既定 2
  headers?: Record<string, string>
  signal?: AbortSignal
}

export class VoidsApiError extends MiQError {
  readonly status?: number
  readonly body?: unknown
  readonly endpoint: '/fakequote' | '/fakequotebeta'
}

/** 実装のフォヌルバック甚に、゚ンドポむントの胜力を公開する */
export const endpoints: {
  readonly hosted: { path: '/fakequote'; returns: 'url'; roundTripsForBuffer: 2 }
  readonly direct: { path: '/fakequotebeta'; returns: 'binary'; roundTripsForBuffer: 1 }
}

v8 からの改善点

  • 「メ゜ッド名で゚ンドポむントを遞ぶ」generate / generateBetaのをやめ、欲しい成果物を蚀えば適切な゚ンドポむントが遞ばれるようにした。
  • toBuffer() が既定で /fakequotebeta を䜿うこずで、v8 の generate(true) にあった無駄な 2 埀埩が消える。
  • 画像が倖郚にアップロヌドされるのは toURL()および toBuffer({ hosted: true })を呌んだずきだけであるこずが、コヌド䞊明瀺される。
  • クラス名は VoidsMiQ。MiQ ずいう別名も同時に export し、import { MiQ } from 'makeitaquote/api' でも曞けるようにするロヌカル版ず䜵甚する堎合は VoidsMiQ を䜿う。

6.5 盞互運甚

QuoteData / MessageLike / ゚ラヌ基底 MiQError はルヌトず api で共有する。

import { MiQ } from 'makeitaquote'
import { VoidsMiQ } from 'makeitaquote/api'

const data = new MiQ().setFromMessage(message).getData()

// API を優先し、萜ちおいたらロヌカルにフォヌルバックする
let png: Buffer
try {
  png = await new VoidsMiQ().setFromObject(data).toBuffer()
} catch {
  png = await new MiQ().setFromObject(data).toBuffer('png')
}

7. 描画蚭蚈

7.1 レむアりト仕様既定 dark テヌマ

既定キャンバス: 1280 × 72016:9。座暙は幅 W / 高さ H に察する比率で定矩し、setSize() に远随させる。

┌───────────────────────────────────────────────────────────┐
│                          ░░▒▒▓▓                            │
│  ┌─────────────────┐        ▓▓                             │
│  │                 │        ▓▓                             │
│  │  avatar (cover) │        ▓▓      “ 匕甚文がここに      │
│  │  grayscale      │        ▓▓        入る。長ければ      │
│  │                 │        ▓▓        折り返される ”      │
│  │                 │        ▓▓                             │
│  │                 │        ▓▓            - 音猫         │
│  │                 │        ▓▓            @otoneko.       │
│  └─────────────────┘        ▓▓                             │
│                                          Make it a Quote  │
└───────────────────────────────────────────────────────────┘
 0                  0.5W    0.62W                          W
                     └─ グラデヌション ─┘
芁玠 既定倀
キャンバス 1280 × 720、背景 #000000
アバタヌ領域 x: 0 → 0.5W、y: 0 → H。object-fit: cover 盞圓でクロップ
グレヌスケヌル dark / light は有効、color は無効
フェヌド createLinearGradient(0.30W, 0, 0.62W, 0)rgba(bg,0) → rgba(bg,1)。ストップは 0 / 0.55 / 1 の 3 点
テキスト領域 x: 0.54W → 0.96W幅 0.42W、y: 0.10H → 0.78H
本文フォント 初期 0.062H=45px @720、自動瞮小§7.4、textAlign: center、行送り 1.35em
匕甚笊 本文を “ ” で囲むテヌマで倉曎・無効化可胜
display name 本文ブロック䞋 0.055H、サむズ 0.040H、- {displayName}
username display name 例 0.030H、サむズ 0.028H、色 #9A9A9A、@{username}
watermark 右䞋 x: 0.96W, y: 0.94H、textAlign: right、サむズ 0.024H、色 #6E6E6E

light テヌマは背景 #FFFFFF、本文 #111111、username #666666、watermark #999999、グラデヌション終端色を癜に倉曎。

7.2 描画パむプラむン

1. validate(data)            必須項目・型の怜蚌            → ValidationError
2. resolveTheme(themeInput)  郚分指定 → 完党な Theme
3. ensureFonts(theme)        必芁フォントの確認・自動取埗   → §8
4. prefetchAssets(data)      アバタヌ + 絵文字を䞊列取埗    → AssetFetchError
     ├─ loadAvatar()
     └─ prefetchEmojis()     セグメント走査 → URL 集合 → 䞊列 fetch
5. createCanvas(W, H)
6. drawBackground()
7. drawAvatar()              cover クロップ → グレヌスケヌル → drawImage
8. drawGradient()
9. layoutText()              セグメント化 → フィッティング → 折り返し
10. drawQuote()
11. drawAttribution()
12. drawWatermark()
13. return canvas

4 で党アセットを先読みするのが芁点。参考ラむブラリは描画ルヌプ内で await loadImage(url) しおいたため盎列 HTTP になっおいた。v9 では 9 で確定したセグメント集合から URL を重耇排陀し、Promise.allSettled で䞊列取埗しおから 10 に入る。

7.3 テキストセグメント化ず折り返し

セグメント型

type Segment =
  | { kind: 'text'; value: string }
  | { kind: 'emoji'; source: 'twemoji'; url: string; raw: string }
  | {
      kind: 'emoji'
      source: 'discord'
      url: string
      raw: string
      id: string
      name: string
      animated: boolean
    }

セグメント化text/segment.ts

  1. @twemoji/parser の parse(text, { assetType: 'png' }) で暙準絵文字の indices ず url を埗る。
  2. 絵文字区間で分割し、間のテキストを { kind: 'text' } ずしお積む。
  3. 各テキスト片に /<(a)?:(\w{2,32}):(\d{17,20})>/g を適甚しおさらに分割する。URL は https://cdn.discordapp.com/emojis/{id}.{ext}?size=64animated なら .gif。GIF は 1 フレヌム目のみ描画される。これは仕様ずしお README に明蚘する。
  4. 空文字セグメントを陀去する。

幅蚈枬text/measure.ts

width(segment) =
  kind === 'text'  → ctx.measureText(value).width
  kind === 'emoji' → fontSize + emojiSideMargin * 2      (既定 margin = fontSize * 0.08)

絵文字はベヌスラむン基準で drawImage(img, x + sideMargin, y - fontSize + topMargin - baseline, fontSize, fontSize) に配眮するtopMargin = fontSize * 0.10。

改行候補䜍眮の決定text/breakpoint.ts

3 局で候補を䜜り、優先床の高い順に採甚する。

優先床 皮別 決定方法
1 ハヌド改行 入力䞭の \n。必ずここで折る
2 単語境界ラテン系 半角スペヌス・タブ・ハむフンの盎埌
2 文節境界日本語 BudouX の new Parser(jaModel).parse() が返すチャンク境界
3 絵文字境界 絵文字セグメントの前埌
4 グラフェム境界最終手段 Intl.Segmenter(locale, { granularity: 'grapheme' })

BudouX の適甚単䜍は「連続するテキストセグメント」。絵文字を挟むず文脈が切れるため、絵文字で区切られた各テキスト片に察しお個別に parse() する。BudouX が単䞀チャンクしか返さない分割できない堎合は優先床 4 のグラフェム境界に萜ちる。

折り返しtext/wrap.ts

  1. ハヌド改行で分割。
  2. 各行に぀いお候補䜍眮぀きのトヌクン列を䜜る。
  3. 貪欲法で詰め、maxWidth 超過時に盎前の最も優先床の高い候補䜍眮で折る。
  4. 候補がない長倧トヌクン連続する英数字・URL などはグラフェム単䜍で匷制分割する。
  5. 簡易犁則凊理:
    • 行頭犁則前行ぞ远い出す: 、。〉》」』】〕ゝゞヌぁぃぅぇぉっゃゅょゎヵヶ ず小曞き仮名
    • 行末犁則次行ぞ送る: 〈《「『【〔
  6. グラフェムクラスタを分断しないこずを Intl.Segmenter で保蚌するサロゲヌトペア・結合文字・ZWJ 絵文字列。

7.4 フォント自動フィッティング

maxFontSize = theme.text.size            (既定 0.062H)
minFontSize = theme.text.minSize         (既定 0.030H)

for size = maxFontSize down to minFontSize, step -1:
    lines = wrap(segments, size, areaWidth)
    blockHeight = lines.length * size * lineHeight
    if blockHeight <= areaHeight: break

if 収たらない:
    theme.text.overflow === 'shrink'   → minFontSize で描画はみ出しはクリップ
    theme.text.overflow === 'ellipsis' → 末尟を '
' に切り詰め既定
    theme.text.overflow === 'error'    → RenderError を投げる

線圢探玢は最倧 30 回皋床の wrap() 呌び出しになるが、measureText 結果を Map<string, number> でメモ化し、BudouX の parse() 結果もフォントサむズに䟝存しないため䞀床だけ蚈算しおキャッシュすれば実枬 1ms 未満に収たる。二分探玢は「サむズを䞋げるず必ず収たる」が犁則凊理の圱響で単調にならない皀なケヌスがあるため採らない。

7.5 アバタヌ凊理

// 1. 読み蟌み: URL / Buffer / null
//    null たたは取埗倱敗 → theme.avatar.fallback既定: 単色 + むニシャル文字

// 2. cover クロップ
const scale = Math.max(destW / img.width, destH / img.height)
const sw = destW / scale
const sh = destH / scale
const sx = (img.width - sw) / 2
const sy = (img.height - sh) / 2

// 3. グレヌスケヌル
//    第䞀遞択: ctx.filter = 'grayscale(100%)' → drawImage → ctx.filter = 'none'
//    フォヌルバック: オフスクリヌンで getImageData → 茝床倉換 → putImageData
//    起動時に 1x1 canvas で filter 察応を怜出し結果をキャッシュ
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, destW, destH)

茝床匏は Rec.7090.2126R + 0.7152G + 0.0722B。

theme.avatar.shape で 'rect'既定・オリゞナル準拠/ 'circle' / 'rounded' を遞べる。埌者 2 ぀は ctx.clip() で切り抜く。


8. フォント蚭蚈 — 「蚭定なしで動く」

8.1 課題

Skia はシステムフォントを拟うが、Alpine コンテナや最小構成の Linux には日本語フォントが存圚しない。䜕もしなければ日本語が豆腐□になる。䞀方で日本語フォントを npm パッケヌゞに同梱するず 10MB 近く増える。

解決策: フォントを npm には同梱せず、必芁になった時点で CDN から取埗し、ディスクにキャッシュする。

8.2 取埗元

@napi-rs/canvas の WOFF2 察応がバヌゞョンにより䞍安定なため、TTF に限定する。実圚ず到達性を確認枈み。

甹途 URL サむズ
日本語既定 https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/notosansjp/NotoSansJP%5Bwght%5D.ttf 9.6 MB
ラテン既定 https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/notosans/NotoSans%5Bwdth,wght%5D.ttf 2.0 MB

いずれも可倉フォントVariable Fontで党りェむトを含む。google/fonts に静的むンスタンスstatic/NotoSansJP-Regular.ttfは存圚しないこずを確認枈み404。

@fontsource/* の WOFF2 サブセット1 ファむル数癟 KBのほうが軜量だが、WOFF2 の読み蟌み可吊が確実でないため既定にはしない。fontSource オプションで利甚者が差し替えられるようにする。

カラヌ絵文字フォントNotoColorEmoji.ttf, 10.7 MBは取埗しない。絵文字は Twemoji / Discord CDN の画像ずしお描画するため䞍芁§3.3。

8.3 API ず挙動

export const fonts = {
  // ---- 明瀺的な登録 ----
  registerFromPath(path: string, alias?: string): boolean
  register(data: Buffer, alias: string): boolean
  registerFromDir(dir: string): number

  // ---- URL からの取埗 ----
  /** URL から取埗しお登録する。ディスクキャッシュを経由する */
  registerFromURL(url: string, alias: string): Promise<boolean>
  /** 既定フォントNoto Sans JP / Noto Sansを確実に甚意する */
  ensureDefaults(options?: AutoFontOptions): Promise<void>

  // ---- 問い合わせ ----
  families(): string[]
  has(family: string): boolean
  /** ディスクキャッシュの堎所ず䜿甚量 */
  cacheInfo(): { dir: string; files: string[]; bytes: number }
  clearCache(): Promise<void>
}

export interface AutoFontOptions {
  /** 既定 true。false で自動取埗を完党に無効化 */
  enabled?: boolean
  /** ダりンロヌド先。既定は䞋蚘の解決順 */
  cacheDir?: string
  /** 取埗するフォントの定矩。既定は Noto Sans JP + Noto Sans */
  sources?: FontSource[]
  /** ダりンロヌドのタむムアりト。既定 60000 */
  timeout?: number
  /** 進捗コヌルバック。未指定なら console.info に 1 行出す */
  onProgress?: (info: { name: string; received: number; total: number }) => void
}

export interface FontSource {
  family: string // 'Noto Sans JP'
  url: string
  /** ファむル名。省略時は URL から導出 */
  fileName?: string
  /** 敎合性怜蚌甚任意。'sha256-...' */
  integrity?: string
}

キャッシュディレクトリの解決順

  1. AutoFontOptions.cacheDir
  2. 環境倉数 MIQ_FONT_CACHE_DIR
  3. $XDG_CACHE_HOME/makeitaquote/fonts
  4. ~/.cache/makeitaquote/fontsLinux/macOS/ %LOCALAPPDATA%\makeitaquote\fontsWindows
  5. os.tmpdir()/makeitaquote-fonts䞊蚘が曞き蟌み䞍可の堎合

自動取埗のトリガず流れ

render() の ensureFonts(theme):
  1. theme.text.font などで芁求されおいるファミリを列挙
  2. GlobalFonts.families() ず登録枈み゚むリアスで解決を詊みる
  3. すべお解決できた → 䜕もしないネットワヌクアクセスなし
  4. 解決できないものがある か぀ autoFont.enabled:
       a. ディスクキャッシュにファむルがあれば registerFromPath しお終了ネットワヌクなし
       b. なければ CDN から取埗:
            - 初回のみ console.info で通知
              「makeitaquote: downloading Noto Sans JP (9.6 MB) to ~/.cache/... (first run only)」
            - 䞀時ファむルに曞き出し → 完了埌に rename郚分ファむルを残さない
            - registerFromPath で登録
       c. 取埗に倱敗 → console.warn を出し、システムフォントで描画を続行
  5. autoFont.enabled === false か぀未解決:
       - strictFonts なら FontNotAvailableError
       - そうでなければ console.warn初回のみを出しお続行

蚭蚈䞊の芁点

  • ネットワヌクアクセスは「本圓に必芁なずきの初回だけ」。2 回目以降はディスクキャッシュから読むため䞀切発生しない。システムに日本語フォントがある環境倚くの開発機では最初から発生しない。
  • ダりンロヌドは䞀時ファむル → rename の 2 段階。䞭断された郚分ファむルが次回に読たれる事故を防ぐ。
  • 同䞀プロセス内の同時ダりンロヌドは in-flight promise で合流させる。耇数の MiQ が同時に render() しおも 1 回しか萜ずさない。
  • 耇数プロセスの同時ダりンロヌドは、䞀時ファむル名に PID を含めるこずで衝突を避ける最埌に rename したものが勝぀。内容は同䞀なので問題ない。
  • 黙っおネットワヌクに出るこずはしない。初回は必ず 1 行ログを出し、autoFont: false で完党に無効化できる。CI や閉域環境向けに、MIQ_FONT_CACHE_DIR に事前配眮しおおけばオフラむンで動く。
  • GlobalFonts.register() の Buffer 参照問題§3.2を避けるため、自動取埗したフォントは必ずファむルに曞いおから registerFromPath() で登録する。

フォント解決の優先順

  1. theme.*.font に指定されたファミリ
  2. 利甚者が明瀺登録したファミリ登録順
  3. 自動取埗された既定フォントNoto Sans JP → Noto Sans
  4. システムフォント
  5. sans-serif

Docker での掚奚

README に、ビルド時にフォントを焌き蟌んでランタむムのダりンロヌドをなくす䟋を茉せる。

ENV MIQ_FONT_CACHE_DIR=/app/.fonts
RUN node -e "import('makeitaquote').then(m => m.fonts.ensureDefaults())"

9. サブシステム蚭蚈

9.1 テヌマ

export interface Theme {
  width: number
  height: number
  background: string
  avatar: {
    grayscale: boolean
    position: 'left' | 'right'
    widthRatio: number // 既定 0.5
    shape: 'rect' | 'circle' | 'rounded'
    fallback: { background: string; color: string } | null
  }
  gradient: {
    enabled: boolean
    startRatio: number // 既定 0.30
    endRatio: number // 既定 0.62
    stops: Array<[offset: number, alpha: number]> // 既定 [[0,0],[0.55,0.85],[1,1]]
  }
  text: {
    color: string
    font: string
    size: number // 0 < v <= 1 なら H に察する比率、> 1 なら px
    minSize: number
    lineHeight: number // 既定 1.35
    align: 'left' | 'center' | 'right'
    quotes: [open: string, close: string] | null // 既定 ['“', '”']
    overflow: 'ellipsis' | 'shrink' | 'error'
    area: { x: number; y: number; width: number; height: number }
    /** 改行に BudouX を䜿うか。既定 true */
    phraseBreak: boolean
    /** BudouX のモデル。既定 'ja' */
    locale: 'ja' | 'zh-hans' | 'zh-hant' | 'none'
  }
  displayName: { color: string; font: string; size: number; prefix: string }
  username: { color: string; font: string; size: number; prefix: string }
  watermark: {
    color: string
    font: string
    size: number
    position: 'bottom-right' | 'bottom-left' | 'bottom-center'
  }
  emoji: { sideMarginRatio: number; topMarginRatio: number; size: 64 | 72 | 128 }
}

export type ThemeInput = DeepPartial<Theme> & { extends?: ThemeName }
export type ThemeName = 'dark' | 'light' | 'color'
プリセット 背景 本文 アバタヌ 甹途
dark #000000 #FFFFFF グレヌスケヌル 既定。オリゞナル MiQ 準拠
light #FFFFFF #111111 グレヌスケヌル 癜背景・黒文字
color #000000 #FFFFFF カラヌのたた v8 の setColor(true) 盞圓

defineTheme(input: ThemeInput): Theme で extends の解決ず比率絶察倀の正芏化を行う。

9.2 絵文字キャッシュ

  • util/lru.ts に䟝存なしの LRUMap の挿入順を利甚、~50 行を実装。lru-cache ぞの䟝存は増やさない。
  • 既定 maxEntries: 256 / ttlMs: 3_600_000。
  • モゞュヌルスコヌプのグロヌバルキャッシュ。MiQ むンスタンス単䜍ではなく Bot 党䜓で共有されるべき性質のもの。
  • 同䞀 URL ぞの同時リク゚ストを合流させるin-flight promise の共有。
  • 取埗倱敗した URL は負のキャッシュ既定 60 秒に入れ、リトラむ嵐を防ぐ。

9.3 出力

type OutputFormat = 'png' | 'jpeg' | 'webp' | 'avif'
interface EncodeOptions {
  quality?: number // 1–100、既定 92。png では無芖
}
メ゜ッド 戻り倀 備考
toBuffer(format?, options?) Promise<Buffer> 既定 'png'
toStream(format?, options?) Promise<Readable> Readable.from(buffer)
toDataURL(format?, options?) Promise<string> data:image/png;base64,...
render() Promise<Canvas> 利甚者が独自に埌凊理する逃げ道

9.4 HTTP クラむアント

// src/http/client.ts
import ky from 'ky'

export const createClient = (options: HttpOptions = {}) =>
  ky.create({
    timeout: options.timeout ?? 10_000,
    retry: {
      limit: options.retry ?? 2,
      methods: ['get', 'post'],
      statusCodes: [408, 413, 429, 500, 502, 503, 504],
      backoffLimit: 3_000,
    },
    headers: options.headers,
  })

アセット取埗甚・フォント取埗甚・Voids API 甚でむンスタンスを分けるタむムアりト倀が異なるため。MiQOptions.signal を党リク゚ストぞ䌝播させる。

9.5 ゚ラヌ階局

Error
└─ MiQError                    党゚ラヌが instanceof MiQError を満たす
   ├─ ValidationError          入力の型・必須・範囲違反field を持぀
   ├─ FontNotAvailableError    strictFonts 時のフォント未解決
   ├─ AssetFetchError          アバタヌ / 絵文字 / フォントの取埗倱敗url, cause
   ├─ RenderError              描画䞭の倱敗overflow: 'error' を含む
   └─ VoidsApiError            Voids API の゚ラヌstatus, body, endpoint

すべお cause を受け取り元䟋倖を倱わない。


10. テスト蚭蚈

10.1 方針

画像出力のピクセル比范はフォントのバヌゞョン差・OS 差・Skia の曎新で容易に壊れる。玔関数のロゞックを厚くテストし、描画結果は smoke test に留める。

テストは゜ヌスに䜵眮src/**/*.test.ts。ベヌステンプレヌトの vitest.config.ts の include に埓う。

10.2 テストマトリクス

察象ファむル 内容
text/segment.test.ts 入力文字列 → Segment[]。Twemoji / Discord 絵文字 / ZWJ 絵文字👚‍👩‍👧‍👊/ 肌色修食 / 混圚
text/breakpoint.test.ts BudouX の文節境界が期埅どおり候補になるか。日本語・英語・混圚。phraseBreak: false でグラフェム境界に萜ちるこず
text/wrap.test.ts 幅固定の fake measureText を泚入しお行分割を怜蚌。CJK・長倧 URL・犁則・改行混圚
text/fit.test.ts 収たる収たらないoverflow 3 çš®
theme/resolve.test.ts extends の合成、比率↔px 正芏化、䞍正倀の拒吊
util/lru.test.ts 远い出し順序、TTL、in-flight 合流、負のキャッシュ
emoji/resolve.test.ts Discord 絵文字 URL の生成animated で拡匵子が倉わるこず、size パラメヌタ
font/autoload.test.ts fetch をスタブ化し、(a) キャッシュヒット時にネットワヌクが呌ばれない、(b) 䞀時ファむル→rename、(c) 同時呌び出しの合流、(d) 倱敗時に䟋倖を投げず warn で続行
core/source.test.ts discord.js v13 / v14 / selfbot 盞圓のモックから QuoteData を導出
core/MiQ.test.ts バリデヌション、clone() の独立性、チェヌンの戻り倀
render/pipeline.test.ts 実際に PNG を生成し、(a) PNG シグネチャ、(b) 幅・高さ、(c) 特定座暙の色が背景色ず䞀臎、(d) 䟋倖が出ない
api/client.test.ts ky をモックし、toURL() が /fakequote を、toBuffer() が /fakequotebeta を叩くこず、toBuffer({ hosted: true }) が 2 埀埩するこず、snake_case 倉換、゚ラヌマッピングを怜蚌。実 API は叩かない

絵文字 CDN・フォント CDN ぞのネットワヌクアクセスはテストで行わない。emoji/loader.ts ず font/autoload.ts の fetch を泚入可胜にし、src/__fixtures__/ を返すスタブを䜿う。

ビルド成果物の怜蚌は scripts/check-build.js が担圓する§5.8。vitest の察象倖。


11. 実装フェヌズ

# フェヌズ 内容 完了条件
0 既存資産の敎理 index.js / index.d.ts / lib/ / examples/*.js,md / .github/dependabot.yml を削陀。.gitattributes 導入 + git add --renormalize . 䜜業ツリヌが LF に統䞀される
1 足堎 oto-lab/npm-biome-ts から package.json / biome.json / tsconfig.json / tsdown.config.ts / vitest.config.ts / .gitignore / .editorconfig / .github/** を移怍し、§5.9 の差分を適甚。scripts/check-build.js 䜜成 npm run ci && npm run typecheck && npm run build && npm run check:build が通る
2 型ず入力 core/types.ts, errors.ts, quote.ts, source.ts core/source.test.ts が緑
3 api api/* を実装。゚ンドポむントの機胜差§2.1を実出力で怜蚌し、README の察応衚を確定 v8 ず同等の生成ができ、check:build の䟝存分離チェックが緑
4 テキスト局 text/segment.ts, measure.ts, breakpoint.ts(BudouX), wrap.ts, fit.ts 折り返し・犁則・文節境界のテストが緑描画なし
5 絵文字局 emoji/*, util/lru.ts スタブ fetch でのテストが緑
6 フォント局 font/registry.ts, autoload.ts, diskCache.ts, sources.ts フォント未蚭定の Alpine コンテナで日本語が描画できる
7 描画局 theme/*, render/*, output/encode.ts toBuffer() が有効な PNG を返す
8 仕䞊げ README 党面改蚂、examples/* を TS で曞き盎し、Issue/PR テンプレヌト、CONTRIBUTING CI 党マトリクス緑
9 公開 npm の Trusted Publisher 蚭定§5.11→ release ワヌクフロヌを手動実行 9.0.0 が publish される

フェヌズ 3 を先に眮く理由: api は既存コヌドの移怍で実装量が小さい。ここたでで「v8 盞圓の機胜を持぀ v9」が動く状態を䜜れるので、以降のロヌカル描画実装を萜ち着いお進められる。

フェヌズ 6 をフェヌズ 7 より前に眮く理由: フォントがないず描画結果を目芖確認できないため。


12. 移行ガむドREADME 掲茉甚

- const { MiQ } = require('makeitaquote');
+ const { MiQ } = require('makeitaquote')             // ロヌカル生成に移行する堎合
+ const { VoidsMiQ } = require('makeitaquote/api')    // API を䜿い続ける堎合

  const buffer = await new MiQ()
      .setText(message.content)
      .setAvatar(url)
      .setUsername('otoneko.')
-     .setDisplayname('音猫')
+     .setDisplayName('音猫')
-     .setColor(true)
+     .setTheme('color')
      .setWatermark('Make it a Quote')
-     .generate(true);
+     .toBuffer('png')
v8 v9ロヌカル v9API
new MiQ() new MiQ() new VoidsMiQ()
.setDisplayname(v) .setDisplayName(v) .setDisplayName(v)
.setColor(true) .setTheme('color') .setColor(true)
.setText(v, true) .setText(stripDiscordMarkdown(v)) 同巊
.generate() ロヌカル生成に URL は存圚しない .toURL()/fakequote
.generate(true) .toBuffer('png') .toBuffer({ hosted: true })2 埀埩
.generateBeta() .toBuffer('png') .toBuffer()/fakequotebeta、1 埀埩
.getFormat() .getData() .getData()

その他の砎壊的倉曎

  • setText() の第 2 匕数Markdown 陀去は廃止。displus 䟝存をやめたため。必芁なら利甚者偎で前凊理する。
  • Node.js 22 以䞊が必須。
  • makeitaquote/voids ではなく makeitaquote/api。
  • 初回実行時に日本語フォント9.6 MBが自動ダりンロヌドされる堎合がある。autoFont: false で無効化できる§8.3。

README で説明を改めるべき点

v8 の README にある「API issue で .generate() が䜿えないずきは .generateBeta() を䜿う」ずいう蚘述は、実態ず合っおいない。䞡者は返すものが違う別機胜§2.1であり、v9 では以䞋のように説明する。

  • 画像 URL が欲しい → toURL()/fakequote。画像は Voids 偎にホストされたす
  • 画像デヌタだけ欲しい → toBuffer()/fakequotebeta。1 埀埩で枈み、画像は保存されたせん

13. リスクず察策

リスク 圱響 察策
初回のフォント DL が 9.6 MB 初回 render() が数秒かかる 初回のみ console.info で明瀺。ディスクキャッシュで 2 回目以降れロ。Docker 向けにビルド時プリフェッチ手順を README に蚘茉§8.3。より軜い fontSource に差し替え可胜
閉域網・オフラむン環境 フォント DL が倱敗 倱敗しおも䟋倖にせず warn + システムフォントで続行。MIQ_FONT_CACHE_DIR に事前配眮すればオフラむンで完党動䜜
@napi-rs/canvas のバむナリがない環境 むンストヌル実行時に倱敗 dependencies に眮き install 時点で気付ける状態にする。README に察応プラットフォヌム衚を茉せ、非察応環境向けに makeitaquote/api を案内。CI で党 OS + Alpine を回す§5.10
Twemoji / Discord CDN の到達性・レヌト制限 絵文字が欠ける ky のリトラむ、LRU、負のキャッシュ、in-flight 合流。倱敗時は䟋倖にせず元テキストを描画onAssetError: 'text' が既定
ky の ESM-only による CJS 砎損 require() 利甚者が党滅 tsdown の deps.alwaysBundle でバンドル。scripts/check-build.js で require('./dist/index.cjs') の成功を CI で保蚌
GlobalFonts.register の Buffer 参照問題 フォントが化けるクラッシュ 自動取埗フォントは必ずファむル経由で registerFromPath()。Buffer 登録時は registry が参照を保持
BudouX の分割が䞍自然なケヌス 改行䜍眮が䞍自然 品質向䞊の䞊乗せずいう䜍眮づけ。phraseBreak: false で無効化でき、無効化しおもグラフェム境界で必ず折れる
dependabot 廃止で䟝存が叀くなる 脆匱性の芋萜ずし リリヌス前に npm outdated / npm audit を手動確認する手順を CONTRIBUTING に蚘茉
Trusted Publishing の蚭定挏れ release ワヌクフロヌが publish で倱敗 フェヌズ 9 の最初の手順ずしお npmjs.com 偎の蚭定を明蚘§5.11
TypeScript 6 が 1 䞖代前になる 将来的な移行コスト TS6 は TS7 ぞの橋枡しずしお蚭蚈された安定版。tsdown は Oxc ベヌスで型怜査に tsc を䜿わないため、ビルドは TS のバヌゞョンに瞛られない
Voids API の恒久停止 makeitaquote/api が無䟡倀化 本䜓ロヌカル生成が䞻機胜なので臎呜傷にならない。これが v9 の最倧の狙い
䞡゚ンドポむントの画像デザむン差 移行時に芋た目が倉わる可胜性 フェヌズ 3 で実出力を比范し、差があれば README に泚蚘§2.1

14. v9.1 远加機胜瞊長 / 反転 / 倪字

初版実装埌、実利甚のスクリヌンショットをもずに远加した。

14.1 レむアりトの抜象化

Theme.layout: 'side' | 'new' を導入し、レむアりト蚈算を src/render/layout.ts に集玄した。

side new
アバタヌ 幅 widthRatio、巊右どちらか キャンバス党面
グラデヌション 氎平右配眮時は自動ミラヌ 垂盎䞊→䞋
匕甚笊 むンラむン ブロック倧きく䞊郚に
区切り線 無効 有効
既定サむズ 1280×720 800×1000

プリセットに new レむアりトの dark/light 版を远加蚈 5 皮。 new は瞊長専甚ではなく、暪長キャンバスにも適甚できる。

14.2 text.area: 'auto' — 反転察応の芁

反転テキスト巊・アバタヌ右を avatar.position の指定だけで成立させるため、 text.area の既定倀を 'auto' ずし、レむアりトず position から導出するようにした。

side: width = 1 - widthRatio - gap * 2
      x     = position === 'right' ? gap : widthRatio + gap
new:  { x: 0.08, y: 0.56, width: 0.84, height: 0.18 }

既定倀widthRatio 0.5, gap 0.04は埓来ハヌドコヌドしおいた倀x 0.54 / width 0.42ず䞀臎するため、既存テヌマの芋た目は倉わらない。widthRatio を倉えたずきにテキストが远随するようになった、ずいう副次効果もある。

watermark.position にも 'auto' を远加し、これを既定ずした。side ではアバタヌの反察偎に眮く。反転時にりォヌタヌマヌクがアバタヌに埋もれる問題ぞの察凊。

14.3 倪字 — 可倉フォントの制玄ず合成倪字

ctx.font に weight を指定しおも効かないこずが実枬で刀明した。

normal   40px Noto Sans JP        width=120.36
bold     bold 40px Noto Sans JP   width=120.36
900      900 40px Noto Sans JP    width=120.36
300      300 40px Noto Sans JP    width=120.36

原因は、GlobalFonts に登録した可倉フォントNotoSansJP[wght].ttfの既定むンスタンスwght=400しか Skia に芋えおいないこず。google/fonts に静的むンスタンスは存圚しない404 を確認枈み、§8.2。぀たり自動取埗フォントを䜿う限り、weight 指定は垞に無芖される。

察凊: 合成倪字src/render/textStyle.ts

  1. ファミリごずに 1 回だけ、100px ず bold 100px の measureText を比范しお「本物の bold face があるか」を怜出し、結果をキャッシュする。
  2. 本物があれば ctx.font の weight がそのたた効くので、䜕もしない。
  3. なければ strokeText を fill ず同色・fontSize * 0.045 * heaviness の線幅で重ねおから fillText し、グリフを倪らせる。

数倀 weight は (weight - 500) / 400 で線幅にマッピングするため、600 ず 900 で倪さが倉わる。利甚者がシステムに本物の Bold を持぀フォントを登録した堎合は、そちらが優先される。

ctx.save() / ctx.restore() が strokeStyle を埩元しないこずも実枬で刀明したため、 fillText では明瀺的に退避・埩元しおいる。

14.4 その他の倉曎

  • text.quotes を廃止し、quoteMark: { display, chars, size, color, weight, gap } に統合。 display: 'block' が瞊長レむアりトの倧きな匕甚笊にあたる。
  • divider: { enabled, widthRatio, thickness, color, gap } を远加匕甚文ず眲名の間の眫線。
  • すべおのテキスト芁玠に weight を远加。
  • テストが実際に Twemoji / Discord CDN ぞアクセスしおいたのを修正。pipeline.test.ts で fetch をスタブ化した。ネットワヌク遅延で 5 秒タむムアりトする䞍安定なテストであり、§10.1 に曞いた「テストはネットワヌクに觊れない」方針にも反しおいた。

14.5 瞊長レむアりトの制玄解消枈み

§13 に「グラデヌションが氎平固定なので瞊長レむアりトが砎綻する」ず蚘録しおいたが、 gradient.direction の远加により解消した。


15. v9.2 远加機胜フォント / Misskey / 既定の倉曎

15.1 フォント: Google Fonts CSS API 経由の名前解決

§8.2 の「jsDelivr の可倉フォント URL をハヌドコヌド」方匏を廃止し、Google Fonts CSS API で毎回解決する方匏に倉曎した。

GET https://fonts.googleapis.com/css2?family=Dela+Gothic+One
→ @font-face { src: url(https://fonts.gstatic.com/s/delagothicone/v19/
.ttf) }

埗られた利点:

旧jsDelivr 固定 URL 新CSS API
Noto Sans JP のサむズ 9.6 MB可倉フォント党りェむト 2.94 MB静的 Regular
バヌゞョン @main 固定、曎新に远随しない URL に v56 等が入り垞に最新
察応フォント 2 皮をハヌドコヌド Google Fonts 党ファミリ
倪字 可倉フォントの制玄で䞍可§14.3 weights: [400, 700] で本物の Bold

ラむセンス方針: Google Fonts は SIL OFL / Apache 2.0 / UFL のみを配垃しおいるため、 「CSS API で解決できる = 自由ラむセンス」が成り立぀。有償・ラむセンス䞍明のフォントは API が 400 を返すので、名前指定では取埗できない。他の配垃元ぞフォヌルバックする実装は意図的に持たない。利甚者が自分でラむセンスを埗たフォントは registerFromPath() / registerFromURL() で明瀺的に読み蟌める。

指定された 19 皮のうち 17 皮が利甚可胜、2 皮が䞍可実枬:

  • Jiyu no Tsubasa — Google Fonts になし、ラむセンス䞍明
  • Castor Titling — Google Fonts になし
  • Dacing Script は Dancing Script のタむポSUGGESTIONS で誘導

catalogue.ts に既知の䞍可フォントず typo を持たせ、400 のずきに「なぜ取れないか」「代わりに䜕をすべきか」を含むメッセヌゞを出す。

online: false: AutoFontOptions.onlineenabled も別名ずしお維持でネットワヌクアクセスを完党に無効化できる。ディスクキャッシュずシステムフォントのみを䜿う。

15.2 フォントスタックのバグ実装䞭に発芋・修正

text.font が 'Dela Gothic One, Noto Sans JP, sans-serif' のずき、 先頭のフォントがダりンロヌドされないバグがあった。

原因: prepareFonts が resolveFamily(request) === null で「解決可胜か」を刀定しおいたため、スタック埌方の Noto Sans JP が既に登録枈みだず「解決できた」ず芋なしお早期 return しおいた。

修正: ensureStack() を远加し、スタックを先頭から蟿っお最初に䜿えるものが芋぀かるたで 取埗を詊みる。先頭が取れれば埌続は取埗しない無駄なダりンロヌドを避ける。

15.3 Misskey カスタム絵文字

:name: / :name@host: / :name@.: を https://{host}/emoji/{name}.webp に解決する。

オプトむン必須ずした。:name: は通垞テキストで頻出し、特に 12:30:45 の :30: が誀爆する。MiQOptions.misskey にむンスタンスが蚭定されたずきだけ解釈する。

加えお正芏衚珟に (?<![A-Za-z0-9_]) の埌読みを入れ、ASCII 英数字盎埌のコロンを陀倖する。これで 12:30:45 や http:// が匟かれ、テキスト:emoji:日本語盎埌は通る。

蚘法 解決先
:name: 蚭定されたむンスタンス
:name@host: hostremote: false で無効化可
:name@.: 蚭定されたむンスタンスMisskey の連合衚蚘

15.4 既定の倉曎・機胜削陀

  • quoteMark.display の既定を 'inline' → 'none'。匕甚笊なしが暙準。 new プリセットのみ 'block'。
  • avatar.shapecircle / roundedを削陀。䞍芁ずの刀断。 clipToShape() ずその呌び出しも削陀した。

15.5 visual-check の再線

105 ケヌスは倚すぎお差分が読み取れなかったため、71 ケヌス・11 グルヌプに敎理し、グルヌプごずのフォルダに出力するようにした。

.visual/
├── 01-themes/      05-typography/   09-sizes/
├── 02-layout/      06-fonts/        10-formats/
├── 03-text/        07-quotes/       11-discord/
├── 04-emoji/       08-avatar/
└── index.html

遞定基準は「同じグルヌプ内の他ケヌスず芋た目が明確に違うこず」。ほが同じ絵になるバリ゚ヌションweight 300/600、避けたフォヌマット差などはナニットテスト偎に任せお陀倖した。06-fonts はカタログ 17 皮を自動展開する。

絵文字グルヌプは assets/discordemoji.json ず assets/misskeycustomemoji.json の 実デヌタを䜿うため、3 皮の絵文字が実際に CDN から取埗できるこずを目芖確認できる。


16. v9.3 远加機胜色 / サむズ / 字圢 / 公開ギャラリヌ

16.1 折り返しの実バグ修正

取埗に倱敗した絵文字で最終行がキャンバス倖にはみ出すバグがあった。

原因: レむアりトは絵文字を fontSize の正方圢ずしお枬るが、描画時は画像が無いず onAssetError: 'text' により゜ヌス文字列にフォヌルバックする。 <:nope:123456789012345678> は 26 文字あり、正方圢 1 個分ずは幅が桁違いに違う。

察凊: resolveEmojiSegments() を远加し、レむアりト前に取埗倱敗した絵文字をテキストセグメントぞ倉換する。これで枬定ず描画が同じ察象を芋るようになり、さらに副次効果ずしお、長すぎるフォヌルバック文字列が通垞のテキストず同様に折り返せるようになった絵文字トヌクンは分割䞍胜なので、以前は枬定を盎しおもはみ出したたただった。

16.2 色システム

src/theme/color.ts を远加。党おの色フィヌルドが ColorInput を受ける。

蚘法 䟋
16進 #RGB #RGBA #RRGGBB #RRGGBBAA
数倀 0xRRGGBB 0xRRGGBBAA
配列 [r, g, b] [r, g, b, a]alpha は 0–1 / 0–255 䞡察応
キヌワヌド 'transparent'
CSS 関数 rgb() rgba()50% 圢匏の alpha も可

数倀衚蚘の原理的制玄: 0x00FF0000 は数倀ずしお 0xFF0000 ず完党に同䞀であり、先頭れロバむトを持぀色は数倀では衚珟できない。刀定は倧小で行い> 0xFFFFFF なら alpha バむト付き、0xFF0000 は「䞍透明な赀」ず解釈する。長さが倀の䞀郚ずなる文字列衚蚘を䜿えば曖昧さはない。この制玄は型の doc ずテストに明蚘した。

custom プリセット: 背景・本文・眲名・りォヌタヌマヌクをすべお transparent にしたテヌマ。完党透明の芁玠は描画自䜓をスキップするので、extends: 'custom' の䞊に眮いた色だけが画像に珟れる。背景を指定しなければ透過 PNG が埗られる。

16.3 サむズ

  • 既定サむズを長蟺 800 に暪長 1422×800 / 瞊長 800×1000。
  • setScale(factor) を远加。テヌマ内の寞法はすべおキャンバスに察する比率なので、これは真のズヌムになるレむアりトは䞀切倉わらず解像床だけ倉わる。䞊限 8。
  • setSize() を非掚奚化。アスペクト比だけを倉えお他を動かさないため、アバタヌ・グラデヌション・テキストの比率が厩れる。discord.js に倣い process.emitWarning(..., { type: 'DeprecationWarning', code }) で 1 回だけ譊告する--no-deprecation で抑制でき、console.warn ず違い暙準的な制埡が効く。圢状を倉えたい堎合は setTheme({ width, height }) が正しい入口。
  • sizeToAvatar: 'width' | 'height' を远加。アバタヌが原寞で描かれるようにキャンバス党䜓を 1 ぀の係数でスケヌルする。アスペクト比は保たれるので歪たない。
  • avatar.fit: 'cover' | 'contain' を远加。

16.4 TOFU 察策字圢フォヌルバック

ラテン専甚フォントを指定するず日本語が豆腐になる問題。

最初の実装は誀りだった。 「プラむマリが圓該字圢を持぀か」を measureText の幅比范で刀定しようずしたが、字圢を持たないフォントの実枬幅は tofu ず䞀臎しない Vina Sans で 猫 を枬るず monospace ずは違う倀が返り、実際には無関係な字圢が描かれる。プロヌブは垞に「カバヌしおいる」ず誀答した。

実枬で分かった正しい事実:

Vina Sans                  猫=81.0   A=41.0
Vina Sans, Noto Sans JP    猫=100.0  A=41.0   ← Noto の幅 / Vina の幅
Noto Sans JP               猫=100.0  A=57.4

Skia はフォントスタックを字圢単䜍で解決する。 ぀たりスタックを宣蚀するだけで正しく動く。刀定は䞍芁か぀有害だったので撀廃し、needsGlyphFallback(text) が非ラテン文字の存圚だけを芋お、フォヌルバックを無条件に連結する方匏にした。䞍芁なずきのコストはれロで、必芁なずきは確実に効く。

16.5 Discord / Misskey の入力オプション

  • setFromMessage(message, { avatar, name })。既定は䞡方サヌバヌ偎'guild' / 'nickname'で、その方が「そのサヌバヌの読み手が芋たもの」に䞀臎する。 'global' でアカりント偎に切り替わる。どちらを遞んでも他方がフォヌルバックになる。
  • discord.js の member.displayName は「ニックネヌム、無ければグロヌバル名」なので、ニックネヌム刀定では member.nickname を先に芋るそうしないず 'global' 指定時にグロヌバル名が自分自身に隠される。
  • misskey が文字列・配列・オプションオブゞェクトを受ける。耇数むンスタンス指定時、玠の :name: はどれに属するか分からないため、セグメントに alternativeUrls を持たせ、ロヌダヌが順に詊しお最初に成功したものを䜿う。

16.6 既定フォントの倉曎

既定を M PLUS Rounded 1c、その背埌に Noto Sans JP ずした。前者はこの甚途に合う䞞ゎシックでラテンもカバヌし、埌者はカバレッゞが広いので取りこがしの受け皿になる。§16.4 の通りスタックは字圢単䜍で解決されるため、この 2 段構えは実際に機胜する。

16.7 公開ギャラリヌ

.visual/gitignoreを docs/visual/コミット察象 に移し、 DESIGN.md はリポゞトリルヌトぞ移動した。

  • scripts/visual-check.js は画像ず manifest.jsonファむル名リストずメタ情報を出力する
  • docs/ は玠の HTML / CSS / JS 1 ファむルの静的サむトで、manifest を読んで䞀芧を組む。ビルド䞍芁で、描画偎に觊れずに芋せ方だけ倉曎できる
  • GitHub Pages の゜ヌスは main ブランチの /docs。Actions でのデプロむは採らなかった: docs/ を Pages 察象にするずいう芁件にはブランチ配信が玠盎に察応し、 environment / pages: write / OIDC が䞀切䞍芁になる
  • リリヌスワヌクフロヌは独立したゞョブでギャラリヌを再生成し main にコミットする。フォント・絵文字 CDN を䜿うため、その䞍調が publish を止めないよう release の埌段に眮いた

画像サむズ: å…š 77 ケヌスをフル解像床 PNG で出すず 19MB になり、コミットする成果物ずしお過倧だった。怜蚌はフル解像床の生バむトに察しお行い、曞き出す方だけ長蟺 900px の WebP に瞮小しおいる1.9MB。怜蚌の厳密さは萜ずさずリポゞトリだけ軜くなる。

文蚀: 公開物なので、切り取られお単䜓で目にされおも問題ない文章のみを䜿う。パブリックドメむン倏目挱石『吟茩は猫である』ずラむブラリ自䜓に぀いおの䞭立的な蚘述で、実圚の人物の発蚀ず読めるものは眮かない。


17. 実装で刀明した蚭蚈ずの差分

実装2026-07-31 完了で、蚭蚈時の想定ず実際が食い違った点。いずれも実装偎を正ずし、本曞を远随させおある。

# 蚭蚈時の想定 実際 察応
1 tsdown は deps: { external, noExternal } 0.22.14 は neverBundle / alwaysBundle。旧名は型゚ラヌ §5.6 を改名埌の名前に修正
2 BudouX にタむ語モデル thModel がある export されおいるのは jaModel / zhHansModel / zhHantModel のみタむ語は loadDefaultThaiParser 経由 PhraseLocale から 'th' を削陀
3 ky の HTTPError から response.clone().text() でボディを読める ky v2 は HTTPError 生成時にボディを消費枈み。しかも error に付く response はフックが芋たものず別むンスタンスなので WeakMap でも远えない afterResponse フックをリク゚スト単䜍で枡し、クロヌゞャに退避する captureErrorBody() を実装§6.4 の実装詳现
4 ky v2 のフック匕数は (request, options, response) 単䞀オブゞェクト { request, options, response, retryCount } 実装で察応
5 throwHttpErrors: false にすればボディを読めおリトラむも効く ky v2 では リトラむも無効化される実枬詊行 1 回のみ 採らず、#3 の方匏に
6 GlobalFonts.registerFromPath は boolean を返す FontKey | null を返す Boolean() で正芏化
7 Canvas#encode は党圢匏で (format, quality) AVIF のみ (format, AvifConfig) encode.ts で分岐
8 2 ゚ントリなら独立したバンドルになる tsdown が共有チャンクdist/source-*.{cjs,mjs}を生成する 実枬で canvas / budoux は含たれず tree shaking が効いおいた。check-build.js を「゚ントリから到達可胜な党チャンクを蟿る」「実際に require しお require.cache を芋る」怜査に匷化§5.8
9 グラデヌション既定は startRatio 0.30 / endRatio 0.62 アバタヌ右端0.5Wでのアルファが 0.88 皋床にしかならず、タむル境界が瞊線ずしお芋える特に light 既定を 0.22 / 0.5 に倉曎し、アバタヌ右端でちょうどアルファ 1 に到達させた
10 フォヌルバックアバタヌのむニシャルは短蟺の 0.4 実出力で巚倧すぎた 0.22 に倉曎
11 fonts.ensureDefaults() / registerFromURL() を registry に眮く registry ↔ autoload が埪環する src/font/index.ts で合成しお公開§8.3 の API 圢状は蚭蚈どおり
12 test/{unit,integration}/ に配眮 ベヌステンプレヌト準拠で src/**/*.test.ts に䜵眮 §4.3 のずおり実装

蚭蚈どおりに機胜したこずの確認

  • makeitaquote/api を require() しおも node_modules から䜕もロヌドされないky はバンドル枈み、canvas は到達䞍胜。check-build.js の 46 怜査で担保。
  • フォント自動取埗が実際に動䜜し、蚭定れロで日本語が描画できるこずを実出力で確認。
  • BudouX による文節改行ず犁則凊理が実出力で機胜。 が行頭に来ない、文節境界で折れる。
  • å…š 174 テスト・型怜査・Biome・ビルド怜査がグリヌン。

18. 補足: 参考にした䞀次情報

すべお 2026-07-31 時点で確認。

  • 珟行 index.js の generate / generateBeta 実装゚ンドポむントの機胜差、§2.1
  • examples/miq-with-discordjs.md / examples/beta-with-discordjs.mdURL 返信 vs 添付ずいう䜿い分けの裏づけ
  • oto-lab/npm-biome-ts の党構成ファむルpackage.json / biome.json / tsconfig.json / tsdown.config.ts / vitest.config.ts / .gitattributes / .gitignore / .github/workflows/{ci,release}.yml / scripts/setup.js
  • @napi-rs/canvas@1.0.3 の optionalDependenciesプリビルド察象プラットフォヌム
  • node-canvas-with-twemoji-and-discord-emoji@1.2.2 の src/drawTextWithTwemoji.js / src/utils/splitEntitiesFromText.js / src/utils/loadTwemojiImageByUrl.jsセグメント化・絵文字配眮・キャッシュのアルゎリズム、canvas ぞの盎接䟝存、maxWidth の実装
  • @miq4d/canvas-with-discord-content@2.1.2 の package.json䟝存バヌゞョン
  • kuromoji@0.1.2 / kuroshiro@1.2.0 の time.modified ず dist.unpackedSize41.3 MB、2022 幎で曎新停止
  • budoux@0.8.4 の README ず dist.unpackedSizeParser + jaModel の同期 API
  • ky@2.0.2 の type / exportsESM-only
  • typescript の dist-tagslatest: 7.0.2、6 系の最新は 6.0.3
  • tsdown@0.22.14 / vitest@4.1.10 / @biomejs/biome@2.5.6 の最新版
  • jsDelivr 䞊の Noto フォントの実圚・サむズ確認curl -INotoSansJP 9,589,900 bytes / NotoSans 2,049,096 bytes、static 版は 404
  • Brooooooklyn/canvas#1006GlobalFonts.register の Buffer 参照問題
  • tsdown: Migrate from tsup蚭定項目の察応。ただし実際の DepsConfig は §14-1 のずおり改名されおいる
  • tsdown@0.22.14 の dist/types-*.d.mts にある DepsConfigneverBundle / alwaysBundle
  • ky@2.0.2 のフック匕数ず゚ラヌ時のレスポンス消費挙動実枬、§14-3〜5

19. v12: Voids API クラむアントの分離

19.1 決定

makeitaquote/api サブパスを 削陀し、Voids API クラむアントを別リポゞトリ・別パッケヌゞ @makeitaquote/voids に切り出す。makeitaquote はロヌカル描画 miq CLIだけを持぀パッケヌゞになる。

require('makeitaquote')          →  ロヌカル画像生成唯䞀の゚ントリ
require('@makeitaquote/voids')   →  Voids API クラむアント別パッケヌゞ

§4.1 で「サブパス名は voids ではなく api」ずした刀断は、将来 Voids 以倖の API 実装を足すずいう前提に立っおいた。3 幎運甚しおその実装は 1 ぀も増えず、前提が成立しなかった。名前の抜象床だけが残り、䞭身は最初から最埌たで Voids 専甚だった。

19.2 分離する理由

# 理由 内容
1 共有しおいたものが少ない src/api/* が本䜓ず共有しおいたのは入力正芏化core/quote・MessageLike 系の型・http/client だけ。描画・フォント・絵文字・テヌマ・出力ずは䞀切の接点がない
2 壊れ方が違う ロヌカル偎はネむティブバむナリの有無で壊れ、API 偎は他人のサヌバの死掻で壊れる。同居させるず Issue が毎回「どちらの゚ントリか」の切り分けから始たる§5.12 の Issue テンプレヌトに実際にその蚭問があった
3 リリヌスが連動しおしたう 描画偎の修正を出すたび、無関係な HTTP クラむアントを再 publish しおいた。逆に API 偎の URL 倉曎ひず぀で本䜓の版が䞊がる
4 運甚䞻䜓が違う Voids API は本パッケヌゞの開発者の運甚物ではない。同じパッケヌゞに同梱するず、その責任分界が README の泚蚘でしか衚珟できない
5 ビルド境界の維持コスト 「makeitaquote/api からネむティブスタックぞ到達しない」保蚌のために check-build.js の怜査 4・5 ず RENDERING_DEPS を維持しおいた。パッケヌゞを分ければ、この䞍倉条件は「そもそも䟝存しおいない」ずいう自明な事実になる

理由 5 が実質的に䞀番倧きい。§17 の「蚭蚈どおりに機胜したこずの確認」に挙がっおいる makeitaquote/api の分離保蚌は、パッケヌゞが 1 ぀であるこずの垳尻を合わせるための仕掛けであり、分離すれば怜査ごず䞍芁になる。

19.3 本リポゞトリから削陀するもの

察象 内容
src/api/ client.ts / client.test.ts / endpoints.ts / types.ts / index.ts の 5 ファむル
examples/api.ts サブパスの䜿甚䟋
package.json exports['./api']、keywords の "api"、description の "or via an external API"
tsdown.config.ts entry から src/api/index.ts
scripts/check-build.js RENDERING_DEPS、怜査 4require 時のロヌド内容、怜査 5到達可胜チャンクの静的怜査、怜査 1 の api/* パス。以降の番号を繰り䞊げ
src/http/client.ts post() ず RequestOptions.json。Voids クラむアント専甚で、他の呌び出し元は get / head / getBuffer しか䜿っおいない

src/http/client.ts 自䜓は残す。絵文字・フォント・アバタヌ・miq env の 4 系統が䜿っおいる。

19.4 残すもの

  • src/theme/presets.ts ず src/core/sizing.test.ts にある api.voids.top ぞの蚀及。これはレむアりト定数の出自を蚘録したコメントで、「その倀を実際に API を叩いお確認した」ずいう事実は分離埌も倉わらない
  • DESIGN.md の §2.1 / §4 / §6.4 など、v9 圓時の蚭蚈蚘録。廃止の泚蚘を付けたうえで残す

19.5 @makeitaquote/voids 偎

移蚭先では名前ず挙動を倉えない。VoidsMiQおよび MiQ 別名・VoidsOptions / VoidsPayload / VoidsQuoteData・VoidsApiError・endpoints / DEFAULT_BASE_URL はすべお同じ。利甚者の差分が import 文 1 行で枈むようにするため。

本䜓ず共有しおいたコヌド入力正芏化・MessageLike 系の型・http/clientは、makeitaquote に䟝存するのではなく移蚭先ぞコピヌする。䟝存させるず「API だけ䜿いたい人にネむティブバむナリを匕かせない」ずいう §4.2 の目的が振り出しに戻るうえ、正芏化ロゞックは 100 行に満たない。

構築手順は .private/stack/*.md に眮く。

19.6 利甚者ぞの圱響

䜿い方 v12 での圱響
import { MiQ } from 'makeitaquote' なし
miq CLI なし
import { VoidsMiQ } from 'makeitaquote/api' npm i @makeitaquote/voids しお import 元を差し替える。それ以倖の倉曎なし
VoidsApiError を root から catch root からは元々 export しおいないため圱響なし

砎壊的倉曎はサブパスの削陀 1 点のみ。MIGRATING.md の「From v11」に蚘茉する。


20. v12: markdown オプション — 陀去ではなく描画も遞べるように

20.1 決定

stripDiscordMarkdown() / stripMfm() / stripMarkdown() は元々「陀去しお plain text にする」機胜しか持たなかった§2.2。v12 では、陀去ではなく倪字・斜䜓・䞋線・取り消し線を実際に画像ぞ描画する遞択肢を远加する。

新蚭する MarkdownMode は方蚀を遞ぶ 1 ぀のオプションで、setFromMessage/setFromNote/setFromTweet/setText/setFromObject の各呌び出しず、new MiQ({ markdown }) のパッケヌゞ党䜓デフォルトに眮く:

type MarkdownMode = true | 'discord' | 'twitter' | 'misskey' | 'raw' | false

false陀去既存の stripX() ず同じ挙動・同じタむミング、'raw'無加工、true暙準 CommonMark+GFM 描画、'discord'/'misskey'/'twitter'それぞれの方蚀の描画。デフォルトは各呌び出しの歎史的デフォルトを完党に保぀Discord/Tweet/汎甚は 'raw'、Note は falseため、オプトむンしない限り既存ナヌザヌの芋た目は䞀切倉わらない。文字サむズが倉わる系芋出し、MFM の small/centerは今回保留 — 構造だけ保持したプレヌンテキストずしお描画する。

20.2 内郚蚭蚈: stripX ず描画甚パヌサヌの統合

陀去甚ず描画甚を方蚀ごずに別々の関数ずしお持぀ず、朚を歩くロゞックが 2 重化する。そこで各方蚀に぀き垞に StyledRun[]{ value, style? } の配列を返すパヌサヌを 1 ぀だけ持ちparseMarkdown / parseDiscordMarkdown / parseMfm / parseTwitterText。いずれも非公開 — 公開 API は既存の stripX() 3 関数のみ据え眮き、陀去はその出力からスタむルを捚おお文字列に朰すだけの薄い埌凊理plainTextOf()ずしお衚珟した。stripMarkdown() 等の公開シグネチャ・挙動は倉えおいない。

discomd は strip()/toHTML() の他に䜍眮ベヌスの parse(): Token[] を公開しおおり、これをそのたたスタむル抜出に転甚できたDiscord 方蚀が䞀番簡単だった理由。mfm-js は <b>/<i>/<s> タグを元々 bold/italic/strike ノヌドずしおパヌスするため、markdown-it 偎のような自前の HTML タグ察応は䞍芁だった。

20.3 生 HTML タグ察応ず䟝存の遞定

暙準 Markdown モヌドtrueでは <u>CommonMark に察応構文が無い・<b>/<strong>・<i>/<em>・<s>/<del> も装食ずしお解釈する。markdown-it は生 HTML を html_inline ずいうフラットなトヌクンずしおしか出さないstrong_open/strong_close のように朚ずしお入れ子にしないため、察になる開始・終了タグを自前でペアリングする小さな倉換pairHtmlTags、text/markdown.tsを远加した。

怜蚎したが䞍採甚にしたもの:

  • turndownHTML→Markdown 倉換: 今回の入力は「Markdown 本文にずころどころ生 HTML タグが混ざったもの」であり、turndown は「HTML 党䜓を Markdown に倉換する」ためのツヌルなので圢が合わない。
  • rehype-rawunified/remark-rehype 系: markdown 内の生 HTML を parse5 で正匏に HTML5 仕様通りパヌスし盎す、最も「正しい」遞択肢。ただし公匏ドキュメント自身が「パフォヌマンス/バンドルサむズ的に重い凊理」ず明蚀しおおり、unified+remark-parse+remark-rehype+rehype-raw の最䜎 4 パッケヌゞが必芁になる。<u>/<b>/<i>/<s> の 6 タグの単玔な開閉ペアリングのためだけに導入するには䞍釣り合いに重い。

結論ずしお、新芏䟝存を远加しない自前の小さなペアリング凊理を採甚した§2.2 で displus を切った「陀去のためだけの薄い䟝存を増やしたくない」ずいう刀断ず同じ理由。

20.4 Twitter モヌド: 実䜓は Unicode 数孊英数蚘号

XTwitterの投皿 API・FxTwitter のいずれも text はプレヌンな文字列のみを返し、倪字/斜䜓を衚す構造化フィヌルドは存圚しないWeb 怜玢で確認。「Twitter の倪字」は実際には Unicode の数孊英数蚘号ブロックU+1D400–U+1D7FFをクラむアント偎たたはツヌルが文字単䜍で眮換しおいるだけで、構造化されたマヌクではない。そのため 'twitter' モヌドは独自のパヌサヌtext/twitterText.tsずしおこれらのコヌドポむント範囲を怜出し、ASCII に正芏化し぀぀スタむルを付䞎する。範囲は連続したオフセット蚈算数個で実装しおおり、ルックアップテヌブルは持たない。

20.5 スコヌプ倖にしたもの

  • MiQConversation: 䟝頌・実䟋が単䞀の quote (MiQ) に぀いおのものだったため、今回は察象倖。conversationPipeline.ts ぞの同圢の远加実装は将来の課題ずしお残す。
  • 文字サむズが倉わる構文芋出し、MFM の small/center: スタむル倪字/斜䜓/䞋線/取り消し線ずサむズ倉曎は別皮の倉曎であり、埌者は保留。
  • 公開 API ずしおの parseX()/StyledRun: stripX() 3 関数は据え眮きで公開のたただが、新蚭した parseMarkdown/parseDiscordMarkdown/parseMfm/parseTwitterText ず StyledRun 型は非公開のたた。既存の segmentText() 等 text/ 配䞋のヘルパヌも同様に非公開であり、その慣䟋に合わせた。

21. v12: watermark の画像察応ず MiQChainMiQConversation の眮き換え

21.1 決定

2぀の独立した远加:

  1. WatermarkTheme はテキストのみだったが、MiQ#setWatermark() が string | URL | Buffer | Uint8Array を受け取れるようにし、ロゎなどの画像を watermark 䜍眮に描画できるようにした。
  2. MiQConversation§20.5 で「今回はスコヌプ倖」ずした耇数メッセヌゞのチャットログ描画を即座に削陀し、代わりに新蚭 MiQChain を远加した。Discord・X・Misskey にはリプラむ元・匕甚元があり、「元投皿返信」を1枚の画像にしたいずいう芁望に察しお、MiQConversation の延長チャットログの行数を増やすではなく、既存の暪向き MiQ 画像を2枚そのたた䞊䞋にくっ぀けるずいう別の圢で応えた。

21.2 watermark の内郚衚珟

QuoteData.watermark: string ず QuoteData.watermarkImage: AvatarSource | null の2フィヌルドに分け、互いに排他ずしたavatar ず違い、watermark では文字列は垞にテキストずしお扱う — URL/Buffer/Uint8Array だけが画像。normalizeWatermarkInput()core/quote.tsが公開の union 入力をこの2フィヌルドぞ振り分ける。

描画偎は theme.watermark.size を、テキストではフォントサむズずしお、画像では高さずしお流甚するテキスト⇔画像を差し替えおも芋た目のスケヌル感が倉わらないようにするため。color/font/weight は画像には適甚されない。

21.3 MiQChain の蚭蚈

API は「既に個別に組み立おた2぀の MiQ むンスタンスを枡す」圢 new MiQChain(top, bottom, options?) にしたナヌザヌ確認枈み。理由: 各 MiQ は既に theme・bold・color・markdown などを持っおいるので、MiQChain 自身がそれらのオプション䜓系を再発明する必芁がない。MiQChain が扱うのは「どちらの半分の avatar.position をどちらの蟺にするか」ずいう、2枚を組み合わせたずきにだけ意味を持぀関心事だけに絞ったChainOptions.flip/topFlip/bottomFlip。

デフォルトのペアリングは 侊='right'、䞋='left'README「Flipping sides」でいう flip/unflip。flip: true で反転、topFlip/bottomFlip で片偎だけ明瀺的に䞊曞きできる。

実装䞊の萜ずし穎: 圓初 top.clone().setTheme({ avatar: { position } }) ずしおいたが、MiQ#setTheme() はむンスタンスの珟圚のテヌマにマヌゞするのではなく、defineTheme() で毎回れロから組み立お盎すextends パレット + 枡した入力のみから。そのため郚分オブゞェクトを枡すず、呌び出し元が蚭定しおいた layout: 'new' を含む他のテヌマ蚭定が黙っお 'dark'/'side' のデフォルトに巻き戻っおしたうバグがあったlayout: 'new' 犁止のテストを曞いお実際に怜出。修正: getTheme()structuredClone 枈みで完党なテヌマのスナップショットを取り、avatar.position だけをそのオブゞェクト䞊で曞き換えおから setTheme() に䞞ごず枡す — defineTheme() は枡された完党なテヌマの党フィヌルドlayout を含むで䞊曞きするため、実質的に「1フィヌルドだけ倉えたコピヌ」になる。

layout: 'new' は巊右のアバタヌボックスずいう抂念自䜓が無いため、今回は非察応ずし ValidationError を投げる。2枚の幅が䞀臎しない堎合も、暗黙のリサむズ/匕き䌞ばしはせず ValidationError で止める。

21.4 MiQConversation 削陀の範囲

src/core/MiQConversation.ts・src/core/conversation.ts・src/render/conversationPipeline.tsそれぞれの .test.ts 含むを削陀。ConversationMessage/ConversationOptions/ConversationThemeName 型ず index.ts からの export も削陀。CLI・examples に参照は無かった。MIGRATING.md の「From v11」に砎壊的倉曎ずしお蚘茉。