一个古籍阅读器不只是排版:ReadTao Reader 的响应式设计与状态模型
古籍阅读器不只是选一款宋体、把正文放到页面中间。ReadTao Reader 需要同时处理稳定发布数据、语义段落对齐、拼音词元、来源注文、桌面与移动布局、阅读偏好、继续阅读,以及与屏幕状态一致的导出和打印。
前四篇已经把 ReadTao 的内容链走到了公共读取 API:
最后一篇回到读者真正看到的页面。
Reader 的 Nuxt 页面、阅读状态、导出逻辑和样式都可以在 ReadTao GitHub 仓库 中查看。
阅读器面对的不是一段 HTML
如果只显示原文,一个 <article> 已经足够。
ReadTao 的一个已发布语义段落却可能同时包含:
- 繁体规范原文;
- 简体原文;
- 与字符偏移对应的拼音词元;
- 一条或多条来源注文;
- 现代汉语;
- English;
- 稳定
segment_id和anchor; - 来源、底本和发布版本信息。
这些内容不是几段松散文字,而是围绕同一个语义段落组织的不同层。
Reader 的任务是根据当前偏好选择和排列这些层,同时保持段落对应、地址稳定和移动端可读。
一条阅读路由怎样拿到发布内容
正式阅读地址由作品、底本和内容单元组成:
/read/{workSlug}/{editionSlug}/{divisionSlug}页面根据路由构造公共章节接口:
const endpoint = computed(() =>
`${apiBase}/api/public/works/` +
`${encodeURIComponent(workSlug.value)}/editions/` +
`${encodeURIComponent(editionSlug.value)}/chapters/` +
`${encodeURIComponent(divisionSlug.value)}`,
)返回的 PublishedChapter 不只包含当前章正文,还带有:
- 当前作品和底本;
- 发布号;
- 完整已发布目录;
- 上一篇和下一篇;
- 当前内容单元的全部
PublishedSegment。
Reader 不读取 Studio 工作区,也不会为了得到译文再发起一组分散请求。一次章节响应已经固定到同一个 Release。
为什么对照必须建立在 Segment 上
桌面阅读支持上下对照和左右对照。
| 左右对照阅读 | 上下对照阅读 |
|---|---|
![]() | ![]() |
如果原文和译文只是两个独立长数组,一处段落拆分变化就会让左右关系全部错位。
ReadTao 的每个 PublishedSegment 已经固定原文、派生层和来源注文,所以页面按段落循环:
<section
v-for="segment in chapter.segments"
:id="segment.anchor"
:key="segment.id"
class="text-segment"
>
<div class="original-block">...</div>
<div class="translation">...</div>
</section>左右布局只是让同一个段落内部变成两列,而不是把全章原文和全章译文拆成两个互不相关的面板。
这样即使某段注文很长,也只影响它所在段落的高度,不会让后面的所有原译对应关系逐渐漂移。
阅读状态不能靠按钮互相改写
Reader 当前主要阅读偏好包括:
interface ReadingPreferences {
scriptMode: 'traditional' | 'simplified'
translationMode: 'compare' | 'original' | 'translation'
translationLanguage: 'modern_zh' | 'en'
layout: 'flow' | 'split'
theme: 'paper' | 'dark' | 'system'
lineHeight: 'compact' | 'standard' | 'relaxed'
fontSize: number
showPinyin: boolean
}这些状态彼此独立:
- 显示模式决定原文和译文是否可见;
- 繁简只影响原文;
- 拼音只附属于原文;
- 界面语言选择现代汉语或英文译文;
- 布局只在对照模式下生效;
- 字号和行距不应该重置其他偏好。
一个常见但危险的写法是:切换“仅译文”时顺手把 showPinyin 设为 false,切回对照以后用户原来的偏好就丢了。
ReadTao 使用派生状态决定哪些控制暂时无效:
export function readingModeState(mode: TranslationMode) {
return {
showOriginal: mode !== 'translation',
showTranslation: mode !== 'original',
languageEnabled: mode !== 'original',
originalControlsEnabled: mode !== 'translation',
layoutEnabled: mode === 'compare',
}
}控件可以禁用,偏好不被清空。
偏好怎样跨页面保存
Reader 使用 Nuxt useState 保存当前响应式状态,并在客户端挂载后从 localStorage 恢复:
export const READING_PREFERENCES_KEY =
'readtao:reading-preferences:v1'
onMounted(() => {
preferences.value = normalizeReadingPreferences(
JSON.parse(localStorage.getItem(READING_PREFERENCES_KEY) ?? 'null'),
)
})读取本地数据时不能直接相信旧值。normalizeReadingPreferences 会:
- 过滤不属于当前枚举的值;
- 给缺失字段补默认值;
- 把字号限制在 20 到 36;
- JSON 损坏时恢复默认配置。
这样以后增加字段或升级偏好结构时,旧浏览器数据不会直接让页面崩溃。
全站配色、日夜模式和界面语言的写入口只放在首页;书库、问道、账号和阅读器消费已经保存的状态,不在每个页面重复一套全局控制。阅读器顶栏只保留目录、显示、繁简、导出和阅读设置。
桌面和移动端不是同一布局等比缩小
Reader 把桌面和手机都当成正式体验,但不会要求它们使用完全相同的布局。
桌面宽屏:
- 64px 固定顶栏;
- 多内容单元典籍默认展开 272px 目录侧栏;
- 单列正文最大约 780px;
- 左右对照最大约 1180px;
- 显示、导出和设置使用顶栏锚定弹层。
移动端:
- 保持 64px 单行顶栏;
- 目录变为覆盖式抽屉;
- 显示、导出和设置变成底部面板;
- 对照阅读强制单列;
- 不显示不可操作的左右布局选项;
- 所有主要触控区域至少为
40×40px。
移动端强制单列只是渲染规则,不会把用户在桌面保存的 layout: split 改成 flow。回到宽屏以后,原来的左右偏好仍然有效。
CSS 中这道边界是明确的:
.reading-pane {
width: min(780px, calc(100% - 64px));
}
.reading-pane.split-reading {
width: min(1180px, calc(100% - 64px));
}
@media (max-width: 960px) {
.segment-split {
display: block;
}
.compare-layout-choice {
display: none;
}
}拼音为什么使用 ruby/rt
拼音不是一串和原文长度碰巧相等的文本。多音节、标点、生僻字和人工覆盖都会影响对应关系。
Backend 发布的是带字符偏移的 PronunciationToken,Reader 再把它映射成阅读单元:
export function buildReadingPieces(displayText, tokens) {
const tokensByOffset = new Map(
tokens.map(token => [token.start_offset, token]),
)
return Array.from(displayText).map((character, offset) => {
const token = tokensByOffset.get(offset)
return token
? { text: character, reading: token.pinyin }
: { text: character }
})
}模板使用语义化 ruby/rt:
<ruby v-if="piece.reading">
<span class="ruby-base">{{ piece.text }}</span>
<rt>{{ piece.reading }}</rt>
</ruby>CSS 使用内容驱动宽度,让一个单元至少容纳完整音节和汉字中较宽的一方,而不是把拼音绝对定位到字的上方。
.ruby-line ruby {
display: inline-grid;
grid-template-areas: "reading" "base";
grid-template-columns: max-content;
min-width: 1em;
}这能减少长音节覆盖相邻字符的问题,也让字号变化时拼音跟随正文一起缩放。
来源注文怎样进入阅读层
Reader 只渲染 Release 固定的已批准来源注文。
桌面左右对照时,注文属于原文侧;移动单列时,顺序是:
原文 → 来源注文 → 译文无注文典籍不会显示空标题、空卡片或“暂无注释”。这是内容类型本身的差异,不是数据不完整。
注文和原文使用相同的古籍字体体系,但通过字号、颜色和间距区分。右侧译文只与正文对应,不为来源注文生成一份看似完整、实际不存在的翻译。
稳定锚点和继续阅读分别解决什么
每个段落容器直接使用发布数据中的稳定锚点:
<section :id="segment.anchor" class="text-segment">这样搜索结果、问道引用、导出文件和分享链接可以指向同一个段落身份。
继续阅读则是另一种状态。当前版本先把最近阅读位置保存在浏览器:
readtao:last-reading:v1它记录作品、底本、内容单元、路由和滚动位置。重新打开同一路由且 URL 没有显式锚点时,页面恢复上次滚动位置;如果链接已经带有 #anchor,则优先尊重分享目标。
一个是可以公开引用的内容身份,一个是用户本地的临时进度,两者不能混成同一个字段。
导出应该复用阅读语义,而不是截图页面
Reader 支持:
- 当前章或整部;
- Markdown 或 TXT;
- 浏览器打印和保存 PDF。
当前章导出直接复用已经加载的 PublishedChapter。只有选择整书时,才请求 PublishedBook,避免每次进入阅读页都下载整部内容。
Markdown/TXT 使用线性语义顺序,不会把屏幕上的左右两列硬编码进文件。导出内容会带有:
- 作品和底本;
- 发布号;
- 来源、提交和许可;
- 当前阅读模式;
- 稳定段落锚点。
打印预览才会应用当前上下或左右布局,并通过 @media print 隐藏工具栏。
阅读界面的可靠性也需要测试
Reader 的验收不能只看我自己的桌面浏览器。
当前约定至少覆盖:
- 手机:
360×800、390×844、430×932; - 平板:
768×1024; - 桌面:
1280×800、1440×900、1920×1080。
检查重点不是每个像素完全相同,而是:
- 有没有横向滚动和内容遮挡;
- 顶栏能否保持单行;
- 目录、弹层和底部面板能否关闭并返回焦点;
- 左右对照是否按语义段落对应;
- 显示模式切换是否丢失其他偏好;
- 稳定锚点、全书导出和打印是否工作;
- 接口失败、无发布版本和缺少内容层是否有明确状态。
仓库里保留了 Vitest、Backend pytest、OpenAPI 检查和 Playwright 浏览器测试。发布前的完整本地检查入口包括:
pnpm typecheck
pnpm test
pnpm build
pnpm api:check
pnpm test:e2e自动测试之外,我仍然需要在真实桌面和手机尺寸检查字体、拼音、长文本和滚动。这些是 DOM 正确却仍可能阅读不舒服的地方。
这一组文章最后回到了“读”
写完这五篇再回头看,ReadTao 的链路并不复杂到需要大量微服务,但它有几条我不想放松的边界:
- 原始来源不能在清洗中消失;
- 正文、来源注文和 AI 派生内容不能混淆;
- 语义段落必须能够回到来源坐标;
- 模型只能产生初稿,任务失败必须可见、可重试;
- 发布快照不可原地覆盖;
- Reader 只读取已发布内容;
- 桌面和手机都要真正可读;
- AI 服务退出以后,古籍仍然存在。
技术最终还是要回到产品目标:让我可以用很长的时间慢慢整理内容,也让读者在任何时候打开页面,都能读到一份来源和版本说得清楚的文本。
完整实现、演示数据、运行命令和验证记录都在 ReadTao GitHub 仓库。

