From 7ae375e9c64d6e2fa20dba3ebe1b82f7b6491e3a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 20:39:11 +0100 Subject: [PATCH 01/46] test(epub-codec): exercise PNG IHDR boundaries and JPEG marker-walking edge cases readImageDimensions and detectImageFormat only had tests for the happy path and one generic truncation, leaving the PNG signature/IHDR boundary checks and most of the JPEG marker walk (fill-byte runs, restart/TEM/EOI markers, the DHT/JPG/DAC frame-marker exclusions, SOS-stops-the-scan, and the exact byte-count boundaries for a marker's length field and an SOF0 payload) unexercised. Add targeted byte-level fixtures for each of these branches so a regression in the marker classification or the segment-skipping arithmetic fails a test instead of silently misreading a real-world PNG/JPEG header. --- .../epub-codec/src/image/dimensions.test.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) diff --git a/packages/epub-codec/src/image/dimensions.test.ts b/packages/epub-codec/src/image/dimensions.test.ts index f805af4e7..e61c0a7be 100644 --- a/packages/epub-codec/src/image/dimensions.test.ts +++ b/packages/epub-codec/src/image/dimensions.test.ts @@ -86,3 +86,251 @@ describe("POINTS_PER_PIXEL", () => { expect(POINTS_PER_PIXEL).toBeCloseTo(0.75); }); }); + +// A generic JPEG marker segment: FF, the marker byte, then a big-endian length (including these two length bytes themselves) followed by (length - 2) payload bytes. Used to build multi-segment streams the fixed-shape fakeJpeg() above can't express. +function jpegSegment(marker: number, payload: number[]): number[] { + const length = payload.length + 2; + return [0xff, marker, (length >> 8) & 0xff, length & 0xff, ...payload]; +} + +function concatBytes(...chunks: number[][]): Uint8Array { + return new Uint8Array(chunks.flat()); +} + +const SOI = [0xff, 0xd8]; +// An SOF0 segment carrying only height/width/components -- the same shape fakeJpeg() builds inline, expressed as a reusable segment for streams that need other markers around it. +function sof0Segment(widthPx: number, heightPx: number): number[] { + return jpegSegment(0xc0, [ + 8, // precision + (heightPx >> 8) & 0xff, + heightPx & 0xff, + (widthPx >> 8) & 0xff, + widthPx & 0xff, + 1, // components + ]); +} + +describe("detectImageFormat boundary cases", () => { + it("recognises a bare 8-byte PNG signature with no IHDR at all", () => { + expect(detectImageFormat(new Uint8Array(PNG_SIG))).toBe("png"); + }); + + it("does not treat a partially-matching 8-byte array as PNG", () => { + // Only byte 0 matches the real signature -- .some() would wrongly accept this, .every() correctly rejects it. + const bytes = new Uint8Array([0x89, 0, 0, 0, 0, 0, 0, 0]); + expect(detectImageFormat(bytes)).toBeUndefined(); + }); + + it("recognises a bare 2-byte JPEG SOI with nothing else", () => { + expect(detectImageFormat(new Uint8Array([0xff, 0xd8]))).toBe("jpeg"); + }); + + it("does not treat a single 0xff byte as JPEG", () => { + expect(detectImageFormat(new Uint8Array([0xff]))).toBeUndefined(); + }); + + it("does not treat 0xff followed by the wrong second byte as JPEG", () => { + expect(detectImageFormat(new Uint8Array([0xff, 0x00]))).toBeUndefined(); + }); + + it("does not treat the wrong first byte followed by 0xd8 as JPEG", () => { + expect(detectImageFormat(new Uint8Array([0x00, 0xd8]))).toBeUndefined(); + }); +}); + +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +describe("readImageDimensions PNG IHDR boundary cases", () => { + it("reads dimensions from an IHDR ending exactly at the 24-byte minimum", () => { + expect(readImageDimensions(fakePng(1, 1).subarray(0, 24))).toEqual({ + widthPx: 1, + heightPx: 1, + }); + }); + + it("returns undefined one byte short of the 24-byte IHDR minimum", () => { + expect(readImageDimensions(fakePng(1, 1).subarray(0, 23))).toBeUndefined(); + }); + + it("rejects an IHDR chunk whose type byte 0 is wrong", () => { + const bytes = fakePng(10, 20); + bytes[12] = 0x00; + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("rejects an IHDR chunk whose type byte 1 is wrong", () => { + const bytes = fakePng(10, 20); + bytes[13] = 0x00; + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("rejects an IHDR chunk whose type byte 2 is wrong", () => { + const bytes = fakePng(10, 20); + bytes[14] = 0x00; + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("rejects an IHDR chunk whose type byte 3 is wrong", () => { + const bytes = fakePng(10, 20); + bytes[15] = 0x00; + expect(readImageDimensions(bytes)).toBeUndefined(); + }); +}); + +describe("readImageDimensions JPEG marker-walking", () => { + it("skips a leading run of 0xff fill bytes before the real marker", () => { + const bytes = concatBytes(SOI, [0xff, 0xff, 0xff], sof0Segment(50, 60)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 50, heightPx: 60 }); + }); + + it("skips a non-0xff stray byte between segments", () => { + const bytes = concatBytes(SOI, [0x00], sof0Segment(50, 60)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 50, heightPx: 60 }); + }); + + it("skips two consecutive non-0xff stray bytes, realigning on the real marker rather than misreading the second stray byte as one", () => { + // A single stray byte happens to still land correctly because the fill-byte loop right after it absorbs the segment's own genuine 0xff. Two non-0xff bytes in a row rules that coincidence out: only advancing offset one byte at a time (rather than jumping straight into marker extraction) reaches the real segment aligned. + const bytes = concatBytes(SOI, [0x00, 0x11], sof0Segment(50, 60)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 50, heightPx: 60 }); + }); + + it("skips an APP0 segment by its declared length to reach SOF0", () => { + const bytes = concatBytes( + SOI, + jpegSegment(0xe0, [0x4a, 0x46, 0x49, 0x46, 0x00]), + sof0Segment(70, 80), + ); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 70, heightPx: 80 }); + }); + + it("skips a restart marker (RST0, no length field) to reach SOF0", () => { + const bytes = concatBytes(SOI, [0xff, 0xd0], sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("skips a restart marker (RST7, no length field) to reach SOF0", () => { + const bytes = concatBytes(SOI, [0xff, 0xd7], sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("skips a TEM marker (0x01, no length field) to reach SOF0", () => { + const bytes = concatBytes(SOI, [0xff, 0x01], sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("skips an EOI marker (0xd9, no length field) to reach a later SOF0", () => { + const bytes = concatBytes(SOI, [0xff, 0xd9], sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("recognises SOF0 at the low end of the frame-marker range (0xc0)", () => { + const bytes = concatBytes(SOI, sof0Segment(1, 2)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 1, heightPx: 2 }); + }); + + it("recognises a frame marker at the high end of the range (0xcf)", () => { + const bytes = concatBytes(SOI, jpegSegment(0xcf, [8, 0, 2, 0, 1, 1])); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 1, heightPx: 2 }); + }); + + it("does not treat DHT (0xc4) as a frame header, and reads the later real SOF0", () => { + const bytes = concatBytes( + SOI, + jpegSegment(0xc4, [8, 0xff, 0xff, 0xff, 0xff, 0xff]), + sof0Segment(11, 22), + ); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("does not treat JPG (0xc8) as a frame header, and reads the later real SOF0", () => { + const bytes = concatBytes( + SOI, + jpegSegment(0xc8, [8, 0xff, 0xff, 0xff, 0xff, 0xff]), + sof0Segment(11, 22), + ); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("does not treat DAC (0xcc) as a frame header, and reads the later real SOF0", () => { + const bytes = concatBytes( + SOI, + jpegSegment(0xcc, [8, 0xff, 0xff, 0xff, 0xff, 0xff]), + sof0Segment(11, 22), + ); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("does not treat a marker just below the frame-marker range (0xbf) as a frame header", () => { + const bytes = concatBytes( + SOI, + jpegSegment(0xbf, [0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), + sof0Segment(11, 22), + ); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("skips a spurious SOI byte (0xd8) reused mid-stream as its own length-less marker", () => { + const bytes = concatBytes(SOI, [0xff, 0xd8], sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 11, heightPx: 22 }); + }); + + it("stops at SOS (0xda) and never reads a frame header appearing after it", () => { + const bytes = concatBytes(SOI, jpegSegment(0xda, []), sof0Segment(11, 22)); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("returns undefined when the stream ends with no marker byte after a trailing 0xff", () => { + const bytes = concatBytes(SOI, [0xff]); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("returns undefined when a marker's length field is exactly cut off", () => { + // offset + 2 === bytes.length: the two length bytes themselves are missing. + const bytes = concatBytes(SOI, [0xff, 0xe0]); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("reads a length field that ends exactly at the buffer boundary", () => { + // offset + 2 === bytes.length for the length field itself, immediately followed by SOF0. + const bytes = concatBytes(SOI, [0xff, 0xe0, 0x00, 0x02], sof0Segment(3, 4)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 3, heightPx: 4 }); + }); + + it("returns undefined when an SOF0 segment is missing part of its width field", () => { + // offset + 7 > bytes.length: two bytes short cuts into the width field the dims reader actually reads (the trailing, unread "components" byte alone isn't enough to trip this guard). + const bytes = concatBytes(SOI, sof0Segment(9, 9)); + expect( + readImageDimensions(bytes.subarray(0, bytes.length - 2)), + ).toBeUndefined(); + }); + + it("reads an SOF0 segment ending exactly at the buffer boundary", () => { + const bytes = concatBytes(SOI, sof0Segment(9, 9)); + expect(readImageDimensions(bytes)).toEqual({ widthPx: 9, heightPx: 9 }); + }); + + it("reads an SOF0 segment when the buffer ends immediately after the width field, one byte short of the unread trailing components byte", () => { + // offset + 7 === bytes.length exactly: every byte the dims reader actually touches is present, with nothing to spare. + const bytes = concatBytes(SOI, sof0Segment(9, 9)); + expect(readImageDimensions(bytes.subarray(0, bytes.length - 1))).toEqual({ + widthPx: 9, + heightPx: 9, + }); + }); + + it("returns undefined when no frame header is ever found", () => { + const bytes = concatBytes(SOI, jpegSegment(0xe0, [1, 2, 3])); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("never scans a non-JPEG buffer for an embedded marker pattern (wrong first byte)", () => { + // If the leading-SOI check on byte 0 were bypassed, the walk would still start at offset 2 and find this real-looking SOF0 segment sitting there by construction. + const bytes = concatBytes([0x00, 0xd8], sof0Segment(5, 6)); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); + + it("never scans a non-JPEG buffer for an embedded marker pattern (wrong second byte)", () => { + const bytes = concatBytes([0xff, 0x00], sof0Segment(5, 6)); + expect(readImageDimensions(bytes)).toBeUndefined(); + }); +}); From 4535494356baf6e9ed61d432b80b51a24b81021a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 20:57:20 +0100 Subject: [PATCH 02/46] test(epub-codec): assert every diagnostic error class's own name and message Every EpubParseError/EpubWriteError subclass's constructor sets this.name and a default or derived message, but the existing tests only ever checked code (and, for a couple of classes, message content loosely via toContain). The base classes themselves were never constructed directly either. Assert name and the exact default/derived message on every class, construct the two base classes directly, and check the unmatchedEnd/unclosedStart ternary picks its own description rather than merely asserting a substring both branches share. --- packages/epub-codec/src/diagnostics.test.ts | 53 ++++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/epub-codec/src/diagnostics.test.ts b/packages/epub-codec/src/diagnostics.test.ts index f332beb30..e843dab3c 100644 --- a/packages/epub-codec/src/diagnostics.test.ts +++ b/packages/epub-codec/src/diagnostics.test.ts @@ -5,8 +5,10 @@ import { EpubInvalidMimetypeError, EpubInvalidOpfError, EpubPackageFlattenError, + EpubParseError, EpubUnbalancedConstructMarkersError, EpubUnsupportedDocumentKindError, + EpubWriteError, NOOP_EPUB_DIAGNOSTIC_SINK, } from "./diagnostics"; @@ -23,49 +25,86 @@ describe("NOOP_EPUB_DIAGNOSTIC_SINK", () => { }); describe("error classes", () => { - it("EpubInvalidMimetypeError carries a stable code and default message", () => { + it("EpubParseError names itself directly, when constructed rather than through a subclass", () => { + const error = new EpubParseError("epub/example", "an example message"); + expect(error.name).toBe("EpubParseError"); + expect(error.code).toBe("epub/example"); + expect(error.message).toBe("an example message"); + }); + + it("EpubWriteError names itself directly, when constructed rather than through a subclass", () => { + const error = new EpubWriteError("epub/example", "an example message"); + expect(error.name).toBe("EpubWriteError"); + expect(error.code).toBe("epub/example"); + expect(error.message).toBe("an example message"); + }); + + it("EpubInvalidMimetypeError carries a stable code, name, and default message", () => { const error = new EpubInvalidMimetypeError(); expect(error.code).toBe("epub/invalid-mimetype"); expect(error.name).toBe("EpubInvalidMimetypeError"); + expect(error.message).toBe( + 'the zip\'s first entry is not a stored "mimetype" entry containing exactly "application/epub+zip"', + ); expect(error).toBeInstanceOf(Error); }); - it("EpubInvalidContainerError carries a stable code", () => { - expect(new EpubInvalidContainerError().code).toBe("epub/invalid-container"); + it("EpubInvalidContainerError carries a stable code, name, and default message", () => { + const error = new EpubInvalidContainerError(); + expect(error.code).toBe("epub/invalid-container"); + expect(error.name).toBe("EpubInvalidContainerError"); + expect(error.message).toBe( + "META-INF/container.xml is missing or names no OPF rootfile", + ); }); - it("EpubInvalidOpfError carries a stable code and a caller message", () => { + it("EpubInvalidOpfError carries a stable code, name, and a caller message", () => { const error = new EpubInvalidOpfError("no root element"); expect(error.code).toBe("epub/invalid-opf"); + expect(error.name).toBe("EpubInvalidOpfError"); expect(error.message).toBe("no root element"); }); - it("EpubEmptySpineError carries a stable code", () => { - expect(new EpubEmptySpineError().code).toBe("epub/empty-spine"); + it("EpubEmptySpineError carries a stable code, name, and default message", () => { + const error = new EpubEmptySpineError(); + expect(error.code).toBe("epub/empty-spine"); + expect(error.name).toBe("EpubEmptySpineError"); + expect(error.message).toBe( + "the spine names no resolvable, readable content", + ); }); it("EpubUnsupportedDocumentKindError names the offending kind", () => { const error = new EpubUnsupportedDocumentKindError("spreadsheet"); expect(error.code).toBe("epub/write-side-not-wordprocessing"); + expect(error.name).toBe("EpubUnsupportedDocumentKindError"); expect(error.kind).toBe("spreadsheet"); + expect(error.message).toBe( + "writeEpubContent only supports a 'wordprocessing' ContentDocument, got 'spreadsheet'", + ); }); it("EpubUnbalancedConstructMarkersError describes an unmatchedEnd", () => { const error = new EpubUnbalancedConstructMarkersError("unmatchedEnd", 3); expect(error.code).toBe("epub/unbalanced-construct-markers"); + expect(error.name).toBe("EpubUnbalancedConstructMarkersError"); expect(error.imbalanceKind).toBe("unmatchedEnd"); expect(error.blockIndex).toBe(3); + expect(error.message).toContain( + "a constructEnd marker closes no open construct", + ); expect(error.message).toContain("index 3"); }); it("EpubUnbalancedConstructMarkersError describes an unclosedStart", () => { const error = new EpubUnbalancedConstructMarkersError("unclosedStart", 0); - expect(error.message).toContain("never closed"); + expect(error.message).toContain("a constructStart marker is never closed"); }); it("EpubPackageFlattenError wraps a thrown cause's message", () => { const error = new EpubPackageFlattenError(new Error("no such style ref")); expect(error.code).toBe("epub/package-flatten-failed"); + expect(error.name).toBe("EpubPackageFlattenError"); expect(error.message).toContain("no such style ref"); }); From 86d7ab7b793a0f1ec0ef081f14d6309a02e1246a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:07:05 +0100 Subject: [PATCH 03/46] test(epub-codec): cover the scheme regex boundary and dot/empty path segments resolvePackagePath's relative-vs-scheme classification and its "./", "", and ".." segment handling had no test past the two segment kinds the fixture paths in other tests happened to exercise incidentally. Add a scheme covering every character class the regex allows, a leading-digit case that must NOT be classified as a scheme (a URI scheme starts with a letter), an explicit "./" segment, and a doubled-slash empty segment. --- packages/epub-codec/src/path.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/epub-codec/src/path.test.ts b/packages/epub-codec/src/path.test.ts index 59d6d135c..e04062050 100644 --- a/packages/epub-codec/src/path.test.ts +++ b/packages/epub-codec/src/path.test.ts @@ -41,4 +41,27 @@ describe("resolvePackagePath", () => { "https://example.com/x.png", ); }); + + it("recognises a scheme carrying every character class the scheme regex allows (letters, digits, +, ., -)", () => { + expect(resolvePackagePath("OEBPS", "epub+zip.v2-1://x")).toBe( + "epub+zip.v2-1://x", + ); + }); + + it("does not treat a leading digit as a scheme, and resolves it as a relative reference instead", () => { + // A URI scheme must start with a letter -- a leading digit makes this a relative segment named "1http", not a scheme. + expect(resolvePackagePath("OEBPS", "1http://x")).toBe("OEBPS/1http:/x"); + }); + + it('drops an explicit current-directory (".") segment', () => { + expect(resolvePackagePath("OEBPS", "./chapter1.xhtml")).toBe( + "OEBPS/chapter1.xhtml", + ); + }); + + it("drops an empty segment produced by a doubled slash", () => { + expect(resolvePackagePath("OEBPS", "images//cover.png")).toBe( + "OEBPS/images/cover.png", + ); + }); }); From 20b44dc5e4d117fe870adf09bc11595e77d84a05 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:10:38 +0100 Subject: [PATCH 04/46] test(epub-codec): add resolveHrefTarget's first dedicated test file resolveHrefTarget had no test file of its own -- only whatever incidental exercise it got through src/xhtml/read.ts and write.ts's own round-trip tests, which left the scheme/empty-href/no-fragment/empty-fragment guards and the hash-index arithmetic unverified in isolation. Cover the empty href, scheme-carrying href, hashless href, empty-fragment href, same-document fragment, cross-document fragment, subdirectory resolution, and a hash at index 1 specifically (to distinguish "no hash found" from "hash found early"). --- .../epub-codec/src/xhtml/link-target.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 packages/epub-codec/src/xhtml/link-target.test.ts diff --git a/packages/epub-codec/src/xhtml/link-target.test.ts b/packages/epub-codec/src/xhtml/link-target.test.ts new file mode 100644 index 000000000..d6fd63ffc --- /dev/null +++ b/packages/epub-codec/src/xhtml/link-target.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { resolveHrefTarget } from "./link-target"; + +describe("resolveHrefTarget", () => { + it("returns undefined for an empty href", () => { + expect(resolveHrefTarget("OEBPS/chapter1.xhtml", "")).toBeUndefined(); + }); + + it("returns undefined for a scheme-carrying (external) href", () => { + expect( + resolveHrefTarget("OEBPS/chapter1.xhtml", "https://example.com#x"), + ).toBeUndefined(); + }); + + it("returns undefined for an href with no fragment at all", () => { + expect( + resolveHrefTarget("OEBPS/chapter1.xhtml", "chapter2.xhtml"), + ).toBeUndefined(); + }); + + it("returns undefined for an href whose fragment is empty (a trailing bare '#')", () => { + expect( + resolveHrefTarget("OEBPS/chapter1.xhtml", "chapter2.xhtml#"), + ).toBeUndefined(); + }); + + it("resolves a same-document fragment against sourceHref itself", () => { + expect(resolveHrefTarget("OEBPS/chapter1.xhtml", "#note1")).toEqual({ + targetHref: "OEBPS/chapter1.xhtml", + fragment: "note1", + }); + }); + + it("resolves a cross-document fragment against sourceHref's own directory", () => { + expect( + resolveHrefTarget("OEBPS/chapter1.xhtml", "chapter2.xhtml#note1"), + ).toEqual({ + targetHref: "OEBPS/chapter2.xhtml", + fragment: "note1", + }); + }); + + it("resolves a hash appearing at index 1, distinguishing 'no hash found' from 'hash found at a small index'", () => { + expect(resolveHrefTarget("OEBPS/chapter1.xhtml", "a#frag")).toEqual({ + targetHref: "OEBPS/a", + fragment: "frag", + }); + }); + + it("resolves a subdirectory path portion with its own fragment", () => { + expect( + resolveHrefTarget("OEBPS/chapter1.xhtml", "images/notes.xhtml#n2"), + ).toEqual({ + targetHref: "OEBPS/images/notes.xhtml", + fragment: "n2", + }); + }); +}); From 49b008bd31b237a38e61aba9aa647627c58c1e62 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:18:49 +0100 Subject: [PATCH 05/46] test(epub-codec): add buildXml's first dedicated test file buildXml had no test of its own, only whatever incidental exercise it got through every other module's own round-trip tests -- which never happened to build a bare comment, cdata, processing-instruction, or declaration node in isolation, and never checked the no-attributes case renders without a stray attribute object. Cover text, comment, cdata, pi, declaration, attribute-bearing and attribute-free elements, and nesting. --- packages/epub-codec/src/xml/build.test.ts | 88 +++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/epub-codec/src/xml/build.test.ts 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
"); + }); +}); From 0b2f21d0c686e4ce337d57c7c7d572eed71de949 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:23:15 +0100 Subject: [PATCH 06/46] test(epub-codec): exercise packageFromEntries' BOM and whitespace classification looksLikeXml's BOM detection and its per-byte whitespace-then-'<' scan had no test at all -- packageFromEntries was only ever exercised end-to-end through real EPUB fixtures, none of which happen to carry a BOM, leading whitespace, or a malformed BOM prefix. Add byte-level fixtures for a real BOM, each of its three bytes individually wrong, a too-short BOM-like prefix, each whitespace byte the scan recognises, a non-whitespace non-'<' byte, an empty part, and a part that is only whitespace with no '<' at all. --- .../epub-codec/src/package-io/read.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/epub-codec/src/package-io/read.test.ts diff --git a/packages/epub-codec/src/package-io/read.test.ts b/packages/epub-codec/src/package-io/read.test.ts new file mode 100644 index 000000000..21365ac18 --- /dev/null +++ b/packages/epub-codec/src/package-io/read.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { packageFromEntries } from "./read"; + +function utf8(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function bytes(...values: number[]): Uint8Array { + return new Uint8Array(values); +} + +function kindOf(entryBytes: Uint8Array): "xml" | "binary" { + const result = packageFromEntries({ part: entryBytes }); + return result.parts.part?.kind ?? "binary"; +} + +describe("packageFromEntries classification (looksLikeXml)", () => { + it("classifies a plain XML declaration as xml", () => { + expect(kindOf(utf8(''))).toBe("xml"); + }); + + it("classifies bytes with no leading '<' as binary", () => { + expect(kindOf(bytes(0x89, 0x50, 0x4e, 0x47))).toBe("binary"); + }); + + it("classifies an empty part as binary", () => { + expect(kindOf(bytes())).toBe("binary"); + }); + + it("classifies a part that is only whitespace, with no '<' ever, as binary", () => { + expect(kindOf(bytes(0x20, 0x09, 0x0a, 0x0d, 0x20))).toBe("binary"); + }); + + it("skips a leading UTF-8 BOM before finding '<'", () => { + const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...utf8("")]); + expect(kindOf(withBom)).toBe("xml"); + }); + + it("does not treat a two-byte prefix as a BOM (needs all three bytes)", () => { + // Only 2 bytes total: the length guard must reject this before indexing byte 2. + expect(kindOf(bytes(0xef, 0xbb))).toBe("binary"); + }); + + it("does not recognise a BOM whose first byte is wrong", () => { + expect(kindOf(bytes(0x00, 0xbb, 0xbf, 0x3c))).toBe("binary"); + }); + + it("does not recognise a BOM whose second byte is wrong", () => { + expect(kindOf(bytes(0xef, 0x00, 0xbf, 0x3c))).toBe("binary"); + }); + + it("does not recognise a BOM whose third byte is wrong", () => { + expect(kindOf(bytes(0xef, 0xbb, 0x00, 0x3c))).toBe("binary"); + }); + + it("returns binary when a real BOM is immediately followed by end of input", () => { + expect(kindOf(bytes(0xef, 0xbb, 0xbf))).toBe("binary"); + }); + + it("skips a leading space before '<'", () => { + expect(kindOf(new Uint8Array([0x20, ...utf8("")]))).toBe("xml"); + }); + + it("skips a leading tab before '<'", () => { + expect(kindOf(new Uint8Array([0x09, ...utf8("")]))).toBe("xml"); + }); + + it("skips a leading line feed before '<'", () => { + expect(kindOf(new Uint8Array([0x0a, ...utf8("")]))).toBe("xml"); + }); + + it("skips a leading carriage return before '<'", () => { + expect(kindOf(new Uint8Array([0x0d, ...utf8("")]))).toBe("xml"); + }); + + it("treats a non-whitespace, non-'<' leading byte as binary immediately", () => { + expect(kindOf(bytes(0x41))).toBe("binary"); + }); + + it("stores a binary part as base64", () => { + const result = packageFromEntries({ "img.png": bytes(0x89, 0x50) }); + const part = result.parts["img.png"]; + expect(part?.kind).toBe("binary"); + if (part?.kind === "binary") { + expect(part.base64).toBe(Buffer.from([0x89, 0x50]).toString("base64")); + } + }); + + it("parses an xml part into its own node tree", () => { + const result = packageFromEntries({ "a.opf": utf8("") }); + const part = result.parts["a.opf"]; + expect(part?.kind).toBe("xml"); + }); + + it("returns one part per entry, keyed by its own path", () => { + const result = packageFromEntries({ + mimetype: bytes(0x61), + "OEBPS/content.opf": utf8(""), + }); + expect(Object.keys(result.parts).sort()).toEqual([ + "OEBPS/content.opf", + "mimetype", + ]); + }); +}); From ba77b6d3f5005cd4e162874489f86a3a1bdba4e4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:28:14 +0100 Subject: [PATCH 07/46] test(epub-codec): add isXmlNode's first dedicated test file isXmlNode had no test of its own -- only whatever incidental exercise it got via Zod's z.custom() call sites elsewhere, which never happened to feed it a declaration or pi node, a malformed attribute, or a recursively malformed element child. Cover every node kind's own field-shape check, the non-object/array/primitive rejections, and recursive element/attribute validation, plus isTextLikeNode's own four-way discrimination. --- packages/epub-codec/src/xml/node.test.ts | 188 +++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/epub-codec/src/xml/node.test.ts 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..6dba61fcc --- /dev/null +++ b/packages/epub-codec/src/xml/node.test.ts @@ -0,0 +1,188 @@ +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("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 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); + }); +}); From 755c30b57fe600a1511e1a37e208fd45027db350 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:32:29 +0100 Subject: [PATCH 08/46] test(epub-codec): parse comment, cdata, and processing-instruction nodes parseXml's element/text/declaration paths were covered but its comment, cdata, and pi branches (parseNode's tagKey === "__comment"/"__cdata" checks and the tagKey.startsWith("?") pi fallback) had no test exercising real XML syntax for any of them. --- packages/epub-codec/src/xml/parse.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/epub-codec/src/xml/parse.test.ts b/packages/epub-codec/src/xml/parse.test.ts index 58c8d8a16..5d10887df 100644 --- a/packages/epub-codec/src/xml/parse.test.ts +++ b/packages/epub-codec/src/xml/parse.test.ts @@ -44,6 +44,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( '', From 5d1ffb73b6a8ed64a794902f901edcebcffd9853 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:35:56 +0100 Subject: [PATCH 09/46] test(epub-codec): cover base64ToBytes' padding-position and whitespace stripping Only the c1-position invalid-padding case ("A===") was tested; add the c0-position case ("=AAA") and a whitespace-stripping equivalence check so the sanitising regex's own effect is asserted rather than merely present. --- packages/epub-codec/src/util/base64.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/epub-codec/src/util/base64.test.ts b/packages/epub-codec/src/util/base64.test.ts index 5ccd869e8..d32b3997a 100644 --- a/packages/epub-codec/src/util/base64.test.ts +++ b/packages/epub-codec/src/util/base64.test.ts @@ -28,4 +28,13 @@ describe("bytesToBase64 / base64ToBytes", () => { it("throws when a padding character appears where a data character is required", () => { expect(() => base64ToBytes("A===")).toThrow("invalid base64 input"); }); + + it("throws when the padding character appears in the very first position", () => { + // Distinct from the existing "A===" case: this hits c0 === 255 specifically, not c1. + expect(() => base64ToBytes("=AAA")).toThrow("invalid base64 input"); + }); + + it("strips embedded whitespace before decoding, matching the same input with it removed", () => { + expect(base64ToBytes("Y W\nJj")).toEqual(base64ToBytes("YWJj")); + }); }); From caf151f2900d9ccb1c1a11b7de2a439df1867649 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:38:00 +0100 Subject: [PATCH 10/46] test(epub-codec): assert resolveOpfPath's own error messages Every failure path already had a class-based toThrow assertion but never checked the message text, leaving each one free to say anything at all. --- packages/epub-codec/src/ocf/container.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/epub-codec/src/ocf/container.test.ts b/packages/epub-codec/src/ocf/container.test.ts index 2cead5924..4bdaba084 100644 --- a/packages/epub-codec/src/ocf/container.test.ts +++ b/packages/epub-codec/src/ocf/container.test.ts @@ -28,14 +28,18 @@ describe("resolveOpfPath", () => { expect(() => resolveOpfPath("")).toThrow( EpubInvalidContainerError, ); + expect(() => resolveOpfPath("")).toThrow( + "META-INF/container.xml has no root element", + ); }); it("throws EpubInvalidContainerError when there is no element", () => { - expect(() => - resolveOpfPath( - '', - ), - ).toThrow(EpubInvalidContainerError); + const xml = + ''; + expect(() => resolveOpfPath(xml)).toThrow(EpubInvalidContainerError); + expect(() => resolveOpfPath(xml)).toThrow( + "META-INF/container.xml has no element", + ); }); it("throws EpubInvalidContainerError when no rootfile carries a full-path", () => { @@ -43,5 +47,8 @@ describe("resolveOpfPath", () => { `; expect(() => resolveOpfPath(xml)).toThrow(EpubInvalidContainerError); + expect(() => resolveOpfPath(xml)).toThrow( + "META-INF/container.xml names no rootfile with a full-path attribute", + ); }); }); From de89158db4e9faec083ed2945401884679ded39b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:47:10 +0100 Subject: [PATCH 11/46] test(epub-codec): cover the remaining single/few-mutant survivors across nine small modules Each of these had one or a handful of untested branches: hasZipHeader's own partial-header-match case, navMatchesSpine's partial-match case, decodeTextLikeNode (never tested directly at all), elementsWithTag (never tested directly at all), mintListNumId/parseListNumId's bullet-with-suffix and ordered-with-no-suffix cases, readNcxHrefs' missing-src content element, readNav3TocHrefs' no-epub:type nav / non-nav-tag-with-toc-type / href-less anchor cases, and reportInertElementSkip (no test file existed for src/xhtml/context.ts at all). --- packages/epub-codec/src/codec.test.ts | 7 +++ packages/epub-codec/src/nav/nav3.test.ts | 33 +++++++++++ packages/epub-codec/src/nav/ncx.test.ts | 10 ++++ packages/epub-codec/src/nav/reconcile.test.ts | 7 +++ packages/epub-codec/src/xhtml/context.test.ts | 56 +++++++++++++++++++ packages/epub-codec/src/xhtml/list-id.test.ts | 13 +++++ packages/epub-codec/src/xml/entities.test.ts | 16 +++++- packages/epub-codec/src/xml/query.test.ts | 20 ++++++- 8 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/epub-codec/src/xhtml/context.test.ts diff --git a/packages/epub-codec/src/codec.test.ts b/packages/epub-codec/src/codec.test.ts index b8bc8c31b..67fa65e10 100644 --- a/packages/epub-codec/src/codec.test.ts +++ b/packages/epub-codec/src/codec.test.ts @@ -27,6 +27,13 @@ describe("EpubBytesSchema", () => { EpubBytesSchema.safeParse(new Uint8Array([0, 1, 2, 3])).success, ).toBe(false); }); + + it("rejects bytes matching only part of the zip header", () => { + // The first byte matches PK\x03\x04's own 0x50, but the rest don't -- .some() would wrongly accept this, .every() correctly rejects it. + expect( + EpubBytesSchema.safeParse(new Uint8Array([0x50, 0, 0, 0])).success, + ).toBe(false); + }); }); describe("epubContentCodec", () => { diff --git a/packages/epub-codec/src/nav/nav3.test.ts b/packages/epub-codec/src/nav/nav3.test.ts index 3ba102bcd..f42a1b77a 100644 --- a/packages/epub-codec/src/nav/nav3.test.ts +++ b/packages/epub-codec/src/nav/nav3.test.ts @@ -31,4 +31,37 @@ describe("readNav3TocHrefs", () => { ), ).toBeUndefined(); }); + + it('does not treat a non-nav element carrying epub:type="toc" as the toc nav', () => { + const xml = ` +

+ + `; + expect(readNav3TocHrefs(xml)).toEqual(["real.xhtml"]); + }); + + it("skips a nav element with no epub:type attribute at all, then finds the real toc nav", () => { + const xml = ` + + + `; + expect(readNav3TocHrefs(xml)).toEqual(["real.xhtml"]); + }); + + it("recognises 'toc' among several tab-separated epub:type values", () => { + const xml = ` + + `; + expect(readNav3TocHrefs(xml)).toEqual(["a.xhtml"]); + }); + + it("skips an with no href attribute, rather than including it as literal 'undefined'", () => { + const xml = ` + + `; + expect(readNav3TocHrefs(xml)).toEqual(["real.xhtml"]); + }); }); diff --git a/packages/epub-codec/src/nav/ncx.test.ts b/packages/epub-codec/src/nav/ncx.test.ts index 00acba3ae..e3abef03e 100644 --- a/packages/epub-codec/src/nav/ncx.test.ts +++ b/packages/epub-codec/src/nav/ncx.test.ts @@ -31,4 +31,14 @@ describe("readNcxHrefs", () => { it("returns undefined when there is no navMap", () => { expect(readNcxHrefs("")).toBeUndefined(); }); + + it("skips a content element with no src attribute, rather than including it as literal 'undefined'", () => { + const xml = ` + + + + + `; + expect(readNcxHrefs(xml)).toEqual(["chapter1.xhtml"]); + }); }); diff --git a/packages/epub-codec/src/nav/reconcile.test.ts b/packages/epub-codec/src/nav/reconcile.test.ts index 4c39e67f4..d2e640ca9 100644 --- a/packages/epub-codec/src/nav/reconcile.test.ts +++ b/packages/epub-codec/src/nav/reconcile.test.ts @@ -17,4 +17,11 @@ describe("navMatchesSpine", () => { it("does not match a different length", () => { expect(navMatchesSpine(["a.xhtml"], ["a.xhtml", "b.xhtml"])).toBe(false); }); + + it("does not match when only some of an equal-length sequence agrees", () => { + // .some() would wrongly accept this (the first element matches); .every() correctly rejects it. + expect( + navMatchesSpine(["a.xhtml", "x.xhtml"], ["a.xhtml", "b.xhtml"]), + ).toBe(false); + }); }); diff --git a/packages/epub-codec/src/xhtml/context.test.ts b/packages/epub-codec/src/xhtml/context.test.ts new file mode 100644 index 000000000..51f74e24e --- /dev/null +++ b/packages/epub-codec/src/xhtml/context.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { EpubDiagnosticCodes } from "../diagnostics"; +import type { EpubDiagnostic } from "../diagnostics"; +import { isInertElement, reportInertElementSkip } from "./context"; +import type { XhtmlReadContext } from "./context"; + +function fakeContext(sink: XhtmlReadContext["sink"]): XhtmlReadContext { + return { + resolveImage: () => undefined, + sink, + sourceHref: "OEBPS/chapter1.xhtml", + idElements: new Map(), + anchorTargets: new Map(), + resolveAnchorHref: () => undefined, + quoteDepth: 0, + }; +} + +describe("isInertElement", () => { + it("is true for script, template, style, and noscript", () => { + expect(isInertElement("script")).toBe(true); + expect(isInertElement("template")).toBe(true); + expect(isInertElement("style")).toBe(true); + expect(isInertElement("noscript")).toBe(true); + }); + + it("is false for an ordinary content element", () => { + expect(isInertElement("p")).toBe(false); + }); +}); + +describe("reportInertElementSkip", () => { + it("reports a diagnostic for a skipped noscript element", () => { + const diagnostics: EpubDiagnostic[] = []; + reportInertElementSkip( + "noscript", + fakeContext((d) => diagnostics.push(d)), + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: EpubDiagnosticCodes.NOSCRIPT_CONTENT_SKIPPED, + severity: "info", + href: "OEBPS/chapter1.xhtml", + }); + expect(diagnostics[0]?.message).toContain("noscript"); + }); + + it("reports nothing for script, template, or style -- only noscript's loss is worth naming", () => { + const diagnostics: EpubDiagnostic[] = []; + const context = fakeContext((d) => diagnostics.push(d)); + reportInertElementSkip("script", context); + reportInertElementSkip("template", context); + reportInertElementSkip("style", context); + expect(diagnostics).toEqual([]); + }); +}); diff --git a/packages/epub-codec/src/xhtml/list-id.test.ts b/packages/epub-codec/src/xhtml/list-id.test.ts index 884c02c5e..400ff7cc8 100644 --- a/packages/epub-codec/src/xhtml/list-id.test.ts +++ b/packages/epub-codec/src/xhtml/list-id.test.ts @@ -24,4 +24,17 @@ describe("mintListNumId / parseListNumId", () => { expect(parseListNumId("list1")).toBeUndefined(); expect(parseListNumId("md1:bullet")).toBeUndefined(); }); + + it("mints a bullet list without an 'ordered@' suffix even when a start is given", () => { + // A bullet list has no start concept -- the type === "ordered" guard must actually gate this. + expect(mintListNumId(4, { type: "bullet", start: 5 })).toBe("epub4:bullet"); + }); + + it("parses an ordered numId with no @start suffix as having no explicit start", () => { + expect(parseListNumId("epub1:ordered")).toEqual({ type: "ordered" }); + }); + + it("ignores an @start suffix on a bullet numId -- start only ever applies to ordered", () => { + expect(parseListNumId("epub1:bullet@5")).toEqual({ type: "bullet" }); + }); }); 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/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 "&amp;" 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 "&". From 623da487915cf98b1b0dacaad4aff01354a9b55b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:51:22 +0100 Subject: [PATCH 12/46] test(epub-codec): add footnote.ts's first dedicated test file sameDocumentFragment, isFootnoteReference, and isFootnoteAside had no test of their own -- only whatever incidental exercise they got through src/xhtml/read.ts's own end-to-end fixtures. Cover the length-2 fragment boundary, the structured epub:type noteref/footnote/rearnote signal, the EPUB 2 class-name idiom on both the anchor and the target, case- insensitivity, the non-aside tag gate, and the some()-not-every() semantics for a multi-value epub:type where only one token matches. --- .../epub-codec/src/xhtml/footnote.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/epub-codec/src/xhtml/footnote.test.ts diff --git a/packages/epub-codec/src/xhtml/footnote.test.ts b/packages/epub-codec/src/xhtml/footnote.test.ts new file mode 100644 index 000000000..e1f12c436 --- /dev/null +++ b/packages/epub-codec/src/xhtml/footnote.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { XmlElement } from "../xml/node"; +import { + isFootnoteAside, + isFootnoteReference, + sameDocumentFragment, +} from "./footnote"; + +function element( + tag: string, + attributes: { name: string; value: string }[] = [], +): XmlElement { + return { type: "element", tag, attributes, children: [] }; +} + +describe("sameDocumentFragment", () => { + it("returns undefined for an undefined href", () => { + expect(sameDocumentFragment(undefined)).toBeUndefined(); + }); + + it("returns undefined for an href with no leading '#'", () => { + expect(sameDocumentFragment("chapter2.xhtml")).toBeUndefined(); + }); + + it("returns undefined for a cross-document href with a fragment", () => { + expect(sameDocumentFragment("chapter2.xhtml#note1")).toBeUndefined(); + }); + + it("returns undefined for a bare '#' with no fragment name", () => { + expect(sameDocumentFragment("#")).toBeUndefined(); + }); + + it("returns the single-character fragment name at the length-2 boundary", () => { + expect(sameDocumentFragment("#a")).toBe("a"); + }); + + it("returns the fragment name for a real same-document href", () => { + expect(sameDocumentFragment("#note1")).toBe("note1"); + }); +}); + +describe("isFootnoteReference", () => { + it('recognises the structured EPUB 3 epub:type="noteref"', () => { + const anchor = element("a", [{ name: "epub:type", value: "noteref" }]); + const target = element("aside"); + expect(isFootnoteReference(anchor, target)).toBe(true); + }); + + it("recognises noteref among several space-separated epub:type values", () => { + const anchor = element("a", [ + { name: "epub:type", value: "footnote noteref" }, + ]); + const target = element("aside"); + expect(isFootnoteReference(anchor, target)).toBe(true); + }); + + it("recognises the EPUB 2 class idiom on the anchor itself", () => { + const anchor = element("a", [{ name: "class", value: "footnote" }]); + const target = element("aside"); + expect(isFootnoteReference(anchor, target)).toBe(true); + }); + + it("recognises the EPUB 2 class idiom on the target when the anchor carries no signal", () => { + const anchor = element("a"); + const target = element("aside", [{ name: "class", value: "noteref" }]); + expect(isFootnoteReference(anchor, target)).toBe(true); + }); + + it("is case-insensitive for the class idiom", () => { + const anchor = element("a", [{ name: "class", value: "FootNote" }]); + expect(isFootnoteReference(anchor, element("aside"))).toBe(true); + }); + + it("is false for an ordinary internal link with neither signal", () => { + const anchor = element("a", [{ name: "epub:type", value: "bodymatter" }]); + const target = element("section"); + expect(isFootnoteReference(anchor, target)).toBe(false); + }); +}); + +describe("isFootnoteAside", () => { + it('recognises epub:type="footnote" on an aside', () => { + expect( + isFootnoteAside( + element("aside", [{ name: "epub:type", value: "footnote" }]), + ), + ).toBe(true); + }); + + it('recognises epub:type="rearnote" on an aside', () => { + expect( + isFootnoteAside( + element("aside", [{ name: "epub:type", value: "rearnote" }]), + ), + ).toBe(true); + }); + + it("recognises footnote among several space-separated epub:type values, not requiring every value to match", () => { + expect( + isFootnoteAside( + element("aside", [{ name: "epub:type", value: "footnote bodymatter" }]), + ), + ).toBe(true); + }); + + it('is false for a non-aside element, even carrying epub:type="footnote"', () => { + expect( + isFootnoteAside( + element("div", [{ name: "epub:type", value: "footnote" }]), + ), + ).toBe(false); + }); + + it("is false for an aside with an unrelated epub:type", () => { + expect( + isFootnoteAside( + element("aside", [{ name: "epub:type", value: "bodymatter" }]), + ), + ).toBe(false); + }); + + it("is false for an aside with no epub:type at all", () => { + expect(isFootnoteAside(element("aside"))).toBe(false); + }); +}); From 7f5fca94936055a2a9061e173375d55e453b0fbd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 21:55:14 +0100 Subject: [PATCH 13/46] test(epub-codec): assert parseOpf's error messages and its item-filtering gates Every parseOpf failure path had a class-only toThrow assertion; add message text for each. Also cover the filter() predicates that drop a malformed manifest item or a spine itemref with no idref (both previously only ever exercised with well-formed input), and a manifest item's multi-valued properties splitting on more than a single whitespace character. --- packages/epub-codec/src/opf/parse.test.ts | 70 +++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/epub-codec/src/opf/parse.test.ts b/packages/epub-codec/src/opf/parse.test.ts index 9733b4961..e2275067d 100644 --- a/packages/epub-codec/src/opf/parse.test.ts +++ b/packages/epub-codec/src/opf/parse.test.ts @@ -56,6 +56,51 @@ describe("parseOpf", () => { }); }); + it("splits a manifest item's multi-valued properties on any whitespace run", () => { + const { manifest } = parseOpf( + ` + + + + + `, + ); + expect(manifest[0]?.properties).toEqual(["nav", "scripted"]); + }); + + it("skips a manifest item missing a required attribute, keeping the well-formed ones", () => { + const { manifest } = parseOpf( + ` + + + + + + `, + ); + expect(manifest).toEqual([ + { + id: "ok", + href: "ok.xhtml", + mediaType: "application/xhtml+xml", + properties: [], + }, + ]); + }); + + it("skips a spine itemref with no idref, keeping the well-formed ones", () => { + const { spine } = parseOpf( + ` + + + + + + `, + ); + expect(spine).toEqual([{ idref: "chapter1", linear: true }]); + }); + it("reads the spine in document order, with linear=no honoured", () => { const { spine, ncxId } = parseOpf(OPF_XML); expect(spine).toEqual([ @@ -68,22 +113,27 @@ describe("parseOpf", () => { it("throws EpubInvalidOpfError with no root", () => { expect(() => parseOpf("")).toThrow(EpubInvalidOpfError); + expect(() => parseOpf("")).toThrow( + "the OPF document has no root element", + ); }); it("throws EpubInvalidOpfError with no ", () => { - expect(() => - parseOpf( - '', - ), - ).toThrow(EpubInvalidOpfError); + const xml = + ''; + expect(() => parseOpf(xml)).toThrow(EpubInvalidOpfError); + expect(() => parseOpf(xml)).toThrow( + "the OPF document has no element", + ); }); it("throws EpubInvalidOpfError with no ", () => { - expect(() => - parseOpf( - '', - ), - ).toThrow(EpubInvalidOpfError); + const xml = + ''; + expect(() => parseOpf(xml)).toThrow(EpubInvalidOpfError); + expect(() => parseOpf(xml)).toThrow( + "the OPF document has no element", + ); }); it("tolerates a missing element", () => { From e2ba8c5a93979ba8ae33789a59429cf2721c7e27 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 22:25:05 +0100 Subject: [PATCH 14/46] refactor(epub-codec): collapse redundant XHTML block-read branches and guards readBlockElementInner's own case "p"/"figure"/"figcaption" each called readContainerChildren identically to the default passthrough case, so they were dead duplicates rather than distinct behaviour; folded them into default and moved their rationale into that case's own comment. headingLevelOf used a /^h([1-6])$/ regex even though the only tags it is ever asked to classify come from the fixed BLOCK_LEVEL_TAGS set, none of which (besides h1-h6 itself) can accidentally satisfy a mis-anchored version of that pattern; replaced it with a literal tag->level lookup table, which also removes the now-unneeded clampHeadingLevel call. Removed three `X.length === 0` guards (readContainerChildren's own segment flush, flushListStrayContent, flushDefinitionListStrayContent, readTable's stray-block computation, and a table row's own stray-cell flush) that only ever short-circuited to a state the following non-empty-result check already reaches on its own, since readContainerChildren/buildInlineRuns are themselves no-ops on an empty input array. Simplified readPreRuns' own flat-text branch condition from `footnoteName === undefined && !containsFootnoteReference([node], ...)` to just the second half: footnoteName can only be defined when node is a footnote-reference , which is exactly what containsFootnoteReference's own first check already tests on that same single-element array, so the first half never adds information the second half didn't already carry. --- packages/epub-codec/src/xhtml/read.ts | 53 ++++++++++----------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/packages/epub-codec/src/xhtml/read.ts b/packages/epub-codec/src/xhtml/read.ts index c91efd77b..a12ff5d50 100644 --- a/packages/epub-codec/src/xhtml/read.ts +++ b/packages/epub-codec/src/xhtml/read.ts @@ -9,7 +9,6 @@ import type { SourceResidue, TextDirection, } from "document-schema.js"; -import { clampHeadingLevel } from "document-schema.js"; import { EpubDiagnosticCodes } from "../diagnostics"; import { detectImageFormat, @@ -370,9 +369,7 @@ function readContainerChildren( const blocks: ContentBlock[] = []; let segment: XmlNode[] = []; const flush = (): void => { - if (segment.length === 0) { - return; - } + // No separate empty-segment early return is needed here: buildInlineRuns([], ...) is a genuine no-op (its own for loop never executes, so it returns {runs: [], constructs: []} with no side effect), and the whitespace-and-construct check immediately below already drops that empty result exactly like it drops a genuinely whitespace-only segment -- an explicit `if (segment.length === 0) return;` guard ahead of it would only ever short-circuit to a state this check reaches anyway. const inline = buildInlineRuns(segment, baseStyle, state.context); segment = []; // A segment whose only content, once built, is whitespace produces no visible paragraph -- the common real-world case being pretty-printed XHTML's own indentation landing as a bare text node between two block-level siblings (e.g. the newline-plus-indent between and its first real child), which every browser's own block-formatting context already collapses to nothing rather than an empty line. The identical rule also covers a producer's own literal `

`/`

` (used for CSS spacing): both read as "no content here" rather than a bogus empty ContentParagraph, matching this package's own documented choice to drop an empty paragraph entirely on read. The construct check alongside it exists for the identical reason readTable's own caption guard needs one: a segment carrying only a footnote-reference anchor with empty text (`
` sitting bare between two block siblings) produces zero text runs but one real RunConstructExtent, and text-emptiness alone would drop that construct along with the whitespace it is vacuously indistinguishable from. @@ -403,11 +400,18 @@ function readContainerChildren( return blocks; } +// A literal tag->level lookup rather than a /^h([1-6])$/ regex: BLOCK_LEVEL_TAGS is the only source of tags this ever sees (readBlockElementInner calls it once per block-level element), and every member of that fixed set other than h1-h6 itself (p, ul, ol, dl, table, blockquote, pre, hr, figure, figcaption, div, section, article, aside, nav, img) already fails to match a heading tag by construction -- there is no producer-supplied tag this function is ever actually asked to classify that a regex's own anchoring subtleties could get wrong. clampHeadingLevel is unneeded here for the identical reason: every value in this table is already a valid heading depth (document-schema.js's own clamp exists for a source format that can carry a level outside 1-6, which a fixed tag literal can never do). +const HEADING_TAG_LEVELS: Readonly> = { + h1: 1, + h2: 2, + h3: 3, + h4: 4, + h5: 5, + h6: 6, +}; + function headingLevelOf(tag: string): number | undefined { - const match = /^h([1-6])$/u.exec(tag); - return match?.[1] === undefined - ? undefined - : clampHeadingLevel(Number(match[1])); + return HEADING_TAG_LEVELS[tag]; } // Wraps readBlockElementInner's own result in an anchor construct pair (footnote or bookmark) when this element's own id is a recognised anchor target -- src/xhtml/read.ts's own whole-document (and, via ReadXhtmlBodyOptions.extraAnchorTargets, whole-spine, ExaDev/documents.js#963) anchor-target registry, run over every once by readXhtmlBody -- the target-side half of the EPUB 2 linked-anchor idiom and of an ordinary internal link, symmetric with an EPUB 3