mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
现状问题。TXT 的段落和缩进原本由服务端决定:`chapter-html:<n>` 把章节渲染成 带 `<p>` 的片段,客户端只是把它塞进 shadow root。于是三件事同时不对。一是缩进 设定只对服务端已经渲染好的那份生效,windowed 路径拿到的 `chapter:<n>` 是纯字符 没有段落,同一个读者同一本书会因为走哪条传输而长得不一样。二是抓取来的文件里 用来当缩进的段前空格被原样留下,和读者的缩进叠加,恰好在最需要这个设定的文件 上看起来像没生效。三是这些设定是每台设备各自的阅读偏好,烧进响应就等于把每次 调整变成一次往返。另一件事是顶栏和底栏可以各自隐藏,读者收起导航后底栏仍压着 每一页的最后两行。 方案依据。把「一段是什么」收敛到客户端的唯一定义(`formats/segments.ts`), 本地加载、windowed、流式三条路都走它,服务端的职责缩小为交出完整章节和在原文 上断段。`ReaderView` 在渲染前对纯文本章节再过一遍这个拆分(按块读回、拼回空行 再拆,所以服务端断得对不对不影响观感),缩进、段间距、去掉段前空格因此无需请求 即可作用于两条路径。顶栏与前底栏由 `.reader-screen` 上的一个 `data-chrome` 统一 开关,收起时两条栏一起归零高度并滑出、正文长满整屏,同时右侧浮出一列快捷按钮, 否则收起工具栏会变成「必须离开才能操作」的状态。 放弃方案。把服务端的 `chapter-html` 直接删掉:`href` 同时是阅读位置的标识,且 `format: 'html'` 字段已是既有契约,删掉会让旧客户端与旧位置一起失效,所以保留 作为传输,只去掉它承担的排版职责。让缩进靠服务端属性(`data-indent`)继续驱动: 那正是「设定是响应的属性」这一问题的根源。收起态用两个独立开关:写法上就能只藏 一半,而这恰是读者报的那个 bug。 改动点。新增 `web/src/formats/segments.ts`(分段、去段前空格、HTML/节点两种形 式、跨 chunk 的 carry 判定);`reader-view.ts` 增加 `retypePlainText` 并去掉旧 排版表里读服务端属性的规则;`render/text.ts` 按 `carry` 拼接跨块段落;服务端 `text-html.ts` 去掉 `indent` 选项、不再截断;`reader-chrome.tsx` 增加侧边快捷列 与设置面板顶部的「快捷」一行;`reader.css` 增加两条栏的收放与按钮列样式;图标 集新增 `text-indent` / `text-type` / `spacing`;`docs/ui.md` 补 §3.7 的边界说明 与 §3.8 沉浸式阅读。 验证方式。`cd web && npm test`(369 条)、`cd server && npm test`(205 条)全绿; `npm run build` 通过;`npm run ui:review` 全绿,并新增「收起工具栏: 顶栏与底栏一 起走 / 都不|不可见 / 正文占满整屏 / 右侧仍有快捷按钮」四条尺寸断言(截图见 `docs/ui-review/03-reader-no-chrome.png`),评审夹具改用全角空格缩进,使「段前 空格被去掉」在截图上可见。 Ref: #34
133 lines
6.7 KiB
TypeScript
133 lines
6.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test, describe } from 'node:test';
|
|
import { escapeHtml, renderChapterHtml, splitParagraphs } from '../src/indexer/formats/text-html.ts';
|
|
|
|
/**
|
|
* The paragraph split is where every interesting failure of the TXT rendition
|
|
* lives, so it gets the tests. The escaping and the wrapping around it are
|
|
* mechanical.
|
|
*/
|
|
describe('splitParagraphs', () => {
|
|
test('a blank line separates paragraphs', () => {
|
|
assert.deepEqual(splitParagraphs('第一段\n\n第二段'), ['第一段', '第二段']);
|
|
});
|
|
|
|
test('a single newline inside a paragraph is a soft wrap, not a break', () => {
|
|
// The common shape in a scraped novel: the source wraps a paragraph across
|
|
// two lines. Splitting there would truncate the first half into its own
|
|
// paragraph and indent a continuation.
|
|
assert.deepEqual(splitParagraphs('这句被硬换行了\n但仍然是同一段'), ['这句被硬换行了但仍然是同一段']);
|
|
});
|
|
|
|
test('a line-ending sentence starts a new paragraph without a blank line', () => {
|
|
// The other common shape: every paragraph on its own line, no blank lines at
|
|
// all. Without this rule such a file renders as one slab — which is exactly
|
|
// the bug this rendition exists to fix.
|
|
assert.deepEqual(
|
|
splitParagraphs('他走了。\n她留下了。\n天亮了。'),
|
|
['他走了。', '她留下了。', '天亮了。'],
|
|
);
|
|
});
|
|
|
|
test('does not split a continuation line that merely starts with punctuation', () => {
|
|
// The lookahead requires the next line to start a sentence. A line beginning
|
|
// with a comma, a closing quote or an ellipsis is a continuation of the one
|
|
// above and must stay joined.
|
|
assert.deepEqual(splitParagraphs('他走了。\n,她没有回头。'), ['他走了。,她没有回头。']);
|
|
assert.deepEqual(splitParagraphs('他走了。\n」她说。'), ['他走了。」她说。']);
|
|
});
|
|
|
|
test('handles CRLF and lone CR line endings', () => {
|
|
// A lone CR is a line ending, not a character inside a word: treated as one,
|
|
// a file off a serial console or an old conversion is a single paragraph.
|
|
assert.deepEqual(splitParagraphs('一段\r\n\r\n二段'), ['一段', '二段']);
|
|
assert.deepEqual(splitParagraphs('他走了。\r她留下了。'), ['他走了。', '她留下了。']);
|
|
});
|
|
|
|
test('drops empty paragraphs rather than rendering blank blocks', () => {
|
|
assert.deepEqual(splitParagraphs('\n\n\n\n一段\n\n\n'), ['一段']);
|
|
assert.deepEqual(splitParagraphs(' \n\n '), []);
|
|
});
|
|
|
|
test('an empty body produces no paragraphs', () => {
|
|
assert.deepEqual(splitParagraphs(''), []);
|
|
});
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
test('escapes the characters that would otherwise rewrite the page', () => {
|
|
assert.equal(escapeHtml('<script>alert(1)</script>'), '<script>alert(1)</script>');
|
|
assert.equal(escapeHtml('a & b'), 'a & b');
|
|
assert.equal(escapeHtml(`"'`), '"'');
|
|
});
|
|
});
|
|
|
|
describe('renderChapterHtml', () => {
|
|
test('wraps paragraphs in a .txt-body div so the client can scope its rules', () => {
|
|
const html = renderChapterHtml('第一段\n\n第二段').toString('utf8');
|
|
assert.match(html, /^<div class="txt-body">\n/);
|
|
assert.equal(html.match(/<p>/g)?.length, 2);
|
|
assert.match(html, /<p>第一段<\/p>/);
|
|
assert.match(html, /<p>第二段<\/p>/);
|
|
});
|
|
|
|
test('carries no typography, so the readers own settings are what apply', () => {
|
|
// The indent, the paragraph spacing and the removal of a scraper's leading
|
|
// spaces are per-device reading preferences, and a server that baked one of
|
|
// them into the payload is what made them a round trip — and what made a book
|
|
// read windowed look different from the same book read whole. The assertion is
|
|
// on the absence, because absence is the contract.
|
|
const html = renderChapterHtml('一段').toString('utf8');
|
|
assert.doesNotMatch(html, /data-indent/);
|
|
assert.doesNotMatch(html, /style=/);
|
|
assert.doesNotMatch(html, /text-indent/);
|
|
});
|
|
|
|
test('a file containing markup cannot inject elements', () => {
|
|
const html = renderChapterHtml(['<img src=x onerror=alert(1)>', '', '<b>粗</b>'].join('\n')).toString('utf8');
|
|
// The characters survive as *text*; what must not survive is an element or an
|
|
// attribute, so the assertion is on the markup rather than on the words — an
|
|
// escaped `onerror` is a paragraph, not a handler.
|
|
assert.doesNotMatch(html, /<img/);
|
|
assert.doesNotMatch(html, /<b>/);
|
|
assert.match(html, /<img src=x onerror=alert\(1\)>/);
|
|
assert.match(html, /<b>粗<\/b>/);
|
|
});
|
|
|
|
test('a chapter is rendered whole, with no truncation', () => {
|
|
// `chapter:` sliced the body at 256KB, which cut a long chapter in half.
|
|
// Stated as a test so the cap cannot come back by accident.
|
|
// Comfortably past the 256KB *byte* budget the plain-text path used: Chinese
|
|
// is three bytes per character, so the character count that overflowed it is
|
|
// under 90000. A chapter this size is unusual but real — web novels ship them.
|
|
const body = Array.from({ length: 40_000 }, (_, i) => `第 ${i} 段的内容。`).join('\n');
|
|
assert.ok(body.length > 256 * 1024 / 3, 'fixture must exceed the old character budget');
|
|
const html = renderChapterHtml(body).toString('utf8');
|
|
assert.match(html, /<p>第 39999 段的内容。<\/p>/);
|
|
});
|
|
|
|
test('promotes the chapter heading instead of gluing it to the first sentence', () => {
|
|
// `splitChapters` puts the heading in the chapter's own slice, so without this
|
|
// the body reads `第二章 落雨雨来了。` — the title and the first line as one
|
|
// paragraph, which is what a reader reports as "格式乱了".
|
|
const html = renderChapterHtml(['第二章 落雨', '雨来了。'].join('\n')).toString('utf8');
|
|
assert.match(html, /<h3>第二章 落雨<\/h3>/);
|
|
assert.match(html, /<p>雨来了。<\/p>/);
|
|
assert.doesNotMatch(html, /落雨雨来了/);
|
|
});
|
|
|
|
test('leaves prose that merely looks like a heading where it is', () => {
|
|
// Only the first line is considered, and only when it is short enough to be a
|
|
// title: a mid-chapter line that mentions a chapter number is a sentence.
|
|
const html = renderChapterHtml(['雨来了。', '他说第二章他看过。'].join('\n')).toString('utf8');
|
|
assert.doesNotMatch(html, /<h3>/);
|
|
assert.match(html, /<p>他说第二章他看过。<\/p>/);
|
|
});
|
|
|
|
test('returns utf-8 bytes whose length matches the payload it reports', () => {
|
|
const buffer = renderChapterHtml('中文内容\n\n第二段');
|
|
assert.equal(buffer.toString('utf8').length <= buffer.byteLength, true);
|
|
assert.equal(buffer.byteLength, Buffer.byteLength(buffer.toString('utf8'), 'utf8'));
|
|
});
|
|
});
|