From 1fb468b3a6ec3967b209393c6f19415ac9bac0bb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 8 Dec 2025 15:52:17 +0800 Subject: [PATCH] fix(epub): support SVG cover for ebooks from standardebooks.org (#2645) --- apps/readest-app/src/services/appService.ts | 9 ++- apps/readest-app/src/utils/svg.ts | 69 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/utils/svg.ts diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index ad8900d3..300e1699 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -72,6 +72,7 @@ import { BOOK_FILE_NOT_FOUND_ERROR } from './errors'; import { CustomTextureInfo } from '@/styles/textures'; import { CustomFont, CustomFontInfo } from '@/styles/fonts'; import { parseFontInfo } from '@/utils/font'; +import { svg2png } from '@/utils/svg'; export abstract class BaseAppService implements AppService { osPlatform: OsPlatform = getOSPlatform(); @@ -418,7 +419,13 @@ export abstract class BaseAppService implements AppService { } } if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { - const cover = await loadedBook.getCover(); + let cover = await loadedBook.getCover(); + if (cover?.type === 'image/svg+xml') { + try { + console.log('Converting SVG cover to PNG...'); + cover = await svg2png(cover); + } catch {} + } if (cover) { await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer()); } diff --git a/apps/readest-app/src/utils/svg.ts b/apps/readest-app/src/utils/svg.ts new file mode 100644 index 00000000..ce1e604e --- /dev/null +++ b/apps/readest-app/src/utils/svg.ts @@ -0,0 +1,69 @@ +function parseSvgLength(value: string) { + const n = parseFloat(value); + if (!isNaN(n)) return n; + + return undefined; +} + +async function getSvgSize( + svgBlob: Blob, + defaultWidth: number = 700, + defaultHeight: number = 1050, +): Promise<{ width: number; height: number }> { + const text = await svgBlob.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'image/svg+xml'); + const svg = doc.documentElement; + + const widthAttr = svg.getAttribute('width'); + const heightAttr = svg.getAttribute('height'); + + if (widthAttr && heightAttr) { + return { + width: parseSvgLength(widthAttr) || defaultWidth, + height: parseSvgLength(heightAttr) || defaultHeight, + }; + } + + const viewBox = svg.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.split(/\s+/).map(Number); + if (parts.length === 4 && !parts.some(isNaN)) { + const [, , vbWidth, vbHeight] = parts; + return { width: vbWidth || defaultWidth, height: vbHeight || defaultHeight }; + } + } + + return { width: defaultWidth, height: defaultHeight }; +} + +export async function svg2png(svgBlob: Blob, quality: number = 0.9): Promise { + const svgText = await svgBlob.text(); + + const svgUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' })); + + const img = new Image(); + img.decoding = 'sync'; + img.crossOrigin = 'anonymous'; + img.src = svgUrl; + + await img.decode().catch( + () => + new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }), + ); + + const canvas = document.createElement('canvas'); + const { width, height } = await getSvgSize(svgBlob); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d')!; + ctx.drawImage(img, 0, 0); + + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob!), 'image/png', quality); + }); +}