diff --git a/docs/ui.md b/docs/ui.md index a5e4bd5..e70efa7 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -604,6 +604,10 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「 (`br`/`p`/`div`/标题/列表项)换成换行、行内标签(`span`/`font`/`b`…)去掉、 常见实体解码、只有空白的行算空行;只认真实的标签名,正文里的 `a` 不动,其余标签照旧转义成文字。 + 书源(`format: 'chapters'`)的 `text/html` 章节是抓下来的网页片段(`
…


`)。 + 和 legado-E 一样,只含文字和换行的章节在读取时(`readStagedSection`)用 `htmlToText` + 读回成字符、标成纯文本,走同一套分段和缩进;含图片、表格、媒体的章节保留标记。 + 在读取时处理,所以服务端早先缓存的章节也会被清洗;EPUB 不会进入这条路径。 `ReaderView` 在 `retypePlainText` 里对拿到的章节过这一段逻辑;如果内容带着 历史的 `.txt-body` 标记(旧服务端、旧缓存),就按块读回文本再拆,所以旧 响应不会被当成新响应渲染。 diff --git a/web/src/formats/segments.ts b/web/src/formats/segments.ts index f1da71b..1afe658 100644 --- a/web/src/formats/segments.ts +++ b/web/src/formats/segments.ts @@ -71,18 +71,45 @@ const WHITESPACE_LINE = /^[ \t\u3000\u00a0]+$/gm; * consumer escapes it, so nothing here can turn into markup. */ export function cleanTextMarkup(text: string): string { - return text - .replace(LINE_ENDINGS, '\n') - .replace(BLOCK_TAG, '\n') - .replace(INLINE_TAG, '') - .replace(ENTITY, (all, decimal: string | undefined, hex: string | undefined, name: string | undefined) => { - if (name) return NAMED_ENTITIES[name.toLowerCase()] ?? all; - const code = decimal ? Number(decimal) : Number.parseInt(hex!, 16); - return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all; - }) + return decodeEntities(text.replace(LINE_ENDINGS, '\n').replace(BLOCK_TAG, '\n').replace(INLINE_TAG, '')) .replace(WHITESPACE_LINE, ''); } +function decodeEntities(text: string): string { + return text.replace(ENTITY, (all, decimal: string | undefined, hex: string | undefined, name: string | undefined) => { + if (name) return NAMED_ENTITIES[name.toLowerCase()] ?? all; + const code = decimal ? Number(decimal) : Number.parseInt(hex!, 16); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : all; + }); +} + +/** Elements a chapter cannot lose by being read as text. */ +const RICH_MARKUP = /<(?:img|svg|table|video|audio|iframe|math|canvas|picture|object|embed|ruby)\b/i; +const COMMENT = //g; +const HIDDEN_BLOCK = /<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi; +const ANY_TAG = /<\/?[a-zA-Z][^<>]*>/g; + +/** + * Whether a book source's HTML chapter is only text with line-level markup, so it + * can be typeset like a TXT without losing anything (no images, tables or media). + */ +export function isTextOnlyMarkup(html: string): boolean { + return !RICH_MARKUP.test(html); +} + +/** + * A book source's HTML chapter as characters, the way legado-E reads it + * (`HtmlFormatter.format`): block tags and line breaks become newlines and every + * other tag is removed. Unlike {@link cleanTextMarkup} the input *is* markup, so + * no tag survives as text; entities are decoded last, so an escaped `<b>` + * in the prose becomes the characters `` rather than being stripped. + */ +export function htmlToText(html: string): string { + const stripped = html.replace(LINE_ENDINGS, '\n').replace(COMMENT, '').replace(HIDDEN_BLOCK, '') + .replace(BLOCK_TAG, '\n').replace(ANY_TAG, ''); + return decodeEntities(stripped).replace(WHITESPACE_LINE, ''); +} + /** Paragraphs are separated by a blank line. */ const PARAGRAPH_BREAK = /\n{2,}/; diff --git a/web/src/formats/windowed.ts b/web/src/formats/windowed.ts index 9b0f24a..cf7fe94 100644 --- a/web/src/formats/windowed.ts +++ b/web/src/formats/windowed.ts @@ -42,7 +42,7 @@ import type { BookDoc, Section, StagedBook, TocEntry } from './types.ts'; */ export interface SectionLoader { /** Section markup for a reflowable section, or a page's bytes. */ - read(item: ContentItem): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array } }>; + read(item: ContentItem): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array }; plainText?: boolean }>; } /** @@ -173,6 +173,9 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc { const loaded = await options.loader.read(item); if (loaded.html !== undefined) section.html = loaded.html; if (loaded.image !== undefined) section.image = loaded.image; + // The loader read the body and found characters (a book source's text-only + // HTML chapter, converted): typeset it like a TXT, and do not parse it. + if (loaded.plainText) { section.plainText = true; section.bodyIsMarkup = false; } return section; }, setWindow(content: BookContent, spine: number): number { diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index abe6e4a..a67fed8 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -36,7 +36,7 @@ import { ReadingTools, type SearchSection } from './reading-tools.tsx'; import { decodeAnchor, encodeAnchor, searchableText, type TextAnchor } from './text-anchor.ts'; import { AnnotationPopover, type AnnotationTarget } from './annotation-popover.tsx'; import { newId } from '../core/id.ts'; -import { textToChapterHtml } from '../formats/segments.ts'; +import { htmlToText, isTextOnlyMarkup, textToChapterHtml } from '../formats/segments.ts'; import { parseLocator } from './locator.ts'; export interface ReaderScreenOptions { @@ -489,7 +489,7 @@ export class ReaderScreen { private async readStagedSection( book: Book, item: { href: string; kind: string; mediaType: string; format?: string; resourceRef?: string }, - ): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array } }> { + ): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array }; plainText?: boolean }> { const ref = renditionRef(item); // Keyed by the rendition, not just the section: a client that read the plain // text before this change and the markup after it must not serve the cached @@ -505,7 +505,16 @@ export class ReaderScreen { if (item.kind === 'page') { return { image: { mediaType: item.mediaType, bytes: new Uint8Array(await blob.arrayBuffer()) } }; } - return { html: await blob.text() }; + const body = await blob.text(); + // A book source's chapter is a scraped page fragment (`
…


`), not + // an authored document: like legado-E, read a text-only one back as characters so + // it gets the TXT paragraphs and the reader's indent. Applied when *read*, so + // chapters the server cached before this change are cleaned too. Chapters with + // images or tables keep their markup; EPUBs never reach this branch. + if (book.format === 'chapters' && item.kind === 'chapter' && /html/i.test(item.mediaType) && isTextOnlyMarkup(body)) { + return { html: htmlToText(body), plainText: true }; + } + return { html: body }; } dispose(): void { diff --git a/web/test/segments.test.ts b/web/test/segments.test.ts index 3dd474c..0a60beb 100644 --- a/web/test/segments.test.ts +++ b/web/test/segments.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { cleanTextMarkup, + htmlToText, + isTextOnlyMarkup, endsAtParagraphBreak, escapeHtml, paragraphElement, @@ -296,3 +298,22 @@ describe('web-page markup in a TXT', () => { expect(endsAtParagraphBreak('一段')).toBe(false); }); }); + +/** A book source's chapter is a scraped HTML fragment; legado-E reads it as text. */ +describe('book-source HTML chapters', () => { + const fragment = '
天斗星作为第一颗行政星。



星球表面本来是没有生物的。

'; + + it('reads a text-only fragment back into the paragraphs it meant', () => { + expect(splitTextParagraphs(htmlToText(fragment)).map((p) => p.text)).toEqual(['天斗星作为第一颗行政星。', '星球表面本来是没有生物的。']); + }); + + it('strips every tag but keeps escaped angle brackets as text', () => { + expect(htmlToText('

a<b>c

')).toBe('\nac\n'); + }); + + it('keeps chapters with images or tables as markup', () => { + expect(isTextOnlyMarkup(fragment)).toBe(true); + expect(isTextOnlyMarkup('

图

')).toBe(false); + expect(isTextOnlyMarkup('
1
')).toBe(false); + }); +});