电子书阅读器之章节拆分

介绍了章节内容分页处理的方式、排版思路(静态排版、动态排版)以及如何使用 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>

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

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

相关推荐
翻滚吧键盘28 分钟前
js代码09
开发语言·javascript·ecmascript
万少1 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL1 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl021 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang1 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景1 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼1 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js
蓝婷儿1 小时前
每天一个前端小知识 Day 21 - 浏览器兼容性与 Polyfill 策略
前端
百锦再1 小时前
Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
前端·javascript·vue.js·vue·web·reactive·ref
jingling5552 小时前
面试版-前端开发核心知识
开发语言·前端·javascript·vue.js·面试·前端框架