介绍了章节内容分页处理的方式、排版思路(静态排版、动态排版)以及如何使用 Canvas API 或 DOM 测量实现动态分页。
之前的 demo 中,使用StPageFlip
库仿真渲染了章节文本内容,但是当时是一句话作为一页来显示的,显然不符合实际情况。
正常情况下,在滚动模式中,章节内容直接全部顺序渲染就行;
在翻页模式中,需要将章节内容手动进行分页,将分页后的内容进行渲染。
实现思路:
- 测量文本高度:根据阅读器可视区域尺寸(宽高)动态计算每页可容纳文本量
- 动态分页:根据容器高度自动分割文本内容到不同页面
- 排版处理:自动处理中英文混排、标点避头尾等排版规则
- 响应式布局:支持字体、字号、行间距等切换时的实时重新分页;考虑不同屏幕尺寸下的自适应布局
- 保持章节内容的完整性,避免跨章节分页
排版策略:
静态排版
:章节加载时一次性计算所有分页,翻页时直接读取缓存数据。只需预生成分页数据(每页内容区间、总页数)。动态排版
:根据任意字符所在位置作为起始坐标,逐字排版直到绘制完本页最后一个字为止。需动态计算文本渲染边界。
1. column 多栏布局
在电子书阅读器之翻页模式中介绍了平滑翻页
模式,其中使用了column 多栏布局
。但是只能支持滑动效果,不能支持"覆盖翻页"、"仿真翻页"效果。
排版效果如下所示:

2. canvas 绘制方案
动态分页:基于 Canvas API 实现,可以根据字号、行高、间距等配置动态分页文本内容。
使用 Canvas 的 measureText
方法计算文本尺寸,包括宽度、高度等,实现智能分页功能。
官方文档可查看:CanvasRenderingContext2D:measureText() 方法
-
- 计算内容区域每行最多渲染多少文字
-
- 通过行高、段间距计算每页可渲染行数
-
- 实现整章内容的分页计算
文本分页
参数配置:

分页代码:
jsx
// 分页+渲染逻辑
useEffect(() => {
const ctx = canvasRef.current.getContext("2d");
// 直接传入的
const { width, height } = canvasRef.current.getBoundingClientRect();
// 设置字体以确保measureText准确
ctx.font = `${fontSize}px Arial`;
// 计算可用区域和每页行数
const availableWidth = width - 2 * margin;
const availableHeight = height - margin;
// 存储分页结果
const pages: iPage[] = [];
let currentPage: iPage = { lines: [], height: 0, paragraphs: [] };
// 处理每个段落
content.forEach((paragraph, pIndex) => {
const text = paragraph.text || ""; // 段落文本
let start = 0; // 当前行的起始位置
let isFirstLineOfParagraph = true; // 当前行是否为段落的第一行
// 将段落拆分成多行
for (let i = 0; i <= text.length; i++) {
// 检查是否需要换行:当到达文本末尾或当前行宽度超过可用宽度时
if (
i === text.length ||
ctx.measureText(text.substring(start, i)).width > availableWidth
) {
const line = text.substring(start, i);
// paragraphSpacing为段落间的间距
const lineHeightToAdd =
isFirstLineOfParagraph && currentPage.lines.length > 0
? lineHeight + paragraphSpacing
: lineHeight;
// 分页检查(考虑安全高度)
if (
currentPage.lines.length > 0 &&
currentPage.height + lineHeightToAdd > availableHeight
) {
// 超过当前页,生成新页数据
pages.push(currentPage);
currentPage = { lines: [], height: 0, paragraphs: [] };
isFirstLineOfParagraph = true; // 新页面的第一行也是段落的第一行
}
// 添加行到当前页
currentPage.lines.push({
text: line,
isFirstLine: isFirstLineOfParagraph, // 每段第一行
paraId: paragraph.id,
paraIdx: pIndex,
});
// 更新当前页的高度
currentPage.height += lineHeightToAdd;
// 如果是新段落的开始,记录段落信息
if (isFirstLineOfParagraph) {
currentPage.paragraphs.push({
id: paragraph.id,
start: start,
end: i,
index: pIndex,
});
isFirstLineOfParagraph = false;
} else {
// 更新段落的结束位置
const lastParagraph =
currentPage.paragraphs[currentPage.paragraphs.length - 1];
if (lastParagraph) {
lastParagraph.end = i;
}
}
start = i;
}
}
});
// 添加最后一页
if (currentPage.lines.length > 0) {
pages.push(currentPage);
}
// 更新状态
const result = { pages, pageCount: pages.length, currentPage: 0 };
console.log("章节拆分数据", result);
pagesRef.current = pages; // 缓存分页结果
setChapterData(result);
}, [content, fontSize, lineHeight, paragraphSpacing, margin]);
分页结果:

canvas 渲染
jsx
// 渲染当前页
const renderPage = (pageIndex: number) => {
const page = pagesRef.current[pageIndex]; // 从 ref 获取页面数据
const ctx = canvasRef.current.getContext("2d");
// 每次渲染前清除画布
ctx.clearRect(0, 0, width, height);
// 设置字体
ctx.font = `${fontSize}px Arial`;
// 计算字体基线偏移量
let y = margin + calcBaselineOffset(fontSize);
page.lines.forEach((line, index) => {
// 非页面首行加段间距
if (line.isFirstLine && index > 0) {
y += paragraphSpacing;
}
ctx.fillText(line.text, margin, y);
y += lineHeight;
});
};
文本渲染效果:

首行缩进
上面效果可以看到,章节分页时,每段文本开头,没有首行缩进,需要优化一下。
jsx
// 计算基线偏移(基于字体大小)
const calcBaselineOffset = (fontSize: number) => {
return fontSize * 0.8;
};
// 计算缩进宽度(两个中文字符的宽度)
const calcIndentWidth = (ctx: any) => {
return ctx.measureText("中中").width;
};
// 1. 分页+渲染逻辑
// 计算当前行可用宽度(如果是首行且需要缩进,则减去缩进宽度)
const currentAvailableWidth = isFirstLineOfParagraph
? availableWidth - calcIndentWidth(ctx)
: availableWidth;
// 检查是否需要换行:当到达文本末尾或当前行宽度超过可用宽度时
if (
i === text.length ||
ctx.measureText(text.substring(start, i)).width > currentAvailableWidth
) {
}
// 2. 渲染当前页
const renderPage = (pageIndex: number) => {
// 绘制文本(首行应用缩进)
if (line.isFirstLine) {
ctx.fillText(line.text, margin + calcIndentWidth(ctx), y);
} else {
ctx.fillText(line.text, margin, y);
}
};
优化后的效果如下:


文本渲染
jsx
// 渲染拆分后的章节页数据
<div className="page-list">
{chapterData.pages.map((o, pIdx) => (
<div className="page" key={pIdx}>
{o.lines.map((p, idx) => (
// indent 首行缩进样式
<p key={idx} className={p.isFirstLine ? "indent" : ""}>
{p.text}
</p>
))}
</div>
))}
</div>
章节拆分后的数据,通过文本渲染,效果如下:

当然,目前只是粗略的处理了数据,在实际应用中,需要根据具体的需求进行优化和扩展。