电子书阅读器之章节拆分

介绍了章节内容分页处理的方式、排版思路(静态排版、动态排版)以及如何使用 Canvas API 或 DOM 测量实现动态分页。

之前的 demo 中,使用StPageFlip库仿真渲染了章节文本内容,但是当时是一句话作为一页来显示的,显然不符合实际情况。

正常情况下,在滚动模式中,章节内容直接全部顺序渲染就行;

在翻页模式中,需要将章节内容手动进行分页,将分页后的内容进行渲染。

实现思路:

  1. 测量文本高度:根据阅读器可视区域尺寸(宽高)动态计算每页可容纳文本量
  2. 动态分页:根据容器高度自动分割文本内容到不同页面
  3. 排版处理:自动处理中英文混排、标点避头尾等排版规则
  4. 响应式布局:支持字体、字号、行间距等切换时的实时重新分页;考虑不同屏幕尺寸下的自适应布局
  5. 保持章节内容的完整性,避免跨章节分页

排版策略:

  1. 静态排版:章节加载时一次性计算所有分页,翻页时直接读取缓存数据。只需预生成分页数据(每页内容区间、总页数)。
  2. 动态排版:根据任意字符所在位置作为起始坐标,逐字排版直到绘制完本页最后一个字为止。需动态计算文本渲染边界。

1. column 多栏布局

电子书阅读器之翻页模式中介绍了平滑翻页模式,其中使用了column 多栏布局。但是只能支持滑动效果,不能支持"覆盖翻页"、"仿真翻页"效果。

排版效果如下所示:

2. canvas 绘制方案

动态分页:基于 Canvas API 实现,可以根据字号、行高、间距等配置动态分页文本内容。

使用 Canvas 的 measureText 方法计算文本尺寸,包括宽度、高度等,实现智能分页功能。

官方文档可查看:CanvasRenderingContext2D:measureText() 方法

    1. 计算内容区域每行最多渲染多少文字
    1. 通过行高、段间距计算每页可渲染行数
    1. 实现整章内容的分页计算

文本分页

参数配置:

分页代码:

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>

章节拆分后的数据,通过文本渲染,效果如下:

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

相关推荐
90后的晨仔24 分钟前
RxSwift 中的 DisposeBag解析
前端·ios
蓝胖子的多啦A梦1 小时前
搭建前端项目 Vue+element UI引入 步骤 (超详细)
前端·vue.js·ui
TE-茶叶蛋1 小时前
WebSocket 前端断连原因与检测方法
前端·websocket·网络协议
骆驼Lara1 小时前
前端跨域解决方案(1):什么是跨域?
前端·javascript
离岸听风1 小时前
学生端前端用户操作手册
前端
onebyte8bits1 小时前
CSS Houdini 解锁前端动画的下一个时代!
前端·javascript·css·html·houdini
yxc_inspire1 小时前
基于Qt的app开发第十四天
前端·c++·qt·app·面向对象·qss
一_个前端1 小时前
Konva 获取鼠标在画布中的位置通用方法
前端
一点也不想取名2 小时前
解决 Java 与 JavaScript 之间特殊字符传递问题的终极方案
java·开发语言·javascript
[email protected]2 小时前
Asp.Net Core SignalR导入数据
前端·后端·asp.net·.netcore