No description
  • TypeScript 95.5%
  • CSS 4%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexander Freynik 1f39dacf27
All checks were successful
Autorelease / autorelease (push) Successful in 4s
Build / build (push) Successful in 8s
fix: block tools stay hidden until you point at the block; rail label lines up with the text
2026-11-01 22:30:53 +01:00
.forgejo/workflows fix: make release.yml actually trigger and run — tag filter matches the bare tag name (v*; Forgejo globs TagName, not the full ref), stage the archive in /tmp (never trust /workspace), map the RELEASE_TOKEN secret to an env var (secrets are not auto-injected), drop the malformed Bearer header 2026-10-30 22:42:39 +01:00
public fix: block tools stay hidden until you point at the block; rail label lines up with the text 2026-11-01 22:30:53 +01:00
scripts feat: ✓ Check — validate the exported book with EPUBCheck 3.0 (POST /api/validate + scripts/validate.ts + report modal; non-blocking, optional dependency) 2026-11-01 12:12:35 +01:00
src fix: put the block's tools on the line above it, at its left 2026-11-01 22:19:51 +01:00
tests feat: chapter file names are editable, with count-up for the chapters that follow 2026-11-01 21:23:21 +01:00
.gitignore fix: updater version source + robust swap — fetch via contents API (raw route 404s repo-root files), Pages mirror public/version.json, cp-based swap (EXDEV across tmpfs), file:// archives for tests, warn on unresolvable NCX TOC anchors 2026-10-30 22:37:55 +01:00
.version.json fix: finish the v0.2.1 version bump 2026-11-01 15:00:42 +01:00
bun.lock feat: ✓ Check — validate the exported book with EPUBCheck 3.0 (POST /api/validate + scripts/validate.ts + report modal; non-blocking, optional dependency) 2026-11-01 12:12:35 +01:00
package.json fix: finish the v0.2.1 version bump 2026-11-01 15:00:42 +01:00
README.md docs: describe the per-block format rail in the features list 2026-11-01 22:14:12 +01:00
tsconfig.json feat: web UI — block editor, TOC/metadata/cover panels, /api/toc/generate; typecheck clean (tsc strict) + misc server/lib fixes 2026-10-30 13:09:04 +01:00
version.json chore: release v0.2.1 #patch 2026-11-01 14:53:36 +01:00

epub-edit

A small web app for cleaning up and editing EPUB files in the browser.

Features (v1)

  • Import EPUB — parses OPF, spine, NCX/nav TOC, metadata and media; keeps a snapshot of every original chapter file. Books always open as-is: nothing is touched until you say so.

  • Strip formatting — per chapter. Each chapter header has three mode chips; the original HTML snapshot is re-converted live (edits to that chapter are discarded, the rest of the book is untouched):

    • As-is: nothing is converted. Every element is kept verbatim as a raw HTML block (editable as source); only standalone images are lifted so they still show and export. Use this to fix TOC/cover/metadata without touching the text.
    • Conservative: real blocks where possible, but anything that cannot be represented faithfully (tables, nested lists, styled containers) is kept verbatim as a “raw HTML” block, classes/styles included.
    • Aggressive: reduces the chapter to structure — headings, paragraphs, lists, quotes, bold/italic/underline/strikethrough/sup/sub, links and images. Classes, styles and layout junk (tables, Word soup) are removed; inline styles that mean emphasis (font-weight, italic, underline) are converted to real marks first. Images — standalone and inline inside a sentence — are kept and rewired to the book's media folder.
    • “apply to all chapters” runs the current chapter’s mode on the whole book; “Re-strip from originals” re-runs the chosen strip mode on the untouched original HTML (text edits are replaced; metadata, TOC and stylesheets are kept). Raw HTML (conservative/as-is) is normalized at export — void tags self-closed, named entities numeric, image and chapter links rewired to the exported files — so the result stays valid XHTML and readable on devices.
  • Block-based text editor — headings H1–H6, paragraphs, quotes, lists; Enter splits a paragraph; autosave (debounced) to the server session. Every block carries its own rail at its left: the type label (H1, ¶, ❝, •, img, table, pre, html), its epub:type chip, and the tools B I U H↑ H↓ ¶ 🖼 ⚑ (they brighten when you point at or edit the block; move/delete sit at its right). A mark button with nothing selected in that block applies to the whole block, and pressing it twice returns to exactly what it was — a word that was already bold keeps its bold. Inline images: 🖼 inserts a picture at the cursor (pick from the book or upload); clicking an inline image opens its ops — width 25/50/75/100 %, alt text, replace, remove. Inline images flow inside the sentence and export as <img> in the paragraph, sized via max-width.

  • SE typography pass (✦ Typography) — deterministic rules from the same manual (8.7), no model involved: straight quotes → typographer's quotes, apostrophes and elisions (’twas, pork ’n’ beans) → the right single quote, .../. . . → one ellipsis glyph, no white space around a dash (-- between words becomes an em dash, 1914 - 1918 becomes an en dash) while real hyphenated compounds (Smoot-Hawley) keep theirs, old contraction spacing closed up ("Would n't" → "Wouldn't"), single space after a sentence, and URLs/e-mails never rewritten. It changes punctuation characters only — no word is ever altered, and it is idempotent, so pressing it twice does nothing new. Raw and preformatted blocks stay byte-exact. Optional SE spacing glyphs adds the invisible marks the manual requires (word joiner before a dash 8.7.7.8.1, hair space before an ellipsis 8.7.6.3, no-break space before an ampersand 8.7.3, hair space between side-by-side quotes 8.7.5.2); off by default, since outside a real SE edition they are invisible noise. Per chapter or whole book, from the row above the editor.

  • TOC editor — auto-generate from headings, then rename, reorder, indent/outdent or remove entries; "Rename chapters from TOC" sets each chapter title to its TOC label (individual "⚏ from TOC" button per chapter too). Exported as both nav.xhtml (EPUB 3) and toc.ncx (EPUB 2 compat).

  • Cover (title image) — upload/replace/remove, or pick any image already in the book; a cover page is generated on export.

  • Chapter file names — the file a chapter becomes inside the .epub is shown under the title (text/chapter-10.xhtml) and is clickable: one dialog sets the chapter title, the file name and how many of the following chapters should count up from it (chapter-5 + 3 → chapter-6, chapter-7, chapter-8, keeping the zero padding and any prefix/suffix like 01 - .). The field holds the name only: .xhtml stands outside it, because a file name in an EPUB is always .xhtml and being able to type the extension is how files get named title.html.xhtml. Nothing typed is ever repaired — a name that cannot be a file name is refused, with the reason (a file name cannot contain a space — use - or _, leave off the extension, not a valid file name: only letters, digits, . _ - (a leading . aside), too long: 64 at most, … cannot be a whole file name on its own for .., . or a reserved device name), and both the OK button and the Enter key stay dead until the field is usable; empty means the generated name. Links and TOC entries pointing at a renamed file are rewritten in the same step, so nothing dangles (the toast says how many were updated). Applies to both layouts; chapter-N.xhtml (original) and the source-file-derived SE names stay the defaults.

  • Book library: EPUBs placed in the server's library folder (parent of the data dir, override with EPUB_LIBRARY_ROOT) are listed on the start page — open or delete them without uploading. Deletion only affects plain .epub files directly in that folder.

  • Metadata editor — title, creators, publisher, language, date, identifier, description.

  • Export — downloads a fresh, valid EPUB 3 package (with NCX fallback).

  • Check (EPUBCheck 3.0) — the ✓ Check button in the preview bar validates the exported file with the official W3C checker, bundled: no Java, no install, ~0.4 s for a 12 MB book. A modal reports errors and warnings with the file, line and id (or “Valid EPUB”). Export and download are never blocked — invalid raw HTML from as-is/conservative chapters is the author's own markup, and you may still want the file. Also standalone: bun run validate OEBPS/../book.epub / bun scripts/validate.ts <file.epub> [more.epub …] prints the same report and exits 1 when a file has errors.

  • Self-update — the app checks version.json on the Forgejo repo (default git.beyondedge.de/acf/epub-edit, branch master) and shows an Update button when a newer version is out; pressing it downloads the repo archive, swaps the code, runs bun install and restarts (data survives; in Docker the restart policy brings it back). Config: EPUB_UPDATE_FORGE, EPUB_UPDATE_REPO, EPUB_UPDATE_REF. Releasing: bump with bun scripts/set-version.ts 0.1.2, commit with #patch/#minor/#major in the message — the autorelease workflow tags and builds the Docker image.

  • Live preview — third column renders the book exactly as an e-reader would, using foliate-js; it refreshes a moment after each autosave and follows chapter clicks in the sidebar. Hide/show is remembered per browser. The preview EPUB is served with a revision-based ETag (plus Cache-Control: private, max-age=0, must-revalidate), so an unchanged book costs a 304 and a re-open is skipped entirely; two saves inside the same second still produce distinct ETags (Last-Modified alone would answer a stale 304, so it is deliberately not trusted). Built packages are also cached per session until the next edit — on the 99-chapter Dune that turns a 460 ms rebuild into a 2 ms cache hit.

  • AI restructure (⚙ Settings → load an instruction) — point the app at any OpenAI-compatible API (host, key, model) and ✨ Simplify this chapter / ✨ Simplify whole book sends the stylesheet + chapter bodies to the model and applies the returned files back into the book, one request per chapter (the stylesheet goes with the first request only, so 99 rewrites of it cannot overwrite each other). Credentials live in browser localStorage; each request carries them directly to the API and the server never stores them. Two built-in instructions ship with the app:

    • Simplify (default): shrink the stylesheet to what the text still uses, flatten Word/Calibre soup into semantic markup, merge split formatting runs — text untouched.
    • Standard Ebooks: derived from the Standard Ebooks Manual of Style 1.9.1 — rebuilds the markup to SE semantics (<section epub:type="chapter"> with the heading first, <hgroup> for ordinal+title titles, <blockquote epub:type="poem|letter|drama"> patterns with i1/i2 line indentation, <cite> as the blockquote's own child, <figure id="illustration-N"> + real alt prose, semantic <em>/<i>/<strong>/<b> choices, xml:lang on foreign words, typographic quotes/dashes/ellipsis, attribute-based CSS with no one-off classes, no inline style=). The story text is frozen: the instruction forbids paraphrasing, reflowing, adding or dropping a single word, and the number-spelling rules are deliberately left out since they would change visible text. Because this app writes the file shell, spine, TOC and OPF itself, the instruction also restricts the model to unprefixed EPUB vocabulary (epub:type="chapter", never z3998:chapter) and tells it to return only the bodies of the files it was given. The preset only fills the instruction field — you can edit it before saving.
  • ⚙ Settings → Max output tokens per request (default 8000) — the reply budget for one pass. A local model that answers with less than a whole chapter cuts the file off mid-page, and the old hard-coded 1024 did exactly that. Raise it for long chapters; the server caps it at EPUB_AI_MAX_TOKENS.

  • Safety net on every answer — the app never blindly writes model output into your book. A chapter whose words drop below EPUB_AI_MIN_RETENTION (default 0.9) of the original is refused and listed as kept as-is. A stylesheet that stops defining a class or id the returned chapters still carry is refused too (turning .italic into <em> is a cleanup; answering with only the two rules you felt like changing is a loss) — the message names the dropped rules. A file we asked for and did not get is reported per file, and applied: [] says plainly that the model did nothing rather than pretending success.

  • Progress window during a whole-book pass: per-chapter status, elapsed + estimated remaining time, a stop button (* chapters already applied stay applied*), and chapters go live in the preview as they finish.

  • ↺ original in the Stylesheets panel puts the imported stylesheet back byte-for-byte: the server opens the .epub as it was uploaded (GET /api/css/original?name=…) and hands that text to the editor, so no extra snapshot is kept and a bad stylesheet answer is always recoverable.

    A few SE constructs do not survive the round trip into the block model: an unwrapped <section>'s epub:type="chapter" is dropped rather than copied onto every paragraph, and its id is inherited by exactly one block (so no duplicate ids in the file).

Planned (see kanban roadmap)

Nothing on the v1 roadmap is left open — chapter management, inline images and the EPUBCheck hook all shipped. Candidate follow-ups (not scheduled): per-chapter re-strip undo history, smarter TOC generation for books whose headings are all one level.

Authentication is deliberately dropped from the roadmap: access control belongs at the network/proxy layer (VPN, basic auth in the reverse proxy). The app keeps per-session folders bound to an HttpOnly cookie, and the AI key never leaves the browser (localStorage) — anything stronger is overkill for this tool.

Run it

Requires Bun ≥ 1.2.

bun install
bun run build:web   # bundles the frontend into public/dist (run once, and after frontend changes)
bun src/server.ts   # http://0.0.0.0:4111

bun run dev does the server with autoreload. Tests: bun test. Typecheck: bun x tsc --noEmit. Validate a file: bun scripts/validate.ts book.epub (EPUBCheck 3.0, bundled — no Java needed).

Configuration

Env var Default Meaning
HOST 0.0.0.0 listen address (127.0.0.1 to keep it local-only)
PORT 4111 listen port
EPUB_DATA_DIR app/data/sessions where per-session books are stored
EPUB_AI_MAX_TOKENS 8000 reply budget per AI pass — small models need more than the default 1024 to finish a chapter
EPUB_AI_MIN_RETENTION 0.9 the share of a chapter's words an AI rewrite must keep, else the file is refused and reported
EPUB_LIBRARY_ROOT parent of the data dir folder scanned for the book library on the start page
EPUB_UPDATE_FORGE / EPUB_UPDATE_REPO / EPUB_UPDATE_REF git.beyondedge.de/acf/epub-edit · master self-update source

Deployment notes

  • Sessions are anonymous: a random id in an HttpOnly epub_sid cookie, one folder per session under EPUB_DATA_DIR, purged after 7 days of inactivity (plus on process start). No accounts yet — treat every user who can reach the server as trusted-by-cookies only.
  • There is a dev convenience: ?sid=<id> behaves like the session cookie (used for debugging without cookies). Remove sidFromReq’s query branch if you deploy this to strangers.
  • Run behind a TLS-terminating reverse proxy for anything beyond a LAN; set Proxy-Authorization-level protection (basic auth / VPN) at the proxy until real auth lands.
  • Limits: uploads ≤ 120 MB, saved docs ≤ 25 MB JSON, single media files ≤ 20 MB (images only). Adjust in src/server.ts.
  • public/dist/main.js is a build artifact and not tracked by git — always bun run build:web before deploying.

Layout

.
  src/lib/model.ts        block/run data model (the doc is plain JSON)
  src/lib/strip.ts        HTML -> blocks (cheerio), all three modes
  src/lib/blocks.ts       blocks -> XHTML
  src/lib/epub-import.ts  .epub -> model (+ original file snapshots)
  src/lib/epub-export.ts  model -> .epub (JSZip)
  src/server.ts           Hono API + static hosting
  src/web/main.ts         frontend (vanilla TS, no framework)
  scripts/make-fixture.ts messy test book generator
  scripts/validate.ts     EPUBCheck 3.0 from the command line
  tests/roundtrip.test.ts import -> edit -> export -> re-import checks
  tests/validate.test.ts  checks the checker itself reports a broken file