", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ runs: [{ text: "x = 1" }],
+ codeLanguage: "js",
+ },
+ ]);
+ expect(xml).toContain('x = 1
');
+ });
+
+ it("recognises a foreign producer's own via the legacy monospace-plus-newline heuristic alone, with neither preformatted nor codeLanguage set", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ runs: [
+ { text: "line one\nline two", fontFamily: MONOSPACE_FONT_FAMILY },
+ ],
+ },
+ ]);
+ expect(xml).toContain("");
+ });
+
+ it("never treats a plain paragraph as preformatted just because it happens to have one monospace run without a newline", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ runs: [{ text: "mono", fontFamily: "Courier New" }],
+ },
+ ]);
+ expect(xml).not.toContain("");
+ expect(xml).toContain("");
+ });
+
+ it("never treats a plain paragraph as preformatted just because it has a newline in a non-monospace run", () => {
+ const xml = write([
+ { kind: "paragraph", runs: [{ text: "line one\nline two" }] },
+ ]);
+ expect(xml).not.toContain("
");
+ });
+
+ it("never treats a plain paragraph as preformatted when the monospace-plus-newline run is not the only run", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ runs: [
+ { text: "line one\nline two", fontFamily: "Courier New" },
+ { text: " and more" },
+ ],
+ },
+ ]);
+ expect(xml).not.toContain("");
+ });
+
+ it("drops a pageBreak block entirely, with no element and no diagnostic", () => {
+ const { xml, diagnostics } = writeWithSink(
+ [{ kind: "pageBreak" }],
+ () => undefined,
+ );
+ expect(xml).toBe(
+ '',
+ );
+ expect(diagnostics).toHaveLength(0);
+ });
+
+ it("never inserts a spurious empty text node between two
elements for a run's own consecutive embedded newlines", () => {
+ const body = writeBody([{ kind: "paragraph", runs: [{ text: "a\n\nb" }] }]);
+ const [p] = body.children;
+ if (p?.type !== "element") {
+ throw new Error("expected a element");
+ }
+ expect(
+ p.children.map((c) =>
+ c.type === "element" ? c.tag : c.type === "text" ? c.value : c.type,
+ ),
+ ).toEqual(["a", "br", "br", "b"]);
+ });
+
+ it("writes a wholly empty, unformatted run as exactly one empty text node, never zero and never placeholder text", () => {
+ const body = writeBody([{ kind: "paragraph", runs: [{ text: "" }] }]);
+ const [p] = body.children;
+ if (p?.type !== "element") {
+ throw new Error("expected a
element");
+ }
+ expect(p.children).toHaveLength(1);
+ expect(p.children[0]).toEqual({ type: "text", value: "" });
+ });
+
+ it("never emits a text node for an empty-text run inside a
, unlike a non-empty sibling run", () => {
+ const body = writeBody([
+ {
+ kind: "paragraph",
+ preformatted: true,
+ runs: [{ text: "" }, { text: "b" }],
+ },
+ ]);
+ const [pre] = body.children;
+ if (pre?.type !== "element") {
+ throw new Error("expected a element");
+ }
+ const [code] = pre.children;
+ if (code?.type !== "element") {
+ throw new Error("expected a element");
+ }
+ expect(code.children).toEqual([{ type: "text", value: "b" }]);
+ });
+
+ it("joins a multi-run footnote range's own text verbatim inside a , with no separator between runs", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ preformatted: true,
+ runs: [{ text: "a" }, { text: "b" }],
+ constructs: [
+ {
+ descriptor: { kind: "anchor", anchorType: "footnote", name: "fn1" },
+ startRun: 0,
+ endRun: 2,
+ },
+ ],
+ },
+ ]);
+ expect(xml).toContain('epub:type="noteref" href="#fn1">ab');
+ });
+
+ it("drops an embeddedObject block with the exact ELEMENT_UNMAPPED message naming the loss", () => {
+ const { diagnostics } = writeWithSink(
+ [
+ {
+ kind: "embeddedObject",
+ objectKind: "wordprocessing",
+ frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 },
+ document: { kind: "wordprocessing", metadata: {}, sections: [] },
+ },
+ ],
+ () => undefined,
+ );
+ expect(diagnostics).toContainEqual(
+ expect.objectContaining({
+ code: EpubDiagnosticCodes.ELEMENT_UNMAPPED,
+ message:
+ "an embedded object has no XHTML representation in this package's writer and was dropped",
+ }),
+ );
+ });
+
it("writes and re-reads a code block with a language", () => {
const blocks: ContentBlock[] = [
{
@@ -507,6 +744,51 @@ describe("writeXhtmlBody", () => {
expect(roundTrip(blocks)).toEqual(blocks);
});
+ it("never reports CONSTRUCT_UNREPRESENTED for a single, cleanly-written point-anchor footnote reference", () => {
+ const blocks: ContentBlock[] = [
+ {
+ kind: "paragraph",
+ runs: [],
+ constructs: [
+ {
+ descriptor: { kind: "anchor", anchorType: "footnote", name: "fn1" },
+ startRun: 0,
+ endRun: 0,
+ },
+ ],
+ },
+ ];
+ const { diagnostics } = writeWithSink(blocks, () => undefined);
+ expect(
+ diagnostics.some(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ ),
+ ).toBe(false);
+ });
+
+ it("never reports CONSTRUCT_UNREPRESENTED for a single, cleanly-written range footnote reference", () => {
+ const blocks: ContentBlock[] = [
+ {
+ kind: "paragraph",
+ runs: [{ text: "See" }, { text: "1" }],
+ constructs: [
+ {
+ descriptor: { kind: "anchor", anchorType: "footnote", name: "fn1" },
+ startRun: 1,
+ endRun: 2,
+ },
+ ],
+ },
+ ];
+ const { xml, diagnostics } = writeWithSink(blocks, () => undefined);
+ expect(
+ diagnostics.some(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ ),
+ ).toBe(false);
+ expect(xml).toContain('href="#fn1"');
+ });
+
// The identical writer gap also orphaned a table caption whose only content is a footnote reference: readTableCaption's own construct check (src/xhtml/read.ts) recovers `` as this same runs: [], construct-only shape, read immediately before the table -- the writer's bug was in the shared run-range walk, not anything caption-specific, so this proves the fix holds for that read shape too rather than only the bare-segment one above.
it("writes and re-reads a bare footnote reference construct in a paragraph sitting immediately before a table, matching a caption's own read shape", () => {
const blocks: ContentBlock[] = [
@@ -620,11 +902,13 @@ describe("writeXhtmlBody", () => {
},
];
const { xml, diagnostics } = writeWithSink(blocks, () => undefined);
- expect(
- diagnostics.some(
- (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
- ),
- ).toBe(true);
+ const unrepresented = diagnostics.find(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ );
+ expect(unrepresented?.message).toContain("footnote reference");
+ expect(unrepresented?.message).toContain(
+ "cannot be represented as its own element",
+ );
// No run text is lost: the winning extent (fn1) wraps the first two runs, and the third run -- past fn1's own endRun -- is still written on its own.
expect(xml).toContain('href="#fn1"');
expect(xml).not.toContain('href="#fn2"');
@@ -633,6 +917,85 @@ describe("writeXhtmlBody", () => {
expect(xml).toContain(".");
});
+ it("names an unrepresented internal-link extent as an 'internal link', not a footnote reference, in its CONSTRUCT_UNREPRESENTED message", () => {
+ const blocks: ContentBlock[] = [
+ {
+ kind: "paragraph",
+ runs: [{ text: "See" }, { text: "this" }],
+ constructs: [
+ {
+ descriptor: {
+ kind: "link",
+ target: { kind: "internal", anchor: "bm1" },
+ },
+ startRun: 0,
+ endRun: 2,
+ },
+ {
+ descriptor: {
+ kind: "link",
+ target: { kind: "internal", anchor: "bm2" },
+ },
+ startRun: 0,
+ endRun: 1,
+ },
+ ],
+ },
+ ];
+ const { diagnostics } = writeWithSink(blocks, () => undefined);
+ const unrepresented = diagnostics.find(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ );
+ expect(unrepresented?.message).toContain("internal link");
+ expect(unrepresented?.message).not.toContain("footnote reference");
+ });
+
+ it("writes a clean internal-link construct extent as its own targeting the resolved anchor", () => {
+ const xml = write([
+ {
+ kind: "paragraph",
+ runs: [{ text: "See" }, { text: "this" }],
+ constructs: [
+ {
+ descriptor: {
+ kind: "link",
+ target: { kind: "internal", anchor: "bm1" },
+ },
+ startRun: 1,
+ endRun: 2,
+ },
+ ],
+ },
+ ]);
+ expect(xml).toContain('this');
+ });
+
+ it("never treats a run-level link construct with an external target as a representable reference extent", () => {
+ // isReferenceExtent recognises only an internal link target here -- an external one is the run-level ContentRun.hyperlink field's own established territory (ExaDev/document-schema.js#22), so a construct-level extent naming one is neither wrapped in its own nor reported as an unhandled anchor (reportUnhandledAnchorExtents only covers descriptor.kind === "anchor").
+ const { xml, diagnostics } = writeWithSink(
+ [
+ {
+ kind: "paragraph",
+ runs: [{ text: "See" }, { text: "this" }],
+ constructs: [
+ {
+ descriptor: {
+ kind: "link",
+ target: { kind: "external", uri: "https://example.com" },
+ },
+ startRun: 1,
+ endRun: 2,
+ },
+ ],
+ },
+ ],
+ () => undefined,
+ );
+ expect(xml).not.toContain(" {
const blocks: ContentBlock[] = [
@@ -694,11 +1057,16 @@ describe("writeXhtmlBody", () => {
},
];
const { xml, diagnostics } = writeWithSink(blocks, () => undefined);
- expect(
- diagnostics.some(
- (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
- ),
- ).toBe(true);
+ const unrepresented = diagnostics.find(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ );
+ // A point anchor's own message names the boundary it marks, distinctly from a range extent's "cannot be represented as its own element" wording.
+ expect(unrepresented?.message).toContain(
+ "marking the boundary before run 1",
+ );
+ expect(unrepresented?.message).toContain(
+ "a point anchor wraps no run text of its own",
+ );
// fn1 wins and wraps every run; fn2's own anchor is not emitted, but no run text is lost -- a point anchor never wraps any of its own.
expect(xml).toContain('href="#fn1"');
expect(xml).not.toContain('href="#fn2"');
@@ -731,6 +1099,77 @@ describe("writeXhtmlBody", () => {
).toBe(true);
});
+ it("restores a bookmark target as an id attribute on its single wrapped element, replacing any id the element already carries", () => {
+ // A nested bookmark: the inner one wraps the paragraph first and stamps its own id onto it; the outer one must overwrite that id with its own name, not leave both id attributes in the array alongside it. The paragraph's own direction gives the wrapped a second, unrelated attribute the filter must leave untouched -- a plain object literal's own duplicate-key-overwrite semantics would otherwise hide a filter that removed nothing at all (an extra "id" entry collapses to the same final serialized attribute either way), so this checks the raw attributes array directly rather than the serialized XML string.
+ const blocks: ContentBlock[] = [
+ {
+ kind: "constructStart",
+ descriptor: { kind: "anchor", anchorType: "bookmark", name: "outer" },
+ },
+ {
+ kind: "constructStart",
+ descriptor: { kind: "anchor", anchorType: "bookmark", name: "inner" },
+ },
+ { kind: "paragraph", direction: "rtl", runs: [{ text: "target" }] },
+ { kind: "constructEnd" },
+ { kind: "constructEnd" },
+ ];
+ const body = writeBody(blocks);
+ const [p] = body.children;
+ if (p?.type !== "element") {
+ throw new Error("expected a
element");
+ }
+ expect(p.attributes).toEqual([
+ { name: "dir", value: "rtl" },
+ { name: "id", value: "outer" },
+ ]);
+ });
+
+ it("reports CONSTRUCT_UNREPRESENTED with the exact message when a bookmark wraps more than one written element", () => {
+ const blocks: ContentBlock[] = [
+ {
+ kind: "constructStart",
+ descriptor: { kind: "anchor", anchorType: "bookmark", name: "bm1" },
+ },
+ { kind: "paragraph", runs: [{ text: "one" }] },
+ { kind: "paragraph", runs: [{ text: "two" }] },
+ { kind: "constructEnd" },
+ ];
+ const { xml, diagnostics } = writeWithSink(blocks, () => undefined);
+ expect(xml).toContain("one");
+ expect(xml).toContain("two");
+ expect(xml).not.toContain('id="bm1"');
+ const diagnostic = diagnostics.find(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ );
+ expect(diagnostic?.message).toBe(
+ "a bookmark target ('bm1') wraps more than one written element (or none); this package's writer only restores an id attribute onto a single wrapped element, so the target's own addressability is dropped",
+ );
+ });
+
+ it.each(["endnote", "comment"] as const)(
+ "reports CONSTRUCT_UNREPRESENTED for a block-scoped '%s' anchor construct group, distinctly from a bookmark, while still writing its content",
+ (anchorType) => {
+ const blocks: ContentBlock[] = [
+ {
+ kind: "constructStart",
+ descriptor: { kind: "anchor", anchorType, name: "x1" },
+ },
+ { kind: "paragraph", runs: [{ text: "body" }] },
+ { kind: "constructEnd" },
+ ];
+ const { xml, diagnostics } = writeWithSink(blocks, () => undefined);
+ expect(xml).toContain("body");
+ expect(xml).not.toContain('id="x1"');
+ const diagnostic = diagnostics.find(
+ (d) => d.code === EpubDiagnosticCodes.CONSTRUCT_UNREPRESENTED,
+ );
+ expect(diagnostic?.message).toBe(
+ "a 'anchor' construct has no XHTML spelling in this package's writer; its extent is written, the construct itself is not",
+ );
+ },
+ );
+
// ExaDev/documents.js#1025: a run-level anchor extent whose anchorType is anything but "footnote" (a bookmark or comment range docx documents can and do carry at run scope, e.g. ooxml.js's own runRangeMarkerExtents) has no representable EPUB spelling this reader's own read side understands yet, so it is reported through the diagnostic sink rather than silently dropped with nothing in the output naming it ever happened. The run text it wraps is unaffected either way -- only the anchor's own marker goes unwritten.
it.each(["bookmark", "endnote", "comment"] as const)(
"reports CONSTRUCT_UNREPRESENTED for a run-level '%s' anchor extent, preserving the run text underneath it",
@@ -773,4 +1212,17 @@ describe("writeXhtmlBody", () => {
expect(xml).toContain('src="images/img1.png"');
expect(xml).toContain('alt="alt"');
});
+
+ it("writes an image with no altText as an empty alt attribute, never a placeholder", () => {
+ const xml = write([
+ {
+ kind: "image",
+ format: "png",
+ base64: "aGVsbG8=",
+ widthPt: 72,
+ heightPt: 72,
+ },
+ ]);
+ expect(xml).toContain('alt=""');
+ });
});
diff --git a/packages/epub-codec/src/xhtml/write.ts b/packages/epub-codec/src/xhtml/write.ts
index f694cfcb5..40222dc1b 100644
--- a/packages/epub-codec/src/xhtml/write.ts
+++ b/packages/epub-codec/src/xhtml/write.ts
@@ -77,15 +77,15 @@ function writeSectionChildren(
): XmlNode[] {
const out: XmlNode[] = [];
let index = 0;
- while (index < children.length) {
+ for (;;) {
const child = children[index];
if (child === undefined) {
break;
}
if (isListGroupNode(child)) {
- // Every consecutive run of sibling ListGroupNode entries at this position is one
/ -- decompose emits one list group per item, as flat siblings, never pre-wrapped in a container element (see document-schema.js's own decomposeSectionBlocks/openListGroup).
+ // Every consecutive run of sibling ListGroupNode entries at this position is one / -- decompose emits one list group per item, as flat siblings, never pre-wrapped in a container element (see document-schema.js's own decomposeSectionBlocks/openListGroup). No separate `end < children.length` bound is needed ahead of isListGroupNode: past the array's own end, children[end] is undefined, and isListGroupNode(undefined) is already false (its own isRecord guard rejects a non-object outright), so the loop terminates at exactly the same point either way.
let end = index;
- while (end < children.length && isListGroupNode(children[end])) {
+ while (isListGroupNode(children[end])) {
end += 1;
}
const items = children.slice(index, end) as ListGroupNode[];
@@ -99,8 +99,9 @@ function writeSectionChildren(
return out;
}
+// Never called with a ListGroupNode: writeSectionChildren's own loop above always groups a run of one or more sibling ListGroupNode entries and writes them through writeList before this function is reached at all, so its own parameter type excludes that member rather than carrying a dead exhaustiveness branch no test can ever reach.
function writeSectionChild(
- child: SectionChild,
+ child: Exclude,
context: XhtmlWriteContext,
): XmlNode[] {
if (isHeadingGroupNode(child)) {
@@ -109,10 +110,6 @@ function writeSectionChild(
...writeSectionChildren(child.children, context),
];
}
- if (isListGroupNode(child)) {
- // Reached only for a lone list group with no sibling run (writeSectionChildren's own loop always groups runs of one or more before calling this) -- kept for exhaustiveness, never actually hit.
- return [writeList([child], context)];
- }
if (isSectionConstructGroupNode(child)) {
return writeSectionConstructGroup(child, context);
}
@@ -149,7 +146,8 @@ function writeList(
const itemId = items[index]?.node.list.itemId;
let end = index + 1;
if (itemId !== undefined) {
- while (end < items.length && items[end]?.node.list.itemId === itemId) {
+ // No separate `end < items.length` bound is needed ahead of the itemId comparison: past the array's own end, items[end] is undefined, and its own optional-chained `?.node.list.itemId` is therefore undefined too -- which can never equal itemId, since this branch only runs when itemId is itself a real, defined string -- so the loop terminates at exactly the same point either way.
+ while (items[end]?.node.list.itemId === itemId) {
end += 1;
}
}
diff --git a/packages/epub-codec/src/xml/build.test.ts b/packages/epub-codec/src/xml/build.test.ts
new file mode 100644
index 000000000..5abde5e19
--- /dev/null
+++ b/packages/epub-codec/src/xml/build.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { buildXml } from "./build";
+
+describe("buildXml", () => {
+ it("builds a bare text node with no wrapping tag", () => {
+ expect(buildXml([{ type: "text", value: "hello" }])).toBe("hello");
+ });
+
+ it("builds a comment node", () => {
+ expect(buildXml([{ type: "comment", value: "a comment" }])).toBe(
+ "",
+ );
+ });
+
+ it("builds a cdata node", () => {
+ expect(buildXml([{ type: "cdata", value: "raw " }])).toBe(
+ "]]>",
+ );
+ });
+
+ it("builds a processing instruction node, keyed by its own target", () => {
+ expect(
+ buildXml([
+ { type: "pi", target: "xml-stylesheet", content: 'href="x.xsl"' },
+ ]),
+ ).toBe("");
+ });
+
+ it("builds an XML declaration carrying its own attributes", () => {
+ expect(
+ buildXml([
+ {
+ type: "declaration",
+ attributes: [
+ { name: "version", value: "1.0" },
+ { name: "encoding", value: "UTF-8" },
+ ],
+ },
+ ]),
+ ).toBe('');
+ });
+
+ it("builds an element with no attributes, omitting the attribute object entirely", () => {
+ expect(
+ buildXml([
+ {
+ type: "element",
+ tag: "p",
+ attributes: [],
+ children: [{ type: "text", value: "hi" }],
+ },
+ ]),
+ ).toBe("hi
");
+ });
+
+ it("builds an element carrying its own attributes", () => {
+ expect(
+ buildXml([
+ {
+ type: "element",
+ tag: "p",
+ attributes: [{ name: "class", value: "note" }],
+ children: [],
+ },
+ ]),
+ ).toBe('');
+ });
+
+ it("builds nested elements in document order", () => {
+ expect(
+ buildXml([
+ {
+ type: "element",
+ tag: "div",
+ attributes: [],
+ children: [
+ {
+ type: "element",
+ tag: "span",
+ attributes: [],
+ children: [{ type: "text", value: "x" }],
+ },
+ ],
+ },
+ ]),
+ ).toBe("x
");
+ });
+});
diff --git a/packages/epub-codec/src/xml/build.ts b/packages/epub-codec/src/xml/build.ts
index 8fe6c692d..03ce37281 100644
--- a/packages/epub-codec/src/xml/build.ts
+++ b/packages/epub-codec/src/xml/build.ts
@@ -13,12 +13,9 @@ const BUILDER = new XMLBuilder({
suppressEmptyNode: false,
});
+// XMLBuilder#build's own type declaration already returns `string` unconditionally (fast-xml-parser's fxp.d.ts: `build(jObj: any): string`), so no runtime check is needed here to narrow it.
export function buildXml(nodes: XmlNode[]): string {
- const out = BUILDER.build(toOrdered(nodes));
- if (typeof out !== "string") {
- throw new Error("XMLBuilder did not return a string");
- }
- return out;
+ return BUILDER.build(toOrdered(nodes));
}
function toOrdered(nodes: XmlNode[]): unknown[] {
@@ -41,19 +38,16 @@ function toOrderedNode(node: XmlNode): Record {
return { __comment: [{ "#text": node.value }] };
case "cdata":
return { __cdata: [{ "#text": node.value }] };
+ // A pi/declaration node's own value is never read at all by fast-xml-parser's builder in preserveOrder mode -- confirmed empirically against every shape tried (an empty array, one holding a real "#text" entry, undefined, null, a plain object): build([{ "?target": }]) always produces the identical "", the same quirk this builder's own reader hits on the way in (a plain or attribute-shaped PI's content parses back as "" either way). undefined is therefore used here as the plainest spelling of "this value is never consulted", not a placeholder standing in for children data the builder would otherwise use.
case "pi":
- return { [`?${node.target}`]: [{ "#text": node.content }] };
+ return { [`?${node.target}`]: undefined };
case "declaration":
- return { "?xml": [{ "#text": "" }], ":@": attrsObject(node.attributes) };
- case "element": {
- const obj: Record = {
+ return { "?xml": undefined, ":@": attrsObject(node.attributes) };
+ case "element":
+ // No emptiness check before setting ":@": XMLBuilder renders an empty attributes object identically to an entirely absent ":@" key (confirmed empirically), so guarding it here would only ever produce output indistinguishable from not guarding it.
+ return {
[node.tag]: toOrdered(node.children),
+ ":@": attrsObject(node.attributes),
};
- const attrs = attrsObject(node.attributes);
- if (Object.keys(attrs).length > 0) {
- obj[":@"] = attrs;
- }
- return obj;
- }
}
}
diff --git a/packages/epub-codec/src/xml/entities.test.ts b/packages/epub-codec/src/xml/entities.test.ts
index 044aa6e82..0b55839cd 100644
--- a/packages/epub-codec/src/xml/entities.test.ts
+++ b/packages/epub-codec/src/xml/entities.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { decodeEntities, encodeEntities } from "./entities";
+import { decodeEntities, decodeTextLikeNode, encodeEntities } from "./entities";
describe("decodeEntities", () => {
it("decodes the five standard XML entities", () => {
@@ -30,6 +30,20 @@ describe("decodeEntities", () => {
});
});
+describe("decodeTextLikeNode", () => {
+ it("decodes entities in a text node", () => {
+ expect(decodeTextLikeNode({ type: "text", value: "A & B" })).toBe(
+ "A & B",
+ );
+ });
+
+ it("leaves a cdata node's value untouched, with no entity decoding applied", () => {
+ expect(decodeTextLikeNode({ type: "cdata", value: "A & B" })).toBe(
+ "A & B",
+ );
+ });
+});
+
describe("encodeEntities", () => {
it("escapes the five standard XML entities, ampersand first", () => {
expect(encodeEntities(`&<>"'`)).toBe("&<>"'");
diff --git a/packages/epub-codec/src/xml/node.test.ts b/packages/epub-codec/src/xml/node.test.ts
new file mode 100644
index 000000000..15ac5da6c
--- /dev/null
+++ b/packages/epub-codec/src/xml/node.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, it } from "vitest";
+import { isTextLikeNode, isXmlNode } from "./node";
+
+describe("isXmlNode", () => {
+ it("rejects null", () => {
+ expect(isXmlNode(null)).toBe(false);
+ });
+
+ it("rejects an array", () => {
+ expect(isXmlNode([])).toBe(false);
+ });
+
+ it("rejects a non-object primitive", () => {
+ expect(isXmlNode("not a node")).toBe(false);
+ expect(isXmlNode(5)).toBe(false);
+ });
+
+ it("rejects an object with an unrecognised type", () => {
+ expect(isXmlNode({ type: "unknown" })).toBe(false);
+ });
+
+ it("rejects an object with an unrecognised type even when it otherwise has every field an element node would need", () => {
+ expect(
+ isXmlNode({
+ type: "unrecognised",
+ tag: "div",
+ attributes: [],
+ children: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects a value whose typeof is not object even when it carries every property a text node would need", () => {
+ // A function value with `type`/`value` properties bolted on directly, deliberately keeping `typeof fnMasqueradingAsText === "function"` -- Object.assign/spread would either lose that (spreading into a plain object) or bypass type checking on the source, so the properties are set one at a time on a value cast to the shape isXmlNode expects a text node to have.
+ const fnMasqueradingAsText = (() => {}) as unknown as {
+ type: string;
+ value: string;
+ };
+ fnMasqueradingAsText.type = "text";
+ fnMasqueradingAsText.value = "hi";
+ expect(isXmlNode(fnMasqueradingAsText)).toBe(false);
+ });
+
+ it("accepts a text node with a string value", () => {
+ expect(isXmlNode({ type: "text", value: "hello" })).toBe(true);
+ });
+
+ it("rejects a text node whose value is not a string", () => {
+ expect(isXmlNode({ type: "text", value: 5 })).toBe(false);
+ });
+
+ it("accepts a cdata node with a string value", () => {
+ expect(isXmlNode({ type: "cdata", value: "raw" })).toBe(true);
+ });
+
+ it("rejects a cdata node whose value is not a string", () => {
+ expect(isXmlNode({ type: "cdata", value: 5 })).toBe(false);
+ });
+
+ it("accepts a comment node with a string value", () => {
+ expect(isXmlNode({ type: "comment", value: "note" })).toBe(true);
+ });
+
+ it("rejects a comment node whose value is not a string", () => {
+ expect(isXmlNode({ type: "comment", value: 5 })).toBe(false);
+ });
+
+ it("accepts a declaration node whose attributes are all well-formed", () => {
+ expect(
+ isXmlNode({
+ type: "declaration",
+ attributes: [{ name: "version", value: "1.0" }],
+ }),
+ ).toBe(true);
+ });
+
+ it("accepts a declaration node with no attributes at all", () => {
+ expect(isXmlNode({ type: "declaration", attributes: [] })).toBe(true);
+ });
+
+ it("rejects a declaration node whose attributes is not an array", () => {
+ expect(isXmlNode({ type: "declaration", attributes: "not-an-array" })).toBe(
+ false,
+ );
+ });
+
+ it("rejects a declaration node with one malformed attribute among well-formed ones", () => {
+ expect(
+ isXmlNode({
+ type: "declaration",
+ attributes: [{ name: "version", value: "1.0" }, { name: 5 }],
+ }),
+ ).toBe(false);
+ });
+
+ it("accepts a pi node with string target and content", () => {
+ expect(isXmlNode({ type: "pi", target: "t", content: "c" })).toBe(true);
+ });
+
+ it("rejects a pi node whose target is not a string", () => {
+ expect(isXmlNode({ type: "pi", target: 5, content: "c" })).toBe(false);
+ });
+
+ it("rejects a pi node whose content is not a string", () => {
+ expect(isXmlNode({ type: "pi", target: "t", content: 5 })).toBe(false);
+ });
+
+ it("accepts an element node with well-formed attributes and children", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [{ name: "class", value: "note" }],
+ children: [{ type: "text", value: "hi" }],
+ }),
+ ).toBe(true);
+ });
+
+ it("accepts an element node with no attributes and no children", () => {
+ expect(
+ isXmlNode({ type: "element", tag: "br", attributes: [], children: [] }),
+ ).toBe(true);
+ });
+
+ it("rejects an element node whose tag is not a string", () => {
+ expect(
+ isXmlNode({ type: "element", tag: 5, attributes: [], children: [] }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node whose attributes is not an array", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: "nope",
+ children: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node with a malformed attribute", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [{ name: 5, value: "x" }],
+ children: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node whose attribute has a well-formed name but a non-string value", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [{ name: "id", value: 5 }],
+ children: [],
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node whose children is not an array", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [],
+ children: "nope",
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node whose children contains a malformed node (recursive check)", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [],
+ children: [{ type: "text", value: 5 }],
+ }),
+ ).toBe(false);
+ });
+
+ it("rejects an element node whose children contains a well-formed sibling followed by a malformed one", () => {
+ expect(
+ isXmlNode({
+ type: "element",
+ tag: "p",
+ attributes: [],
+ children: [{ type: "text", value: "ok" }, { type: "unknown" }],
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("isTextLikeNode", () => {
+ it("is true for a text node", () => {
+ expect(isTextLikeNode({ type: "text", value: "x" })).toBe(true);
+ });
+
+ it("is true for a cdata node", () => {
+ expect(isTextLikeNode({ type: "cdata", value: "x" })).toBe(true);
+ });
+
+ it("is false for a comment node", () => {
+ expect(isTextLikeNode({ type: "comment", value: "x" })).toBe(false);
+ });
+
+ it("is false for an element node", () => {
+ expect(
+ isTextLikeNode({
+ type: "element",
+ tag: "p",
+ attributes: [],
+ children: [],
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/epub-codec/src/xml/parse.test.ts b/packages/epub-codec/src/xml/parse.test.ts
index 58c8d8a16..091857bcd 100644
--- a/packages/epub-codec/src/xml/parse.test.ts
+++ b/packages/epub-codec/src/xml/parse.test.ts
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import { buildXml } from "./build";
import { rootElement } from "./query";
-import { parseXml } from "./parse";
+import {
+ parseAttributes,
+ parseNode,
+ parseNodes,
+ parseXml,
+ scalarText,
+} from "./parse";
describe("parseXml", () => {
it("parses a simple element with attributes and text", () => {
@@ -44,6 +50,26 @@ describe("parseXml", () => {
});
});
+ it("parses a comment node", () => {
+ const nodes = parseXml("");
+ expect(nodes[0]).toEqual({ type: "comment", value: "a comment" });
+ });
+
+ it("parses a cdata node", () => {
+ const nodes = parseXml("]]>
");
+ const root = rootElement(nodes);
+ expect(root?.children).toEqual([{ type: "cdata", value: "raw " }]);
+ });
+
+ it("parses a processing instruction node, keyed by its own target", () => {
+ const nodes = parseXml('');
+ expect(nodes[0]).toEqual({
+ type: "pi",
+ target: "xml-stylesheet",
+ content: "",
+ });
+ });
+
it("parses namespaced tag and attribute names verbatim", () => {
const nodes = parseXml(
'',
@@ -76,3 +102,86 @@ describe("buildXml", () => {
expect(reparsed?.children).toEqual([]);
});
});
+
+// The functions below validate the shape of fast-xml-parser's own `any`-typed output -- no syntactically valid XML string can drive parseXml itself into most of these branches, since the shape they check is the library's own internal invariant, not something malformed markup can violate. Exercised directly with adversarial `unknown` values instead, exactly like node.ts's own isXmlNode.
+describe("parseNodes", () => {
+ it("rejects a value that is not an array", () => {
+ expect(() => parseNodes("not an array")).toThrow(
+ "fast-xml-parser output was not an ordered array",
+ );
+ });
+});
+
+describe("parseNode", () => {
+ it("rejects a value that is not a plain record", () => {
+ expect(() => parseNode("not a record")).toThrow(
+ "fast-xml-parser node was not an object",
+ );
+ });
+
+ it("rejects null", () => {
+ expect(() => parseNode(null)).toThrow(
+ "fast-xml-parser node was not an object",
+ );
+ });
+
+ it("rejects an array", () => {
+ expect(() => parseNode([])).toThrow(
+ "fast-xml-parser node was not an object",
+ );
+ });
+
+ it("rejects a record with more than one non-:@ key", () => {
+ expect(() => parseNode({ a: [], b: [] })).toThrow(
+ "XML node had multiple tag keys",
+ );
+ });
+
+ it("rejects a record with no tag key at all", () => {
+ expect(() => parseNode({ ":@": {} })).toThrow("XML node had no tag key");
+ });
+
+ it("rejects a #text node whose own text is not a string", () => {
+ expect(() => parseNode({ "#text": 5 })).toThrow(
+ "expected string while parsing XML, got number",
+ );
+ });
+});
+
+describe("parseAttributes", () => {
+ it("returns an empty list for undefined", () => {
+ expect(parseAttributes(undefined)).toEqual([]);
+ });
+
+ it("rejects a value that is not a plain record", () => {
+ expect(() => parseAttributes("not a record")).toThrow(
+ "XML attributes were not an object",
+ );
+ });
+
+ it("rejects a key without the @_ prefix", () => {
+ expect(() => parseAttributes({ id: "x" })).toThrow(
+ "unexpected attribute key without @_ prefix: id",
+ );
+ });
+});
+
+describe("scalarText", () => {
+ it("rejects a value that is not an array", () => {
+ expect(() => scalarText("not an array")).toThrow(
+ "expected a scalar-text wrapper array",
+ );
+ });
+
+ it("rejects an empty array", () => {
+ expect(() => scalarText([])).toThrow(
+ "expected a scalar-text wrapper array",
+ );
+ });
+
+ it("rejects a wrapper whose first element is not a record", () => {
+ expect(() => scalarText(["not a record"])).toThrow(
+ "scalar-text wrapper was not an object",
+ );
+ });
+});
diff --git a/packages/epub-codec/src/xml/parse.ts b/packages/epub-codec/src/xml/parse.ts
index d85a0d42f..fe72419b3 100644
--- a/packages/epub-codec/src/xml/parse.ts
+++ b/packages/epub-codec/src/xml/parse.ts
@@ -34,14 +34,15 @@ function asString(value: unknown): string {
return value;
}
-function parseNodes(raw: unknown): XmlNode[] {
+export function parseNodes(raw: unknown): XmlNode[] {
if (!isUnknownArray(raw)) {
throw new Error("fast-xml-parser output was not an ordered array");
}
return raw.map(parseNode);
}
-function parseNode(raw: unknown): XmlNode {
+// Exported alongside parseAttributes and scalarText below purely so a test can drive each of this module's own runtime shape checks directly with an adversarial `unknown` value: fast-xml-parser's own `.parse()` return type is `any`, so nothing upstream of parseXml can guarantee these shapes at compile time, and no syntactically valid XML string reaches most of these branches through fast-xml-parser's own preserveOrder output (its shape is the library's own internal invariant, not something malformed input can violate) -- these are the same kind of runtime boundary check as node.ts's own isXmlNode, which is exported and unit-tested the identical way.
+export function parseNode(raw: unknown): XmlNode {
if (!isRecord(raw)) {
throw new Error("fast-xml-parser node was not an object");
}
@@ -86,7 +87,7 @@ function parseNode(raw: unknown): XmlNode {
};
}
-function parseAttributes(raw: unknown): Attribute[] {
+export function parseAttributes(raw: unknown): Attribute[] {
if (raw === undefined) {
return [];
}
@@ -104,7 +105,7 @@ function parseAttributes(raw: unknown): Attribute[] {
}
// Comments, CDATA and PIs wrap their text as [{ '#text': string }].
-function scalarText(raw: unknown): string {
+export function scalarText(raw: unknown): string {
if (!isUnknownArray(raw) || raw.length === 0) {
throw new Error("expected a scalar-text wrapper array");
}
diff --git a/packages/epub-codec/src/xml/query.test.ts b/packages/epub-codec/src/xml/query.test.ts
index 2cc39fcba..260b3fd59 100644
--- a/packages/epub-codec/src/xml/query.test.ts
+++ b/packages/epub-codec/src/xml/query.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { XmlNode } from "./node";
-import { decodedTextContent, textContent } from "./query";
+import { decodedTextContent, elementsWithTag, textContent } from "./query";
function text(value: string): XmlNode {
return { type: "text", value };
@@ -31,6 +31,24 @@ describe("textContent", () => {
});
});
+describe("elementsWithTag", () => {
+ it("finds every element with the given tag anywhere in the forest, skipping other tags and non-element nodes", () => {
+ const forest: XmlNode[] = [
+ text("intro"),
+ el("div", [el("p", [text("a")]), el("span", [text("b")])]),
+ el("p", [cdata("c")]),
+ ];
+ expect(elementsWithTag(forest, "p")).toEqual([
+ el("p", [text("a")]),
+ el("p", [cdata("c")]),
+ ]);
+ });
+
+ it("returns an empty array when no element matches", () => {
+ expect(elementsWithTag([el("div", [text("x")])], "p")).toEqual([]);
+ });
+});
+
describe("decodedTextContent", () => {
it("decodes entities in a text-node descendant exactly once", () => {
// A literal source "&" is the two-character entity "&" written out verbatim -- one decode pass restores it to the five-character string "&"; a second pass would over-decode it to a bare "&".