diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 4a2da498..3cb2cfbd 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -9,7 +9,7 @@ import { useClickEvent, useTouchEvent } from '../hooks/useIframeEvents'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { useProgressSync } from '../hooks/useProgressSync'; import { useProgressAutoSave } from '../hooks/useProgressAutoSave'; -import { getStyles, mountAdditionalFonts } from '@/utils/style'; +import { getStyles, mountAdditionalFonts, transformStylesheet } from '@/utils/style'; import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book'; import { useUICSS } from '@/hooks/useUICSS'; import { @@ -23,6 +23,7 @@ import { handleTouchEnd, } from '../utils/iframeEventHandlers'; import { getMaxInlineSize } from '@/utils/config'; +import { transformContent } from '@/services/transformService'; const FoliateViewer: React.FC<{ bookKey: string; @@ -53,6 +54,28 @@ const FoliateViewer: React.FC<{ setProgress(bookKey, detail.cfi, detail.tocItem, detail.section, detail.location, detail.range); }; + const docTransformHandler = (event: Event) => { + const { detail } = event as CustomEvent; + detail.data = Promise.resolve(detail.data) + .then((data) => { + const viewSettings = getViewSettings(bookKey)!; + if (detail.type === 'text/css') return transformStylesheet(data); + if (detail.type === 'application/xhtml+xml') { + const ctx = { + bookKey, + viewSettings, + content: data, + }; + return Promise.resolve(transformContent(ctx)); + } + return data; + }) + .catch((e) => { + console.error(new Error(`Failed to load ${detail.name}`, { cause: e })); + return ''; + }); + }; + const docLoadHandler = (event: Event) => { const detail = (event as CustomEvent).detail; console.log('doc loaded:', detail); @@ -154,6 +177,9 @@ const FoliateViewer: React.FC<{ viewRef.current = view; setFoliateView(bookKey, view); + const { book } = view; + + book.transformTarget?.addEventListener('data', docTransformHandler); view.renderer.setStyles?.(getStyles(viewSettings)); const isScrolled = viewSettings.scrolled!; diff --git a/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx b/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx index dae404ec..0aaddad2 100644 --- a/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx +++ b/apps/readest-app/src/app/reader/components/settings/LayoutPanel.tsx @@ -122,7 +122,9 @@ const LayoutPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => { if (writingMode === viewSettings.writingMode) return; // global settings are not supported for writing mode const prevWritingMode = viewSettings.writingMode; - if (!writingMode.includes('vertical')) { + if (writingMode.includes('vertical')) { + viewSettings.vertical = true; + } else { viewSettings.vertical = false; } saveViewSettings(envConfig, bookKey, 'writingMode', writingMode, true); diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index a456d7e0..b4ec878f 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -66,6 +66,7 @@ export interface BookDoc { dir: string; toc?: Array; sections?: Array; + transformTarget?: EventTarget; splitTOCHref(href: string): Array; getCover(): Promise; } diff --git a/apps/readest-app/src/services/transformService.ts b/apps/readest-app/src/services/transformService.ts new file mode 100644 index 00000000..0db64743 --- /dev/null +++ b/apps/readest-app/src/services/transformService.ts @@ -0,0 +1,16 @@ +import { activeTransformers } from './transformers'; +import { TransformContext } from './transformers/types'; + +export const transformContent = async (ctx: TransformContext): Promise => { + let transformed = ctx.content; + + for (const transformer of activeTransformers) { + try { + transformed = await transformer.transform({ ...ctx, content: transformed }); + } catch (error) { + console.warn(`Error in transformer ${transformer.name}:`, error); + } + } + + return transformed; +}; diff --git a/apps/readest-app/src/services/transformers/index.ts b/apps/readest-app/src/services/transformers/index.ts new file mode 100644 index 00000000..c7da70c2 --- /dev/null +++ b/apps/readest-app/src/services/transformers/index.ts @@ -0,0 +1,9 @@ +import type { Transformer } from './types'; +import { translateTransformer } from './translate'; +import { punctuationTransformer } from './punctuation'; + +export const activeTransformers: Transformer[] = [ + punctuationTransformer, + translateTransformer, + // Add more transformers here +]; diff --git a/apps/readest-app/src/services/transformers/punctuation.ts b/apps/readest-app/src/services/transformers/punctuation.ts new file mode 100644 index 00000000..ea07aa2d --- /dev/null +++ b/apps/readest-app/src/services/transformers/punctuation.ts @@ -0,0 +1,41 @@ +import type { Transformer } from './types'; + +const punctuationMap: Record = { + ',': '︐', // full-width comma + '。': '︒', // full stop + '、': '︑', // enumeration comma + ':': '︓', // colon + ';': '︔', // semicolon + '!': '︕', // exclamation + '?': '︖', // question + '(': '︵', + ')': '︶', + '《': '︽', + '》': '︾', + '「': '﹁', + '」': '﹂', + '『': '﹃', + '』': '﹄', + '“': '﹁', + '”': '﹂', + '‘': '﹃', + '’': '﹄', +}; + +export const punctuationTransformer: Transformer = { + name: 'punctuation', + + transform: async (ctx) => { + if (!ctx.content.includes(' { + return ctx.content; + }, +}; diff --git a/apps/readest-app/src/services/transformers/types.ts b/apps/readest-app/src/services/transformers/types.ts new file mode 100644 index 00000000..5cc1cbda --- /dev/null +++ b/apps/readest-app/src/services/transformers/types.ts @@ -0,0 +1,12 @@ +import { ViewSettings } from '@/types/book'; + +export type TransformContext = { + bookKey: string; + viewSettings: ViewSettings; + content: string; +}; + +export type Transformer = { + name: string; + transform: (ctx: TransformContext) => Promise; +}; diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 41ab62a8..af17cbc3 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -207,12 +207,11 @@ const getLayoutStyles = ( background-color: transparent !important; } p, li, blockquote, dd { - margin: ${vertical ? `0 ${paragraphMargin}em` : `${paragraphMargin}em 0`} ${overrideLayout ? '!important' : ''}; line-height: ${lineSpacing} ${overrideLayout ? '!important' : ''}; word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''}; letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''}; - text-indent: ${textIndent}em ${overrideLayout ? '!important' : ''}; - text-align: ${overrideLayout ? 'var(--default-text-align) !important' : 'inherit'}; + text-indent: ${vertical ? textIndent * 1.2 : textIndent}em; + text-align: ${overrideLayout ? 'var(--default-text-align)' : 'inherit'}; -webkit-hyphens: ${hyphenate ? 'auto' : 'manual'}; hyphens: ${hyphenate ? 'auto' : 'manual'}; -webkit-hyphenate-limit-before: 3; @@ -221,6 +220,12 @@ const getLayoutStyles = ( hanging-punctuation: allow-end last; widows: 2; } + p { + ${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} + ${vertical ? `margin-right: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} + ${!vertical ? `margin-top: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} + ${!vertical ? `margin-bottom: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''} + } li, p:has(> :is(img, video, font, h1, h2, h3, h4, h5, table)) { text-indent: 0 !important; } @@ -240,6 +245,14 @@ const getLayoutStyles = ( display: none; } + img.pi { + ${vertical ? 'transform: rotate(90deg);' : ''} + ${vertical ? 'transform-origin: center;' : ''} + ${vertical ? 'height: 2em;' : ''} + ${vertical ? `width: ${lineSpacing}em;` : ''} + ${vertical ? `vertical-align: unset;` : ''} + } + .duokan-footnote-content, .duokan-footnote-item { display: none; @@ -384,3 +397,20 @@ export const mountAdditionalFonts = (document: Document) => { style.textContent = getAdditionalFontFaces(); document.head.appendChild(style); }; + +export const transformStylesheet = (css: string) => { + // replace absolute font sizes with rem units + // replace hardcoded colors + return css + .replace(/font-size\s*:\s*xx-small/gi, 'font-size: 0.6rem') + .replace(/font-size\s*:\s*x-small/gi, 'font-size: 0.75rem') + .replace(/font-size\s*:\s*small/gi, 'font-size: 0.875rem') + .replace(/font-size\s*:\s*medium/gi, 'font-size: 1rem') + .replace(/font-size\s*:\s*large/gi, 'font-size: 1.2rem') + .replace(/font-size\s*:\s*x-large/gi, 'font-size: 1.5rem') + .replace(/font-size\s*:\s*xx-large/gi, 'font-size: 2rem') + .replace(/font-size\s*:\s*xxx-large/gi, 'font-size: 3rem') + .replace(/\scolor\s*:\s*#000000/gi, 'color: var(--theme-fg-color)') + .replace(/\scolor\s*:\s*#000/gi, 'color: var(--theme-fg-color)') + .replace(/\scolor\s*:\s*rgb\(0,\s*0,\s*0\)/gi, 'color: var(--theme-fg-color)'); +}; diff --git a/packages/foliate-js b/packages/foliate-js index 605246a3..be37c6c0 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 605246a3753e2677de3a4104f6cc8e8edff5c19a +Subproject commit be37c6c03b286630e50b86e4e158b00bb388a9cf