feat: replace punctuation marks in vertical reading mode (#754)

This commit is contained in:
Huang Xin
2025-03-29 19:42:23 +08:00
committed by GitHub
parent a66786444f
commit 4acc0d8e12
10 changed files with 152 additions and 6 deletions
@@ -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!;
@@ -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);
+1
View File
@@ -66,6 +66,7 @@ export interface BookDoc {
dir: string;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
transformTarget?: EventTarget;
splitTOCHref(href: string): Array<string | number>;
getCover(): Promise<Blob | null>;
}
@@ -0,0 +1,16 @@
import { activeTransformers } from './transformers';
import { TransformContext } from './transformers/types';
export const transformContent = async (ctx: TransformContext): Promise<string> => {
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;
};
@@ -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
];
@@ -0,0 +1,41 @@
import type { Transformer } from './types';
const punctuationMap: Record<string, string> = {
'': '︐', // full-width comma
'。': '︒', // full stop
'、': '︑', // enumeration comma
'': '︓', // colon
'': '︔', // semicolon
'': '︕', // exclamation
'': '︖', // question
'': '︵',
'': '︶',
'《': '︽',
'》': '︾',
'「': '﹁',
'」': '﹂',
'『': '﹃',
'』': '﹄',
'“': '﹁',
'”': '﹂',
'': '﹃',
'': '﹄',
};
export const punctuationTransformer: Transformer = {
name: 'punctuation',
transform: async (ctx) => {
if (!ctx.content.includes('<html')) return ctx.content;
const shouldTransform = ctx.viewSettings.vertical === true;
if (!shouldTransform) return ctx.content;
let result = ctx.content;
for (const [original, vertical] of Object.entries(punctuationMap)) {
result = result.replace(new RegExp(original, 'g'), vertical);
}
return result;
},
};
@@ -0,0 +1,9 @@
import type { Transformer } from './types';
export const translateTransformer: Transformer = {
name: 'translate',
transform: async (ctx) => {
return ctx.content;
},
};
@@ -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<string>;
};
+33 -3
View File
@@ -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)');
};