跳到正文
DEV Community· Yan Wang·· 4 小時前AI 評分30

如何按 EPUB spine 而非 ZIP 檔名規劃閲讀順序

Plan EPUB Reading Order From the Spine, Not ZIP Filenames

AI 導讀

這個 JavaScript 範例按 EPUB 的 spine 次序規劃閲讀順序,而非依 ZIP 條目或檔名字典序排列,並保留輔助內容及其位置。測試涵蓋四項有效斷言、14 個無效案例及兩項負面對照,退出碼為零。這個規劃器不會開啟 EPUB、解析 XML 或驗證壓縮檔;輸出亦不代表資源可以安全讀取或可靠轉換。

正文

A ZIP listing can look like a reading plan: Text/02.xhtml, then Text/10.xhtml, then a notes file. It’s a tempting shortcut that answers the wrong question. Archive entry order and lexical filename order describe storage; they do not establish the EPUB’s intended reading order.

The EPUB package has a manifest of resources and a spine that references manifest items in order. The EPUB 3.3 spine specification describes that relationship. A navigation table of contents is separate, and a spine item is not necessarily a human chapter. A conversion pipeline that sorts archive names before reading the spine can produce a plausible-looking document in the wrong order.

The small planner below operates on already normalized JavaScript metadata. It is standalone example code, not LensUp production code. Its job is to preserve the order of spine references while checking a deliberately narrow contract. It does not open an EPUB, parse XML, or validate an archive.

Contract before implementation

Both inputs must be nonempty, dense arrays of at most 1,000 entries. The limit is a deliberately chosen budget for this example, not an EPUB rule or a LensUp product limit. A caller can choose a different budget after assessing the resources in its own environment.

Each manifest entry needs a unique, nonblank string id and a nonblank string href. Each spine reference needs a nonblank idref resolving to a manifest ID, and each ID may be referenced at most once. A linear value may be absent, yes, or no. An absent value defaults to primary reading order; no marks auxiliary content. The planner retains auxiliary entries and their position, with an explicit Boolean flag. It rejects an all-auxiliary plan under this example’s contract rather than silently returning a plan with no primary item.

Strings are checked for blankness but are not trimmed or rewritten. IDs are matched exactly. Hrefs remain opaque text: their presence is checked, but they must not be treated as trusted filesystem or network paths. This function does not validate URLs, verify resource presence, or establish that an EPUB is safe to process.

Put this block followed by the test block in one .mjs file, then run that file with Node. The export is valid in the same module as the assertions.

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);

What the assertions establish

The fixture deliberately puts Text/10.xhtml before Text/02.xhtml in the spine. The first assertion checks the resulting ID order; the second checks the default, auxiliary, and explicit primary flags; the third checks retained source positions. Reversing the manifest then demonstrates that manifest order does not change this plan. The two blocks contain only those four valid assertions, not the separate invalid-case checks.

An actual Node run of the normalized-metadata check suite reported four valid assertions, 14 invalid cases, and two negative controls with exit code zero. Those results concern the pure planner, not a rendered or converted book. In particular, this example does not implement fallback chains, inspect scripts or media-type support, check assets or DRM, resolve crosslinks, paginate, perform file I/O, or defend against archive bombs. Those concerns need their own processing and validation stages.

Treat the planner’s output as an ordered set of references for a later stage, not as proof that those resources can be safely read or faithfully converted. Retaining the auxiliary flag lets that later stage make an explicit policy decision without confusing linear="no" with a deletion instruction.

This article was substantially drafted by GPT-6.1 Terra for Yan Wang / LensUp. A Codex agent inspected formal source and live page text on 2026-10-29 and ran the pure normalized-metadata checks described above. No new EPUB conversion, browser or reader rendering, device test, or human-owner test was performed.

來源:DEV Community · dev.to