Files
readest/apps/readest-app/src/services/send/conversion/buildEpub.ts
T
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.

Architecture

- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
  target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
  fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
  resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
  127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
  Top-level navigation isn't governed by CSP connect-src / form-action /
  WebKit Private Network Access — the four earlier transports
  (fetch, <form>, custom URI scheme, window.name) were each blocked by
  one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
  src → data-src → data-original → data-srcset → srcset fallback so lazy-
  loading sites don't ship a 60px LQIP; fetches assets in parallel with a
  per-asset timeout + per-asset/total caps; failed images degrade to alt-
  text placeholders. A per-site rules table (seeded with WeChat MP) + a
  selector fallback catches articles Readability misextracts. Builder
  prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.

UI surfaces

- "From Web URL" entry in the library Import menu, gated to Tauri; web
  build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
  default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
  decorations + overlay title bar; other desktops decorationless with a
  drop shadow; native background + in-page loading overlay pick up the
  caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
  render correctly. Title localised, all five overlay/title strings
  translated across 33 locales.

Notes

- Gates the macOS traffic-light positioner to main/reader-* windows so
  the decorationless clip window no longer null-derefs in
  `position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
  hex-color parsing rejects malformed values, server endpoint returns
  400 on missing/invalid base64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:48:09 +02:00

165 lines
6.2 KiB
TypeScript

import { configureZip } from '@/utils/zip';
import { buildNavMap } from './toc';
import type { EpubChapter, EpubBuildMetadata, EpubImage } from './types';
// Zero the zip timestamps so converting the same source twice yields
// byte-identical EPUBs — that keeps the import-time hash stable and dedups
// a book a user sends more than once.
const zipWriteOptions = {
lastAccessDate: new Date(0),
lastModDate: new Date(0),
};
const escapeXml = (str: string): string => {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
// System font stack only — the bundled EPUB stays self-contained and never
// reaches out to the network when opened offline.
const CSS = `
body { line-height: 1.6; font-size: 1em; text-align: justify;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; }
h1, h2, h3 { line-height: 1.3; }
p { margin: 0.6em 0; }
img { max-width: 100%; height: auto; }
figure { margin: 1em 0; }
figcaption { font-size: 0.9em; color: #666; text-align: center; }
blockquote { margin: 1em 1.5em; color: #444; border-left: 3px solid #ccc; padding-left: 1em; }
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
pre { white-space: pre-wrap; background: #f5f5f5; padding: 0.6em 0.8em; border-radius: 4px; }
`;
/**
* Build a minimal, valid EPUB 2.0 from sanitized HTML chapters. Shares the
* `@zip.js/zip.js` assembly pattern with `TxtToEpubConverter` — mimetype first
* and uncompressed, `container.xml`, `content.opf`, `toc.ncx`, zeroed
* timestamps.
*/
export async function buildEpub(
chapters: EpubChapter[],
metadata: EpubBuildMetadata,
images: EpubImage[] = [],
): Promise<Blob> {
if (chapters.length === 0) {
throw new Error('buildEpub: no chapters');
}
await configureZip();
const { BlobWriter, TextReader, Uint8ArrayReader, ZipWriter } = await import('@zip.js/zip.js');
const { title, author, language, identifier } = metadata;
const zipWriter = new ZipWriter(new BlobWriter('application/epub+zip'), {
extendedTimestamp: false,
});
await zipWriter.add('mimetype', new TextReader('application/epub+zip'), zipWriteOptions);
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`;
await zipWriter.add('META-INF/container.xml', new TextReader(containerXml), zipWriteOptions);
// Prefer a heading-based nested navMap when the caller supplied one —
// the EPUB reader's TOC sidebar then mirrors the article's section
// structure rather than showing a single "Article Title" entry. Falls
// back to one navPoint per chapter when no headings were extracted
// (a Substack-style image-and-paragraph post with no h2s, etc).
const navPoints =
metadata.toc && metadata.toc.length > 0
? buildNavMap(metadata.toc, 'OEBPS/chapter1.xhtml', escapeXml)
: chapters
.map((chapter, i) => {
const id = `chapter${i + 1}`;
return (
`<navPoint id="navPoint-${id}" playOrder="${i + 1}">` +
`<navLabel><text>${escapeXml(chapter.title)}</text></navLabel>` +
`<content src="./OEBPS/${id}.xhtml"/></navPoint>`
);
})
.join('\n');
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="${escapeXml(identifier)}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle><text>${escapeXml(title)}</text></docTitle>
<docAuthor><text>${escapeXml(author)}</text></docAuthor>
<navMap>${navPoints}</navMap>
</ncx>`;
await zipWriter.add('toc.ncx', new TextReader(tocNcx), zipWriteOptions);
await zipWriter.add('style.css', new TextReader(CSS), zipWriteOptions);
// Inline images: keep relative to OEBPS/ so `<img src="images/abc.png">`
// inside a chapter resolves correctly.
for (const image of images) {
await zipWriter.add(
`OEBPS/${image.path}`,
new Uint8ArrayReader(new Uint8Array(image.bytes)),
zipWriteOptions,
);
}
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]!;
const xhtml = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="${escapeXml(language)}" xml:lang="${escapeXml(language)}">
<head>
<title>${escapeXml(chapter.title)}</title>
<link rel="stylesheet" type="text/css" href="../style.css"/>
</head>
<body>${chapter.html}</body>
</html>`;
await zipWriter.add(`OEBPS/chapter${i + 1}.xhtml`, new TextReader(xhtml), zipWriteOptions);
}
const manifest = chapters
.map(
(_, i) =>
`<item id="chap${i + 1}" href="OEBPS/chapter${i + 1}.xhtml" media-type="application/xhtml+xml"/>`,
)
.join('\n ');
const imageManifest = images
.map(
(image, i) =>
`<item id="img${i + 1}" href="OEBPS/${escapeXml(image.path)}" media-type="${escapeXml(image.mime)}"/>`,
)
.join('\n ');
const spine = chapters.map((_, i) => `<itemref idref="chap${i + 1}"/>`).join('\n ');
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>${escapeXml(title)}</dc:title>
<dc:language>${escapeXml(language)}</dc:language>
<dc:creator>${escapeXml(author)}</dc:creator>
<dc:identifier id="book-id">${escapeXml(identifier)}</dc:identifier>
</metadata>
<manifest>
${manifest}
${imageManifest}
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="css" href="style.css" media-type="text/css"/>
</manifest>
<spine toc="ncx">
${spine}
</spine>
</package>`;
await zipWriter.add('content.opf', new TextReader(contentOpf), zipWriteOptions);
return (await zipWriter.close()) as Blob;
}