mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的 测试或量出来的数字,再改。 ## 2. txt 没有处理就渲染,没有默认样式,也无法调整 三层都缺,所以修了三层。 服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段 (`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以 句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。 章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是 `chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增 `format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果—— 那个截断会把长章节切一半。 客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域 `.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用 `2em` 而不是 `2rem`:缩进要跟字号缩放。 阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项 是写在 stage 上的自定义属性,所以调整不需要重新注入章节。 ## 3. 翻页异常,往前翻页还会来回跳 `previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用 `scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。 这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是 来回跳。 改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight - clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因—— 2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被 夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。 `stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在 `previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。 ## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁 两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome 树,滚动时每帧三次;合并成一次。 `flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就 一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完 最后一页就关书,那一页永远发不出去。 ## 5/6. 布局样式,以及目录/设置改半屏 面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、 调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。 状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去 一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。 页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成 「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来 而不是静静地没反应。 ## 7. 点击页面的事件处理层次 一次触摸只属于一个目标,最里面那个赢。两个真缺陷: - 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里, shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出 「段落」和「同一个 shadow 树里的按钮」的区别。 - **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动 的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远 到不了。这是「点击页面没反应」的真正原因。 另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。 面板打开时它拥有点击:旁边露出的那一条正文不翻页。 ## 8. 端到端测试要做 UI 评审 新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**, 对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷), 尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示 不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review` 流水线,只在界面相关文件变化时触发。 这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged` 用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入 loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏; 以及上面那条阅读面吃掉手势。 ## 测试 web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、 `reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、 `mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、 转义、标题提升、不截断)。
128 lines
6.4 KiB
TypeScript
128 lines
6.4 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('marks the payload when no indent was applied', () => {
|
|
const html = renderChapterHtml('一段', { indent: false }).toString('utf8');
|
|
assert.match(html, /data-indent="none"/);
|
|
// The default carries no attribute, so the stylesheet's own indent applies.
|
|
assert.doesNotMatch(renderChapterHtml('一段').toString('utf8'), /data-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'));
|
|
});
|
|
});
|