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:
hectorqinandClaude Opus 5.5 committed 2026-10-29 06:20:27 +00:00
1 parent 52b757bbd3
commit 60d4b41bf1
5 files changed
+77 -13

No files matched your search

+4
View File
@@ -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` 标记(旧服务端、旧缓存),就按块读回文本再拆,所以旧
响应不会被当成新响应渲染。
+36 -9
View File
@@ -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 `&lt;b&gt;`
* 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,}/;
+4 -1
View File
@@ -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 {
+12 -3
View File
@@ -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 {
+21
View File
@@ -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&lt;b&gt;<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);
});
});