解決できないID参照、重複したmanifest ID、同じIDへの重複参照、全項目が補助コンテンツ、そしてファイル名の辞書順を読書順と誤認すること。いずれも、変換結果が一見もっともらしく見える段階で見逃したくない問題です。
EPUBのspineはmanifest項目への参照を順序付きで並べます。ZIPの格納順やファイル名順ではありません。詳しくはEPUB 3.3のspine仕様を参照してください。目次も別の構造で、各項目が人間の考える「1章」と一致するとは限りません。
この例の入力契約
以下は、すでに正規化されたJavaScriptメタデータだけを受け取る独立した例です。LensUpの製品コードではなく、XMLパーサーやEPUBバリデーターでもありません。manifestとspineは、空でない穴のない配列とし、それぞれ最大1,000項目に制限します。1,000は処理量を区切るための例示的な予算で、EPUBの規則や製品の上限ではありません。
manifestの各項目には、一意で空白だけではない文字列IDと、空白だけではないhrefが必要です。spineのidrefは既存IDに完全一致し、同じIDを二度参照できません。linearは省略、yes、noだけを受け付けます。省略時はyes相当です。
linear="no"は補助コンテンツを示します。削除指示として扱わず、元の位置とlinear: falseを保持します。この例では、主たる項目が一つもない入力はエラーにします。文字列の空白判定は行いますが、値の正規化や書き換えはしません。
hrefは不透明な文字列として保持するだけです。存在確認、URL検証、安全なファイルパスやネットワーク先としての信頼は、この関数の責務ではありません。
次の二つのブロックを順番に一つの.mjsファイルへ入れ、Nodeで実行できます。
export function planSpine(manifest, spine) {
const bounded = value => Array.isArray(value) &&
value.length > 0 && value.length <= 1000 &&
Array.from({ length: value.length }, (_, i) => i in value).every(Boolean);
const text = value => typeof value === "string" && value.trim().length > 0;
if (!bounded(manifest) || !bounded(spine)) throw new TypeError("bounded lists required");
const byId = new Map();
for (const item of manifest) {
if (!item || !text(item.id) || !text(item.href) || byId.has(item.id)) {
throw new TypeError("unique manifest ids and nonempty hrefs required");
}
byId.set(item.id, item.href);
}
const seen = new Set();
const plan = spine.map((ref, sourceIndex) => {
if (!ref || !text(ref.idref) || !byId.has(ref.idref) || seen.has(ref.idref) ||
![undefined, "yes", "no"].includes(ref.linear)) {
throw new TypeError("invalid spine reference");
}
seen.add(ref.idref);
return { id: ref.idref, href: byId.get(ref.idref),
linear: ref.linear !== "no", sourceIndex };
});
if (!plan.some(item => item.linear)) throw new TypeError("a primary item is required");
return plan;
}
import assert from "node:assert/strict";
const manifest = [
{ id: "later", href: "Text/02.xhtml" },
{ id: "start", href: "Text/10.xhtml" },
{ id: "notes", href: "Text/notes.xhtml" },
];
const spine = [{ idref: "start" }, { idref: "notes", linear: "no" },
{ idref: "later", linear: "yes" }];
const plan = planSpine(manifest, spine);
assert.deepEqual(plan.map(item => item.id), ["start", "notes", "later"]);
assert.deepEqual(plan.map(item => item.linear), [true, false, true]);
assert.deepEqual(plan.map(item => item.sourceIndex), [0, 1, 2]);
assert.deepEqual(planSpine([...manifest].reverse(), spine), plan);
エラーから元のpackageへ戻る手順
次の表では、manifestを [{ id: "a", href: "Text/a.xhtml" }] とした最小入力を使います。メッセージは同じ関数をNodeで実行して確認したものです。同じエラー名でも、参照先がない場合と重複参照では直す場所が違います。
| 問題 | spineの最小入力 | 実際の例外とメッセージ | 確認と修正 |
|---|---|---|---|
| 参照先IDがない | [{ idref: "missing" }] |
TypeError: invalid spine reference |
元のpackageのmanifestとidrefを照合し、抽出・正規化でIDを落としていないか調べます。架空の項目を補わず、参照先の実在も後続工程で確認します。 |
| 同じIDを二度参照 | [{ idref: "a" }, { idref: "a" }] |
TypeError: invalid spine reference |
元のspineと比較して、正規化のループが同じ参照を二度追加していないか確認します。原因を特定してから重複を直し、残りの順序を変えません。 |
| 全項目が補助扱い | [{ idref: "a", linear: "no" }] |
TypeError: a primary item is required |
元のlinear属性と省略時の扱いを確認します。通すためだけにyesへ書き換えず、主たる内容を正しく抽出できるまで処理を止めます。 |
まずエラーをこの小さな入力で再現し、次に元のpackageと正規化後の配列を比べます。原因を直したら、spine順とlinearフラグを再確認します。hrefの存在と安全性、実際の本文の読み込みは別工程です。この切り分けなら、ファイル名で並べ替えて見かけだけ整えたり、補助項目を捨ててエラーを隠したりせずに調べられます。
結果の読み方と限界
例ではText/10.xhtmlがText/02.xhtmlより先です。四つのassertは、spine順、各linearフラグ、元の位置、manifestを逆順にしても計画が変わらないことを確かめます。提示した二つのブロック自体には、不正入力のテストは含まれていません。
別途実行された純粋な正規化メタデータのチェックは、正常系のassert 4件、不正ケース14件、ネガティブコントロール2件を報告し、終了コードは0でした。これは実際の本の解析や表示が成功した証拠ではありません。
フォールバックチェーン、スクリプト、mediaTypeの対応、アセットの存在、DRM、ページ分割、相互リンク、ファイル入出力、アーカイブ爆弾は対象外です。後続処理では、保持された補助フラグを使って扱いを明示的に決め、それぞれの検証を別に行ってください。
本稿はYan Wang / LensUp向けにGPT-6.1 Terraが大部分を起草しました。Codexエージェントが2026-10-29に正式ソースと公開ページ本文を確認し、上記の純粋なメタデータ検査を実行しました。新たなEPUB変換、ブラウザーやリーダーでの表示、端末または人間の所有者によるテストは行っていません。