mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
fix(web): typeset book-source HTML chapters like TXT
Source chapters are scraped fragments (`<div> text<br><br><br><br>`) served as text/html, so the TXT cleanup never ran: every paragraph was followed by three empty lines and the indent setting did nothing. Like legado-E, a text-only chapter of a `chapters` book is read back as characters when it is loaded (htmlToText) and typeset by the TXT path. Done at read time, so chapters the server cached earlier are cleaned too; chapters with images or tables keep their markup and EPUBs never take this path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
52b757bbd3
commit
60d4b41bf1
5 files changed
+77
-13
No files matched your search
@@ -604,6 +604,10 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
(`br`/`p`/`div`/标题/列表项)换成换行、行内标签(`span`/`font`/`b`…)去掉、
|
||||
常见实体解码、只有空白的行算空行;只认真实的标签名,正文里的 `a<b`、`<书名>`
|
||||
不动,其余标签照旧转义成文字。
|
||||
书源(`format: 'chapters'`)的 `text/html` 章节是抓下来的网页片段(`<div>…<br><br><br>`)。
|
||||
和 legado-E 一样,只含文字和换行的章节在读取时(`readStagedSection`)用 `htmlToText`
|
||||
读回成字符、标成纯文本,走同一套分段和缩进;含图片、表格、媒体的章节保留标记。
|
||||
在读取时处理,所以服务端早先缓存的章节也会被清洗;EPUB 不会进入这条路径。
|
||||
`ReaderView` 在 `retypePlainText` 里对拿到的章节过这一段逻辑;如果内容带着
|
||||
历史的 `.txt-body` 标记(旧服务端、旧缓存),就按块读回文本再拆,所以旧
|
||||
响应不会被当成新响应渲染。
|
||||
|
||||
@@ -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 = /<!--[\s\S]*?-->/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 `<b>` 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,}/;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (`<div>…<br><br><br>`), 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 {
|
||||
|
||||
@@ -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 = '<div> 天斗星作为第一颗行政星。<br /><br /><br /><br /> 星球表面本来是没有生物的。<br /><br /></div>';
|
||||
|
||||
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('<p>a<b><custom-tag x>c</custom-tag><!-- ad --><script>bad()</script></p>')).toBe('\na<b>c\n');
|
||||
});
|
||||
|
||||
it('keeps chapters with images or tables as markup', () => {
|
||||
expect(isTextOnlyMarkup(fragment)).toBe(true);
|
||||
expect(isTextOnlyMarkup('<p>图</p><img src="x">')).toBe(false);
|
||||
expect(isTextOnlyMarkup('<table><tr><td>1</td></tr></table>')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user