forked from akai/readest
a1279a65ce
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>
165 lines
6.2 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
};
|
|
|
|
// 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;
|
|
}
|