news 2026/5/28 7:38:20

接近完美的HTML文本双行合一排版

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
接近完美的HTML文本双行合一排版

昨天看了篇Pretext项目的文章,于是产生了利用Canvas.measureText()实现HTML中文本双行合一效果的想法,基本思路是利用Canvas.measureText()手工测量文本宽度,将原始HTML文本切分为一系列span进行重排。当然我不会一行行敲代码,而是让AI根据这个思路试一下。跟通义千问奋战了一小时没有结果(鄙视,总是不能将注释排成双行),后来通过copilot调用了美帝的AI,两分钟出了初步效果(看来我们的遥遥领先只能相对于印度吹一吹😁),然后跟它沟通了一下,基本上实现了设想,效果如下:

可以看到,在浏览器窗口大小变化以及浏览器中文字缩放时,都能完美实现自动双行排版。

测试代码如下,还是很容易看懂的:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>多段落专业级双行合一排版</title> <style> :root { --anno-scale: 0.6; } .container { margin: 20px auto; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #fff; } .line { display: block; white-space: nowrap; clear: both; } .dual-container { display: inline-block; vertical-align: middle; line-height: max(calc(2 * var(--anno-scale)), 1.3); background-color: rgba(210, 105, 30, 0.05); border-bottom: 1px dotted #d2691e; margin: 0 1px; } .dual-up, .dual-down { display: block; font-size: max(calc(1em * var(--anno-scale)), 0.6em); color: #d2691e; white-space: nowrap; } </style> </head> <body> <div class="container" id="main-content"> <p class="dual-layout">&ensp;&ensp;&ensp;&ensp;这是一段专业测试文本。《诗经》是中国古代诗歌的开端,其中<span class="annotation">包含一些较长的注释内容,我们想要让注释内容以“流式双行绕接”的形式显示。建立一个禁止出现在行首的字符集(如 。以及,)》)和禁止出现在行尾的字符集(如 (《“)。</span>排版引擎应当自动处理这些复杂的换行边界。</p> <p class="dual-layout">&ensp;&ensp;&ensp;&ensp;第二段专业测试文本。其中<span class="annotation">包含一些较长的注释内容,测试多段落同时排版的效果。这里的注释如果很长很长很长很长很长很长,它会跨越多个行间距,但依然保持段落感。</span>核心优化思路:避头尾逻辑、响应式缩放、可见区计算。</p> </div> <script> class ParagraphScanner { constructor(element) { this.element = element; // 1. 备份该段落原始的HTML内容 this.originalHTML = element.innerHTML; this.noLeading = "),。》、?!:;”’"; this.noTrailing = "(《“‘"; this.ctx = document.createElement('canvas').getContext('2d'); this.ticking = false; this.init(); } init() { const observer = new ResizeObserver(() => { if (!this.ticking) { requestAnimationFrame(() => { this.render(); this.ticking = false; }); this.ticking = true; } }); observer.observe(this.element); this.render(); } updateMetrics() { const style = window.getComputedStyle(this.element); this.fontSize = parseFloat(style.fontSize); this.fontFamily = style.fontFamily; const rate = getComputedStyle(document.documentElement).getPropertyValue('--anno-scale').trim() || "0.6"; this.annoSize = parseFloat(rate) < 0.6 ? this.fontSize * 0.6 : this.fontSize * parseFloat(rate); // 获取当前段落的物理宽度 const rect = this.element.getBoundingClientRect(); this.maxWidth = rect.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) - 5; } measure(text, isAnno = false) { this.ctx.font = `${isAnno ? this.annoSize : this.fontSize}px ${this.fontFamily}`; return this.ctx.measureText(text).width; } parse() { const temp = document.createElement('div'); temp.innerHTML = this.originalHTML; return Array.from(temp.childNodes).map(node => { if (node.nodeType === 3) return {type: 'text', content: node.textContent}; if (node.nodeType === 1 && node.classList.contains('annotation')) return {type: 'anno', content: node.textContent}; return null; }).filter(Boolean); } render() { this.updateMetrics(); if (this.maxWidth <= 0) return; const parts = this.parse(); const lines = []; let curLine = { elements: [], width: 0 }; parts.forEach(part => { let content = part.content; while (content.length > 0) { const remainW = this.maxWidth - curLine.width; if (part.type === 'text') { let count = 0; for (let i = 1; i <= content.length; i++) { if (this.measure(content.substring(0, i)) > remainW) break; count = i; } while (count > 0 && this.noLeading.includes(content[count])) count--; while (count > 0 && this.noTrailing.includes(content[count-1])) count--; if (count === 0 && curLine.elements.length > 0) { lines.push(curLine); curLine = { elements: [], width: 0 }; continue; } const take = count || 1; const w = this.measure(content.substring(0, take)); curLine.elements.push({ type: 'text', content: content.substring(0, take) }); curLine.width += w; content = content.substring(take); } else { let count = 0; for (let i = 2; i <= content.length + 1; i += 2) { let half = Math.ceil(i / 2); if (this.measure(content.substring(0, half), true) > remainW) break; count = i; } if (count <= 0 && curLine.elements.length > 0) { lines.push(curLine); curLine = { elements: [], width: 0 }; continue; } const take = (count <= 0) ? Math.min(content.length, 2) : count; let break_point = Math.ceil(take / 2); // 注释避开行尾行首非法字符 while(break_point < content.length && (this.noTrailing.includes(content[break_point - 1]) || this.noLeading.includes(content[break_point]))) break_point ++; const up = content.substring(0, break_point); curLine.elements.push({ type: 'anno', up, down: content.substring(break_point, take) }); curLine.width += this.measure(up, true); content = content.substring(take); } if (curLine.width >= this.maxWidth * 0.99) { lines.push(curLine); curLine = { elements: [], width: 0 }; } } }); if (curLine.elements.length > 0) lines.push(curLine); this.draw(lines); } draw(lines) { const fragment = document.createDocumentFragment(); lines.forEach(line => { const lineDiv = document.createElement('div'); lineDiv.className = 'line'; lineDiv.style.width = `${this.maxWidth + 5}px`; line.elements.forEach(el => { const span = document.createElement('span'); if (el.type === 'text') { span.textContent = el.content; } else { span.className = 'dual-container'; span.innerHTML = `<span class="dual-up">${el.up || '\u00A0'}</span><span class="dual-down">${el.down || '\u00A0'}</span>`; } lineDiv.appendChild(span); }); fragment.appendChild(lineDiv); }); this.element.innerHTML = ''; this.element.appendChild(fragment); } // 静态批量初始化工具 static activate(selector) { const elements = document.querySelectorAll(selector); return Array.from(elements).map(el => new ParagraphScanner(el)); } } // 页面加载完成后,一键激活所有 class 为 dual-layout 的 p 标签 window.onload = () => { ParagraphScanner.activate('.dual-layout'); }; </script> </body> </html>

稍作扩展:现在ParagraphScanner接受一个元素作为参数,针对该元素中的内容拆分成一系列span进行重排。这样,容器中可以包含多个段落。如果要实现首行缩进排版,需要在每个段落开头手动插入四个半字符空格:&ensp;。避开行首行尾字符的问题也接近解决了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/28 7:37:32

2026全年求职时间线|应届生必看,错过可能再等一年

关注 霍格沃兹测试学院公众号&#xff0c;回复「资料」, 领取人工智能测试开发技术合集如果你是2026届、2027届毕业生&#xff0c;这篇文章建议收藏转发。应届生身份只有一次&#xff0c;用好了是红利&#xff0c;用错了可能错过一整年机会。都说今年工作难找&#xff0c;那我们…

作者头像 李华
网站建设 2026/4/1 2:09:37

Mantis Biotech打造人体“数字孪生“解决医学数据短缺难题

在庞大数据集上训练的大语言模型能够加速基因组学研究、简化临床文档记录、改善实时诊断、支持临床决策制定、加快药物发现&#xff0c;甚至生成合成数据来推进实验进展。然而&#xff0c;这些模型在改变生物医学研究方面的承诺往往遇到瓶颈&#xff1a;除了医疗保健依赖的结构…

作者头像 李华
网站建设 2026/4/1 2:07:42

Murashige Skoog 培养基:植物组织培养与快速繁殖的核心营养基础

在现代生物技术迅猛发展的背景下&#xff0c;植物组织培养已成为科研与农业生产中不可或缺的重要手段。而在这其中&#xff0c;Murashige & Skoog 培养基&#xff08;简称 MS 培养基&#xff09;因其卓越的性能和广泛的适用性&#xff0c;被誉为植物组织培养领域的“黄金标…

作者头像 李华
网站建设 2026/4/7 16:27:23

遗传算法在多车容量约束VRP问题中的应用与求解

遗传算法VRP问题&#xff1a;VRP&#xff0c;多车容量约束 针对物流问题&#xff0c;根据实际情况&#xff0c;设置多车多容量&#xff0c;采用遗传算法分析求解&#xff0c;在matlab实现并画图&#xff0c;展示求解结果前阵子帮做物流的表哥捋了捋他们的配送问题&#xff0c;本…

作者头像 李华
网站建设 2026/4/2 19:54:13

DanKoe 视频笔记:人工智能与未来工作:不愉快的真相与应对策略

在本节课中&#xff0c;我们将探讨一个正在发生的现实&#xff1a;人工智能&#xff08;AI&#xff09;正在深刻改变就业市场。我们将分析现状&#xff0c;理解其影响&#xff0c;并学习如何通过转变思维和行动&#xff0c;积极适应这场变革&#xff0c;从而在未来保持竞争力。…

作者头像 李华