1 Commits
Author SHA1 Message Date
zemion efd80eb18a Release EPUB Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 08:53:49 +02:00
41 changed files with 3250 additions and 614 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded, deterministic UTF-8 HTML/XHTML, Markdown and plain-text import
adapters that create an explicit one-chapter EPUB workspace with loss
evidence and no network resource fetching.
- Synchronized safe-reader backgrounds and text with explicit Toolbox light, dark, and system themes.
- Added visible, accessible phase and item-count progress while an EPUB opens and its first chapter is prepared.
- Resolved and validated both modern SVG `href` and legacy namespaced `xlink:href` image references, including standalone SVG title pages.
- Disabled namespaced SVG navigation links, reused object URLs for repeated images, and bounded unique/cumulative chapter image extraction.
- Added unit and cross-browser regression coverage for opening progress, dark reader contrast, SVG cover rendering, broken XLink resources, and repeated-image caching.
- Hardened standalone HTML export with a strict DOMPurify element/attribute
allowlist so publication meta refreshes, base URLs, templates, frames and
other active or network-capable structures cannot survive into the result.
## 0.1.0 - 2026-09-01
- Added the initial local-first EPUB Tools workbench.
+29 -8
View File
@@ -1,16 +1,23 @@
# EPUB Tools
EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved.
EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. Bounded UTF-8 HTML/XHTML, Markdown and plain text can also be adapted locally into an explicit one-chapter EPUB workspace.
## Version 0.1.0
## Current capabilities
- Opens strict ZIP-based EPUB containers and parses `container.xml`, OPF metadata, manifest, spine, EPUB 3 navigation documents, and EPUB 2 NCX navigation.
- Shows a reading-order sidebar and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Local images are resolved to temporary object URLs.
- Adapts UTF-8 HTML/XHTML through a structural allowlist, a documented
lightweight Markdown subset, or plain text into a deterministic one-chapter
EPUB workspace; every adapter records its format loss in the validation view.
- Shows named, determinate opening progress while the archive, package, navigation, and content documents are inspected.
- Shows the real nested EPUB navigation tree (with spine fallback) and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Internal chapter/fragment links are handled by trusted reader controls; external links remain inert. The reader follows the Toolbox light, dark, or system theme.
- Resolves bounded packaged stylesheets, fonts, images, audio/video, SVG `href`/legacy `xlink:href`, and CSS URLs to temporary object URLs. CSS imports, active constructs, network URLs, and unresolved resources are removed or blocked by the iframe CSP.
- Interprets package/document direction, page progression, and rendition layout/orientation/spread, including a fixed-layout presentation mode.
- Searches bounded reading-order text with progress/cancellation and adds in-memory bookmarks, notes, and selected-text quotations that can be explicitly exported/imported as publication-scoped JSON.
- Inventories the package and checks required metadata, mimetype ordering/compression, manifest/spine targets, navigation, active content, external resources, missing relative links, hidden entries, encryption metadata, and signatures.
- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text, and downloads sanitized chapters.
- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text/Markdown/safe standalone HTML, and downloads sanitized chapters.
- Rebuilds a fresh EPUB with the uncompressed mimetype first, sorted paths, optional normalized timestamps, updated OPF metadata, and an optional replacement cover.
The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. DRM is detected and reported; it is never bypassed. Rebuilds are disabled when encryption metadata is present and invalidate existing signatures.
The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. Unsupported encryption and DRM are detected and reported; they are never bypassed. The standardized IDPF font-obfuscation mask is decoded locally for reading and preserved (including identifier re-keying) during rebuilds. Rebuilds remain disabled for unsupported protection and invalidate existing signatures.
## Safety limits
@@ -18,10 +25,24 @@ The validator is a focused preflight, not a browser port of EPUBCheck. Version 0
- 64 MiB per resource, 500:1 maximum declared expansion ratio
- 16 MiB package/chapter parse limit
- Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed
- Reader image resolution is capped at 25 MiB per image; plain-text export at 32 MiB
- Reader local-resource resolution is capped at 25 MiB per resource, 128 unique resources, and 128 MiB total per rendered chapter; repeated references reuse one object URL. At most 16 packaged stylesheets, 2 MiB each/8 MiB total, are loaded. Search reads at most 2,000 documents, 8 MiB each/64 MiB total. Derived text/HTML/Markdown export is capped at 32 MiB.
- Absolute, backslash, drive-letter, NUL, and parent-traversal archive paths are rejected
DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources, navigation, stylesheet links, CSS imports/URLs, and event handlers are blocked or removed from the reader surface. Rendering still depends on the current browser and installed fonts.
DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources/navigation, CSS imports/network URLs, and event handlers are blocked or removed from the reader surface. The iframe permits same-origin DOM access only so the trusted parent reader can intercept internal links, scroll to fragments, and capture an explicitly selected quote; publication scripts remain absent and the iframe CSP denies network access. Rendering still depends on the current browser and font decoder.
## Format and export direction
EPUB 2/3 plus bounded UTF-8 HTML/XHTML, Markdown and plain text are current
import formats. Remaining deeper-fidelity work includes vertical-writing
controls, media-overlay playback, multiple-rendition selection, and more
exhaustive accessibility/CSS validation. Further modular, DRM-free import
adapters remain candidates for:
1. MOBI/PRC and KF8 (`.azw3`), then FB2/FB2.ZIP and CBZ.
2. HTMLZ and asset-preserving HTML packages.
3. KFX only as an explicitly experimental later adapter if a maintainable, auditable parser and representative DRM-free fixtures are available. KFX is not treated as a general interchange or publishing target, and protected books will not be decrypted.
Current downloadable outputs are a rebuilt EPUB, reading-order text, text-centric Markdown, safe standalone HTML, a sanitized XHTML chapter, original cover, reading-notes JSON, and JSON metadata/validation/package reports. Asset-preserving HTML ZIP, a print/PDF hand-off, CBZ for image-only publications, and stricter EPUB 3 publishing profiles remain future candidates. Lossy conversion is labelled, and EPUB remains the preferred editable/publishing output.
## Development
@@ -33,7 +54,7 @@ npm run check
npm run test:browser
```
`npm run release:artifact` creates `release/epub-tools-0.1.0.zip` and its SHA-256 sidecar.
`npm run release:artifact` creates `release/epub-tools-0.2.0.zip` and its SHA-256 sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for EPUB Tools 0.1.0 is available at:
The corresponding source for EPUB Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+2 -2
View File
@@ -4,8 +4,8 @@ Production release inventories reproduce exact dependency licence texts under `L
- `@zip.js/zip.js` 2.9.0 — BSD-3-Clause — bounded ZIP reading and EPUB rebuilding.
- `dompurify` 3.4.14 — MPL-2.0 OR Apache-2.0 — active-content sanitization before sandboxed rendering.
- Toolbox contract, shell, and test packages 0.2.3 — Apache-2.0.
- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0.
- `@add-ideas/toolbox-helpers` 0.2.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
- React and React DOM 19.2.8 — MIT.
EPUB publications opened by a user are not bundled and retain their own copyright and licence terms.
+1 -1
View File
@@ -1,5 +1,5 @@
# Accessibility
The application uses labelled file inputs, fields, tabs, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. The iframe has a chapter-specific accessible title.
The application uses labelled file inputs, fields, pressed-state workspace buttons, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. Opening progress is announced as a polite status with the selected filename and a labelled native progress element. The iframe has a chapter-specific accessible title and explicit light/dark background and foreground colours.
Reader sanitization can remove scripted or form-based interactions by design. EPUB-internal navigation is disabled in v0.1; the Toolbox reading-order sidebar remains keyboard accessible. Publisher content can itself have poor semantics, contrast, directionality, alt text, or reading order. The safe reader does not certify EPUB accessibility and its fallback stylesheet may change publisher presentation.
+9 -4
View File
@@ -2,12 +2,17 @@
The app is a relocatable static React application using the Toolbox shell and contract. EPUB work is divided into small TypeScript modules under `src/epub/`:
- `archive.ts` applies ZIP/path/entry/ratio limits and opens the container with zip.js.
- `archive.ts` applies ZIP/path/entry/ratio limits, opens the container with zip.js, and reports bounded phase/item progress to the workbench.
- `adapters.ts` turns bounded UTF-8 HTML/XHTML, Markdown, and plain text into a
deterministic one-chapter EPUB workspace before the same archive validation
path runs. The HTML adapter retains only a small structural element/attribute
allowlist; Markdown implements a documented lightweight subset.
- `xml.ts`, `package.ts`, and `paths.ts` decode bounded XML, reject DTD/entity input, parse OPF/navigation/NCX, and resolve package-relative references without traversal.
- `validation.ts` performs bounded package and cross-document diagnostics and reports skipped scope.
- `reader.ts` sanitizes content with DOMPurify, neutralizes navigation and CSS URLs, resolves bounded local images, injects a restrictive iframe CSP, and returns object URLs with an explicit revocation lifecycle.
- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, and reading-order text exports.
- `reader.ts` sanitizes content with DOMPurify, neutralizes regular/namespaced navigation, rewrites bounded packaged stylesheets/fonts/images/media and CSS URLs to local object URLs, injects a restrictive iframe CSP plus direction/layout/theme metadata, and returns cached URLs with an explicit revocation lifecycle.
- `search.ts` extracts bounded inert reading-order text and performs cancellable literal search. `annotations.ts` validates deterministic, publication-scoped bookmark/annotation interchange without silently persisting book data.
- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, reading-order text, text-centric Markdown, and standalone HTML. The HTML path replaces packaged media, removes links and active controls, then applies a strict DOMPurify structural element/attribute allowlist before adding a deny-by-default CSP.
zip.js receives a lazy browser `BlobReader`, performs CRC checks when entry data is read, and can use web workers. React retains one open reader and closes the previous reader when a new book replaces it. The original File is immutable; all changes exist only in staged React state or a newly downloaded Blob.
The safe reader uses both sanitization and an iframe without sandbox permissions. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheet links are intentionally removed in v0.1.
The safe reader uses both sanitization and an iframe with `allow-same-origin` but without scripts, forms, popups, navigation, downloads, modals, or device permissions. Same-origin access lets the trusted parent intercept inert EPUB links, scroll to a target fragment, and read an explicitly selected quote. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheets are size/count bounded; imports and network URLs are removed, while CSP remains the final network boundary. A mutation observer synchronizes explicit light/dark reader tokens with Toolbox preference changes; system mode follows the browser media preference.
+14 -3
View File
@@ -2,8 +2,19 @@
All source and generated publication data remains in the browser. The app has no telemetry, account, analytics, remote font, CDN, or default network integration. The production CSP keeps `connect-src` and worker sources same-origin.
EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is reported because it can describe valid font obfuscation or DRM; the app does not distinguish every scheme and never attempts circumvention.
EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is inspected: the standardized IDPF font-obfuscation mask is decoded locally, while unsupported protection is reported and never circumvented.
Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers, navigation and resource references are neutralized or replaced with bounded local object URLs, and rendering happens inside a permissionless sandboxed iframe with its own restrictive CSP. Temporary object URLs are revoked when chapters change.
HTML/XHTML, Markdown, and plain-text adapters accept at most 16 MiB of valid
UTF-8. HTML is parsed inertly and copied through a small structural
element/attribute allowlist; scripts, styles, forms, event handlers, embedded
media, external resources, and non-fragment links are not transferred. The
adapter bounds nodes, nesting and Markdown lines, derives a local content
identifier with Web Crypto, and then sends its generated one-chapter EPUB
through the same strict archive and reader path. The reported losses are part
of the resulting workspace rather than a claim of source fidelity.
Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for encrypted publications.
Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers; regular and namespaced SVG navigation/resource attributes are neutralized or replaced with bounded local object URLs; and rendering happens inside a scriptless sandboxed iframe with its own restrictive CSP. The iframe has `allow-same-origin` solely so trusted application code can intercept internal links, scroll to fragments, and capture a quote the user explicitly selected; it receives no script, form, popup, top-navigation, download, modal, or device permissions. Stylesheets/resources are bounded, CSS imports and external URLs are removed, object URLs are reused per resource and revoked when chapters change.
Search operates on bounded extracted text. Bookmarks and annotations remain in React memory unless the user explicitly downloads their JSON; importing validates schema, counts, path safety, field sizes, and publication identifier. Derived HTML/Markdown/text exports intentionally omit active content and network resources and are lossy reading-order representations, not publication-preserving conversions. Standalone HTML is rebuilt through a strict DOMPurify structural allowlist that excludes meta refreshes, base URLs, templates, frames, links, media and event/style attributes, then receives a deny-by-default CSP.
Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for unsupported encrypted publications. IDPF-obfuscated fonts are re-keyed if the unique publication identifier changes.
+22 -23
View File
@@ -1,24 +1,24 @@
{
"name": "epub-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "epub-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@zip.js/zip.js": "2.9.0",
"dompurify": "3.4.14",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -44,24 +44,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -69,17 +69,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "epub-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Read, inspect and repair EPUB publications locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,16 +39,16 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@zip.js/zip.js": "2.9.0",
"dompurify": "3.4.14",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded, deterministic UTF-8 HTML/XHTML, Markdown and plain-text import
adapters that create an explicit one-chapter EPUB workspace with loss
evidence and no network resource fetching.
- Synchronized safe-reader backgrounds and text with explicit Toolbox light, dark, and system themes.
- Added visible, accessible phase and item-count progress while an EPUB opens and its first chapter is prepared.
- Resolved and validated both modern SVG `href` and legacy namespaced `xlink:href` image references, including standalone SVG title pages.
- Disabled namespaced SVG navigation links, reused object URLs for repeated images, and bounded unique/cumulative chapter image extraction.
- Added unit and cross-browser regression coverage for opening progress, dark reader contrast, SVG cover rendering, broken XLink resources, and repeated-image caching.
- Hardened standalone HTML export with a strict DOMPurify element/attribute
allowlist so publication meta refreshes, base URLs, templates, frames and
other active or network-capable structures cannot survive into the result.
## 0.1.0 - 2026-09-01
- Added the initial local-first EPUB Tools workbench.
+4 -370
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -1141,20 +1141,10 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
==============================================================================
dompurify@3.3.3
dompurify@3.4.14
Declared licence: (MPL-2.0 OR Apache-2.0)
==============================================================================
--- LICENSE ---
DOMPurify
Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53
DOMPurify is free software; you can redistribute it and/or modify it under the
terms of either:
a) the Apache License Version 2.0, or
b) the Mozilla Public License Version 2.0
-----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
@@ -1358,362 +1348,6 @@ b) the Mozilla Public License Version 2.0
See the License for the specific language governing permissions and
limitations under the License.
-----------------------------------------------------------------------------
Mozilla Public License, version 2.0
1. Definitions
1.1. “Contributor”
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. “Contributor Version”
means the combination of the Contributions of others (if any) used by a
Contributor and that particular Contributors Contribution.
1.3. “Contribution”
means Covered Software of a particular Contributor.
1.4. “Covered Software”
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form, and
Modifications of such Source Code Form, in each case including portions
thereof.
1.5. “Incompatible With Secondary Licenses”
means
a. that the initial Contributor has attached the notice described in
Exhibit B to the Covered Software; or
b. that the Covered Software was made available under the terms of version
1.1 or earlier of the License, but not also under the terms of a
Secondary License.
1.6. “Executable Form”
means any form of the work other than Source Code Form.
1.7. “Larger Work”
means a work that combines Covered Software with other material, in a separate
file or files, that is not Covered Software.
1.8. “License”
means this document.
1.9. “Licensable”
means having the right to grant, to the maximum extent possible, whether at the
time of the initial grant or subsequently, any and all of the rights conveyed by
this License.
1.10. “Modifications”
means any of the following:
a. any file in Source Code Form that results from an addition to, deletion
from, or modification of the contents of Covered Software; or
b. any new file in Source Code Form that contains any Covered Software.
1.11. “Patent Claims” of a Contributor
means any patent claim(s), including without limitation, method, process,
and apparatus claims, in any patent Licensable by such Contributor that
would be infringed, but for the grant of the License, by the making,
using, selling, offering for sale, having made, import, or transfer of
either its Contributions or its Contributor Version.
1.12. “Secondary License”
means either the GNU General Public License, Version 2.0, the GNU Lesser
General Public License, Version 2.1, the GNU Affero General Public
License, Version 3.0, or any later versions of those licenses.
1.13. “Source Code Form”
means the form of the work preferred for making modifications.
1.14. “You” (or “Your”)
means an individual or a legal entity exercising rights under this
License. For legal entities, “You” includes any entity that controls, is
controlled by, or is under common control with You. For purposes of this
definition, “control” means (a) the power, direct or indirect, to cause
the direction or management of such entity, whether by contract or
otherwise, or (b) ownership of more than fifty percent (50%) of the
outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
a. under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or as
part of a Larger Work; and
b. under Patent Claims of such Contributor to make, use, sell, offer for
sale, have made, import, and otherwise transfer either its Contributions
or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution become
effective for each Contribution on the date the Contributor first distributes
such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under this
License. No additional rights or licenses will be implied from the distribution
or licensing of Covered Software under this License. Notwithstanding Section
2.1(b) above, no patent license is granted by a Contributor:
a. for any code that a Contributor has removed from Covered Software; or
b. for infringements caused by: (i) Your and any other third partys
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
c. under Patent Claims infringed by Covered Software in the absence of its
Contributions.
This License does not grant any rights in the trademarks, service marks, or
logos of any Contributor (except as may be necessary to comply with the
notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this License
(see Section 10.2) or under the terms of a Secondary License (if permitted
under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its Contributions
are its original creation(s) or it has sufficient rights to grant the
rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under applicable
copyright doctrines of fair use, fair dealing, or other equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under the
terms of this License. You must inform recipients that the Source Code Form
of the Covered Software is governed by the terms of this License, and how
they can obtain a copy of this License. You may not attempt to alter or
restrict the recipients rights in the Source Code Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
a. such Covered Software must also be made available in Source Code Form,
as described in Section 3.1, and You must inform recipients of the
Executable Form how they can obtain a copy of such Source Code Form by
reasonable means in a timely manner, at a charge no more than the cost
of distribution to the recipient; and
b. You may distribute such Executable Form under the terms of this License,
or sublicense it under different terms, provided that the license for
the Executable Form does not attempt to limit or alter the recipients
rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for the
Covered Software. If the Larger Work is a combination of Covered Software
with a work governed by one or more Secondary Licenses, and the Covered
Software is not Incompatible With Secondary Licenses, this License permits
You to additionally distribute such Covered Software under the terms of
such Secondary License(s), so that the recipient of the Larger Work may, at
their option, further distribute the Covered Software under the terms of
either this License or such Secondary License(s).
3.4. Notices
You may not remove or alter the substance of any license notices (including
copyright notices, patent notices, disclaimers of warranty, or limitations
of liability) contained within the Source Code Form of the Covered
Software, except that You may alter any license notices to the extent
required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on behalf
of any Contributor. You must make it absolutely clear that any such
warranty, support, indemnity, or liability obligation is offered by You
alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute, judicial
order, or regulation then You must: (a) comply with the terms of this License
to the maximum extent possible; and (b) describe the limitations and the code
they affect. Such description must be placed in a text file included with all
distributions of the Covered Software under this License. Except to the
extent prohibited by statute or regulation, such description must be
sufficiently detailed for a recipient of ordinary skill to be able to
understand it.
5. Termination
5.1. The rights granted under this License will terminate automatically if You
fail to comply with any of its terms. However, if You become compliant,
then the rights granted under this License from a particular Contributor
are reinstated (a) provisionally, unless and until such Contributor
explicitly and finally terminates Your grants, and (b) on an ongoing basis,
if such Contributor fails to notify You of the non-compliance by some
reasonable means prior to 60 days after You have come back into compliance.
Moreover, Your grants from a particular Contributor are reinstated on an
ongoing basis if such Contributor notifies You of the non-compliance by
some reasonable means, this is the first time You have received notice of
non-compliance with this License from such Contributor, and You become
compliant prior to 30 days after Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions, counter-claims,
and cross-claims) alleging that a Contributor Version directly or
indirectly infringes any patent, then the rights granted to You by any and
all Contributors for the Covered Software under Section 2.1 of this License
shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
license agreements (excluding distributors and resellers) which have been
validly granted by You or Your distributors under this License prior to
termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an “as is” basis, without
warranty of any kind, either expressed, implied, or statutory, including,
without limitation, warranties that the Covered Software is free of defects,
merchantable, fit for a particular purpose or non-infringing. The entire
risk as to the quality and performance of the Covered Software is with You.
Should any Covered Software prove defective in any respect, You (not any
Contributor) assume the cost of any necessary servicing, repair, or
correction. This disclaimer of warranty constitutes an essential part of this
License. No use of any Covered Software is authorized under this License
except under this disclaimer.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any
character including, without limitation, damages for lost profits, loss of
goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses, even if such party shall have been
informed of the possibility of such damages. This limitation of liability
shall not apply to liability for death or personal injury resulting from such
partys negligence to the extent applicable law prohibits such limitation.
Some jurisdictions do not allow the exclusion or limitation of incidental or
consequential damages, so this exclusion and limitation may not apply to You.
8. Litigation
Any litigation relating to this License may be brought only in the courts of
a jurisdiction where the defendant maintains its principal place of business
and such litigation shall be governed by laws of that jurisdiction, without
reference to its conflict-of-law provisions. Nothing in this Section shall
prevent a partys ability to bring cross-claims or counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject matter
hereof. If any provision of this License is held to be unenforceable, such
provision shall be reformed only to the extent necessary to make it
enforceable. Any law or regulation which provides that the language of a
contract shall be construed against the drafter shall not be used to construe
this License against a Contributor.
10. Versions of the License
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version of
the License under which You originally received the Covered Software, or
under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a modified
version of this License if you rename the license and remove any
references to the name of the license steward (except to note that such
modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file, then
You may include the notice in a location (such as a LICENSE file in a relevant
directory) where a recipient would be likely to look for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - “Incompatible With Secondary Licenses” Notice
This Source Code Form is “Incompatible
With Secondary Licenses”, as defined by
the Mozilla Public License, v. 2.0.
==============================================================================
react@19.2.8
+29 -8
View File
@@ -1,16 +1,23 @@
# EPUB Tools
EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved.
EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. Bounded UTF-8 HTML/XHTML, Markdown and plain text can also be adapted locally into an explicit one-chapter EPUB workspace.
## Version 0.1.0
## Current capabilities
- Opens strict ZIP-based EPUB containers and parses `container.xml`, OPF metadata, manifest, spine, EPUB 3 navigation documents, and EPUB 2 NCX navigation.
- Shows a reading-order sidebar and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Local images are resolved to temporary object URLs.
- Adapts UTF-8 HTML/XHTML through a structural allowlist, a documented
lightweight Markdown subset, or plain text into a deterministic one-chapter
EPUB workspace; every adapter records its format loss in the validation view.
- Shows named, determinate opening progress while the archive, package, navigation, and content documents are inspected.
- Shows the real nested EPUB navigation tree (with spine fallback) and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Internal chapter/fragment links are handled by trusted reader controls; external links remain inert. The reader follows the Toolbox light, dark, or system theme.
- Resolves bounded packaged stylesheets, fonts, images, audio/video, SVG `href`/legacy `xlink:href`, and CSS URLs to temporary object URLs. CSS imports, active constructs, network URLs, and unresolved resources are removed or blocked by the iframe CSP.
- Interprets package/document direction, page progression, and rendition layout/orientation/spread, including a fixed-layout presentation mode.
- Searches bounded reading-order text with progress/cancellation and adds in-memory bookmarks, notes, and selected-text quotations that can be explicitly exported/imported as publication-scoped JSON.
- Inventories the package and checks required metadata, mimetype ordering/compression, manifest/spine targets, navigation, active content, external resources, missing relative links, hidden entries, encryption metadata, and signatures.
- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text, and downloads sanitized chapters.
- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text/Markdown/safe standalone HTML, and downloads sanitized chapters.
- Rebuilds a fresh EPUB with the uncompressed mimetype first, sorted paths, optional normalized timestamps, updated OPF metadata, and an optional replacement cover.
The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. DRM is detected and reported; it is never bypassed. Rebuilds are disabled when encryption metadata is present and invalidate existing signatures.
The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. Unsupported encryption and DRM are detected and reported; they are never bypassed. The standardized IDPF font-obfuscation mask is decoded locally for reading and preserved (including identifier re-keying) during rebuilds. Rebuilds remain disabled for unsupported protection and invalidate existing signatures.
## Safety limits
@@ -18,10 +25,24 @@ The validator is a focused preflight, not a browser port of EPUBCheck. Version 0
- 64 MiB per resource, 500:1 maximum declared expansion ratio
- 16 MiB package/chapter parse limit
- Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed
- Reader image resolution is capped at 25 MiB per image; plain-text export at 32 MiB
- Reader local-resource resolution is capped at 25 MiB per resource, 128 unique resources, and 128 MiB total per rendered chapter; repeated references reuse one object URL. At most 16 packaged stylesheets, 2 MiB each/8 MiB total, are loaded. Search reads at most 2,000 documents, 8 MiB each/64 MiB total. Derived text/HTML/Markdown export is capped at 32 MiB.
- Absolute, backslash, drive-letter, NUL, and parent-traversal archive paths are rejected
DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources, navigation, stylesheet links, CSS imports/URLs, and event handlers are blocked or removed from the reader surface. Rendering still depends on the current browser and installed fonts.
DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources/navigation, CSS imports/network URLs, and event handlers are blocked or removed from the reader surface. The iframe permits same-origin DOM access only so the trusted parent reader can intercept internal links, scroll to fragments, and capture an explicitly selected quote; publication scripts remain absent and the iframe CSP denies network access. Rendering still depends on the current browser and font decoder.
## Format and export direction
EPUB 2/3 plus bounded UTF-8 HTML/XHTML, Markdown and plain text are current
import formats. Remaining deeper-fidelity work includes vertical-writing
controls, media-overlay playback, multiple-rendition selection, and more
exhaustive accessibility/CSS validation. Further modular, DRM-free import
adapters remain candidates for:
1. MOBI/PRC and KF8 (`.azw3`), then FB2/FB2.ZIP and CBZ.
2. HTMLZ and asset-preserving HTML packages.
3. KFX only as an explicitly experimental later adapter if a maintainable, auditable parser and representative DRM-free fixtures are available. KFX is not treated as a general interchange or publishing target, and protected books will not be decrypted.
Current downloadable outputs are a rebuilt EPUB, reading-order text, text-centric Markdown, safe standalone HTML, a sanitized XHTML chapter, original cover, reading-notes JSON, and JSON metadata/validation/package reports. Asset-preserving HTML ZIP, a print/PDF hand-off, CBZ for image-only publications, and stricter EPUB 3 publishing profiles remain future candidates. Lossy conversion is labelled, and EPUB remains the preferred editable/publishing output.
## Development
@@ -33,7 +54,7 @@ npm run check
npm run test:browser
```
`npm run release:artifact` creates `release/epub-tools-0.1.0.zip` and its SHA-256 sidecar.
`npm run release:artifact` creates `release/epub-tools-0.2.0.zip` and its SHA-256 sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for EPUB Tools 0.1.0 is available at:
The corresponding source for EPUB Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+2 -2
View File
@@ -4,8 +4,8 @@ Production release inventories reproduce exact dependency licence texts under `L
- `@zip.js/zip.js` 2.9.0 — BSD-3-Clause — bounded ZIP reading and EPUB rebuilding.
- `dompurify` 3.4.14 — MPL-2.0 OR Apache-2.0 — active-content sanitization before sandboxed rendering.
- Toolbox contract, shell, and test packages 0.2.3 — Apache-2.0.
- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0.
- `@add-ideas/toolbox-helpers` 0.2.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
- React and React DOM 19.2.8 — MIT.
EPUB publications opened by a user are not bundled and retain their own copyright and licence terms.
+1 -1
View File
@@ -1,5 +1,5 @@
# Accessibility
The application uses labelled file inputs, fields, tabs, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. The iframe has a chapter-specific accessible title.
The application uses labelled file inputs, fields, pressed-state workspace buttons, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. Opening progress is announced as a polite status with the selected filename and a labelled native progress element. The iframe has a chapter-specific accessible title and explicit light/dark background and foreground colours.
Reader sanitization can remove scripted or form-based interactions by design. EPUB-internal navigation is disabled in v0.1; the Toolbox reading-order sidebar remains keyboard accessible. Publisher content can itself have poor semantics, contrast, directionality, alt text, or reading order. The safe reader does not certify EPUB accessibility and its fallback stylesheet may change publisher presentation.
+9 -4
View File
@@ -2,12 +2,17 @@
The app is a relocatable static React application using the Toolbox shell and contract. EPUB work is divided into small TypeScript modules under `src/epub/`:
- `archive.ts` applies ZIP/path/entry/ratio limits and opens the container with zip.js.
- `archive.ts` applies ZIP/path/entry/ratio limits, opens the container with zip.js, and reports bounded phase/item progress to the workbench.
- `adapters.ts` turns bounded UTF-8 HTML/XHTML, Markdown, and plain text into a
deterministic one-chapter EPUB workspace before the same archive validation
path runs. The HTML adapter retains only a small structural element/attribute
allowlist; Markdown implements a documented lightweight subset.
- `xml.ts`, `package.ts`, and `paths.ts` decode bounded XML, reject DTD/entity input, parse OPF/navigation/NCX, and resolve package-relative references without traversal.
- `validation.ts` performs bounded package and cross-document diagnostics and reports skipped scope.
- `reader.ts` sanitizes content with DOMPurify, neutralizes navigation and CSS URLs, resolves bounded local images, injects a restrictive iframe CSP, and returns object URLs with an explicit revocation lifecycle.
- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, and reading-order text exports.
- `reader.ts` sanitizes content with DOMPurify, neutralizes regular/namespaced navigation, rewrites bounded packaged stylesheets/fonts/images/media and CSS URLs to local object URLs, injects a restrictive iframe CSP plus direction/layout/theme metadata, and returns cached URLs with an explicit revocation lifecycle.
- `search.ts` extracts bounded inert reading-order text and performs cancellable literal search. `annotations.ts` validates deterministic, publication-scoped bookmark/annotation interchange without silently persisting book data.
- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, reading-order text, text-centric Markdown, and standalone HTML. The HTML path replaces packaged media, removes links and active controls, then applies a strict DOMPurify structural element/attribute allowlist before adding a deny-by-default CSP.
zip.js receives a lazy browser `BlobReader`, performs CRC checks when entry data is read, and can use web workers. React retains one open reader and closes the previous reader when a new book replaces it. The original File is immutable; all changes exist only in staged React state or a newly downloaded Blob.
The safe reader uses both sanitization and an iframe without sandbox permissions. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheet links are intentionally removed in v0.1.
The safe reader uses both sanitization and an iframe with `allow-same-origin` but without scripts, forms, popups, navigation, downloads, modals, or device permissions. Same-origin access lets the trusted parent intercept inert EPUB links, scroll to a target fragment, and read an explicitly selected quote. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheets are size/count bounded; imports and network URLs are removed, while CSP remains the final network boundary. A mutation observer synchronizes explicit light/dark reader tokens with Toolbox preference changes; system mode follows the browser media preference.
+14 -3
View File
@@ -2,8 +2,19 @@
All source and generated publication data remains in the browser. The app has no telemetry, account, analytics, remote font, CDN, or default network integration. The production CSP keeps `connect-src` and worker sources same-origin.
EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is reported because it can describe valid font obfuscation or DRM; the app does not distinguish every scheme and never attempts circumvention.
EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is inspected: the standardized IDPF font-obfuscation mask is decoded locally, while unsupported protection is reported and never circumvented.
Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers, navigation and resource references are neutralized or replaced with bounded local object URLs, and rendering happens inside a permissionless sandboxed iframe with its own restrictive CSP. Temporary object URLs are revoked when chapters change.
HTML/XHTML, Markdown, and plain-text adapters accept at most 16 MiB of valid
UTF-8. HTML is parsed inertly and copied through a small structural
element/attribute allowlist; scripts, styles, forms, event handlers, embedded
media, external resources, and non-fragment links are not transferred. The
adapter bounds nodes, nesting and Markdown lines, derives a local content
identifier with Web Crypto, and then sends its generated one-chapter EPUB
through the same strict archive and reader path. The reported losses are part
of the resulting workspace rather than a claim of source fidelity.
Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for encrypted publications.
Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers; regular and namespaced SVG navigation/resource attributes are neutralized or replaced with bounded local object URLs; and rendering happens inside a scriptless sandboxed iframe with its own restrictive CSP. The iframe has `allow-same-origin` solely so trusted application code can intercept internal links, scroll to fragments, and capture a quote the user explicitly selected; it receives no script, form, popup, top-navigation, download, modal, or device permissions. Stylesheets/resources are bounded, CSS imports and external URLs are removed, object URLs are reused per resource and revoked when chapters change.
Search operates on bounded extracted text. Bookmarks and annotations remain in React memory unless the user explicitly downloads their JSON; importing validates schema, counts, path safety, field sizes, and publication identifier. Derived HTML/Markdown/text exports intentionally omit active content and network resources and are lossy reading-order representations, not publication-preserving conversions. Standalone HTML is rebuilt through a strict DOMPurify structural allowlist that excludes meta refreshes, base URLs, templates, frames, links, media and event/style attributes, then receives a deny-by-default CSP.
Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for unsupported encrypted publications. IDPF-obfuscated fonts are re-keyed if the unique publication identifier changes.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "epub-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+20 -3
View File
@@ -3,24 +3,41 @@
"schemaVersion": 1,
"id": "de.add-ideas.epub-tools",
"name": "EPUB Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Read, inspect and repair EPUB publications locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "ebooks", "publishing"],
"tags": ["epub", "ebook", "metadata", "reader", "repair"],
"tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"secureContext": true,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{ "mediaType": "text/html", "extensions": [".html", ".htm"] },
{ "mediaType": "application/xhtml+xml", "extensions": [".xhtml"] },
{ "mediaType": "text/markdown", "extensions": [".md", ".markdown"] },
{ "mediaType": "text/plain", "extensions": [".txt"] }
],
"produces": [
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{ "mediaType": "text/plain", "extensions": [".txt"] },
{ "mediaType": "text/markdown", "extensions": [".md"] },
{ "mediaType": "text/html", "extensions": [".html"] },
{ "mediaType": "application/json", "extensions": [".json"] }
]
},
"capabilities": { "required": ["workers", "web-crypto"], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+12 -1
View File
@@ -31,11 +31,22 @@ export function HelpDialog({
×
</button>
</div>
<p>Read, inspect and repair EPUB publications locally in the browser.</p>
<p>
Read, search, navigate, annotate, inspect and repair EPUB publications
locally in the browser. UTF-8 HTML/XHTML, Markdown and plain text can be
adapted into a one-chapter EPUB workspace with visible loss evidence.
Packaged styles, fonts, images and media are bounded and rewritten to
temporary local URLs.
</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
</p>
<p>
Notes stay in memory unless you explicitly export them. The safe reader
removes publication scripts and network resources; its parent-only DOM
access handles internal links, fragment scrolling and selected quotes.
</p>
</dialog>
);
}
File diff suppressed because it is too large Load Diff
+386
View File
@@ -0,0 +1,386 @@
import { BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
import {
openEpub,
type EpubOpenProgress,
type EpubOpenProgressCallback,
} from "./archive";
import type { EpubAdaptation, EpubBook } from "./types";
const TEXT_LIMIT = 16 * 1024 * 1024;
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const SAFE_ELEMENTS = new Set([
"a",
"article",
"aside",
"blockquote",
"br",
"code",
"dd",
"div",
"dl",
"dt",
"em",
"figcaption",
"figure",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"q",
"s",
"section",
"small",
"span",
"strong",
"sub",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"u",
"ul",
]);
const SAFE_ATTRIBUTES = new Set([
"colspan",
"dir",
"id",
"lang",
"rowspan",
"scope",
"title",
]);
export type PublicationAdapter = "epub" | "html" | "markdown" | "text";
function extension(name: string): string {
return name.toLowerCase().split(".").pop() ?? "";
}
export function publicationAdapterForFile(
file: Pick<File, "name" | "type">,
): PublicationAdapter | undefined {
const suffix = extension(file.name);
if (suffix === "epub" || file.type === "application/epub+zip") return "epub";
if (
["html", "htm", "xhtml"].includes(suffix) ||
/^(?:text\/html|application\/xhtml\+xml)$/iu.test(file.type)
)
return "html";
if (
["md", "markdown", "mdown", "mkd"].includes(suffix) ||
file.type === "text/markdown"
)
return "markdown";
if (suffix === "txt" || file.type === "text/plain") return "text";
return undefined;
}
function xmlEscape(value: string): string {
return value
.replace(/\p{Cc}/gu, (character) =>
character === "\t" || character === "\n" || character === "\r"
? character
: "",
)
.replace(/[\ufffe\uffff]/gu, "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function titleFromFilename(name: string): string {
const value = name
.replace(/\.[^.]+$/u, "")
.replace(/[_-]+/gu, " ")
.trim();
return (value || "Imported publication").slice(0, 300);
}
async function contentIdentifier(bytes: Uint8Array): Promise<string> {
const buffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", buffer));
return `urn:sha256:${[...digest]
.map((value) => value.toString(16).padStart(2, "0"))
.join("")}`;
}
function decodeUtf8(bytes: Uint8Array): string {
if (!bytes.length || bytes.length > TEXT_LIMIT)
throw new RangeError("Text publication adapters accept 1 byte16 MiB.");
const offset =
bytes.length >= 3 &&
bytes[0] === 0xef &&
bytes[1] === 0xbb &&
bytes[2] === 0xbf
? 3
: 0;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(offset),
);
} catch {
throw new TypeError(
"Text, Markdown and HTML adapters require valid UTF-8 input.",
);
}
}
function renderHtmlNode(node: Node, depth = 0, budget = { nodes: 0 }): string {
budget.nodes += 1;
if (budget.nodes > 100_000)
throw new RangeError("HTML adaptation exceeds 100,000 nodes.");
if (depth > 80) throw new RangeError("HTML nesting exceeds 80 levels.");
if (node.nodeType === Node.TEXT_NODE)
return xmlEscape(node.textContent ?? "");
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
const tag = element.localName.toLowerCase();
const children = [...element.childNodes]
.map((child) => renderHtmlNode(child, depth + 1, budget))
.join("");
if (!SAFE_ELEMENTS.has(tag)) return children;
const attributes: string[] = [];
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
if (SAFE_ATTRIBUTES.has(name))
attributes.push(
` ${name}="${xmlEscape(attribute.value.slice(0, 2_000))}"`,
);
else if (
tag === "a" &&
name === "href" &&
/^#[A-Za-z][\w:.-]{0,255}$/u.test(attribute.value)
)
attributes.push(` href="${xmlEscape(attribute.value)}"`);
}
if (tag === "br" || tag === "hr") return `<${tag}${attributes.join("")}/>`;
return `<${tag}${attributes.join("")}>${children}</${tag}>`;
}
function adaptHtml(
source: string,
fallbackTitle: string,
): { title: string; body: string; losses: string[] } {
if (/<!ENTITY/iu.test(source))
throw new TypeError("HTML adapters reject entity declarations.");
const document = new DOMParser().parseFromString(source, "text/html");
const title = (document.title.trim() || fallbackTitle).slice(0, 300);
const budget = { nodes: 0 };
const body = [...document.body.childNodes]
.map((node) => renderHtmlNode(node, 0, budget))
.join("");
return {
title,
body: body || `<p>${xmlEscape(fallbackTitle)}</p>`,
losses: [
"Scripts, forms, embedded media, styles, classes, external resources and non-fragment links were removed while adapting HTML.",
],
};
}
function inlineMarkdown(value: string): string {
return xmlEscape(value)
.replace(/`([^`\n]+)`/gu, "<code>$1</code>")
.replace(/\*\*([^*\n]+)\*\*/gu, "<strong>$1</strong>")
.replace(/_([^_\n]+)_/gu, "<em>$1</em>");
}
function adaptMarkdown(
source: string,
fallbackTitle: string,
): { title: string; body: string; losses: string[] } {
const output: string[] = [];
const paragraph: string[] = [];
let title = fallbackTitle;
let code: string[] | undefined;
let list: "ol" | "ul" | undefined;
const flushParagraph = () => {
if (paragraph.length) {
output.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`);
paragraph.length = 0;
}
};
const closeList = () => {
if (list) output.push(`</${list}>`);
list = undefined;
};
const lines = source.replaceAll("\r\n", "\n").split("\n");
if (lines.length > 200_000)
throw new RangeError("Markdown adaptation exceeds 200,000 lines.");
for (const line of lines) {
if (/^\s*```/u.test(line)) {
flushParagraph();
closeList();
if (code) {
output.push(`<pre><code>${xmlEscape(code.join("\n"))}</code></pre>`);
code = undefined;
} else code = [];
continue;
}
if (code) {
code.push(line);
continue;
}
const heading = /^(#{1,6})\s+(.+)$/u.exec(line);
if (heading) {
flushParagraph();
closeList();
const level = heading[1]!.length;
if (level === 1 && title === fallbackTitle)
title =
heading[2]!.replace(/[*_`]/gu, "").trim().slice(0, 300) || title;
output.push(`<h${level}>${inlineMarkdown(heading[2]!)}</h${level}>`);
continue;
}
const item = /^\s*(?:(\d+)\.|[-+*])\s+(.+)$/u.exec(line);
if (item) {
flushParagraph();
const next = item[1] ? "ol" : "ul";
if (list !== next) {
closeList();
output.push(`<${next}>`);
list = next;
}
output.push(`<li>${inlineMarkdown(item[2]!)}</li>`);
continue;
}
if (!line.trim()) {
flushParagraph();
closeList();
} else paragraph.push(line.trim());
}
if (code)
output.push(`<pre><code>${xmlEscape(code.join("\n"))}</code></pre>`);
flushParagraph();
closeList();
return {
title,
body: output.join("\n") || `<p>${xmlEscape(fallbackTitle)}</p>`,
losses: [
"Markdown adaptation supports headings, paragraphs, lists, fenced code, emphasis and inline code; other syntax remains literal text.",
],
};
}
async function createAdaptedEpub(
sourceFile: File,
kind: Exclude<PublicationAdapter, "epub">,
onProgress?: EpubOpenProgressCallback,
): Promise<{ file: File; adaptation: EpubAdaptation }> {
if (!sourceFile.size || sourceFile.size > TEXT_LIMIT)
throw new RangeError("Text publication adapters accept 1 byte16 MiB.");
onProgress?.({
phase: "archive",
message: `Adapting ${kind === "text" ? "plain text" : kind} into a local publication…`,
progress: 0.03,
});
const bytes = new Uint8Array(await sourceFile.arrayBuffer());
const source = decodeUtf8(bytes);
const fallbackTitle = titleFromFilename(sourceFile.name);
const adapted =
kind === "html"
? adaptHtml(source, fallbackTitle)
: kind === "markdown"
? adaptMarkdown(source, fallbackTitle)
: {
title: fallbackTitle,
body: `<pre>${xmlEscape(source)}</pre>`,
losses: [
"Plain text has no publication structure; line breaks are preserved in one preformatted chapter.",
],
};
const identifier = await contentIdentifier(bytes);
const chapter = `<?xml version="1.0" encoding="UTF-8"?>\n<html xmlns="${XHTML_NS}" lang="und"><head><title>${xmlEscape(adapted.title)}</title></head><body>${adapted.body}</body></html>`;
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
await writer.add("mimetype", new TextReader("application/epub+zip"), {
level: 0,
});
await writer.add(
"META-INF/container.xml",
new TextReader(
'<?xml version="1.0" encoding="UTF-8"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0"><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
),
);
await writer.add(
"EPUB/package.opf",
new TextReader(
`<?xml version="1.0" encoding="UTF-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="publication-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="publication-id">${identifier}</dc:identifier><dc:title>${xmlEscape(adapted.title)}</dc:title><dc:language>und</dc:language><meta property="dcterms:modified">1970-01-01T00:00:00Z</meta></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="content" href="content.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="content"/></spine></package>`,
),
);
await writer.add(
"EPUB/nav.xhtml",
new TextReader(
`<?xml version="1.0" encoding="UTF-8"?><html xmlns="${XHTML_NS}" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="content.xhtml">${xmlEscape(adapted.title)}</a></li></ol></nav></body></html>`,
),
);
await writer.add("EPUB/content.xhtml", new TextReader(chapter));
const blob = await writer.close();
return {
file: new File([blob], `${titleFromFilename(sourceFile.name)}.epub`, {
type: "application/epub+zip",
lastModified: sourceFile.lastModified,
}),
adaptation: {
kind,
sourceFilename: sourceFile.name,
sourceMediaType:
sourceFile.type || (kind === "text" ? "text/plain" : `text/${kind}`),
sourceBytes: sourceFile.size,
losses: adapted.losses,
},
};
}
function mapProgress(progress: EpubOpenProgress): EpubOpenProgress {
return { ...progress, progress: 0.2 + progress.progress * 0.8 };
}
export async function openPublication(
file: File,
onProgress?: EpubOpenProgressCallback,
): Promise<EpubBook> {
let kind = publicationAdapterForFile(file);
if (!kind && file.size >= 4) {
const signature = new Uint8Array(await file.slice(0, 4).arrayBuffer());
if (
signature[0] === 0x50 &&
signature[1] === 0x4b &&
((signature[2] === 0x03 && signature[3] === 0x04) ||
(signature[2] === 0x05 && signature[3] === 0x06) ||
(signature[2] === 0x07 && signature[3] === 0x08))
)
kind = "epub";
}
if (!kind)
throw new TypeError(
"Supported inputs are EPUB, UTF-8 HTML/XHTML, Markdown and plain text.",
);
if (kind === "epub") return openEpub(file, onProgress);
const adapted = await createAdaptedEpub(file, kind, onProgress);
const book = await openEpub(adapted.file, (progress) =>
onProgress?.(mapProgress(progress)),
);
book.adapter = adapted.adaptation;
book.issues.unshift({
severity: "info",
code: "adapted-publication",
message: `${adapted.adaptation.sourceFilename} was converted locally into a one-chapter EPUB workspace. ${adapted.adaptation.losses.join(" ")}`,
path: adapted.adaptation.sourceFilename,
});
return book;
}
+146
View File
@@ -0,0 +1,146 @@
import { stableStringify } from "@add-ideas/toolbox-helpers";
export interface EpubBookmark {
id: string;
path: string;
fragment?: string;
label: string;
}
export interface EpubAnnotation extends EpubBookmark {
note: string;
quote?: string;
}
export interface EpubReadingNotes {
schemaVersion: 1;
publication: { identifier: string; title: string };
bookmarks: EpubBookmark[];
annotations: EpubAnnotation[];
}
const NOTES_LIMITS = {
text: 2 * 1024 * 1024,
entries: 5_000,
path: 2_048,
label: 500,
fragment: 500,
note: 16_000,
quote: 2_000,
} as const;
export function createReadingNoteId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return [...bytes]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
export function exportReadingNotes(notes: EpubReadingNotes): string {
return (
stableStringify(validateReadingNotes(notes), 2, {
maxDepth: 8,
maxNodes: 50_000,
maxTextChars: NOTES_LIMITS.text,
}) + "\n"
);
}
export function importReadingNotes(
source: string,
publication: EpubReadingNotes["publication"],
): EpubReadingNotes {
if (source.length > NOTES_LIMITS.text)
throw new Error("Reading-notes JSON exceeds 2 MiB.");
let parsed: unknown;
try {
parsed = JSON.parse(source) as unknown;
} catch {
throw new Error("Reading-notes input is not valid JSON.");
}
const notes = validateReadingNotes(parsed);
if (
notes.publication.identifier &&
publication.identifier &&
notes.publication.identifier !== publication.identifier
)
throw new Error(
"Reading notes belong to a different publication identifier.",
);
return { ...notes, publication };
}
function validateReadingNotes(value: unknown): EpubReadingNotes {
if (!record(value) || value.schemaVersion !== 1 || !record(value.publication))
throw new Error("Unsupported reading-notes document.");
const bookmarks = entries(value.bookmarks, false) as EpubBookmark[];
const annotations = entries(value.annotations, true) as EpubAnnotation[];
if (bookmarks.length + annotations.length > NOTES_LIMITS.entries)
throw new Error("Reading notes exceed 5,000 entries.");
return {
schemaVersion: 1,
publication: {
identifier: text(value.publication.identifier, "identifier", 1_000, true),
title: text(value.publication.title, "title", 1_000, true),
},
bookmarks,
annotations,
};
}
function entries(
value: unknown,
annotation: boolean,
): Array<EpubBookmark | EpubAnnotation> {
if (!Array.isArray(value))
throw new Error("Reading-note entries must be arrays.");
return value.map((item, index) => {
if (!record(item))
throw new Error(`Reading-note entry ${index + 1} is invalid.`);
const base: EpubBookmark = {
id: text(item.id, "id", 100),
path: safePath(text(item.path, "path", NOTES_LIMITS.path)),
label: text(item.label, "label", NOTES_LIMITS.label),
...(item.fragment === undefined
? {}
: { fragment: text(item.fragment, "fragment", NOTES_LIMITS.fragment) }),
};
return annotation
? {
...base,
note: text(item.note, "note", NOTES_LIMITS.note, true),
...(item.quote === undefined
? {}
: { quote: text(item.quote, "quote", NOTES_LIMITS.quote) }),
}
: base;
});
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function text(
value: unknown,
field: string,
maximum: number,
empty = false,
): string {
if (typeof value !== "string" || (!empty && !value.trim()))
throw new Error(`${field} must be a${empty ? "" : " non-empty"} string.`);
if (value.length > maximum) throw new Error(`${field} is too long.`);
return value;
}
function safePath(value: string): string {
if (
value.startsWith("/") ||
value.includes("\\") ||
value.includes("\0") ||
value.split("/").some((part) => part === "." || part === "..")
)
throw new Error("Reading-note path must be publication-relative.");
return value;
}
+199 -5
View File
@@ -17,19 +17,156 @@ import { validatePublication } from "./validation";
export { EPUB_LIMITS, readEntryBytes, readEntryText } from "./entries";
export type EpubOpenPhase =
| "archive"
| "entries"
| "container"
| "package"
| "navigation"
| "validation"
| "ready";
export interface EpubOpenProgress {
phase: EpubOpenPhase;
message: string;
progress: number;
current?: number;
total?: number;
}
export type EpubOpenProgressCallback = (progress: EpubOpenProgress) => void;
function ratio(current: number, total: number): number {
return total > 0 ? Math.min(1, Math.max(0, current / total)) : 0;
}
const IDPF_FONT_OBFUSCATION = "http://www.idpf.org/2008/embedding" as const;
async function inspectEncryption(
source: Pick<EpubBook, "entryByPath">,
identifier: string,
): Promise<{
fonts: EpubBook["obfuscatedFonts"];
unsupported: number;
issues: EpubIssue[];
}> {
const fonts: EpubBook["obfuscatedFonts"] = new Map();
if (!source.entryByPath.has("META-INF/encryption.xml"))
return { fonts, unsupported: 0, issues: [] };
const issues: EpubIssue[] = [];
let unsupported = 0;
try {
const xml = await readEntryText(
source,
"META-INF/encryption.xml",
EPUB_LIMITS.xmlBytes,
);
const document = parseXml(xml, "META-INF/encryption.xml");
let key: Uint8Array | undefined;
for (const encrypted of elementsByLocalName(document, "EncryptedData")) {
const method = elementsByLocalName(encrypted, "EncryptionMethod")[0];
const reference = elementsByLocalName(encrypted, "CipherReference")[0];
const algorithm = method?.getAttribute("Algorithm") ?? "";
const uri = reference?.getAttribute("URI") ?? "";
let path = "";
try {
path = validateEntryPath(
decodeURIComponent(uri.split("#", 1)[0] ?? ""),
);
} catch {
unsupported += 1;
issues.push({
severity: "error",
code: "invalid-encryption-reference",
message: `Encryption entry has an unsafe or invalid resource URI: ${uri || "(empty)"}.`,
path: "META-INF/encryption.xml",
});
continue;
}
if (algorithm !== IDPF_FONT_OBFUSCATION) {
unsupported += 1;
issues.push({
severity: "warning",
code: "unsupported-encryption",
message: `Protected resource uses an unsupported encryption algorithm: ${algorithm || "(missing)"}.`,
path,
});
continue;
}
if (!identifier) {
unsupported += 1;
issues.push({
severity: "error",
code: "font-obfuscation-key-missing",
message:
"The IDPF-obfuscated font cannot be decoded because the package unique identifier is empty.",
path,
});
continue;
}
if (!key) {
const digest = await globalThis.crypto?.subtle?.digest(
"SHA-1",
new TextEncoder().encode(identifier.replace(/[ \t\r\n]/gu, "")),
);
if (!digest)
throw new Error(
"Web Crypto SHA-1 digest support is unavailable for standardized font deobfuscation.",
);
key = new Uint8Array(digest);
}
fonts.set(path, { algorithm: IDPF_FONT_OBFUSCATION, key });
}
} catch (reason) {
unsupported += 1;
issues.push({
severity: "error",
code: "invalid-encryption-document",
message:
reason instanceof Error
? reason.message
: "META-INF/encryption.xml could not be inspected.",
path: "META-INF/encryption.xml",
});
}
if (fonts.size)
issues.push({
severity: "info",
code: "font-obfuscation-supported",
message: `${fonts.size} font resource(s) use the standardized IDPF mask and can be decoded locally for reading.`,
path: "META-INF/encryption.xml",
});
return { fonts, unsupported, issues };
}
export async function closeBook(book: EpubBook | undefined): Promise<void> {
if (book) await book.reader.close();
}
export async function openEpub(file: File): Promise<EpubBook> {
export async function openEpub(
file: File,
onProgress?: EpubOpenProgressCallback,
): Promise<EpubBook> {
if (file.size === 0 || file.size > EPUB_LIMITS.fileBytes)
throw new RangeError("EPUB must be between 1 byte and 512 MiB.");
onProgress?.({
phase: "archive",
message: "Reading the ZIP directory…",
progress: 0.02,
});
const reader = new ZipReader(new BlobReader(file), {
useWebWorkers: true,
strictness: "strict",
});
try {
const entries = await reader.getEntries();
const entries = await reader.getEntries({
onprogress: (current, total) =>
onProgress?.({
phase: "archive",
message: `Reading archive directory · ${current.toLocaleString()} / ${total.toLocaleString()} entries`,
progress: 0.02 + ratio(current, total) * 0.16,
current,
total,
}),
});
if (!entries.length || entries.length > EPUB_LIMITS.entries)
throw new RangeError(
`EPUB entry count must be between 1 and ${EPUB_LIMITS.entries.toLocaleString()}.`,
@@ -37,7 +174,20 @@ export async function openEpub(file: File): Promise<EpubBook> {
let totalUncompressed = 0;
let compressedBytes = 0;
const entryByPath = new Map<string, Entry>();
for (const entry of entries) {
const entryProgressInterval = Math.max(1, Math.ceil(entries.length / 100));
for (const [entryIndex, entry] of entries.entries()) {
if (
entryIndex === 0 ||
entryIndex % entryProgressInterval === 0 ||
entryIndex + 1 === entries.length
)
onProgress?.({
phase: "entries",
message: `Checking archive limits · ${(entryIndex + 1).toLocaleString()} / ${entries.length.toLocaleString()} entries`,
progress: 0.18 + ratio(entryIndex + 1, entries.length) * 0.18,
current: entryIndex + 1,
total: entries.length,
});
validateEntryPath(entry.filename.replace(/\/$/u, "") || entry.filename);
if (entryByPath.has(entry.filename))
throw new Error(`Duplicate archive path: ${entry.filename}`);
@@ -80,6 +230,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
const mimetypeFirst = physicalFiles[0]?.filename === "mimetype";
const mimetypeStored = mimetypeEntry.compressionMethod === 0;
const provisional = { entryByPath };
onProgress?.({
phase: "container",
message: "Reading the EPUB container declaration…",
progress: 0.4,
});
const containerXml = await readEntryText(
provisional,
"META-INF/container.xml",
@@ -92,6 +247,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
if (!rootfiles.length)
throw new Error("container.xml does not declare a package rootfile.");
const packagePath = validateEntryPath(rootfiles[0] ?? "");
onProgress?.({
phase: "package",
message: "Parsing package metadata and reading order…",
progress: 0.5,
});
const packageXml = await readEntryText(
provisional,
packagePath,
@@ -99,6 +259,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
);
const parsed = parsePackageDocument(packageXml, packagePath);
const issues: EpubIssue[] = [];
const encryption = await inspectEncryption(
provisional,
parsed.package.uniqueIdentifierValue,
);
issues.push(...encryption.issues);
if (rootfiles.length > 1)
issues.push({
severity: "info",
@@ -114,6 +279,7 @@ export async function openEpub(file: File): Promise<EpubBook> {
package: parsed.package,
packageXml,
issues,
obfuscatedFonts: encryption.fonts,
summary: {
filename: file.name,
fileSize: file.size,
@@ -124,9 +290,15 @@ export async function openEpub(file: File): Promise<EpubBook> {
mimetypeStored,
encryptedResources:
entries.some((entry) => entry.encrypted) ||
entryByPath.has("META-INF/encryption.xml"),
encryption.unsupported > 0,
obfuscatedFontCount: encryption.fonts.size,
},
};
onProgress?.({
phase: "navigation",
message: "Reading publication navigation…",
progress: 0.62,
});
if (book.package.nav?.path && entryByPath.has(book.package.nav.path)) {
try {
book.package.navigation = parseNavigationDocument(
@@ -163,7 +335,29 @@ export async function openEpub(file: File): Promise<EpubBook> {
});
}
}
issues.push(...(await validatePublication(book)));
onProgress?.({
phase: "validation",
message: "Validating content documents and local links…",
progress: 0.72,
});
issues.push(
...(await validatePublication(book, (current, total, path) =>
onProgress?.({
phase: "validation",
message: path
? `Validating content · ${current.toLocaleString()} / ${total.toLocaleString()} · ${path}`
: "Finishing publication validation…",
progress: 0.72 + ratio(current, total) * 0.23,
current,
total,
}),
)),
);
onProgress?.({
phase: "ready",
message: "Publication structure is ready.",
progress: 0.96,
});
return book;
} catch (reason) {
await reader.close().catch(() => undefined);
+17
View File
@@ -39,3 +39,20 @@ export async function readEntryText(
): Promise<string> {
return decodeXml(await readEntryBytes(book, path, maximum));
}
/** Read a publication resource and reverse the standardized IDPF font mask. */
export async function readPublicationResourceBytes(
book: EpubBook,
path: string,
maximum = EPUB_LIMITS.resourceBytes,
): Promise<Uint8Array> {
const bytes = await readEntryBytes(book, path, maximum);
const obfuscation = book.obfuscatedFonts.get(path);
if (!obfuscation) return bytes;
const output = bytes.slice();
const length = Math.min(1_040, output.length);
for (let index = 0; index < length; index += 1)
output[index] =
output[index]! ^ obfuscation.key[index % obfuscation.key.length]!;
return output;
}
+246 -3
View File
@@ -1,6 +1,13 @@
import { BlobReader, BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js";
import {
BlobReader,
BlobWriter,
TextReader,
Uint8ArrayReader,
ZipWriter,
} from "@zip.js/zip.js";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { readEntryText } from "./entries";
import DOMPurify from "dompurify";
import { readEntryText, readPublicationResourceBytes } from "./entries";
import type { EpubBook, EpubMetadata } from "./types";
import { elementsByLocalName, parseXml } from "./xml";
@@ -49,9 +56,16 @@ export function patchPackageMetadata(
setMetadataValues(document, metadataElement, "title", [metadata.title]);
setMetadataValues(document, metadataElement, "creator", metadata.creators);
setMetadataValues(document, metadataElement, "language", [metadata.language]);
const identifierId =
document.documentElement.getAttribute("unique-identifier") || "pub-id";
document.documentElement.setAttribute("unique-identifier", identifierId);
setMetadataValues(document, metadataElement, "identifier", [
metadata.identifier,
]);
elementsByLocalName(metadataElement, "identifier")[0]?.setAttribute(
"id",
identifierId,
);
setMetadataValues(document, metadataElement, "publisher", [
metadata.publisher,
]);
@@ -93,7 +107,7 @@ export async function rebuildEpub(
): Promise<{ blob: Blob; changes: string[]; packageXml: string }> {
if (book.summary.encryptedResources)
throw new Error(
"Rebuilding is disabled when encryption or DRM metadata is present; copying may invalidate protection or obfuscation metadata.",
"Rebuilding is disabled when unsupported encryption or DRM metadata is present.",
);
if (options.cover && !book.package.cover)
throw new Error("This publication has no declared cover item to replace.");
@@ -110,6 +124,26 @@ export async function rebuildEpub(
"Rewrote package metadata",
"Placed an uncompressed mimetype entry first",
];
const oldIdentifier = book.package.uniqueIdentifierValue.replace(
/[ \t\r\n]/gu,
"",
);
const newIdentifier = options.metadata.identifier.replace(/[ \t\r\n]/gu, "");
let newObfuscationKey: Uint8Array | undefined;
if (book.obfuscatedFonts.size && oldIdentifier !== newIdentifier) {
const digest = await globalThis.crypto?.subtle?.digest(
"SHA-1",
new TextEncoder().encode(newIdentifier),
);
if (!digest)
throw new Error(
"Web Crypto SHA-1 support is required to preserve obfuscated fonts after changing the publication identifier.",
);
newObfuscationKey = new Uint8Array(digest);
changes.push(
`Re-keyed ${book.obfuscatedFonts.size} standardized obfuscated font(s)`,
);
}
await writer.add("mimetype", new TextReader("application/epub+zip"), {
level: 0,
lastModDate: options.normalizeTimestamps ? fixedDate : undefined,
@@ -134,6 +168,19 @@ export async function rebuildEpub(
lastModDate,
});
changes.push(`Replaced cover bytes at ${entry.filename}`);
} else if (newObfuscationKey && book.obfuscatedFonts.has(entry.filename)) {
const bytes = await readPublicationResourceBytes(
book,
entry.filename,
64 * 1024 * 1024,
);
for (let byte = 0; byte < Math.min(1_040, bytes.length); byte += 1)
bytes[byte] =
bytes[byte]! ^ newObfuscationKey[byte % newObfuscationKey.length]!;
await writer.add(entry.filename, new Uint8ArrayReader(bytes), {
level: entry.compressionMethod === 0 ? 0 : 6,
lastModDate,
});
} else {
const blob = await entry.getData(new BlobWriter(), {
checkSignature: true,
@@ -179,3 +226,199 @@ export async function exportPlainText(book: EpubBook): Promise<Blob> {
type: "text/plain;charset=utf-8",
});
}
interface ExportSection {
title: string;
text: string;
html: string;
}
const SAFE_EXPORT_TAGS = [
"article",
"aside",
"b",
"bdi",
"bdo",
"blockquote",
"br",
"caption",
"cite",
"code",
"col",
"colgroup",
"data",
"dd",
"del",
"details",
"dfn",
"div",
"dl",
"dt",
"em",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hgroup",
"hr",
"i",
"ins",
"kbd",
"li",
"main",
"mark",
"ol",
"p",
"pre",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"section",
"small",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"time",
"tr",
"u",
"ul",
"var",
"wbr",
] as const;
const SAFE_EXPORT_ATTRIBUTES = [
"abbr",
"colspan",
"datetime",
"dir",
"headers",
"id",
"lang",
"open",
"reversed",
"role",
"rowspan",
"scope",
"span",
"start",
"title",
"value",
] as const;
async function readingOrderSections(book: EpubBook): Promise<ExportSection[]> {
const sections: ExportSection[] = [];
let total = 0;
for (const spine of book.package.spine) {
if (!spine.item || !/xhtml|html/iu.test(spine.item.mediaType)) continue;
const source = await readEntryText(book, spine.item.path, 4 * 1024 * 1024);
const document = new DOMParser().parseFromString(source, "text/html");
document
.querySelectorAll(
"script, style, link, iframe, object, embed, form, input, button, textarea, select, video, audio, source",
)
.forEach((element) => element.remove());
document.querySelectorAll("img, svg").forEach((element) => {
const replacement = document.createElement("span");
replacement.textContent = element.getAttribute("alt")
? `[Image: ${element.getAttribute("alt")}]`
: "[Image omitted]";
element.replaceWith(replacement);
});
document.querySelectorAll("a").forEach((anchor) => {
anchor.removeAttribute("href");
anchor.removeAttribute("target");
});
document.querySelectorAll("*").forEach((element) => {
for (const attribute of [...element.attributes])
if (
attribute.name === "style" ||
attribute.name.startsWith("on") ||
attribute.name === "srcset"
)
element.removeAttribute(attribute.name);
});
const title = document.title.trim() || spine.item.id || spine.item.path;
const text = (document.body.textContent ?? "")
.replace(/[ \t]+/gu, " ")
.replace(/\n{3,}/gu, "\n\n")
.trim();
const html = String(
DOMPurify.sanitize(document.body.innerHTML, {
ALLOWED_TAGS: [...SAFE_EXPORT_TAGS],
ALLOWED_ATTR: [...SAFE_EXPORT_ATTRIBUTES],
ALLOW_ARIA_ATTR: true,
ALLOW_DATA_ATTR: false,
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: [
"base",
"frame",
"frameset",
"meta",
"noscript",
"template",
],
}),
);
total += text.length + html.length;
if (total > 32 * 1024 * 1024)
throw new RangeError(
"Reading-order export exceeds the 32 MiB text limit.",
);
sections.push({ title, text, html });
}
return sections;
}
export async function exportMarkdown(book: EpubBook): Promise<Blob> {
const sections = await readingOrderSections(book);
const escapeHeading = (value: string) =>
value.replaceAll(/([\\`*_{}[\]()#+.!|-])/gu, "\\$1");
return new Blob(
[
sections
.map(
(section) => `# ${escapeHeading(section.title)}\n\n${section.text}`,
)
.join("\n\n---\n\n"),
],
{ type: "text/markdown;charset=utf-8" },
);
}
export async function exportReadingOrderHtml(book: EpubBook): Promise<Blob> {
const sections = await readingOrderSections(book);
const escape = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
const title = book.package.metadata.title || "EPUB reading order";
const html = `<!doctype html>
<html lang="${escape(book.package.metadata.language || "und")}" dir="${book.package.direction}">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>${escape(title)}</title><style>body{max-width:48rem;margin:auto;padding:2rem;font:1rem/1.65 ui-serif,serif;color:#202332;background:#fff}section+section{margin-top:3rem;padding-top:2rem;border-top:1px solid #bbb}table{border-collapse:collapse;max-width:100%}td,th{border:1px solid;padding:.3rem}pre{white-space:pre-wrap}</style></head>
<body><header><h1>${escape(title)}</h1><p>${escape(book.package.metadata.creators.join(", "))}</p></header>${sections
.map(
(section) =>
`<section><h1>${escape(section.title)}</h1>${section.html}</section>`,
)
.join("")}</body></html>`;
return new Blob([html], { type: "text/html;charset=utf-8" });
}
+52 -1
View File
@@ -19,6 +19,15 @@ function firstDirect(parent: Element, name: string): Element | undefined {
return directChildrenByLocalName(parent, name)[0];
}
function enumeration<T extends string>(
value: string | null | undefined,
allowed: readonly T[],
fallback: T,
): T {
const normalized = value?.trim().toLowerCase() as T | undefined;
return normalized && allowed.includes(normalized) ? normalized : fallback;
}
export function parsePackageDocument(
packageXml: string,
packagePath: string,
@@ -49,6 +58,12 @@ export function parsePackageDocument(
.map((item) => text(item))
.filter(Boolean),
};
const uniqueIdentifier = root.getAttribute("unique-identifier") ?? undefined;
const identifierElements = elementsByLocalName(metadataElement, "identifier");
const uniqueIdentifierValue =
identifierElements
.find((element) => element.getAttribute("id") === uniqueIdentifier)
?.textContent?.trim() ?? metadata.identifier;
const packageDirectory = directoryOf(packagePath);
const manifest: ManifestItem[] = directChildrenByLocalName(
manifestElement,
@@ -93,16 +108,52 @@ export function parsePackageDocument(
const ncx =
(ncxId ? byId.get(ncxId) : undefined) ??
manifest.find((item) => item.mediaType === "application/x-dtbncx+xml");
const rendition = new Map(
elementsByLocalName(metadataElement, "meta")
.map(
(element) =>
[element.getAttribute("property") ?? "", text(element)] as const,
)
.filter(([property]) => property.startsWith("rendition:")),
);
return {
package: {
version: root.getAttribute("version") ?? "",
uniqueIdentifier: root.getAttribute("unique-identifier") ?? undefined,
uniqueIdentifier,
uniqueIdentifierValue,
packagePath,
packageDirectory,
metadata,
manifest,
spine,
navigation: [],
direction: enumeration(
root.getAttribute("dir"),
["ltr", "rtl", "auto"],
"auto",
),
pageProgressionDirection: enumeration(
spineElement.getAttribute("page-progression-direction"),
["ltr", "rtl", "default"],
"default",
),
rendition: {
layout: enumeration(
rendition.get("rendition:layout"),
["reflowable", "pre-paginated"],
"reflowable",
),
orientation: enumeration(
rendition.get("rendition:orientation"),
["auto", "landscape", "portrait"],
"auto",
),
spread: enumeration(
rendition.get("rendition:spread"),
["auto", "none", "landscape", "portrait", "both"],
"auto",
),
},
cover,
nav,
ncx,
+318 -47
View File
@@ -1,11 +1,59 @@
import DOMPurify from "dompurify";
import { readEntryBytes, readEntryText } from "./entries";
import { isExternalReference, resolvePackageHref } from "./paths";
import { readEntryText, readPublicationResourceBytes } from "./entries";
import {
isExternalReference,
resolvePackageHref,
splitReference,
} from "./paths";
import type { EpubBook, NavigationItem, RenderedChapter } from "./types";
export type ReaderTheme = "light" | "dark";
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
const READER_IMAGE_LIMITS = {
bytesPerImage: 25 * 1024 * 1024,
uniqueImages: 128,
totalBytes: 128 * 1024 * 1024,
} as const;
const READER_STYLE_LIMITS = {
stylesheets: 16,
bytesPerStylesheet: 2 * 1024 * 1024,
totalStylesheetBytes: 8 * 1024 * 1024,
links: 500,
} as const;
const readerStyle = `
:root { color-scheme: light dark; font-family: ui-serif, Georgia, serif; line-height: 1.62; }
body { max-width: 48rem; margin: 0 auto; padding: 1.4rem; overflow-wrap: anywhere; }
:root {
--epub-reader-background: #ffffff;
--epub-reader-text: #202332;
color-scheme: light;
font-family: ui-serif, Georgia, serif;
line-height: 1.62;
background: var(--epub-reader-background) !important;
color: var(--epub-reader-text) !important;
}
:root[data-epub-reader-theme="dark"] {
--epub-reader-background: #171d2d;
--epub-reader-text: #edf1fb;
color-scheme: dark;
}
body {
min-height: 100vh;
max-width: 48rem;
margin: 0 auto;
padding: 1.4rem;
overflow-wrap: anywhere;
background: var(--epub-reader-background) !important;
color: var(--epub-reader-text) !important;
}
:root[data-epub-layout="pre-paginated"] body {
max-width: none;
padding: 0;
}
:root[dir="rtl"] body { direction: rtl; }
[data-epub-reader-target="true"] {
outline: .2rem solid #8055c7 !important;
outline-offset: .2rem;
}
img, svg { max-width: 100%; height: auto; }
table { max-width: 100%; border-collapse: collapse; overflow-wrap: anywhere; }
th, td { padding: .3rem; border: 1px solid currentColor; }
@@ -13,6 +61,13 @@ const readerStyle = `
a { color: inherit; text-decoration: underline dotted; }
`;
export function applyReaderTheme(html: string, theme: ReaderTheme): string {
return html.replace(
/<html(?=[\s>])/u,
`<html data-epub-reader-theme="${theme}"`,
);
}
function flattenNavigation(items: NavigationItem[]): NavigationItem[] {
return items.flatMap((item) => [item, ...flattenNavigation(item.children)]);
}
@@ -24,6 +79,38 @@ function safeInlineStyle(value: string): string {
.replace(/expression\s*\([^)]*\)/giu, "");
}
function safeStylesheetBase(value: string): string {
return value
.replace(/@charset\s+[^;]+;?/giu, "")
.replace(/@import\s+(?:url\s*\([^)]*\)|[^;])+;?/giu, "")
.replace(/expression\s*\([^)]*\)/giu, "")
.replace(/(?:behavior|-moz-binding)\s*:[^;}]*/giu, "");
}
async function rewriteStylesheetUrls(
value: string,
basePath: string,
resolve: (reference: string, basePath: string) => Promise<string | undefined>,
): Promise<string> {
const source = safeStylesheetBase(value);
const pattern = /url\s*\(\s*(["']?)(.*?)\1\s*\)/giu;
const output: string[] = [];
let cursor = 0;
for (const match of source.matchAll(pattern)) {
const index = match.index;
if (index === undefined) continue;
output.push(source.slice(cursor, index));
const reference = (match[2] ?? "").trim();
const replacement = reference
? await resolve(reference, basePath).catch(() => undefined)
: undefined;
output.push(replacement ? `url("${replacement}")` : "url(about:blank)");
cursor = index + match[0].length;
}
output.push(source.slice(cursor));
return output.join("");
}
export async function renderChapter(
book: EpubBook,
path: string,
@@ -41,6 +128,18 @@ export async function renderChapter(
"Only XHTML, HTML, and SVG spine documents can be rendered.",
);
const source = await readEntryText(book, path);
const originalDocument = new DOMParser().parseFromString(source, "text/html");
const linkedStylesheets = [
...originalDocument.querySelectorAll<HTMLLinkElement>("link[href]"),
]
.filter((link) =>
(link.getAttribute("rel") ?? "")
.toLowerCase()
.split(/\s+/u)
.includes("stylesheet"),
)
.slice(0, READER_STYLE_LIMITS.stylesheets)
.map((link) => link.getAttribute("href") ?? "");
const sanitized = DOMPurify.sanitize(source, {
WHOLE_DOCUMENT: true,
USE_PROFILES: { html: true, svg: true, svgFilters: false },
@@ -64,70 +163,221 @@ export async function renderChapter(
String(sanitized),
"text/html",
);
const objectUrls: string[] = [];
const manifestByPath = new Map(
book.package.manifest.map((candidate) => [candidate.path, candidate]),
);
const resourceUrls = new Map<string, string>();
let resolvedResourceBytes = 0;
const resolveResource = async (
reference: string,
basePath: string,
allowed = /^(?:image|font|audio|video)\//iu,
): Promise<string | undefined> => {
const value = reference.trim();
if (!value || value.startsWith("#")) return value || undefined;
if (value.startsWith("data:"))
return /^data:(?:image|font|audio|video)\//iu.test(value)
? value
: undefined;
if (isExternalReference(value)) return undefined;
const target = resolvePackageHref(basePath, value);
const resource = target ? manifestByPath.get(target) : undefined;
const applicationFont =
resource &&
/^(?:application\/(?:font-woff|vnd\.ms-fontobject|x-font-(?:ttf|opentype)))$/iu.test(
resource.mediaType,
);
if (
!target ||
!resource ||
(!allowed.test(resource.mediaType) &&
!(applicationFont && allowed.test("font/legacy"))) ||
!book.entryByPath.has(target)
)
return undefined;
let url = resourceUrls.get(target);
if (!url) {
const entry = book.entryByPath.get(target);
if (
!entry ||
resourceUrls.size >= READER_IMAGE_LIMITS.uniqueImages ||
entry.uncompressedSize > READER_IMAGE_LIMITS.bytesPerImage ||
resolvedResourceBytes + entry.uncompressedSize >
READER_IMAGE_LIMITS.totalBytes
)
return undefined;
const bytes = await readPublicationResourceBytes(
book,
target,
READER_IMAGE_LIMITS.bytesPerImage,
);
url = URL.createObjectURL(
new Blob([bytes as BlobPart], { type: resource.mediaType }),
);
resourceUrls.set(target, url);
objectUrls.push(url);
resolvedResourceBytes += entry.uncompressedSize;
}
const { fragment } = splitReference(value);
return fragment ? `${url}#${fragment}` : url;
};
for (const element of [...document.querySelectorAll<HTMLElement>("[style]")])
element.setAttribute(
"style",
safeInlineStyle(element.getAttribute("style") ?? ""),
);
for (const style of [...document.querySelectorAll("style")])
style.textContent = safeInlineStyle(style.textContent ?? "");
for (const anchor of [
...document.querySelectorAll<HTMLAnchorElement>("a[href]"),
]) {
const original = anchor.getAttribute("href") ?? "";
style.textContent = await rewriteStylesheetUrls(
style.textContent ?? "",
path,
resolveResource,
);
const links: RenderedChapter["links"] = [];
for (const anchor of [...document.querySelectorAll("a")]) {
const direct = anchor.getAttribute("href");
const legacy = anchor.getAttributeNS(XLINK_NAMESPACE, "href");
const original = direct?.trim() ? direct : (legacy ?? direct ?? "");
if (direct === null && legacy === null) continue;
const external = isExternalReference(original);
let targetPath: string | undefined;
if (!external && original)
try {
targetPath = resolvePackageHref(path, original);
} catch {
targetPath = undefined;
}
const { fragment } = splitReference(original);
if (links.length < READER_STYLE_LIMITS.links)
links.push({
label:
(anchor.textContent ?? "")
.replace(/\s+/gu, " ")
.trim()
.slice(0, 300) ||
original ||
"Untitled link",
reference: original,
...(targetPath ? { path: targetPath } : {}),
...(fragment ? { fragment } : {}),
external,
});
anchor.removeAttribute("href");
anchor.removeAttribute("xlink:href");
anchor.removeAttributeNS(XLINK_NAMESPACE, "href");
anchor.setAttribute("data-epub-href", original);
anchor.setAttribute("href", "#");
anchor.removeAttribute("target");
anchor.title = original
? `EPUB link disabled in safe reader: ${original}`
: "EPUB link";
anchor.setAttribute(
"title",
original ? `EPUB reader link: ${original}` : "EPUB link",
);
}
const objectUrls: string[] = [];
const resourceNodes: Array<{ element: Element; attribute: string }> = [
const resourceNodes: Array<{
reference: string;
allowed: RegExp;
replace: (value: string) => void;
remove: () => void;
}> = [
...[...document.querySelectorAll("img[src]")].map((element) => ({
element,
attribute: "src",
reference: element.getAttribute("src") ?? "",
allowed: /^image\//iu,
replace: (value: string) => element.setAttribute("src", value),
remove: () => element.removeAttribute("src"),
})),
...[...document.querySelectorAll("svg image[href]")].map((element) => ({
element,
attribute: "href",
...[
...document.querySelectorAll("audio[src], video[src], source[src]"),
].map((element) => ({
reference: element.getAttribute("src") ?? "",
allowed: /^(?:audio|video)\//iu,
replace: (value: string) => element.setAttribute("src", value),
remove: () => element.removeAttribute("src"),
})),
...[...document.querySelectorAll("svg image[xlink\\:href]")].map(
(element) => ({ element, attribute: "xlink:href" }),
),
...[...document.querySelectorAll("video[poster]")].map((element) => ({
reference: element.getAttribute("poster") ?? "",
allowed: /^image\//iu,
replace: (value: string) => element.setAttribute("poster", value),
remove: () => element.removeAttribute("poster"),
})),
...[...document.querySelectorAll("svg image")]
.map((element) => {
const direct = element.getAttribute("href");
const legacy = element.getAttributeNS(XLINK_NAMESPACE, "href");
const reference = direct?.trim() ? direct : (legacy ?? direct);
if (reference === null) return undefined;
const remove = () => {
element.removeAttribute("href");
element.removeAttribute("xlink:href");
element.removeAttributeNS(XLINK_NAMESPACE, "href");
};
return {
reference,
allowed: /^image\//iu,
replace: (value: string) => {
remove();
if (direct !== null) element.setAttribute("href", value);
else element.setAttributeNS(XLINK_NAMESPACE, "xlink:href", value);
},
remove,
};
})
.filter((item) => item !== undefined),
];
for (const { element, attribute } of resourceNodes) {
const reference = element.getAttribute(attribute) ?? "";
if (
!reference ||
reference.startsWith("data:") ||
isExternalReference(reference)
) {
if (isExternalReference(reference)) element.removeAttribute(attribute);
for (const resourceNode of resourceNodes) {
const { reference } = resourceNode;
if (!reference) {
resourceNode.remove();
continue;
}
try {
const target = resolvePackageHref(path, reference);
const resource = target
? book.package.manifest.find((candidate) => candidate.path === target)
const replacement = await resolveResource(
reference,
path,
resourceNode.allowed,
);
if (replacement) resourceNode.replace(replacement);
else resourceNode.remove();
} catch {
resourceNode.remove();
}
}
const publisherStyles: HTMLStyleElement[] = [];
let stylesheetBytes = 0;
for (const reference of linkedStylesheets) {
try {
const stylesheetPath = resolvePackageHref(path, reference);
const stylesheetItem = stylesheetPath
? manifestByPath.get(stylesheetPath)
: undefined;
const entry = stylesheetPath
? book.entryByPath.get(stylesheetPath)
: undefined;
if (
!target ||
!resource ||
!resource.mediaType.startsWith("image/") ||
!book.entryByPath.has(target)
) {
element.removeAttribute(attribute);
!stylesheetPath ||
stylesheetItem?.mediaType !== "text/css" ||
!entry ||
entry.uncompressedSize > READER_STYLE_LIMITS.bytesPerStylesheet ||
stylesheetBytes + entry.uncompressedSize >
READER_STYLE_LIMITS.totalStylesheetBytes
)
continue;
}
const bytes = await readEntryBytes(book, target, 25 * 1024 * 1024);
const url = URL.createObjectURL(
new Blob([bytes as BlobPart], { type: resource.mediaType }),
const css = await readEntryText(
book,
stylesheetPath,
READER_STYLE_LIMITS.bytesPerStylesheet,
);
objectUrls.push(url);
element.setAttribute(attribute, url);
const style = document.createElement("style");
style.dataset.epubPublisherStylesheet = stylesheetPath;
style.textContent = await rewriteStylesheetUrls(
css,
stylesheetPath,
resolveResource,
);
publisherStyles.push(style);
stylesheetBytes += entry.uncompressedSize;
} catch {
element.removeAttribute(attribute);
// A missing or malformed optional publisher stylesheet cannot make the
// safe fallback reader unusable.
}
}
const csp = document.createElement("meta");
@@ -136,7 +386,25 @@ export async function renderChapter(
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data: blob:; media-src blob: data:; form-action 'none'; base-uri 'none'";
const style = document.createElement("style");
style.textContent = readerStyle;
document.head.prepend(csp, style);
document.documentElement.removeAttribute("data-epub-reader-theme");
const sourceDirection = originalDocument.documentElement.getAttribute("dir");
const direction = /^(?:ltr|rtl)$/iu.test(sourceDirection ?? "")
? (sourceDirection!.toLowerCase() as "ltr" | "rtl")
: book.package.direction;
const spineItem = book.package.spine.find(
(candidate) => candidate.item?.path === path,
);
const layout =
item.properties.includes("rendition:layout-pre-paginated") ||
spineItem?.properties.includes("rendition:layout-pre-paginated")
? "pre-paginated"
: item.properties.includes("rendition:layout-reflowable") ||
spineItem?.properties.includes("rendition:layout-reflowable")
? "reflowable"
: book.package.rendition.layout;
document.documentElement.setAttribute("dir", direction);
document.documentElement.dataset.epubLayout = layout;
document.head.prepend(csp, ...publisherStyles, style);
const title =
document.title.trim() ||
flattenNavigation(book.package.navigation).find(
@@ -151,6 +419,9 @@ export async function renderChapter(
title,
objectUrls,
text,
direction,
layout,
links,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { readEntryText } from "./entries";
import type { EpubBook, NavigationItem } from "./types";
export interface EpubSearchResult {
path: string;
title: string;
occurrences: number;
snippets: string[];
}
export interface EpubSearchProgress {
current: number;
total: number;
path: string;
}
const SEARCH_LIMITS = {
query: 200,
documents: 2_000,
bytesPerDocument: 8 * 1024 * 1024,
totalBytes: 64 * 1024 * 1024,
results: 500,
occurrencesPerDocument: 10_000,
snippetsPerDocument: 5,
} as const;
function flatten(items: readonly NavigationItem[]): NavigationItem[] {
return items.flatMap((item) => [item, ...flatten(item.children)]);
}
function visibleText(source: string): { title: string; text: string } {
const document = new DOMParser().parseFromString(source, "text/html");
document
.querySelectorAll(
"script, style, template, iframe, object, embed, form, noscript",
)
.forEach((element) => element.remove());
return {
title: document.title.trim(),
text: (
document.body.textContent ??
document.documentElement.textContent ??
""
)
.replace(/\s+/gu, " ")
.trim(),
};
}
export async function searchPublication(
book: EpubBook,
input: string,
onProgress?: (progress: EpubSearchProgress) => void,
signal?: AbortSignal,
): Promise<EpubSearchResult[]> {
const query = input.trim();
if (!query) return [];
if (query.length > SEARCH_LIMITS.query)
throw new Error("Search query exceeds 200 characters.");
const candidates = book.package.spine
.flatMap((spine) =>
spine.item &&
/^(?:application\/xhtml\+xml|text\/html)$/iu.test(spine.item.mediaType)
? [spine.item]
: [],
)
.slice(0, SEARCH_LIMITS.documents);
const navigation = flatten(book.package.navigation);
const needle = query.toLocaleLowerCase();
const results: EpubSearchResult[] = [];
let bytes = 0;
for (const [index, item] of candidates.entries()) {
if (signal?.aborted)
throw new DOMException("Search cancelled.", "AbortError");
const entry = book.entryByPath.get(item.path);
if (!entry || entry.uncompressedSize > SEARCH_LIMITS.bytesPerDocument)
continue;
bytes += entry.uncompressedSize;
if (bytes > SEARCH_LIMITS.totalBytes)
throw new Error("Publication search reached the 64 MiB text limit.");
onProgress?.({
current: index + 1,
total: candidates.length,
path: item.path,
});
const extracted = visibleText(
await readEntryText(book, item.path, SEARCH_LIMITS.bytesPerDocument),
);
const haystack = extracted.text.toLocaleLowerCase();
let cursor = 0;
let occurrences = 0;
const snippets: string[] = [];
while (cursor <= haystack.length - needle.length) {
const found = haystack.indexOf(needle, cursor);
if (found < 0) break;
occurrences += 1;
if (snippets.length < SEARCH_LIMITS.snippetsPerDocument) {
const start = Math.max(0, found - 70);
const end = Math.min(extracted.text.length, found + query.length + 90);
snippets.push(
`${start ? "…" : ""}${extracted.text.slice(start, end)}${end < extracted.text.length ? "…" : ""}`,
);
}
cursor = found + Math.max(1, needle.length);
if (occurrences >= SEARCH_LIMITS.occurrencesPerDocument) break;
}
if (occurrences && results.length < SEARCH_LIMITS.results)
results.push({
path: item.path,
title:
extracted.title ||
navigation.find((navigationItem) => navigationItem.path === item.path)
?.label ||
item.id ||
item.path,
occurrences,
snippets,
});
if (index % 8 === 0)
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
return results.sort(
(left, right) =>
right.occurrences - left.occurrences ||
left.title.localeCompare(right.title),
);
}
+31
View File
@@ -43,12 +43,20 @@ export interface NavigationItem {
export interface EpubPackage {
version: string;
uniqueIdentifier?: string;
uniqueIdentifierValue: string;
packagePath: string;
packageDirectory: string;
metadata: EpubMetadata;
manifest: ManifestItem[];
spine: SpineItem[];
navigation: NavigationItem[];
direction: "ltr" | "rtl" | "auto";
pageProgressionDirection: "ltr" | "rtl" | "default";
rendition: {
layout: "reflowable" | "pre-paginated";
orientation: "auto" | "landscape" | "portrait";
spread: "auto" | "none" | "landscape" | "portrait" | "both";
};
cover?: ManifestItem;
nav?: ManifestItem;
ncx?: ManifestItem;
@@ -61,6 +69,14 @@ export interface EpubEntrySummary {
directory: boolean;
}
export interface EpubAdaptation {
kind: "html" | "markdown" | "text";
sourceFilename: string;
sourceMediaType: string;
sourceBytes: number;
losses: string[];
}
export interface EpubBook {
file: File;
reader: ZipReader<Blob>;
@@ -69,6 +85,11 @@ export interface EpubBook {
package: EpubPackage;
packageXml: string;
issues: EpubIssue[];
adapter?: EpubAdaptation;
obfuscatedFonts: Map<
string,
{ algorithm: "http://www.idpf.org/2008/embedding"; key: Uint8Array }
>;
summary: {
filename: string;
fileSize: number;
@@ -78,6 +99,7 @@ export interface EpubBook {
mimetypeFirst: boolean;
mimetypeStored: boolean;
encryptedResources: boolean;
obfuscatedFontCount: number;
};
}
@@ -87,4 +109,13 @@ export interface RenderedChapter {
title: string;
objectUrls: string[];
text: string;
direction: "ltr" | "rtl" | "auto";
layout: "reflowable" | "pre-paginated";
links: Array<{
label: string;
reference: string;
path?: string;
fragment?: string;
external: boolean;
}>;
}
+46 -18
View File
@@ -3,6 +3,14 @@ import { isExternalReference, resolvePackageHref } from "./paths";
import type { EpubBook, EpubIssue } from "./types";
import { parseXml } from "./xml";
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
export type ValidationProgressCallback = (
current: number,
total: number,
path?: string,
) => void;
function issue(
severity: EpubIssue["severity"],
code: string,
@@ -14,6 +22,7 @@ function issue(
export async function validatePublication(
book: EpubBook,
onProgress?: ValidationProgressCallback,
): Promise<EpubIssue[]> {
const result: EpubIssue[] = [];
if (!book.summary.mimetypeFirst)
@@ -101,12 +110,12 @@ export async function validatePublication(
book.package.packagePath,
),
);
if (book.entryByPath.has("META-INF/encryption.xml"))
if (book.summary.encryptedResources)
result.push(
issue(
"warning",
"encrypted-resources",
"META-INF/encryption.xml is present. Font obfuscation may be valid; DRM-protected content is detected but not bypassed.",
"Unsupported encrypted/protected resources are present. DRM is detected but never bypassed.",
"META-INF/encryption.xml",
),
);
@@ -199,14 +208,20 @@ export async function validatePublication(
const linkedTargets = new Set<string>();
let inspectedBytes = 0;
let inspectedDocuments = 0;
for (const item of book.package.manifest) {
if (
!/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
const contentItems = book.package.manifest.filter(
(item) =>
/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
item.mediaType,
) ||
!book.entryByPath.has(item.path)
) && book.entryByPath.has(item.path),
);
const progressInterval = Math.max(1, Math.ceil(contentItems.length / 100));
for (const [contentIndex, item] of contentItems.entries()) {
if (
contentIndex === 0 ||
contentIndex % progressInterval === 0 ||
contentIndex + 1 === contentItems.length
)
continue;
onProgress?.(contentIndex, contentItems.length, item.path);
const entry = book.entryByPath.get(item.path);
if (
!entry ||
@@ -232,15 +247,27 @@ export async function validatePublication(
item.path,
),
);
for (const element of [
...document.querySelectorAll("[href], [src], [poster]"),
]) {
const attribute = element.hasAttribute("href")
? "href"
: element.hasAttribute("src")
? "src"
: "poster";
const reference = element.getAttribute(attribute)?.trim() ?? "";
const references = [...document.querySelectorAll("*")].flatMap(
(element) => {
const values: Array<{ attribute: string; reference: string }> = [];
for (const attribute of ["href", "src", "poster"])
if (element.hasAttribute(attribute))
values.push({
attribute,
reference: element.getAttribute(attribute) ?? "",
});
const legacyHref = element.getAttributeNS(XLINK_NAMESPACE, "href");
if (legacyHref !== null)
values.push({ attribute: "xlink:href", reference: legacyHref });
return values.map((value) => ({ element, ...value }));
},
);
for (const {
element,
attribute,
reference: rawReference,
} of references) {
const reference = rawReference.trim();
if (
!reference ||
reference.startsWith("#") ||
@@ -248,7 +275,7 @@ export async function validatePublication(
)
continue;
if (isExternalReference(reference)) {
if (attribute !== "href" || element.localName !== "a")
if (!attribute.endsWith("href") || element.localName !== "a")
result.push(
issue(
"warning",
@@ -299,6 +326,7 @@ export async function validatePublication(
);
}
}
onProgress?.(contentItems.length, contentItems.length);
if (
inspectedDocuments <
book.package.manifest.filter((item) =>
+158 -2
View File
@@ -60,6 +60,10 @@ button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.6;
}
:where(button, input, select, textarea, a):focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
@@ -97,6 +101,11 @@ input[type="file"] {
display: grid;
gap: 1rem;
}
.book-workspaces {
display: grid;
min-width: 0;
gap: 1rem;
}
.hero,
.panel,
.summary-grid article {
@@ -239,7 +248,7 @@ input[type="file"] {
overflow-x: auto;
padding: 0.75rem;
}
.workspace-tabs button[aria-selected="true"] {
.workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
@@ -292,18 +301,109 @@ input[type="file"] {
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
}
.navigation-tree {
display: grid;
gap: 0.3rem;
}
.navigation-tree[data-depth="1"] {
margin-left: 0.75rem;
}
.navigation-tree[data-depth="2"],
.navigation-tree[data-depth="3"] {
margin-left: 1rem;
}
.navigation-tree button {
width: 100%;
}
.reader-tool {
margin-top: 0.45rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
.reader-tool > summary {
padding: 0.65rem;
cursor: pointer;
font-weight: 760;
}
.reader-tool-body {
display: grid;
gap: 0.6rem;
padding: 0 0.65rem 0.65rem;
}
.reader-tool-body textarea {
min-height: 5rem;
}
.reader-tool-body > small {
color: var(--toolbox-muted);
line-height: 1.4;
}
.search-results,
.reading-notes-list {
display: grid;
gap: 0.35rem;
max-height: 18rem;
overflow: auto;
}
.search-results button {
display: grid;
justify-content: stretch;
gap: 0.2rem;
text-align: left;
}
.search-results button :where(small, span),
.reading-notes-list small {
color: var(--toolbox-muted);
font-size: 0.7rem;
font-weight: 500;
line-height: 1.35;
}
.reading-notes-list article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem;
}
.reading-notes-list article > button:first-child {
min-width: 0;
display: grid;
justify-content: start;
gap: 0.2rem;
overflow-wrap: anywhere;
text-align: left;
}
.reading-notes-list article > button:last-child {
width: 2.55rem;
padding: 0.4rem;
}
.page-reader {
min-height: 38rem;
min-width: 0;
overflow: auto;
}
.page-reader .panel-heading small {
display: block;
margin-top: 0.3rem;
color: var(--toolbox-muted);
}
.page-reader iframe {
width: 100%;
height: min(65rem, calc(100vh - 13rem));
min-height: 34rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: #fff;
background: var(--toolbox-surface);
color-scheme: inherit;
}
.page-reader iframe[data-reader-layout="pre-paginated"] {
min-height: min(75rem, calc(100vh - 10rem));
}
.reader-busy {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.75rem;
color: var(--toolbox-muted);
font-size: 0.8rem;
font-weight: 700;
}
.field {
display: grid;
@@ -453,6 +553,62 @@ td code {
margin: 2rem auto;
padding: 1rem;
}
.opening-overlay {
position: fixed;
z-index: 30;
inset: 4.5rem 0 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--toolbox-background) 78%, transparent);
backdrop-filter: blur(5px);
}
.opening-card {
width: min(100%, 34rem);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.8rem 0.9rem;
align-items: center;
box-shadow: 0 18px 55px rgb(10 18 40 / 24%);
}
.opening-card h2,
.opening-card p {
margin: 0;
overflow-wrap: anywhere;
}
.opening-card p:not(.eyebrow),
.opening-card small {
color: var(--toolbox-muted);
}
.opening-card progress,
.opening-card small {
grid-column: 1 / -1;
width: 100%;
}
.opening-card progress {
height: 0.6rem;
accent-color: var(--toolbox-accent);
}
.opening-spinner {
width: 1.15rem;
height: 1.15rem;
flex: 0 0 auto;
border: 0.18rem solid var(--toolbox-border);
border-top-color: var(--toolbox-accent);
border-radius: 50%;
animation: opening-spin 0.8s linear infinite;
}
@keyframes opening-spin {
to {
transform: rotate(1turn);
}
}
@media (prefers-reduced-motion: reduce) {
.opening-spinner {
animation: none;
border-right-color: var(--toolbox-accent);
}
}
.help-dialog {
width: min(36rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
+23 -3
View File
@@ -3,24 +3,44 @@
"schemaVersion": 1,
"id": "de.add-ideas.epub-tools",
"name": "EPUB Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Read, inspect and repair EPUB publications locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["documents", "ebooks", "publishing"],
"tags": ["epub", "ebook", "metadata", "reader", "repair"],
"tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"secureContext": true,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{ "mediaType": "text/html", "extensions": [".html", ".htm"] },
{ "mediaType": "application/xhtml+xml", "extensions": [".xhtml"] },
{ "mediaType": "text/markdown", "extensions": [".md", ".markdown"] },
{ "mediaType": "text/plain", "extensions": [".txt"] }
],
"produces": [
{ "mediaType": "application/epub+zip", "extensions": [".epub"] },
{ "mediaType": "text/plain", "extensions": [".txt"] },
{ "mediaType": "text/markdown", "extensions": [".md"] },
{ "mediaType": "text/html", "extensions": [".html"] },
{ "mediaType": "application/json", "extensions": [".json"] }
]
},
"capabilities": {
"required": ["workers", "web-crypto"],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+60 -6
View File
@@ -33,13 +33,19 @@ async function epubFixture(): Promise<Buffer> {
await writer.add(
"EPUB/package.opf",
new TextReader(
`<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">urn:browser:test</dc:identifier><dc:title>Browser Fixture</dc:title><dc:language>en</dc:language><dc:creator>Local Author</dc:creator></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/></manifest><spine><itemref idref="chapter"/></spine></package>`,
`<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">urn:browser:test</dc:identifier><dc:title>Browser Fixture</dc:title><dc:language>en</dc:language><dc:creator>Local Author</dc:creator></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="title-page" href="title.xhtml" media-type="application/xhtml+xml"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/></manifest><spine><itemref idref="title-page"/><itemref idref="chapter"/></spine></package>`,
),
);
await writer.add(
"EPUB/nav.xhtml",
new TextReader(
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml">A safe chapter</a></li></ol></nav></body></html>`,
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><ol><li><a href="title.xhtml">Cover</a></li><li><a href="chapter.xhtml">A safe chapter</a></li></ol></nav></body></html>`,
),
);
await writer.add(
"EPUB/title.xhtml",
new TextReader(
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Cover</title></head><body><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="100%" height="100%" viewBox="0 0 1283 1920" preserveAspectRatio="none"><image width="1283" height="1920" xlink:href="cover.png"></image></svg></body></html>`,
),
);
await writer.add(
@@ -85,17 +91,45 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/epub/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.epub-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
});
});
test("adapts Markdown locally and reports the resulting EPUB workspace", async ({
page,
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/epub/");
await page.getByLabel("Choose publication").setInputFiles({
name: "local-notes.md",
mimeType: "text/markdown",
buffer: Buffer.from("# Local notes\n\nA **private** chapter."),
});
await expect(
page.getByText("Local notes", { exact: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("MARKDOWN → EPUB workspace")).toBeVisible();
await expect(
page
.frameLocator("iframe[title^='Safe EPUB preview']")
.getByRole("heading", { name: "Local notes" }),
).toBeVisible();
await page.getByRole("button", { name: "Validation" }).click();
await expect(page.getByText(/was converted locally into/iu)).toBeVisible();
expect(external).toEqual([]);
});
test("opens, sanitizes, edits, rebuilds, and reopens a real EPUB", async ({
page,
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/epub/");
await page.getByLabel("Choose EPUB").setInputFiles({
await page.getByRole("button", { name: "Personalize" }).click();
await page.getByRole("button", { name: "Dark" }).click();
await page.getByLabel("Choose publication").setInputFiles({
name: "fixture.epub",
mimeType: "application/epub+zip",
buffer: await epubFixture(),
@@ -104,13 +138,33 @@ test("opens, sanitizes, edits, rebuilds, and reopens a real EPUB", async ({
page.getByText("Browser Fixture", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
const reader = page.frameLocator("iframe[title^='Safe EPUB preview']");
const titleImage = reader.locator("svg image");
await expect(titleImage).toBeVisible();
expect(
await titleImage.evaluate((element) =>
element.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
),
).toMatch(/^blob:/u);
await expect(reader.locator("html")).toHaveAttribute(
"data-epub-reader-theme",
"dark",
);
await expect
.poll(() =>
reader.locator("body").evaluate((element) => {
const style = getComputedStyle(element);
return [style.backgroundColor, style.color];
}),
)
.toEqual(["rgb(23, 29, 45)", "rgb(237, 241, 251)"]);
await page.getByRole("button", { name: /A safe chapter/u }).click();
await expect(
reader.getByRole("heading", { name: "Hello from EPUB" }),
).toBeVisible();
await expect(reader.locator("script")).toHaveCount(0);
await page.getByRole("tab", { name: "Metadata" }).click();
await page.getByRole("button", { name: "Metadata" }).click();
await page.getByLabel("Title").fill("Updated in browser");
await page.getByRole("tab", { name: "Export & rebuild" }).click();
await page.getByRole("button", { name: "Export & rebuild" }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Create updated EPUB" }).click();
const download = await downloadPromise;
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/epub/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+36 -2
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { App } from "../../src/App";
@@ -13,7 +13,41 @@ describe("EPUB Tools", () => {
await screen.findByRole("heading", { name: "EPUB Tools" }),
).toBeVisible();
expect(
await screen.findByText("Sandboxed reader · local files"),
await screen.findByText("Sandboxed reader · local files", undefined, {
timeout: 4_000,
}),
).toBeVisible();
});
it("shows the selected filename and progress before archive work starts", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Not found", { status: 404 })),
);
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 1),
);
render(<App />);
const input = await screen.findByLabelText(
"Choose publication",
undefined,
{ timeout: 4_000 },
);
fireEvent.change(input, {
target: {
files: [
new File(["not read yet"], "large-publication.epub", {
type: "application/epub+zip",
}),
],
},
});
const status = await screen.findByRole("status");
expect(status).toHaveTextContent("large-publication.epub");
expect(status).toHaveTextContent("Preparing the local file");
expect(
screen.getByRole("progressbar", { name: "EPUB opening progress" }),
).toHaveValue(0);
});
});
+330 -2
View File
@@ -1,10 +1,33 @@
import { Blob as NodeBlob, File as NodeFile } from "node:buffer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeBook, openEpub } from "../../src/epub/archive";
import { patchPackageMetadata, rebuildEpub } from "../../src/epub/export";
import {
openPublication,
publicationAdapterForFile,
} from "../../src/epub/adapters";
import {
readEntryText,
readPublicationResourceBytes,
} from "../../src/epub/entries";
import {
exportMarkdown,
exportReadingOrderHtml,
patchPackageMetadata,
rebuildEpub,
} from "../../src/epub/export";
import {
exportReadingNotes,
importReadingNotes,
type EpubReadingNotes,
} from "../../src/epub/annotations";
import { resolvePackageHref, validateEntryPath } from "../../src/epub/paths";
import { renderChapter, revokeRenderedChapter } from "../../src/epub/reader";
import {
applyReaderTheme,
renderChapter,
revokeRenderedChapter,
} from "../../src/epub/reader";
import type { EpubBook } from "../../src/epub/types";
import { searchPublication } from "../../src/epub/search";
import { createEpubFixture } from "./fixture";
let opened: EpubBook[] = [];
@@ -31,6 +54,51 @@ describe("EPUB path policy", () => {
});
describe("EPUB package inspection", () => {
it("adapts bounded Markdown, HTML and plain text into explicit EPUB workspaces", async () => {
expect(publicationAdapterForFile({ name: "notes.md", type: "" })).toBe(
"markdown",
);
const markdown = await openPublication(
new File(["# Adapter title\n\nA **local** paragraph."], "notes.md", {
type: "text/markdown",
}),
);
opened.push(markdown);
expect(markdown.adapter).toMatchObject({
kind: "markdown",
sourceFilename: "notes.md",
});
expect(markdown.package.metadata.title).toBe("Adapter title");
expect(await readEntryText(markdown, "EPUB/content.xhtml")).toContain(
"<strong>local</strong>",
);
expect(markdown.issues).toContainEqual(
expect.objectContaining({ code: "adapted-publication" }),
);
const html = await openPublication(
new File(
[
"<!doctype html><title>Safe source</title><style>body{color:red}</style><h1 onclick='bad()'>Heading</h1><script>bad()</script><a href='https://example.invalid'>external</a>",
],
"source.html",
{ type: "text/html" },
),
);
opened.push(html);
const adaptedHtml = await readEntryText(html, "EPUB/content.xhtml");
expect(adaptedHtml).toContain("<h1>Heading</h1>");
expect(adaptedHtml).not.toMatch(/script|onclick|example\.invalid/iu);
const text = await openPublication(
new File(["first\nsecond"], "plain.txt", { type: "text/plain" }),
);
opened.push(text);
expect(await readEntryText(text, "EPUB/content.xhtml")).toContain(
"<pre>first\nsecond</pre>",
);
});
it("opens metadata, navigation, spine, and bounded inventory", async () => {
const book = await openEpub(await createEpubFixture());
opened.push(book);
@@ -52,6 +120,34 @@ describe("EPUB package inspection", () => {
expect(book.issues.filter((item) => item.severity === "error")).toEqual([]);
});
it("reports monotonic, named opening phases", async () => {
const updates: Array<{ phase: string; progress: number }> = [];
const book = await openEpub(await createEpubFixture(), (progress) =>
updates.push(progress),
);
opened.push(book);
expect(updates.map((update) => update.phase)).toEqual(
expect.arrayContaining([
"archive",
"entries",
"container",
"package",
"navigation",
"validation",
"ready",
]),
);
expect(updates.at(-1)).toMatchObject({ phase: "ready", progress: 0.96 });
for (const [index, update] of updates.entries()) {
expect(update.progress).toBeGreaterThanOrEqual(0);
expect(update.progress).toBeLessThanOrEqual(1);
if (index > 0)
expect(update.progress).toBeGreaterThanOrEqual(
updates[index - 1]?.progress ?? 0,
);
}
});
it("reports active content and broken resources", async () => {
const book = await openEpub(
await createEpubFixture({ activeContent: true, brokenLink: true }),
@@ -61,6 +157,22 @@ describe("EPUB package inspection", () => {
expect.arrayContaining(["active-content", "broken-link"]),
);
});
it("parses direction, page progression, and fixed-layout metadata", async () => {
const book = await openEpub(
await createEpubFixture({ direction: "rtl", fixedLayout: true }),
);
opened.push(book);
expect(book.package).toMatchObject({
direction: "rtl",
pageProgressionDirection: "rtl",
rendition: {
layout: "pre-paginated",
orientation: "portrait",
spread: "none",
},
});
});
});
describe("safe rendering and editing", () => {
@@ -82,6 +194,186 @@ describe("safe rendering and editing", () => {
revokeRenderedChapter(chapter);
});
it("resolves SVG XLink images, disables XLink navigation, and caches repeats", async () => {
const createObjectURL = vi.fn(() => "blob:fixture-cover");
vi.stubGlobal("URL", {
...URL,
createObjectURL,
revokeObjectURL: vi.fn(),
});
const book = await openEpub(
await createEpubFixture({ svgTitlePage: true, repeatedImages: 100 }),
);
opened.push(book);
const chapter = await renderChapter(book, "EPUB/title.svg");
const document = new DOMParser().parseFromString(chapter.html, "text/html");
const image = document.querySelector("svg image");
const anchor = document.querySelector("svg a");
expect(image?.getAttributeNS("http://www.w3.org/1999/xlink", "href")).toBe(
"blob:fixture-cover",
);
expect(
anchor?.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
).toBeNull();
expect(anchor?.getAttribute("href")).toBe("#");
expect(anchor?.getAttribute("data-epub-href")).toBe(
"https://example.invalid/tracker",
);
revokeRenderedChapter(chapter);
const repeated = await renderChapter(book, "EPUB/chapter.xhtml");
expect(repeated.objectUrls).toHaveLength(1);
expect(createObjectURL).toHaveBeenCalledTimes(2);
revokeRenderedChapter(repeated);
});
it("reports missing SVG XLink resources and removes them from rendering", async () => {
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn(),
});
const book = await openEpub(
await createEpubFixture({
svgTitlePage: true,
svgImageHref: "missing-cover.jpeg",
}),
);
opened.push(book);
expect(book.issues).toContainEqual(
expect.objectContaining({
code: "broken-link",
path: "EPUB/title.svg",
}),
);
const chapter = await renderChapter(book, "EPUB/title.svg");
const document = new DOMParser().parseFromString(chapter.html, "text/html");
const image = document.querySelector("svg image");
expect(image?.getAttribute("href")).toBeNull();
expect(
image?.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
).toBeNull();
});
it("applies an explicit reader theme independently of the operating system", () => {
const source = "<!doctype html><html><head></head><body>Text</body></html>";
expect(applyReaderTheme(source, "dark")).toContain(
'<html data-epub-reader-theme="dark">',
);
expect(applyReaderTheme(source, "light")).toContain(
'<html data-epub-reader-theme="light">',
);
});
it("loads bounded packaged CSS/fonts, blocks network URLs, and records internal links", async () => {
let url = 0;
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => `blob:fixture-${++url}`),
revokeObjectURL: vi.fn(),
});
const book = await openEpub(
await createEpubFixture({
packagedStyles: true,
secondChapter: true,
direction: "rtl",
fixedLayout: true,
}),
);
opened.push(book);
const chapter = await renderChapter(book, "EPUB/chapter.xhtml");
expect(chapter).toMatchObject({
direction: "rtl",
layout: "pre-paginated",
});
expect(chapter.html).toContain("data-epub-publisher-stylesheet");
expect(chapter.html).toMatch(/font-family:\s*Fixture/iu);
expect(chapter.html).toContain("blob:fixture-");
expect(chapter.html).not.toContain("example.invalid/tracker.png");
expect(chapter.links).toContainEqual(
expect.objectContaining({
path: "EPUB/chapter-two.xhtml",
fragment: "answer",
external: false,
}),
);
});
it("decodes standardized IDPF-obfuscated fonts without treating them as DRM", async () => {
const book = await openEpub(
await createEpubFixture({ obfuscatedFont: true }),
);
opened.push(book);
expect(book.summary).toMatchObject({
encryptedResources: false,
obfuscatedFontCount: 1,
});
expect(book.issues).toContainEqual(
expect.objectContaining({ code: "font-obfuscation-supported" }),
);
const font = await readPublicationResourceBytes(
book,
"EPUB/fonts/book.woff2",
1024,
);
expect(new TextDecoder().decode(font.slice(0, 4))).toBe("wOF2");
});
it("searches bounded reading-order text and exports safe derived formats", async () => {
const book = await openEpub(
await createEpubFixture({ secondChapter: true, activeContent: true }),
);
opened.push(book);
const progress: number[] = [];
const results = await searchPublication(
book,
"searchable phrase",
(value) => progress.push(value.current),
);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
path: "EPUB/chapter-two.xhtml",
occurrences: 2,
});
expect(progress.at(-1)).toBe(2);
const markdown = await (await exportMarkdown(book)).text();
const html = await (await exportReadingOrderHtml(book)).text();
expect(markdown).toContain("# Second chapter");
expect(html).toContain("Content-Security-Policy");
expect(html).not.toContain("<script");
expect(html).not.toContain('http-equiv="refresh"');
expect(html).not.toContain("<template");
expect(html).not.toContain("example.invalid");
});
it("round-trips bounded publication-scoped bookmarks and annotations", () => {
const notes: EpubReadingNotes = {
schemaVersion: 1,
publication: { identifier: "urn:test:book", title: "Fixture Book" },
bookmarks: [
{ id: "bookmark-1", path: "EPUB/chapter.xhtml", label: "Opening" },
],
annotations: [
{
id: "note-1",
path: "EPUB/chapter-two.xhtml",
fragment: "answer",
label: "Answer",
note: "Review",
quote: "searchable phrase",
},
],
};
const output = exportReadingNotes(notes);
expect(importReadingNotes(output, notes.publication)).toEqual(notes);
expect(() =>
importReadingNotes(output, {
identifier: "urn:other",
title: "Other",
}),
).toThrow(/different publication/u);
});
it("patches metadata and rebuilds a readable normalized EPUB", async () => {
const book = await openEpub(await createEpubFixture());
opened.push(book);
@@ -111,4 +403,40 @@ describe("safe rendering and editing", () => {
mimetypeStored: true,
});
});
it("re-keys standardized obfuscated fonts when the identifier changes", async () => {
const book = await openEpub(
await createEpubFixture({ obfuscatedFont: true }),
);
opened.push(book);
const metadata = {
...book.package.metadata,
identifier: "urn:test:updated-book",
creators: [...book.package.metadata.creators],
subjects: [...book.package.metadata.subjects],
};
const rebuilt = await rebuildEpub(book, { metadata });
expect(rebuilt.changes).toContain(
"Re-keyed 1 standardized obfuscated font(s)",
);
const reopened = await openEpub(
new File([rebuilt.blob], "re-keyed.epub", {
type: "application/epub+zip",
}),
);
opened.push(reopened);
expect(reopened.package.uniqueIdentifierValue).toBe(
"urn:test:updated-book",
);
expect(reopened.summary).toMatchObject({
encryptedResources: false,
obfuscatedFontCount: 1,
});
const font = await readPublicationResourceBytes(
reopened,
"EPUB/fonts/book.woff2",
1024,
);
expect(new TextDecoder().decode(font.slice(0, 4))).toBe("wOF2");
});
});
+53 -3
View File
@@ -10,8 +10,17 @@ export async function createEpubFixture(
brokenLink?: boolean;
activeContent?: boolean;
compressedMimetype?: boolean;
svgTitlePage?: boolean;
svgImageHref?: string;
repeatedImages?: number;
packagedStyles?: boolean;
obfuscatedFont?: boolean;
secondChapter?: boolean;
direction?: "ltr" | "rtl";
fixedLayout?: boolean;
} = {},
): Promise<File> {
const packagedStyles = options.packagedStyles || options.obfuscatedFont;
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
await writer.add("mimetype", new TextReader("application/epub+zip"), {
level: options.compressedMimetype ? 6 : 0,
@@ -22,24 +31,65 @@ export async function createEpubFixture(
`<?xml version="1.0"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
),
);
if (options.obfuscatedFont)
await writer.add(
"META-INF/encryption.xml",
new TextReader(
`<?xml version="1.0" encoding="UTF-8"?><encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:enc="http://www.w3.org/2001/04/xmlenc#"><enc:EncryptedData><enc:EncryptionMethod Algorithm="http://www.idpf.org/2008/embedding"/><enc:CipherData><enc:CipherReference URI="EPUB/fonts/book.woff2"/></enc:CipherData></enc:EncryptedData></encryption>`,
),
);
await writer.add(
"EPUB/package.opf",
new TextReader(
`<?xml version="1.0" encoding="UTF-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">urn:test:book</dc:identifier><dc:title>Fixture Book</dc:title><dc:language>en</dc:language><dc:creator>Ada Example</dc:creator></metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/></manifest><spine><itemref idref="chapter"/></spine></package>`,
`<?xml version="1.0" encoding="UTF-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id"${options.direction ? ` dir="${options.direction}"` : ""}><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="book-id">urn:test:book</dc:identifier><dc:title>Fixture Book</dc:title><dc:language>en</dc:language><dc:creator>Ada Example</dc:creator>${options.fixedLayout ? '<meta property="rendition:layout">pre-paginated</meta><meta property="rendition:orientation">portrait</meta><meta property="rendition:spread">none</meta>' : ""}</metadata><manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>${options.secondChapter ? '<item id="chapter-two" href="chapter-two.xhtml" media-type="application/xhtml+xml"/>' : ""}<item id="cover" href="cover.png" media-type="image/png" properties="cover-image"/>${packagedStyles ? '<item id="style" href="styles/book.css" media-type="text/css"/><item id="font" href="fonts/book.woff2" media-type="font/woff2"/>' : ""}${options.svgTitlePage ? '<item id="title-page" href="title.svg" media-type="image/svg+xml"/>' : ""}</manifest><spine${options.direction ? ` page-progression-direction="${options.direction}"` : ""}>${options.svgTitlePage ? '<itemref idref="title-page"/>' : ""}<itemref idref="chapter"/>${options.secondChapter ? '<itemref idref="chapter-two"/>' : ""}</spine></package>`,
),
);
await writer.add(
"EPUB/nav.xhtml",
new TextReader(
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml">Opening chapter</a></li></ol></nav></body></html>`,
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>Contents</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml">Opening chapter</a>${options.secondChapter ? '<ol><li><a href="chapter-two.xhtml#answer">Second chapter</a></li></ol>' : ""}</li></ol></nav></body></html>`,
),
);
await writer.add(
"EPUB/chapter.xhtml",
new TextReader(
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Opening chapter</title></head><body><h1>Hello</h1><p>Local reading.</p>${options.activeContent ? "<script>globalThis.pwned=true</script><form><input/></form>" : ""}<img src="cover.png"/><a href="${options.brokenLink ? "missing.xhtml" : "nav.xhtml"}">Next</a></body></html>`,
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"${options.direction ? ` dir="${options.direction}"` : ""}><head><title>Opening chapter</title>${packagedStyles ? '<link rel="stylesheet" href="styles/book.css"/>' : ""}</head><body><h1 id="opening">Hello</h1><p>Local reading and searchable phrase.</p>${options.activeContent ? '<script>globalThis.pwned=true</script><form><input/></form><meta http-equiv="refresh" content="0;url=https://example.invalid/export-leak"/><template><iframe src="https://example.invalid/template-leak"></iframe></template>' : ""}${Array.from({ length: options.repeatedImages ?? 1 }, () => '<img src="cover.png"/>').join("")}<a href="${options.brokenLink ? "missing.xhtml" : options.secondChapter ? "chapter-two.xhtml#answer" : "nav.xhtml"}">Next</a></body></html>`,
),
);
if (options.secondChapter)
await writer.add(
"EPUB/chapter-two.xhtml",
new TextReader(
`<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Second chapter</title></head><body><h1 id="answer">Answer</h1><p>The searchable phrase appears twice: searchable phrase.</p></body></html>`,
),
);
if (packagedStyles) {
await writer.add(
"EPUB/styles/book.css",
new TextReader(
`@font-face{font-family:Fixture;src:url("../fonts/book.woff2")}body{font-family:Fixture;background-image:url("https://example.invalid/tracker.png")}h1{color:rebeccapurple}`,
),
);
const fontBytes = new Uint8Array([0x77, 0x4f, 0x46, 0x32, 0, 0, 0, 0]);
if (options.obfuscatedFont) {
const key = new Uint8Array(
await crypto.subtle.digest(
"SHA-1",
new TextEncoder().encode("urn:test:book"),
),
);
for (let index = 0; index < fontBytes.length; index += 1)
fontBytes[index] = fontBytes[index]! ^ key[index % key.length]!;
}
await writer.add("EPUB/fonts/book.woff2", new Uint8ArrayReader(fontBytes));
}
if (options.svgTitlePage)
await writer.add(
"EPUB/title.svg",
new TextReader(
`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="100%" height="100%" viewBox="0 0 1283 1920" preserveAspectRatio="none"><a xlink:href="https://example.invalid/tracker"><image width="1283" height="1920" xlink:href="${options.svgImageHref ?? "cover.png"}"/></a></svg>`,
),
);
await writer.add(
"EPUB/cover.png",
new Uint8ArrayReader(