Release EPUB Tools 0.2.0
This commit is contained in:
@@ -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
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first EPUB Tools workbench.
|
- Added the initial local-first EPUB Tools workbench.
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# EPUB Tools
|
# 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.
|
- 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.
|
- 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.
|
- 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
|
## 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
|
- 64 MiB per resource, 500:1 maximum declared expansion ratio
|
||||||
- 16 MiB package/chapter parse limit
|
- 16 MiB package/chapter parse limit
|
||||||
- Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed
|
- 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
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -33,7 +54,7 @@ npm run check
|
|||||||
npm run test:browser
|
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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -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.
|
- `@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.
|
- `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.
|
- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0.
|
||||||
- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
|
- `@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.
|
- 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.
|
EPUB publications opened by a user are not bundled and retain their own copyright and licence terms.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Accessibility
|
# 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.
|
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.
|
||||||
|
|||||||
@@ -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/`:
|
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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
- `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.
|
- `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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|||||||
Generated
+22
-23
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "epub-tools",
|
"name": "epub-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "epub-tools",
|
"name": "epub-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@zip.js/zip.js": "2.9.0",
|
"@zip.js/zip.js": "2.9.0",
|
||||||
"dompurify": "3.4.14",
|
"dompurify": "3.4.14",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -44,24 +44,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"node": ">=20"
|
"license": "Apache-2.0"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"node": ">=22"
|
"license": "GPL-3.0-or-later"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"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",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -69,17 +69,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"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,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epub-tools",
|
"name": "epub-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,16 +39,16 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"@zip.js/zip.js": "2.9.0",
|
"@zip.js/zip.js": "2.9.0",
|
||||||
"dompurify": "3.4.14",
|
"dompurify": "3.4.14",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first EPUB Tools workbench.
|
- Added the initial local-first EPUB Tools workbench.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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)
|
Declared licence: (MPL-2.0 OR Apache-2.0)
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Apache License
|
||||||
Version 2.0, January 2004
|
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
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 Contributor’s 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 party’s
|
|
||||||
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
|
|
||||||
party’s 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 party’s 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
|
react@19.2.8
|
||||||
|
|||||||
+29
-8
@@ -1,16 +1,23 @@
|
|||||||
# EPUB Tools
|
# 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.
|
- 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.
|
- 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.
|
- 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
|
## 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
|
- 64 MiB per resource, 500:1 maximum declared expansion ratio
|
||||||
- 16 MiB package/chapter parse limit
|
- 16 MiB package/chapter parse limit
|
||||||
- Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed
|
- 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
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -33,7 +54,7 @@ npm run check
|
|||||||
npm run test:browser
|
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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -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.
|
- `@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.
|
- `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.
|
- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0.
|
||||||
- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads.
|
- `@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.
|
- 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.
|
EPUB publications opened by a user are not bundled and retain their own copyright and licence terms.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Accessibility
|
# 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.
|
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.
|
||||||
|
|||||||
@@ -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/`:
|
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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
- `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.
|
- `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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "epub-tools-shell-";
|
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"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+20
-3
@@ -3,24 +3,41 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.epub-tools",
|
"id": "de.add-ideas.epub-tools",
|
||||||
"name": "EPUB Tools",
|
"name": "EPUB Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "ebooks", "publishing"],
|
"categories": ["documents", "ebooks", "publishing"],
|
||||||
"tags": ["epub", "ebook", "metadata", "reader", "repair"],
|
"tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
"embedding": "unsupported"
|
"embedding": "unsupported"
|
||||||
},
|
},
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"secureContext": false,
|
"secureContext": true,
|
||||||
"workers": true,
|
"workers": true,
|
||||||
"indexedDb": false,
|
"indexedDb": false,
|
||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
@@ -31,11 +31,22 @@ export function HelpDialog({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p>
|
||||||
All processing is performed in this browser. Imported data is treated as
|
All processing is performed in this browser. Imported data is treated as
|
||||||
untrusted and bounded before parsing.
|
untrusted and bounded before parsing.
|
||||||
</p>
|
</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>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+719
-57
File diff suppressed because it is too large
Load Diff
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 byte–16 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 byte–16 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;
|
||||||
|
}
|
||||||
@@ -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
@@ -17,19 +17,156 @@ import { validatePublication } from "./validation";
|
|||||||
|
|
||||||
export { EPUB_LIMITS, readEntryBytes, readEntryText } from "./entries";
|
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> {
|
export async function closeBook(book: EpubBook | undefined): Promise<void> {
|
||||||
if (book) await book.reader.close();
|
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)
|
if (file.size === 0 || file.size > EPUB_LIMITS.fileBytes)
|
||||||
throw new RangeError("EPUB must be between 1 byte and 512 MiB.");
|
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), {
|
const reader = new ZipReader(new BlobReader(file), {
|
||||||
useWebWorkers: true,
|
useWebWorkers: true,
|
||||||
strictness: "strict",
|
strictness: "strict",
|
||||||
});
|
});
|
||||||
try {
|
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)
|
if (!entries.length || entries.length > EPUB_LIMITS.entries)
|
||||||
throw new RangeError(
|
throw new RangeError(
|
||||||
`EPUB entry count must be between 1 and ${EPUB_LIMITS.entries.toLocaleString()}.`,
|
`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 totalUncompressed = 0;
|
||||||
let compressedBytes = 0;
|
let compressedBytes = 0;
|
||||||
const entryByPath = new Map<string, Entry>();
|
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);
|
validateEntryPath(entry.filename.replace(/\/$/u, "") || entry.filename);
|
||||||
if (entryByPath.has(entry.filename))
|
if (entryByPath.has(entry.filename))
|
||||||
throw new Error(`Duplicate archive path: ${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 mimetypeFirst = physicalFiles[0]?.filename === "mimetype";
|
||||||
const mimetypeStored = mimetypeEntry.compressionMethod === 0;
|
const mimetypeStored = mimetypeEntry.compressionMethod === 0;
|
||||||
const provisional = { entryByPath };
|
const provisional = { entryByPath };
|
||||||
|
onProgress?.({
|
||||||
|
phase: "container",
|
||||||
|
message: "Reading the EPUB container declaration…",
|
||||||
|
progress: 0.4,
|
||||||
|
});
|
||||||
const containerXml = await readEntryText(
|
const containerXml = await readEntryText(
|
||||||
provisional,
|
provisional,
|
||||||
"META-INF/container.xml",
|
"META-INF/container.xml",
|
||||||
@@ -92,6 +247,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
|||||||
if (!rootfiles.length)
|
if (!rootfiles.length)
|
||||||
throw new Error("container.xml does not declare a package rootfile.");
|
throw new Error("container.xml does not declare a package rootfile.");
|
||||||
const packagePath = validateEntryPath(rootfiles[0] ?? "");
|
const packagePath = validateEntryPath(rootfiles[0] ?? "");
|
||||||
|
onProgress?.({
|
||||||
|
phase: "package",
|
||||||
|
message: "Parsing package metadata and reading order…",
|
||||||
|
progress: 0.5,
|
||||||
|
});
|
||||||
const packageXml = await readEntryText(
|
const packageXml = await readEntryText(
|
||||||
provisional,
|
provisional,
|
||||||
packagePath,
|
packagePath,
|
||||||
@@ -99,6 +259,11 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
|||||||
);
|
);
|
||||||
const parsed = parsePackageDocument(packageXml, packagePath);
|
const parsed = parsePackageDocument(packageXml, packagePath);
|
||||||
const issues: EpubIssue[] = [];
|
const issues: EpubIssue[] = [];
|
||||||
|
const encryption = await inspectEncryption(
|
||||||
|
provisional,
|
||||||
|
parsed.package.uniqueIdentifierValue,
|
||||||
|
);
|
||||||
|
issues.push(...encryption.issues);
|
||||||
if (rootfiles.length > 1)
|
if (rootfiles.length > 1)
|
||||||
issues.push({
|
issues.push({
|
||||||
severity: "info",
|
severity: "info",
|
||||||
@@ -114,6 +279,7 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
|||||||
package: parsed.package,
|
package: parsed.package,
|
||||||
packageXml,
|
packageXml,
|
||||||
issues,
|
issues,
|
||||||
|
obfuscatedFonts: encryption.fonts,
|
||||||
summary: {
|
summary: {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
@@ -124,9 +290,15 @@ export async function openEpub(file: File): Promise<EpubBook> {
|
|||||||
mimetypeStored,
|
mimetypeStored,
|
||||||
encryptedResources:
|
encryptedResources:
|
||||||
entries.some((entry) => entry.encrypted) ||
|
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)) {
|
if (book.package.nav?.path && entryByPath.has(book.package.nav.path)) {
|
||||||
try {
|
try {
|
||||||
book.package.navigation = parseNavigationDocument(
|
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;
|
return book;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
await reader.close().catch(() => undefined);
|
await reader.close().catch(() => undefined);
|
||||||
|
|||||||
@@ -39,3 +39,20 @@ export async function readEntryText(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return decodeXml(await readEntryBytes(book, path, maximum));
|
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
@@ -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 { 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 type { EpubBook, EpubMetadata } from "./types";
|
||||||
import { elementsByLocalName, parseXml } from "./xml";
|
import { elementsByLocalName, parseXml } from "./xml";
|
||||||
|
|
||||||
@@ -49,9 +56,16 @@ export function patchPackageMetadata(
|
|||||||
setMetadataValues(document, metadataElement, "title", [metadata.title]);
|
setMetadataValues(document, metadataElement, "title", [metadata.title]);
|
||||||
setMetadataValues(document, metadataElement, "creator", metadata.creators);
|
setMetadataValues(document, metadataElement, "creator", metadata.creators);
|
||||||
setMetadataValues(document, metadataElement, "language", [metadata.language]);
|
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", [
|
setMetadataValues(document, metadataElement, "identifier", [
|
||||||
metadata.identifier,
|
metadata.identifier,
|
||||||
]);
|
]);
|
||||||
|
elementsByLocalName(metadataElement, "identifier")[0]?.setAttribute(
|
||||||
|
"id",
|
||||||
|
identifierId,
|
||||||
|
);
|
||||||
setMetadataValues(document, metadataElement, "publisher", [
|
setMetadataValues(document, metadataElement, "publisher", [
|
||||||
metadata.publisher,
|
metadata.publisher,
|
||||||
]);
|
]);
|
||||||
@@ -93,7 +107,7 @@ export async function rebuildEpub(
|
|||||||
): Promise<{ blob: Blob; changes: string[]; packageXml: string }> {
|
): Promise<{ blob: Blob; changes: string[]; packageXml: string }> {
|
||||||
if (book.summary.encryptedResources)
|
if (book.summary.encryptedResources)
|
||||||
throw new Error(
|
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)
|
if (options.cover && !book.package.cover)
|
||||||
throw new Error("This publication has no declared cover item to replace.");
|
throw new Error("This publication has no declared cover item to replace.");
|
||||||
@@ -110,6 +124,26 @@ export async function rebuildEpub(
|
|||||||
"Rewrote package metadata",
|
"Rewrote package metadata",
|
||||||
"Placed an uncompressed mimetype entry first",
|
"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"), {
|
await writer.add("mimetype", new TextReader("application/epub+zip"), {
|
||||||
level: 0,
|
level: 0,
|
||||||
lastModDate: options.normalizeTimestamps ? fixedDate : undefined,
|
lastModDate: options.normalizeTimestamps ? fixedDate : undefined,
|
||||||
@@ -134,6 +168,19 @@ export async function rebuildEpub(
|
|||||||
lastModDate,
|
lastModDate,
|
||||||
});
|
});
|
||||||
changes.push(`Replaced cover bytes at ${entry.filename}`);
|
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 {
|
} else {
|
||||||
const blob = await entry.getData(new BlobWriter(), {
|
const blob = await entry.getData(new BlobWriter(), {
|
||||||
checkSignature: true,
|
checkSignature: true,
|
||||||
@@ -179,3 +226,199 @@ export async function exportPlainText(book: EpubBook): Promise<Blob> {
|
|||||||
type: "text/plain;charset=utf-8",
|
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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
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
@@ -19,6 +19,15 @@ function firstDirect(parent: Element, name: string): Element | undefined {
|
|||||||
return directChildrenByLocalName(parent, name)[0];
|
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(
|
export function parsePackageDocument(
|
||||||
packageXml: string,
|
packageXml: string,
|
||||||
packagePath: string,
|
packagePath: string,
|
||||||
@@ -49,6 +58,12 @@ export function parsePackageDocument(
|
|||||||
.map((item) => text(item))
|
.map((item) => text(item))
|
||||||
.filter(Boolean),
|
.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 packageDirectory = directoryOf(packagePath);
|
||||||
const manifest: ManifestItem[] = directChildrenByLocalName(
|
const manifest: ManifestItem[] = directChildrenByLocalName(
|
||||||
manifestElement,
|
manifestElement,
|
||||||
@@ -93,16 +108,52 @@ export function parsePackageDocument(
|
|||||||
const ncx =
|
const ncx =
|
||||||
(ncxId ? byId.get(ncxId) : undefined) ??
|
(ncxId ? byId.get(ncxId) : undefined) ??
|
||||||
manifest.find((item) => item.mediaType === "application/x-dtbncx+xml");
|
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 {
|
return {
|
||||||
package: {
|
package: {
|
||||||
version: root.getAttribute("version") ?? "",
|
version: root.getAttribute("version") ?? "",
|
||||||
uniqueIdentifier: root.getAttribute("unique-identifier") ?? undefined,
|
uniqueIdentifier,
|
||||||
|
uniqueIdentifierValue,
|
||||||
packagePath,
|
packagePath,
|
||||||
packageDirectory,
|
packageDirectory,
|
||||||
metadata,
|
metadata,
|
||||||
manifest,
|
manifest,
|
||||||
spine,
|
spine,
|
||||||
navigation: [],
|
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,
|
cover,
|
||||||
nav,
|
nav,
|
||||||
ncx,
|
ncx,
|
||||||
|
|||||||
+318
-47
@@ -1,11 +1,59 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { readEntryBytes, readEntryText } from "./entries";
|
import { readEntryText, readPublicationResourceBytes } from "./entries";
|
||||||
import { isExternalReference, resolvePackageHref } from "./paths";
|
import {
|
||||||
|
isExternalReference,
|
||||||
|
resolvePackageHref,
|
||||||
|
splitReference,
|
||||||
|
} from "./paths";
|
||||||
import type { EpubBook, NavigationItem, RenderedChapter } from "./types";
|
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 = `
|
const readerStyle = `
|
||||||
:root { color-scheme: light dark; font-family: ui-serif, Georgia, serif; line-height: 1.62; }
|
:root {
|
||||||
body { max-width: 48rem; margin: 0 auto; padding: 1.4rem; overflow-wrap: anywhere; }
|
--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; }
|
img, svg { max-width: 100%; height: auto; }
|
||||||
table { max-width: 100%; border-collapse: collapse; overflow-wrap: anywhere; }
|
table { max-width: 100%; border-collapse: collapse; overflow-wrap: anywhere; }
|
||||||
th, td { padding: .3rem; border: 1px solid currentColor; }
|
th, td { padding: .3rem; border: 1px solid currentColor; }
|
||||||
@@ -13,6 +61,13 @@ const readerStyle = `
|
|||||||
a { color: inherit; text-decoration: underline dotted; }
|
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[] {
|
function flattenNavigation(items: NavigationItem[]): NavigationItem[] {
|
||||||
return items.flatMap((item) => [item, ...flattenNavigation(item.children)]);
|
return items.flatMap((item) => [item, ...flattenNavigation(item.children)]);
|
||||||
}
|
}
|
||||||
@@ -24,6 +79,38 @@ function safeInlineStyle(value: string): string {
|
|||||||
.replace(/expression\s*\([^)]*\)/giu, "");
|
.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(
|
export async function renderChapter(
|
||||||
book: EpubBook,
|
book: EpubBook,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -41,6 +128,18 @@ export async function renderChapter(
|
|||||||
"Only XHTML, HTML, and SVG spine documents can be rendered.",
|
"Only XHTML, HTML, and SVG spine documents can be rendered.",
|
||||||
);
|
);
|
||||||
const source = await readEntryText(book, path);
|
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, {
|
const sanitized = DOMPurify.sanitize(source, {
|
||||||
WHOLE_DOCUMENT: true,
|
WHOLE_DOCUMENT: true,
|
||||||
USE_PROFILES: { html: true, svg: true, svgFilters: false },
|
USE_PROFILES: { html: true, svg: true, svgFilters: false },
|
||||||
@@ -64,70 +163,221 @@ export async function renderChapter(
|
|||||||
String(sanitized),
|
String(sanitized),
|
||||||
"text/html",
|
"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]")])
|
for (const element of [...document.querySelectorAll<HTMLElement>("[style]")])
|
||||||
element.setAttribute(
|
element.setAttribute(
|
||||||
"style",
|
"style",
|
||||||
safeInlineStyle(element.getAttribute("style") ?? ""),
|
safeInlineStyle(element.getAttribute("style") ?? ""),
|
||||||
);
|
);
|
||||||
for (const style of [...document.querySelectorAll("style")])
|
for (const style of [...document.querySelectorAll("style")])
|
||||||
style.textContent = safeInlineStyle(style.textContent ?? "");
|
style.textContent = await rewriteStylesheetUrls(
|
||||||
for (const anchor of [
|
style.textContent ?? "",
|
||||||
...document.querySelectorAll<HTMLAnchorElement>("a[href]"),
|
path,
|
||||||
]) {
|
resolveResource,
|
||||||
const original = anchor.getAttribute("href") ?? "";
|
);
|
||||||
|
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("data-epub-href", original);
|
||||||
anchor.setAttribute("href", "#");
|
anchor.setAttribute("href", "#");
|
||||||
anchor.removeAttribute("target");
|
anchor.removeAttribute("target");
|
||||||
anchor.title = original
|
anchor.setAttribute(
|
||||||
? `EPUB link disabled in safe reader: ${original}`
|
"title",
|
||||||
: "EPUB link";
|
original ? `EPUB reader link: ${original}` : "EPUB link",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const objectUrls: string[] = [];
|
const resourceNodes: Array<{
|
||||||
const resourceNodes: Array<{ element: Element; attribute: string }> = [
|
reference: string;
|
||||||
|
allowed: RegExp;
|
||||||
|
replace: (value: string) => void;
|
||||||
|
remove: () => void;
|
||||||
|
}> = [
|
||||||
...[...document.querySelectorAll("img[src]")].map((element) => ({
|
...[...document.querySelectorAll("img[src]")].map((element) => ({
|
||||||
element,
|
reference: element.getAttribute("src") ?? "",
|
||||||
attribute: "src",
|
allowed: /^image\//iu,
|
||||||
|
replace: (value: string) => element.setAttribute("src", value),
|
||||||
|
remove: () => element.removeAttribute("src"),
|
||||||
})),
|
})),
|
||||||
...[...document.querySelectorAll("svg image[href]")].map((element) => ({
|
...[
|
||||||
element,
|
...document.querySelectorAll("audio[src], video[src], source[src]"),
|
||||||
attribute: "href",
|
].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(
|
...[...document.querySelectorAll("video[poster]")].map((element) => ({
|
||||||
(element) => ({ element, attribute: "xlink:href" }),
|
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) {
|
for (const resourceNode of resourceNodes) {
|
||||||
const reference = element.getAttribute(attribute) ?? "";
|
const { reference } = resourceNode;
|
||||||
if (
|
if (!reference) {
|
||||||
!reference ||
|
resourceNode.remove();
|
||||||
reference.startsWith("data:") ||
|
|
||||||
isExternalReference(reference)
|
|
||||||
) {
|
|
||||||
if (isExternalReference(reference)) element.removeAttribute(attribute);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const target = resolvePackageHref(path, reference);
|
const replacement = await resolveResource(
|
||||||
const resource = target
|
reference,
|
||||||
? book.package.manifest.find((candidate) => candidate.path === target)
|
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;
|
: undefined;
|
||||||
if (
|
if (
|
||||||
!target ||
|
!stylesheetPath ||
|
||||||
!resource ||
|
stylesheetItem?.mediaType !== "text/css" ||
|
||||||
!resource.mediaType.startsWith("image/") ||
|
!entry ||
|
||||||
!book.entryByPath.has(target)
|
entry.uncompressedSize > READER_STYLE_LIMITS.bytesPerStylesheet ||
|
||||||
) {
|
stylesheetBytes + entry.uncompressedSize >
|
||||||
element.removeAttribute(attribute);
|
READER_STYLE_LIMITS.totalStylesheetBytes
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
}
|
const css = await readEntryText(
|
||||||
const bytes = await readEntryBytes(book, target, 25 * 1024 * 1024);
|
book,
|
||||||
const url = URL.createObjectURL(
|
stylesheetPath,
|
||||||
new Blob([bytes as BlobPart], { type: resource.mediaType }),
|
READER_STYLE_LIMITS.bytesPerStylesheet,
|
||||||
);
|
);
|
||||||
objectUrls.push(url);
|
const style = document.createElement("style");
|
||||||
element.setAttribute(attribute, url);
|
style.dataset.epubPublisherStylesheet = stylesheetPath;
|
||||||
|
style.textContent = await rewriteStylesheetUrls(
|
||||||
|
css,
|
||||||
|
stylesheetPath,
|
||||||
|
resolveResource,
|
||||||
|
);
|
||||||
|
publisherStyles.push(style);
|
||||||
|
stylesheetBytes += entry.uncompressedSize;
|
||||||
} catch {
|
} catch {
|
||||||
element.removeAttribute(attribute);
|
// A missing or malformed optional publisher stylesheet cannot make the
|
||||||
|
// safe fallback reader unusable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const csp = document.createElement("meta");
|
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'";
|
"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");
|
const style = document.createElement("style");
|
||||||
style.textContent = readerStyle;
|
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 =
|
const title =
|
||||||
document.title.trim() ||
|
document.title.trim() ||
|
||||||
flattenNavigation(book.package.navigation).find(
|
flattenNavigation(book.package.navigation).find(
|
||||||
@@ -151,6 +419,9 @@ export async function renderChapter(
|
|||||||
title,
|
title,
|
||||||
objectUrls,
|
objectUrls,
|
||||||
text,
|
text,
|
||||||
|
direction,
|
||||||
|
layout,
|
||||||
|
links,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,12 +43,20 @@ export interface NavigationItem {
|
|||||||
export interface EpubPackage {
|
export interface EpubPackage {
|
||||||
version: string;
|
version: string;
|
||||||
uniqueIdentifier?: string;
|
uniqueIdentifier?: string;
|
||||||
|
uniqueIdentifierValue: string;
|
||||||
packagePath: string;
|
packagePath: string;
|
||||||
packageDirectory: string;
|
packageDirectory: string;
|
||||||
metadata: EpubMetadata;
|
metadata: EpubMetadata;
|
||||||
manifest: ManifestItem[];
|
manifest: ManifestItem[];
|
||||||
spine: SpineItem[];
|
spine: SpineItem[];
|
||||||
navigation: NavigationItem[];
|
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;
|
cover?: ManifestItem;
|
||||||
nav?: ManifestItem;
|
nav?: ManifestItem;
|
||||||
ncx?: ManifestItem;
|
ncx?: ManifestItem;
|
||||||
@@ -61,6 +69,14 @@ export interface EpubEntrySummary {
|
|||||||
directory: boolean;
|
directory: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EpubAdaptation {
|
||||||
|
kind: "html" | "markdown" | "text";
|
||||||
|
sourceFilename: string;
|
||||||
|
sourceMediaType: string;
|
||||||
|
sourceBytes: number;
|
||||||
|
losses: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EpubBook {
|
export interface EpubBook {
|
||||||
file: File;
|
file: File;
|
||||||
reader: ZipReader<Blob>;
|
reader: ZipReader<Blob>;
|
||||||
@@ -69,6 +85,11 @@ export interface EpubBook {
|
|||||||
package: EpubPackage;
|
package: EpubPackage;
|
||||||
packageXml: string;
|
packageXml: string;
|
||||||
issues: EpubIssue[];
|
issues: EpubIssue[];
|
||||||
|
adapter?: EpubAdaptation;
|
||||||
|
obfuscatedFonts: Map<
|
||||||
|
string,
|
||||||
|
{ algorithm: "http://www.idpf.org/2008/embedding"; key: Uint8Array }
|
||||||
|
>;
|
||||||
summary: {
|
summary: {
|
||||||
filename: string;
|
filename: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -78,6 +99,7 @@ export interface EpubBook {
|
|||||||
mimetypeFirst: boolean;
|
mimetypeFirst: boolean;
|
||||||
mimetypeStored: boolean;
|
mimetypeStored: boolean;
|
||||||
encryptedResources: boolean;
|
encryptedResources: boolean;
|
||||||
|
obfuscatedFontCount: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +109,13 @@ export interface RenderedChapter {
|
|||||||
title: string;
|
title: string;
|
||||||
objectUrls: string[];
|
objectUrls: string[];
|
||||||
text: 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
@@ -3,6 +3,14 @@ import { isExternalReference, resolvePackageHref } from "./paths";
|
|||||||
import type { EpubBook, EpubIssue } from "./types";
|
import type { EpubBook, EpubIssue } from "./types";
|
||||||
import { parseXml } from "./xml";
|
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(
|
function issue(
|
||||||
severity: EpubIssue["severity"],
|
severity: EpubIssue["severity"],
|
||||||
code: string,
|
code: string,
|
||||||
@@ -14,6 +22,7 @@ function issue(
|
|||||||
|
|
||||||
export async function validatePublication(
|
export async function validatePublication(
|
||||||
book: EpubBook,
|
book: EpubBook,
|
||||||
|
onProgress?: ValidationProgressCallback,
|
||||||
): Promise<EpubIssue[]> {
|
): Promise<EpubIssue[]> {
|
||||||
const result: EpubIssue[] = [];
|
const result: EpubIssue[] = [];
|
||||||
if (!book.summary.mimetypeFirst)
|
if (!book.summary.mimetypeFirst)
|
||||||
@@ -101,12 +110,12 @@ export async function validatePublication(
|
|||||||
book.package.packagePath,
|
book.package.packagePath,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (book.entryByPath.has("META-INF/encryption.xml"))
|
if (book.summary.encryptedResources)
|
||||||
result.push(
|
result.push(
|
||||||
issue(
|
issue(
|
||||||
"warning",
|
"warning",
|
||||||
"encrypted-resources",
|
"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",
|
"META-INF/encryption.xml",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -199,14 +208,20 @@ export async function validatePublication(
|
|||||||
const linkedTargets = new Set<string>();
|
const linkedTargets = new Set<string>();
|
||||||
let inspectedBytes = 0;
|
let inspectedBytes = 0;
|
||||||
let inspectedDocuments = 0;
|
let inspectedDocuments = 0;
|
||||||
for (const item of book.package.manifest) {
|
const contentItems = book.package.manifest.filter(
|
||||||
if (
|
(item) =>
|
||||||
!/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
|
/^(?:application\/xhtml\+xml|image\/svg\+xml|text\/html)$/iu.test(
|
||||||
item.mediaType,
|
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);
|
const entry = book.entryByPath.get(item.path);
|
||||||
if (
|
if (
|
||||||
!entry ||
|
!entry ||
|
||||||
@@ -232,15 +247,27 @@ export async function validatePublication(
|
|||||||
item.path,
|
item.path,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (const element of [
|
const references = [...document.querySelectorAll("*")].flatMap(
|
||||||
...document.querySelectorAll("[href], [src], [poster]"),
|
(element) => {
|
||||||
]) {
|
const values: Array<{ attribute: string; reference: string }> = [];
|
||||||
const attribute = element.hasAttribute("href")
|
for (const attribute of ["href", "src", "poster"])
|
||||||
? "href"
|
if (element.hasAttribute(attribute))
|
||||||
: element.hasAttribute("src")
|
values.push({
|
||||||
? "src"
|
attribute,
|
||||||
: "poster";
|
reference: element.getAttribute(attribute) ?? "",
|
||||||
const reference = element.getAttribute(attribute)?.trim() ?? "";
|
});
|
||||||
|
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 (
|
if (
|
||||||
!reference ||
|
!reference ||
|
||||||
reference.startsWith("#") ||
|
reference.startsWith("#") ||
|
||||||
@@ -248,7 +275,7 @@ export async function validatePublication(
|
|||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
if (isExternalReference(reference)) {
|
if (isExternalReference(reference)) {
|
||||||
if (attribute !== "href" || element.localName !== "a")
|
if (!attribute.endsWith("href") || element.localName !== "a")
|
||||||
result.push(
|
result.push(
|
||||||
issue(
|
issue(
|
||||||
"warning",
|
"warning",
|
||||||
@@ -299,6 +326,7 @@ export async function validatePublication(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onProgress?.(contentItems.length, contentItems.length);
|
||||||
if (
|
if (
|
||||||
inspectedDocuments <
|
inspectedDocuments <
|
||||||
book.package.manifest.filter((item) =>
|
book.package.manifest.filter((item) =>
|
||||||
|
|||||||
+158
-2
@@ -60,6 +60,10 @@ button:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
.button[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
:where(button, input, select, textarea, a):focus-visible {
|
:where(button, input, select, textarea, a):focus-visible {
|
||||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -97,6 +101,11 @@ input[type="file"] {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.book-workspaces {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
.hero,
|
.hero,
|
||||||
.panel,
|
.panel,
|
||||||
.summary-grid article {
|
.summary-grid article {
|
||||||
@@ -239,7 +248,7 @@ input[type="file"] {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
.workspace-tabs button[aria-selected="true"] {
|
.workspace-tabs button[aria-pressed="true"] {
|
||||||
border-color: var(--toolbox-accent);
|
border-color: var(--toolbox-accent);
|
||||||
background: var(--toolbox-accent);
|
background: var(--toolbox-accent);
|
||||||
color: var(--toolbox-accent-contrast);
|
color: var(--toolbox-accent-contrast);
|
||||||
@@ -292,18 +301,109 @@ input[type="file"] {
|
|||||||
background: var(--toolbox-accent-soft);
|
background: var(--toolbox-accent-soft);
|
||||||
color: var(--toolbox-accent);
|
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 {
|
.page-reader {
|
||||||
min-height: 38rem;
|
min-height: 38rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.page-reader .panel-heading small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
color: var(--toolbox-muted);
|
||||||
|
}
|
||||||
.page-reader iframe {
|
.page-reader iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: min(65rem, calc(100vh - 13rem));
|
height: min(65rem, calc(100vh - 13rem));
|
||||||
min-height: 34rem;
|
min-height: 34rem;
|
||||||
border: 1px solid var(--toolbox-border);
|
border: 1px solid var(--toolbox-border);
|
||||||
border-radius: 0.65rem;
|
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 {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -453,6 +553,62 @@ td code {
|
|||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 1rem;
|
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 {
|
.help-dialog {
|
||||||
width: min(36rem, calc(100% - 2rem));
|
width: min(36rem, calc(100% - 2rem));
|
||||||
border: 1px solid var(--toolbox-border);
|
border: 1px solid var(--toolbox-border);
|
||||||
|
|||||||
@@ -3,24 +3,44 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.epub-tools",
|
"id": "de.add-ideas.epub-tools",
|
||||||
"name": "EPUB Tools",
|
"name": "EPUB Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
"description": "Read, inspect and repair EPUB publications locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
"categories": ["documents", "ebooks", "publishing"],
|
"categories": ["documents", "ebooks", "publishing"],
|
||||||
"tags": ["epub", "ebook", "metadata", "reader", "repair"],
|
"tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"],
|
||||||
"integration": {
|
"integration": {
|
||||||
"contextVersion": 1,
|
"contextVersion": 1,
|
||||||
"launchModes": ["navigate", "new-tab"],
|
"launchModes": ["navigate", "new-tab"],
|
||||||
"embedding": "unsupported"
|
"embedding": "unsupported"
|
||||||
},
|
},
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"secureContext": false,
|
"secureContext": true,
|
||||||
"workers": true,
|
"workers": true,
|
||||||
"indexedDb": false,
|
"indexedDb": false,
|
||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -33,13 +33,19 @@ async function epubFixture(): Promise<Buffer> {
|
|||||||
await writer.add(
|
await writer.add(
|
||||||
"EPUB/package.opf",
|
"EPUB/package.opf",
|
||||||
new TextReader(
|
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(
|
await writer.add(
|
||||||
"EPUB/nav.xhtml",
|
"EPUB/nav.xhtml",
|
||||||
new TextReader(
|
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(
|
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");
|
const manifest = await request.get("/deep/nested/epub/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.epub-tools",
|
id: "de.add-ideas.epub-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
entry: "./",
|
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 ({
|
test("opens, sanitizes, edits, rebuilds, and reopens a real EPUB", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const external = await localOnly(page);
|
const external = await localOnly(page);
|
||||||
await page.goto("/deep/nested/epub/");
|
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",
|
name: "fixture.epub",
|
||||||
mimeType: "application/epub+zip",
|
mimeType: "application/epub+zip",
|
||||||
buffer: await epubFixture(),
|
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(),
|
page.getByText("Browser Fixture", { exact: true }).first(),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
const reader = page.frameLocator("iframe[title^='Safe EPUB preview']");
|
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(
|
await expect(
|
||||||
reader.getByRole("heading", { name: "Hello from EPUB" }),
|
reader.getByRole("heading", { name: "Hello from EPUB" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(reader.locator("script")).toHaveCount(0);
|
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.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");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await page.getByRole("button", { name: "Create updated EPUB" }).click();
|
await page.getByRole("button", { name: "Create updated EPUB" }).click();
|
||||||
const download = await downloadPromise;
|
const download = await downloadPromise;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 { describe, expect, it, vi } from "vitest";
|
||||||
import { App } from "../../src/App";
|
import { App } from "../../src/App";
|
||||||
|
|
||||||
@@ -13,7 +13,41 @@ describe("EPUB Tools", () => {
|
|||||||
await screen.findByRole("heading", { name: "EPUB Tools" }),
|
await screen.findByRole("heading", { name: "EPUB Tools" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Sandboxed reader · local files"),
|
await screen.findByText("Sandboxed reader · local files", undefined, {
|
||||||
|
timeout: 4_000,
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).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
@@ -1,10 +1,33 @@
|
|||||||
import { Blob as NodeBlob, File as NodeFile } from "node:buffer";
|
import { Blob as NodeBlob, File as NodeFile } from "node:buffer";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { closeBook, openEpub } from "../../src/epub/archive";
|
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 { 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 type { EpubBook } from "../../src/epub/types";
|
||||||
|
import { searchPublication } from "../../src/epub/search";
|
||||||
import { createEpubFixture } from "./fixture";
|
import { createEpubFixture } from "./fixture";
|
||||||
|
|
||||||
let opened: EpubBook[] = [];
|
let opened: EpubBook[] = [];
|
||||||
@@ -31,6 +54,51 @@ describe("EPUB path policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("EPUB package inspection", () => {
|
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 () => {
|
it("opens metadata, navigation, spine, and bounded inventory", async () => {
|
||||||
const book = await openEpub(await createEpubFixture());
|
const book = await openEpub(await createEpubFixture());
|
||||||
opened.push(book);
|
opened.push(book);
|
||||||
@@ -52,6 +120,34 @@ describe("EPUB package inspection", () => {
|
|||||||
expect(book.issues.filter((item) => item.severity === "error")).toEqual([]);
|
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 () => {
|
it("reports active content and broken resources", async () => {
|
||||||
const book = await openEpub(
|
const book = await openEpub(
|
||||||
await createEpubFixture({ activeContent: true, brokenLink: true }),
|
await createEpubFixture({ activeContent: true, brokenLink: true }),
|
||||||
@@ -61,6 +157,22 @@ describe("EPUB package inspection", () => {
|
|||||||
expect.arrayContaining(["active-content", "broken-link"]),
|
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", () => {
|
describe("safe rendering and editing", () => {
|
||||||
@@ -82,6 +194,186 @@ describe("safe rendering and editing", () => {
|
|||||||
revokeRenderedChapter(chapter);
|
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 () => {
|
it("patches metadata and rebuilds a readable normalized EPUB", async () => {
|
||||||
const book = await openEpub(await createEpubFixture());
|
const book = await openEpub(await createEpubFixture());
|
||||||
opened.push(book);
|
opened.push(book);
|
||||||
@@ -111,4 +403,40 @@ describe("safe rendering and editing", () => {
|
|||||||
mimetypeStored: true,
|
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
@@ -10,8 +10,17 @@ export async function createEpubFixture(
|
|||||||
brokenLink?: boolean;
|
brokenLink?: boolean;
|
||||||
activeContent?: boolean;
|
activeContent?: boolean;
|
||||||
compressedMimetype?: boolean;
|
compressedMimetype?: boolean;
|
||||||
|
svgTitlePage?: boolean;
|
||||||
|
svgImageHref?: string;
|
||||||
|
repeatedImages?: number;
|
||||||
|
packagedStyles?: boolean;
|
||||||
|
obfuscatedFont?: boolean;
|
||||||
|
secondChapter?: boolean;
|
||||||
|
direction?: "ltr" | "rtl";
|
||||||
|
fixedLayout?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<File> {
|
): Promise<File> {
|
||||||
|
const packagedStyles = options.packagedStyles || options.obfuscatedFont;
|
||||||
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
|
const writer = new ZipWriter(new BlobWriter("application/epub+zip"));
|
||||||
await writer.add("mimetype", new TextReader("application/epub+zip"), {
|
await writer.add("mimetype", new TextReader("application/epub+zip"), {
|
||||||
level: options.compressedMimetype ? 6 : 0,
|
level: options.compressedMimetype ? 6 : 0,
|
||||||
@@ -22,22 +31,63 @@ 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>`,
|
`<?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(
|
await writer.add(
|
||||||
"EPUB/package.opf",
|
"EPUB/package.opf",
|
||||||
new TextReader(
|
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(
|
await writer.add(
|
||||||
"EPUB/nav.xhtml",
|
"EPUB/nav.xhtml",
|
||||||
new TextReader(
|
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(
|
await writer.add(
|
||||||
"EPUB/chapter.xhtml",
|
"EPUB/chapter.xhtml",
|
||||||
new TextReader(
|
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(
|
await writer.add(
|
||||||
|
|||||||
Reference in New Issue
Block a user