From efd80eb18a6117c5ad0c507b04f49e3d8f888d51 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 08:53:49 +0200 Subject: [PATCH] Release EPUB Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++ CHANGELOG.md | 14 + README.md | 37 +- SOURCE.md | 4 +- THIRD_PARTY_NOTICES.md | 4 +- docs/ACCESSIBILITY.md | 2 +- docs/ARCHITECTURE.md | 13 +- docs/PRIVACY-SECURITY.md | 17 +- package-lock.json | 45 +- package.json | 10 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 14 + public/LICENSES/npm-runtime-licenses.txt | 374 +---------- public/README.md | 37 +- public/SOURCE.md | 4 +- public/THIRD_PARTY_NOTICES.md | 4 +- public/docs/ACCESSIBILITY.md | 2 +- public/docs/ARCHITECTURE.md | 13 +- public/docs/PRIVACY-SECURITY.md | 17 +- public/sw.js | 2 +- public/toolbox-app.json | 23 +- src/components/HelpDialog.tsx | 13 +- src/components/Workbench.tsx | 814 ++++++++++++++++++++--- src/epub/adapters.ts | 386 +++++++++++ src/epub/annotations.ts | 146 ++++ src/epub/archive.ts | 204 +++++- src/epub/entries.ts | 17 + src/epub/export.ts | 249 ++++++- src/epub/package.ts | 53 +- src/epub/reader.ts | 365 ++++++++-- src/epub/search.ts | 127 ++++ src/epub/types.ts | 31 + src/epub/validation.ts | 64 +- src/styles.css | 160 ++++- src/toolbox/manifest.source.json | 26 +- src/version.ts | 2 +- tests/browser/app.spec.ts | 66 +- tests/browser/responsive.spec.ts | 18 + tests/components/app.test.tsx | 38 +- tests/epub/core.test.ts | 332 ++++++++- tests/epub/fixture.ts | 56 +- 41 files changed, 3250 insertions(+), 614 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 src/epub/adapters.ts create mode 100644 src/epub/annotations.ts create mode 100644 src/epub/search.ts create mode 100644 tests/browser/responsive.spec.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4ab57..dc913fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added bounded, deterministic UTF-8 HTML/XHTML, Markdown and plain-text import + adapters that create an explicit one-chapter EPUB workspace with loss + evidence and no network resource fetching. +- Synchronized safe-reader backgrounds and text with explicit Toolbox light, dark, and system themes. +- Added visible, accessible phase and item-count progress while an EPUB opens and its first chapter is prepared. +- Resolved and validated both modern SVG `href` and legacy namespaced `xlink:href` image references, including standalone SVG title pages. +- Disabled namespaced SVG navigation links, reused object URLs for repeated images, and bounded unique/cumulative chapter image extraction. +- Added unit and cross-browser regression coverage for opening progress, dark reader contrast, SVG cover rendering, broken XLink resources, and repeated-image caching. +- Hardened standalone HTML export with a strict DOMPurify element/attribute + allowlist so publication meta refreshes, base URLs, templates, frames and + other active or network-capable structures cannot survive into the result. + ## 0.1.0 - 2026-09-01 - Added the initial local-first EPUB Tools workbench. diff --git a/README.md b/README.md index c6d456c..eb474cf 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ # EPUB Tools -EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. +EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. Bounded UTF-8 HTML/XHTML, Markdown and plain text can also be adapted locally into an explicit one-chapter EPUB workspace. -## Version 0.1.0 +## Current capabilities - Opens strict ZIP-based EPUB containers and parses `container.xml`, OPF metadata, manifest, spine, EPUB 3 navigation documents, and EPUB 2 NCX navigation. -- Shows a reading-order sidebar and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Local images are resolved to temporary object URLs. +- Adapts UTF-8 HTML/XHTML through a structural allowlist, a documented + lightweight Markdown subset, or plain text into a deterministic one-chapter + EPUB workspace; every adapter records its format loss in the validation view. +- Shows named, determinate opening progress while the archive, package, navigation, and content documents are inspected. +- Shows the real nested EPUB navigation tree (with spine fallback) and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Internal chapter/fragment links are handled by trusted reader controls; external links remain inert. The reader follows the Toolbox light, dark, or system theme. +- Resolves bounded packaged stylesheets, fonts, images, audio/video, SVG `href`/legacy `xlink:href`, and CSS URLs to temporary object URLs. CSS imports, active constructs, network URLs, and unresolved resources are removed or blocked by the iframe CSP. +- Interprets package/document direction, page progression, and rendition layout/orientation/spread, including a fixed-layout presentation mode. +- Searches bounded reading-order text with progress/cancellation and adds in-memory bookmarks, notes, and selected-text quotations that can be explicitly exported/imported as publication-scoped JSON. - Inventories the package and checks required metadata, mimetype ordering/compression, manifest/spine targets, navigation, active content, external resources, missing relative links, hidden entries, encryption metadata, and signatures. -- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text, and downloads sanitized chapters. +- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text/Markdown/safe standalone HTML, and downloads sanitized chapters. - Rebuilds a fresh EPUB with the uncompressed mimetype first, sorted paths, optional normalized timestamps, updated OPF metadata, and an optional replacement cover. -The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. DRM is detected and reported; it is never bypassed. Rebuilds are disabled when encryption metadata is present and invalidate existing signatures. +The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. Unsupported encryption and DRM are detected and reported; they are never bypassed. The standardized IDPF font-obfuscation mask is decoded locally for reading and preserved (including identifier re-keying) during rebuilds. Rebuilds remain disabled for unsupported protection and invalidate existing signatures. ## Safety limits @@ -18,10 +25,24 @@ The validator is a focused preflight, not a browser port of EPUBCheck. Version 0 - 64 MiB per resource, 500:1 maximum declared expansion ratio - 16 MiB package/chapter parse limit - Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed -- Reader image resolution is capped at 25 MiB per image; plain-text export at 32 MiB +- Reader local-resource resolution is capped at 25 MiB per resource, 128 unique resources, and 128 MiB total per rendered chapter; repeated references reuse one object URL. At most 16 packaged stylesheets, 2 MiB each/8 MiB total, are loaded. Search reads at most 2,000 documents, 8 MiB each/64 MiB total. Derived text/HTML/Markdown export is capped at 32 MiB. - Absolute, backslash, drive-letter, NUL, and parent-traversal archive paths are rejected -DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources, navigation, stylesheet links, CSS imports/URLs, and event handlers are blocked or removed from the reader surface. Rendering still depends on the current browser and installed fonts. +DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources/navigation, CSS imports/network URLs, and event handlers are blocked or removed from the reader surface. The iframe permits same-origin DOM access only so the trusted parent reader can intercept internal links, scroll to fragments, and capture an explicitly selected quote; publication scripts remain absent and the iframe CSP denies network access. Rendering still depends on the current browser and font decoder. + +## Format and export direction + +EPUB 2/3 plus bounded UTF-8 HTML/XHTML, Markdown and plain text are current +import formats. Remaining deeper-fidelity work includes vertical-writing +controls, media-overlay playback, multiple-rendition selection, and more +exhaustive accessibility/CSS validation. Further modular, DRM-free import +adapters remain candidates for: + +1. MOBI/PRC and KF8 (`.azw3`), then FB2/FB2.ZIP and CBZ. +2. HTMLZ and asset-preserving HTML packages. +3. KFX only as an explicitly experimental later adapter if a maintainable, auditable parser and representative DRM-free fixtures are available. KFX is not treated as a general interchange or publishing target, and protected books will not be decrypted. + +Current downloadable outputs are a rebuilt EPUB, reading-order text, text-centric Markdown, safe standalone HTML, a sanitized XHTML chapter, original cover, reading-notes JSON, and JSON metadata/validation/package reports. Asset-preserving HTML ZIP, a print/PDF hand-off, CBZ for image-only publications, and stricter EPUB 3 publishing profiles remain future candidates. Lossy conversion is labelled, and EPUB remains the preferred editable/publishing output. ## Development @@ -33,7 +54,7 @@ npm run check npm run test:browser ``` -`npm run release:artifact` creates `release/epub-tools-0.1.0.zip` and its SHA-256 sidecar. +`npm run release:artifact` creates `release/epub-tools-0.2.0.zip` and its SHA-256 sidecar. ## Licence diff --git a/SOURCE.md b/SOURCE.md index 267be24..3f828c8 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for EPUB Tools 0.1.0 is available at: +The corresponding source for EPUB Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.2.0 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 72c44c9..adb272d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,8 +4,8 @@ Production release inventories reproduce exact dependency licence texts under `L - `@zip.js/zip.js` 2.9.0 — BSD-3-Clause — bounded ZIP reading and EPUB rebuilding. - `dompurify` 3.4.14 — MPL-2.0 OR Apache-2.0 — active-content sanitization before sandboxed rendering. -- Toolbox contract, shell, and test packages 0.2.3 — Apache-2.0. -- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads. +- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0. +- `@add-ideas/toolbox-helpers` 0.2.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads. - React and React DOM 19.2.8 — MIT. EPUB publications opened by a user are not bundled and retain their own copyright and licence terms. diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index c98c95b..73e355f 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -1,5 +1,5 @@ # Accessibility -The application uses labelled file inputs, fields, tabs, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. The iframe has a chapter-specific accessible title. +The application uses labelled file inputs, fields, pressed-state workspace buttons, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. Opening progress is announced as a polite status with the selected filename and a labelled native progress element. The iframe has a chapter-specific accessible title and explicit light/dark background and foreground colours. Reader sanitization can remove scripted or form-based interactions by design. EPUB-internal navigation is disabled in v0.1; the Toolbox reading-order sidebar remains keyboard accessible. Publisher content can itself have poor semantics, contrast, directionality, alt text, or reading order. The safe reader does not certify EPUB accessibility and its fallback stylesheet may change publisher presentation. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1eacf9a..0854403 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,12 +2,17 @@ The app is a relocatable static React application using the Toolbox shell and contract. EPUB work is divided into small TypeScript modules under `src/epub/`: -- `archive.ts` applies ZIP/path/entry/ratio limits and opens the container with zip.js. +- `archive.ts` applies ZIP/path/entry/ratio limits, opens the container with zip.js, and reports bounded phase/item progress to the workbench. +- `adapters.ts` turns bounded UTF-8 HTML/XHTML, Markdown, and plain text into a + deterministic one-chapter EPUB workspace before the same archive validation + path runs. The HTML adapter retains only a small structural element/attribute + allowlist; Markdown implements a documented lightweight subset. - `xml.ts`, `package.ts`, and `paths.ts` decode bounded XML, reject DTD/entity input, parse OPF/navigation/NCX, and resolve package-relative references without traversal. - `validation.ts` performs bounded package and cross-document diagnostics and reports skipped scope. -- `reader.ts` sanitizes content with DOMPurify, neutralizes navigation and CSS URLs, resolves bounded local images, injects a restrictive iframe CSP, and returns object URLs with an explicit revocation lifecycle. -- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, and reading-order text exports. +- `reader.ts` sanitizes content with DOMPurify, neutralizes regular/namespaced navigation, rewrites bounded packaged stylesheets/fonts/images/media and CSS URLs to local object URLs, injects a restrictive iframe CSP plus direction/layout/theme metadata, and returns cached URLs with an explicit revocation lifecycle. +- `search.ts` extracts bounded inert reading-order text and performs cancellable literal search. `annotations.ts` validates deterministic, publication-scoped bookmark/annotation interchange without silently persisting book data. +- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, reading-order text, text-centric Markdown, and standalone HTML. The HTML path replaces packaged media, removes links and active controls, then applies a strict DOMPurify structural element/attribute allowlist before adding a deny-by-default CSP. zip.js receives a lazy browser `BlobReader`, performs CRC checks when entry data is read, and can use web workers. React retains one open reader and closes the previous reader when a new book replaces it. The original File is immutable; all changes exist only in staged React state or a newly downloaded Blob. -The safe reader uses both sanitization and an iframe without sandbox permissions. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheet links are intentionally removed in v0.1. +The safe reader uses both sanitization and an iframe with `allow-same-origin` but without scripts, forms, popups, navigation, downloads, modals, or device permissions. Same-origin access lets the trusted parent intercept inert EPUB links, scroll to a target fragment, and read an explicitly selected quote. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheets are size/count bounded; imports and network URLs are removed, while CSP remains the final network boundary. A mutation observer synchronizes explicit light/dark reader tokens with Toolbox preference changes; system mode follows the browser media preference. diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 610369c..2ed77a3 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -2,8 +2,19 @@ All source and generated publication data remains in the browser. The app has no telemetry, account, analytics, remote font, CDN, or default network integration. The production CSP keeps `connect-src` and worker sources same-origin. -EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is reported because it can describe valid font obfuscation or DRM; the app does not distinguish every scheme and never attempts circumvention. +EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is inspected: the standardized IDPF font-obfuscation mask is decoded locally, while unsupported protection is reported and never circumvented. -Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers, navigation and resource references are neutralized or replaced with bounded local object URLs, and rendering happens inside a permissionless sandboxed iframe with its own restrictive CSP. Temporary object URLs are revoked when chapters change. +HTML/XHTML, Markdown, and plain-text adapters accept at most 16 MiB of valid +UTF-8. HTML is parsed inertly and copied through a small structural +element/attribute allowlist; scripts, styles, forms, event handlers, embedded +media, external resources, and non-fragment links are not transferred. The +adapter bounds nodes, nesting and Markdown lines, derives a local content +identifier with Web Crypto, and then sends its generated one-chapter EPUB +through the same strict archive and reader path. The reported losses are part +of the resulting workspace rather than a claim of source fidelity. -Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for encrypted publications. +Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers; regular and namespaced SVG navigation/resource attributes are neutralized or replaced with bounded local object URLs; and rendering happens inside a scriptless sandboxed iframe with its own restrictive CSP. The iframe has `allow-same-origin` solely so trusted application code can intercept internal links, scroll to fragments, and capture a quote the user explicitly selected; it receives no script, form, popup, top-navigation, download, modal, or device permissions. Stylesheets/resources are bounded, CSS imports and external URLs are removed, object URLs are reused per resource and revoked when chapters change. + +Search operates on bounded extracted text. Bookmarks and annotations remain in React memory unless the user explicitly downloads their JSON; importing validates schema, counts, path safety, field sizes, and publication identifier. Derived HTML/Markdown/text exports intentionally omit active content and network resources and are lossy reading-order representations, not publication-preserving conversions. Standalone HTML is rebuilt through a strict DOMPurify structural allowlist that excludes meta refreshes, base URLs, templates, frames, links, media and event/style attributes, then receives a deny-by-default CSP. + +Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for unsupported encrypted publications. IDPF-obfuscated fonts are re-keyed if the unique publication identifier changes. diff --git a/package-lock.json b/package-lock.json index d39167a..0087c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "epub-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "epub-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "@zip.js/zip.js": "2.9.0", "dompurify": "3.4.14", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -44,24 +44,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "license": "Apache-2.0", - "engines": { - "node": ">=20" - } + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", + "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "license": "GPL-3.0-or-later", - "engines": { - "node": ">=22" - } + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz", + "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==", + "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -69,17 +69,16 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" - }, - "engines": { - "node": ">=20" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index b21f706..81d720b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epub-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Read, inspect and repair EPUB publications locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,16 +39,16 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "@zip.js/zip.js": "2.9.0", "dompurify": "3.4.14", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 731da62..2ab6a22 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 7d4ab57..dc913fb 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Added bounded, deterministic UTF-8 HTML/XHTML, Markdown and plain-text import + adapters that create an explicit one-chapter EPUB workspace with loss + evidence and no network resource fetching. +- Synchronized safe-reader backgrounds and text with explicit Toolbox light, dark, and system themes. +- Added visible, accessible phase and item-count progress while an EPUB opens and its first chapter is prepared. +- Resolved and validated both modern SVG `href` and legacy namespaced `xlink:href` image references, including standalone SVG title pages. +- Disabled namespaced SVG navigation links, reused object URLs for repeated images, and bounded unique/cumulative chapter image extraction. +- Added unit and cross-browser regression coverage for opening progress, dark reader contrast, SVG cover rendering, broken XLink resources, and repeated-image caching. +- Hardened standalone HTML export with a strict DOMPurify element/attribute + allowlist so publication meta refreshes, base URLs, templates, frames and + other active or network-capable structures cannot survive into the result. + ## 0.1.0 - 2026-09-01 - Added the initial local-first EPUB Tools workbench. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 1e9f6fa..c3cbf75 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -1141,20 +1141,10 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ============================================================================== -dompurify@3.3.3 +dompurify@3.4.14 Declared licence: (MPL-2.0 OR Apache-2.0) ============================================================================== --- LICENSE --- -DOMPurify -Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53 - -DOMPurify is free software; you can redistribute it and/or modify it under the -terms of either: - -a) the Apache License Version 2.0, or -b) the Mozilla Public License Version 2.0 - ------------------------------------------------------------------------------ Apache License Version 2.0, January 2004 @@ -1358,362 +1348,6 @@ b) the Mozilla Public License Version 2.0 See the License for the specific language governing permissions and limitations under the License. ------------------------------------------------------------------------------ -Mozilla Public License, version 2.0 - -1. Definitions - -1.1. “Contributor” - - means each individual or legal entity that creates, contributes to the - creation of, or owns Covered Software. - -1.2. “Contributor Version” - - means the combination of the Contributions of others (if any) used by a - Contributor and that particular 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 diff --git a/public/README.md b/public/README.md index c6d456c..eb474cf 100644 --- a/public/README.md +++ b/public/README.md @@ -1,16 +1,23 @@ # EPUB Tools -EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. +EPUB Tools is a standalone, local-first EPUB 2/3 reader and publication workbench for the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Books are opened from a browser `File`; no upload, account, analytics, or server-side conversion is involved. Bounded UTF-8 HTML/XHTML, Markdown and plain text can also be adapted locally into an explicit one-chapter EPUB workspace. -## Version 0.1.0 +## Current capabilities - Opens strict ZIP-based EPUB containers and parses `container.xml`, OPF metadata, manifest, spine, EPUB 3 navigation documents, and EPUB 2 NCX navigation. -- Shows a reading-order sidebar and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Local images are resolved to temporary object URLs. +- Adapts UTF-8 HTML/XHTML through a structural allowlist, a documented + lightweight Markdown subset, or plain text into a deterministic one-chapter + EPUB workspace; every adapter records its format loss in the validation view. +- Shows named, determinate opening progress while the archive, package, navigation, and content documents are inspected. +- Shows the real nested EPUB navigation tree (with spine fallback) and renders XHTML/HTML/SVG content in a sandboxed, script-free iframe. Internal chapter/fragment links are handled by trusted reader controls; external links remain inert. The reader follows the Toolbox light, dark, or system theme. +- Resolves bounded packaged stylesheets, fonts, images, audio/video, SVG `href`/legacy `xlink:href`, and CSS URLs to temporary object URLs. CSS imports, active constructs, network URLs, and unresolved resources are removed or blocked by the iframe CSP. +- Interprets package/document direction, page progression, and rendition layout/orientation/spread, including a fixed-layout presentation mode. +- Searches bounded reading-order text with progress/cancellation and adds in-memory bookmarks, notes, and selected-text quotations that can be explicitly exported/imported as publication-scoped JSON. - Inventories the package and checks required metadata, mimetype ordering/compression, manifest/spine targets, navigation, active content, external resources, missing relative links, hidden entries, encryption metadata, and signatures. -- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text, and downloads sanitized chapters. +- Edits common Dublin Core fields, replaces an existing declared JPEG/PNG/WebP/SVG cover, exports metadata/report/tree JSON, extracts the cover, exports reading-order text/Markdown/safe standalone HTML, and downloads sanitized chapters. - Rebuilds a fresh EPUB with the uncompressed mimetype first, sorted paths, optional normalized timestamps, updated OPF metadata, and an optional replacement cover. -The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. DRM is detected and reported; it is never bypassed. Rebuilds are disabled when encryption metadata is present and invalidate existing signatures. +The validator is a focused preflight, not a browser port of EPUBCheck. Version 0.1 does not claim complete schema, CSS, accessibility, media-overlay, signature, DRM, or font-licence validation. Unsupported encryption and DRM are detected and reported; they are never bypassed. The standardized IDPF font-obfuscation mask is decoded locally for reading and preserved (including identifier re-keying) during rebuilds. Rebuilds remain disabled for unsupported protection and invalidate existing signatures. ## Safety limits @@ -18,10 +25,24 @@ The validator is a focused preflight, not a browser port of EPUBCheck. Version 0 - 64 MiB per resource, 500:1 maximum declared expansion ratio - 16 MiB package/chapter parse limit - Link validation scans at most 500 content documents and 32 MiB total, with every skipped scope disclosed -- Reader image resolution is capped at 25 MiB per image; plain-text export at 32 MiB +- Reader local-resource resolution is capped at 25 MiB per resource, 128 unique resources, and 128 MiB total per rendered chapter; repeated references reuse one object URL. At most 16 packaged stylesheets, 2 MiB each/8 MiB total, are loaded. Search reads at most 2,000 documents, 8 MiB each/64 MiB total. Derived text/HTML/Markdown export is capped at 32 MiB. - Absolute, backslash, drive-letter, NUL, and parent-traversal archive paths are rejected -DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources, navigation, stylesheet links, CSS imports/URLs, and event handlers are blocked or removed from the reader surface. Rendering still depends on the current browser and installed fonts. +DTD/entity declarations, ZIP encryption, scripts, forms, frames, objects, external reader resources/navigation, CSS imports/network URLs, and event handlers are blocked or removed from the reader surface. The iframe permits same-origin DOM access only so the trusted parent reader can intercept internal links, scroll to fragments, and capture an explicitly selected quote; publication scripts remain absent and the iframe CSP denies network access. Rendering still depends on the current browser and font decoder. + +## Format and export direction + +EPUB 2/3 plus bounded UTF-8 HTML/XHTML, Markdown and plain text are current +import formats. Remaining deeper-fidelity work includes vertical-writing +controls, media-overlay playback, multiple-rendition selection, and more +exhaustive accessibility/CSS validation. Further modular, DRM-free import +adapters remain candidates for: + +1. MOBI/PRC and KF8 (`.azw3`), then FB2/FB2.ZIP and CBZ. +2. HTMLZ and asset-preserving HTML packages. +3. KFX only as an explicitly experimental later adapter if a maintainable, auditable parser and representative DRM-free fixtures are available. KFX is not treated as a general interchange or publishing target, and protected books will not be decrypted. + +Current downloadable outputs are a rebuilt EPUB, reading-order text, text-centric Markdown, safe standalone HTML, a sanitized XHTML chapter, original cover, reading-notes JSON, and JSON metadata/validation/package reports. Asset-preserving HTML ZIP, a print/PDF hand-off, CBZ for image-only publications, and stricter EPUB 3 publishing profiles remain future candidates. Lossy conversion is labelled, and EPUB remains the preferred editable/publishing output. ## Development @@ -33,7 +54,7 @@ npm run check npm run test:browser ``` -`npm run release:artifact` creates `release/epub-tools-0.1.0.zip` and its SHA-256 sidecar. +`npm run release:artifact` creates `release/epub-tools-0.2.0.zip` and its SHA-256 sidecar. ## Licence diff --git a/public/SOURCE.md b/public/SOURCE.md index 267be24..3f828c8 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,7 +1,7 @@ # Corresponding source -The corresponding source for EPUB Tools 0.1.0 is available at: +The corresponding source for EPUB Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/epub-tools/src/tag/v0.2.0 Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 72c44c9..adb272d 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -4,8 +4,8 @@ Production release inventories reproduce exact dependency licence texts under `L - `@zip.js/zip.js` 2.9.0 — BSD-3-Clause — bounded ZIP reading and EPUB rebuilding. - `dompurify` 3.4.14 — MPL-2.0 OR Apache-2.0 — active-content sanitization before sandboxed rendering. -- Toolbox contract, shell, and test packages 0.2.3 — Apache-2.0. -- `@add-ideas/toolbox-helpers` 0.1.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads. +- Toolbox contract, shell, and test packages 0.3.0 — Apache-2.0. +- `@add-ideas/toolbox-helpers` 0.2.0 — GPL-3.0-or-later — bounded decoding, filenames, and browser downloads. - React and React DOM 19.2.8 — MIT. EPUB publications opened by a user are not bundled and retain their own copyright and licence terms. diff --git a/public/docs/ACCESSIBILITY.md b/public/docs/ACCESSIBILITY.md index c98c95b..73e355f 100644 --- a/public/docs/ACCESSIBILITY.md +++ b/public/docs/ACCESSIBILITY.md @@ -1,5 +1,5 @@ # Accessibility -The application uses labelled file inputs, fields, tabs, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. The iframe has a chapter-specific accessible title. +The application uses labelled file inputs, fields, pressed-state workspace buttons, status/error regions, keyboard-operable chapter and action buttons, visible focus indicators, responsive layouts, and scrollable tables/readers. Opening progress is announced as a polite status with the selected filename and a labelled native progress element. The iframe has a chapter-specific accessible title and explicit light/dark background and foreground colours. Reader sanitization can remove scripted or form-based interactions by design. EPUB-internal navigation is disabled in v0.1; the Toolbox reading-order sidebar remains keyboard accessible. Publisher content can itself have poor semantics, contrast, directionality, alt text, or reading order. The safe reader does not certify EPUB accessibility and its fallback stylesheet may change publisher presentation. diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 1eacf9a..0854403 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -2,12 +2,17 @@ The app is a relocatable static React application using the Toolbox shell and contract. EPUB work is divided into small TypeScript modules under `src/epub/`: -- `archive.ts` applies ZIP/path/entry/ratio limits and opens the container with zip.js. +- `archive.ts` applies ZIP/path/entry/ratio limits, opens the container with zip.js, and reports bounded phase/item progress to the workbench. +- `adapters.ts` turns bounded UTF-8 HTML/XHTML, Markdown, and plain text into a + deterministic one-chapter EPUB workspace before the same archive validation + path runs. The HTML adapter retains only a small structural element/attribute + allowlist; Markdown implements a documented lightweight subset. - `xml.ts`, `package.ts`, and `paths.ts` decode bounded XML, reject DTD/entity input, parse OPF/navigation/NCX, and resolve package-relative references without traversal. - `validation.ts` performs bounded package and cross-document diagnostics and reports skipped scope. -- `reader.ts` sanitizes content with DOMPurify, neutralizes navigation and CSS URLs, resolves bounded local images, injects a restrictive iframe CSP, and returns object URLs with an explicit revocation lifecycle. -- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, and reading-order text exports. +- `reader.ts` sanitizes content with DOMPurify, neutralizes regular/namespaced navigation, rewrites bounded packaged stylesheets/fonts/images/media and CSS URLs to local object URLs, injects a restrictive iframe CSP plus direction/layout/theme metadata, and returns cached URLs with an explicit revocation lifecycle. +- `search.ts` extracts bounded inert reading-order text and performs cancellable literal search. `annotations.ts` validates deterministic, publication-scoped bookmark/annotation interchange without silently persisting book data. +- `export.ts` patches Dublin Core metadata and streams entries into a fresh normalized EPUB. It also produces JSON, cover, chapter, reading-order text, text-centric Markdown, and standalone HTML. The HTML path replaces packaged media, removes links and active controls, then applies a strict DOMPurify structural element/attribute allowlist before adding a deny-by-default CSP. zip.js receives a lazy browser `BlobReader`, performs CRC checks when entry data is read, and can use web workers. React retains one open reader and closes the previous reader when a new book replaces it. The original File is immutable; all changes exist only in staged React state or a newly downloaded Blob. -The safe reader uses both sanitization and an iframe without sandbox permissions. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheet links are intentionally removed in v0.1. +The safe reader uses both sanitization and an iframe with `allow-same-origin` but without scripts, forms, popups, navigation, downloads, modals, or device permissions. Same-origin access lets the trusted parent intercept inert EPUB links, scroll to a target fragment, and read an explicitly selected quote. Its `srcdoc` adds `default-src 'none'` and permits only inline styles plus local data/blob image/font/media URLs. Publisher stylesheets are size/count bounded; imports and network URLs are removed, while CSP remains the final network boundary. A mutation observer synchronizes explicit light/dark reader tokens with Toolbox preference changes; system mode follows the browser media preference. diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 610369c..2ed77a3 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -2,8 +2,19 @@ All source and generated publication data remains in the browser. The app has no telemetry, account, analytics, remote font, CDN, or default network integration. The production CSP keeps `connect-src` and worker sources same-origin. -EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is reported because it can describe valid font obfuscation or DRM; the app does not distinguish every scheme and never attempts circumvention. +EPUB input is adversarial. Before extraction the app enforces file, entry, expanded-size, per-entry, expansion-ratio, duplicate-name, and path rules. Required XML is size-bounded, supports UTF-8/UTF-16, and rejects DTD/entity declarations. ZIP-encrypted entries cannot be read. `META-INF/encryption.xml` is inspected: the standardized IDPF font-obfuscation mask is decoded locally, while unsupported protection is reported and never circumvented. -Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers, navigation and resource references are neutralized or replaced with bounded local object URLs, and rendering happens inside a permissionless sandboxed iframe with its own restrictive CSP. Temporary object URLs are revoked when chapters change. +HTML/XHTML, Markdown, and plain-text adapters accept at most 16 MiB of valid +UTF-8. HTML is parsed inertly and copied through a small structural +element/attribute allowlist; scripts, styles, forms, event handlers, embedded +media, external resources, and non-fragment links are not transferred. The +adapter bounds nodes, nesting and Markdown lines, derives a local content +identifier with Web Crypto, and then sends its generated one-chapter EPUB +through the same strict archive and reader path. The reported losses are part +of the resulting workspace rather than a claim of source fidelity. -Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for encrypted publications. +Content documents are never mounted into the application DOM. DOMPurify removes active elements and event handlers; regular and namespaced SVG navigation/resource attributes are neutralized or replaced with bounded local object URLs; and rendering happens inside a scriptless sandboxed iframe with its own restrictive CSP. The iframe has `allow-same-origin` solely so trusted application code can intercept internal links, scroll to fragments, and capture a quote the user explicitly selected; it receives no script, form, popup, top-navigation, download, modal, or device permissions. Stylesheets/resources are bounded, CSS imports and external URLs are removed, object URLs are reused per resource and revoked when chapters change. + +Search operates on bounded extracted text. Bookmarks and annotations remain in React memory unless the user explicitly downloads their JSON; importing validates schema, counts, path safety, field sizes, and publication identifier. Derived HTML/Markdown/text exports intentionally omit active content and network resources and are lossy reading-order representations, not publication-preserving conversions. Standalone HTML is rebuilt through a strict DOMPurify structural allowlist that excludes meta refreshes, base URLs, templates, frames, links, media and event/style attributes, then receives a deny-by-default CSP. + +Validation is intentionally bounded and incomplete. It does not establish publication safety, conformance, accessibility, authenticity, ownership, or freedom from hidden data. Reports identify the checks performed and relevant unsupported areas. Rebuilding changes compressed bytes and invalidates signatures; it is disabled for unsupported encrypted publications. IDPF-obfuscated fonts are re-keyed if the unique publication identifier changes. diff --git a/public/sw.js b/public/sw.js index 8e6db22..b48ebfd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "epub-tools-shell-"; -const CACHE_NAME = CACHE_PREFIX + "0.1.0"; +const CACHE_NAME = CACHE_PREFIX + "0.2.0"; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { event.waitUntil( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 5ccdd3a..a894a7e 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,24 +3,41 @@ "schemaVersion": 1, "id": "de.add-ideas.epub-tools", "name": "EPUB Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Read, inspect and repair EPUB publications locally in the browser.", "entry": "./", "icon": "./favicon.svg", "categories": ["documents", "ebooks", "publishing"], - "tags": ["epub", "ebook", "metadata", "reader", "repair"], + "tags": ["epub", "ebook", "metadata", "reader", "repair", "html", "markdown"], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], "embedding": "unsupported" }, "requirements": { - "secureContext": false, + "secureContext": true, "workers": true, "indexedDb": false, "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "application/epub+zip", "extensions": [".epub"] }, + { "mediaType": "text/html", "extensions": [".html", ".htm"] }, + { "mediaType": "application/xhtml+xml", "extensions": [".xhtml"] }, + { "mediaType": "text/markdown", "extensions": [".md", ".markdown"] }, + { "mediaType": "text/plain", "extensions": [".txt"] } + ], + "produces": [ + { "mediaType": "application/epub+zip", "extensions": [".epub"] }, + { "mediaType": "text/plain", "extensions": [".txt"] }, + { "mediaType": "text/markdown", "extensions": [".md"] }, + { "mediaType": "text/html", "extensions": [".html"] }, + { "mediaType": "application/json", "extensions": [".json"] } + ] + }, + "capabilities": { "required": ["workers", "web-crypto"], "optional": [] }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 7f47f33..0ba9a35 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -31,11 +31,22 @@ export function HelpDialog({ × -

Read, inspect and repair EPUB publications locally in the browser.

+

+ 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. +

All processing is performed in this browser. Imported data is treated as untrusted and bounded before parsing.

+

+ 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. +

); } diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 895ea96..b025d2d 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,16 +1,35 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { sanitizeDownloadFilename, triggerBlobDownload, } from "@add-ideas/toolbox-helpers"; -import { closeBook, openEpub, readEntryBytes } from "../epub/archive"; +import { + closeBook, + readEntryBytes, + type EpubOpenProgress, +} from "../epub/archive"; +import { openPublication } from "../epub/adapters"; import { downloadJson, + exportMarkdown, exportPlainText, + exportReadingOrderHtml, rebuildEpub, type MetadataPatch, } from "../epub/export"; -import { renderChapter, revokeRenderedChapter } from "../epub/reader"; +import { + createReadingNoteId, + exportReadingNotes, + importReadingNotes, + type EpubReadingNotes, +} from "../epub/annotations"; +import { + applyReaderTheme, + renderChapter, + revokeRenderedChapter, + type ReaderTheme, +} from "../epub/reader"; +import { searchPublication, type EpubSearchResult } from "../epub/search"; import type { EpubBook, ManifestItem, @@ -26,6 +45,9 @@ const views: ReadonlyArray = [ ["package", "Package tree"], ["export", "Export & rebuild"], ]; +interface OpeningState extends EpubOpenProgress { + filename: string; +} function initialMetadata(book: EpubBook): MetadataPatch { return { @@ -49,7 +71,82 @@ function flatten(items: NavigationItem[]): NavigationItem[] { return items.flatMap((item) => [item, ...flatten(item.children)]); } -function EmptyState({ onFile }: { onFile: (file: File) => void }) { +function emptyReadingNotes(book?: EpubBook): EpubReadingNotes { + return { + schemaVersion: 1, + publication: { + identifier: book?.package.metadata.identifier ?? "", + title: book?.package.metadata.title ?? "", + }, + bookmarks: [], + annotations: [], + }; +} + +function resolvedReaderTheme(): ReaderTheme { + const shell = document.querySelector("[data-toolbox-theme]"); + if (shell) { + const scheme = getComputedStyle(shell).colorScheme; + if (/\bdark\b/iu.test(scheme) && !/\blight\b/iu.test(scheme)) return "dark"; + if (/\blight\b/iu.test(scheme) && !/\bdark\b/iu.test(scheme)) + return "light"; + const selected = shell.dataset.toolboxTheme; + if (selected === "dark" || selected === "light") return selected; + } + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useReaderTheme(): ReaderTheme { + const [theme, setTheme] = useState(resolvedReaderTheme); + useEffect(() => { + const shell = document.querySelector("[data-toolbox-theme]"); + const media = + typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-color-scheme: dark)") + : undefined; + const update = () => setTheme(resolvedReaderTheme()); + update(); + const observer = shell ? new MutationObserver(update) : undefined; + if (shell) + observer?.observe(shell, { + attributes: true, + attributeFilter: ["data-toolbox-theme"], + }); + media?.addEventListener("change", update); + return () => { + observer?.disconnect(); + media?.removeEventListener("change", update); + }; + }, []); + return theme; +} + +function waitForPaint(): Promise { + return new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (resolved) return; + resolved = true; + window.clearTimeout(fallback); + resolve(); + }; + const fallback = window.setTimeout(finish, 100); + if (typeof requestAnimationFrame === "function") + requestAnimationFrame(() => window.setTimeout(finish, 0)); + else window.setTimeout(finish, 0); + }); +} + +function EmptyState({ + onFile, + disabled, +}: { + onFile: (file: File) => void; + disabled: boolean; +}) { return (
); } +function NavigationTree({ + items, + book, + chapter, + disabled, + onOpen, + depth = 0, +}: { + items: NavigationItem[]; + book: EpubBook; + chapter?: RenderedChapter; + disabled: boolean; + onOpen: (item: ManifestItem, fragment?: string) => void; + depth?: number; +}) { + return ( +
+ {items.map((navigationItem, index) => { + const item = book.package.manifest.find( + (candidate) => candidate.path === navigationItem.path, + ); + return ( +
+ + {navigationItem.children.length > 0 && ( + + )} +
+ ); + })} +
+ ); +} + function ReaderView({ book, chapter, + targetFragment, + theme, busy, + disabled, error, onOpen, + notes, + setNotes, }: { book: EpubBook; chapter?: RenderedChapter; + targetFragment?: string; + theme: ReaderTheme; busy: boolean; + disabled: boolean; error: string; - onOpen: (item: ManifestItem) => void; + onOpen: (item: ManifestItem, fragment?: string) => void; + notes: EpubReadingNotes; + setNotes: (notes: EpubReadingNotes) => void; }) { + const frame = useRef(null); const navigation = useMemo(() => flatten(book.package.navigation), [book]); const spine = book.package.spine.filter((item) => item.item && item.linear); + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchStatus, setSearchStatus] = useState(""); + const [searching, setSearching] = useState(false); + const [note, setNote] = useState(""); + const [readerStatus, setReaderStatus] = useState(""); + const searchController = useRef(undefined); + useEffect( + () => () => { + searchController.current?.abort(); + }, + [book], + ); + const openPath = (path: string | undefined, fragment?: string) => { + const item = book.package.manifest.find( + (candidate) => candidate.path === path, + ); + if (item) onOpen(item, fragment); + else + setReaderStatus( + `The target is not a readable manifest item: ${path ?? "(none)"}`, + ); + }; + const scrollToFragment = (fragment?: string) => { + if (!fragment || !frame.current?.contentDocument) return; + let identifier = fragment; + try { + identifier = decodeURIComponent(fragment); + } catch { + // Preserve the original fragment when percent-decoding fails. + } + const target = + frame.current.contentDocument.getElementById(identifier) ?? + frame.current.contentDocument.getElementsByName(identifier)[0]; + if (target) { + frame.current.contentDocument + .querySelectorAll('[data-epub-reader-target="true"]') + .forEach((element) => + element.removeAttribute("data-epub-reader-target"), + ); + target.setAttribute("data-epub-reader-target", "true"); + target.scrollIntoView({ block: "start" }); + } else + setReaderStatus(`Fragment #${fragment} was not found in this chapter.`); + }; + const runSearch = async () => { + searchController.current?.abort(); + const controller = new AbortController(); + searchController.current = controller; + setSearching(true); + setSearchStatus("Searching the reading order locally…"); + try { + const results = await searchPublication( + book, + query, + (progress) => + setSearchStatus( + `Searching ${progress.current} of ${progress.total}: ${progress.path}`, + ), + controller.signal, + ); + if (searchController.current !== controller) return; + setSearchResults(results); + setSearchStatus( + `${results.length.toLocaleString()} chapter(s) contain the literal query.`, + ); + } catch (reason) { + if (searchController.current !== controller) return; + if (!(reason instanceof DOMException && reason.name === "AbortError")) + setSearchStatus( + reason instanceof Error ? reason.message : "Search failed.", + ); + } finally { + if (searchController.current === controller) { + searchController.current = undefined; + setSearching(false); + } + } + }; + const addBookmark = () => { + if (!chapter) return; + if ( + notes.bookmarks.some( + (bookmark) => + bookmark.path === chapter.path && + bookmark.fragment === targetFragment, + ) + ) { + setReaderStatus("This location is already bookmarked."); + return; + } + setNotes({ + ...notes, + bookmarks: [ + ...notes.bookmarks, + { + id: createReadingNoteId(), + path: chapter.path, + ...(targetFragment ? { fragment: targetFragment } : {}), + label: chapter.title, + }, + ], + }); + setReaderStatus("Bookmark added to this in-memory reading session."); + }; + const addAnnotation = () => { + if (!chapter) return; + const selection = frame.current?.contentWindow + ?.getSelection() + ?.toString() + .replace(/\s+/gu, " ") + .trim() + .slice(0, 2_000); + if (!selection && !note.trim()) { + setReaderStatus("Select text in the chapter or enter a note first."); + return; + } + setNotes({ + ...notes, + annotations: [ + ...notes.annotations, + { + id: createReadingNoteId(), + path: chapter.path, + ...(targetFragment ? { fragment: targetFragment } : {}), + label: chapter.title, + note: note.trim().slice(0, 16_000), + ...(selection ? { quote: selection } : {}), + }, + ], + }); + setNote(""); + setReaderStatus("Annotation added to this in-memory reading session."); + }; return (