接近完美的HTML文本双行合一排版
昨天看了篇Pretext项目的文章于是产生了利用Canvas.measureText()实现HTML中文本双行合一效果的想法基本思路是利用Canvas.measureText()手工测量文本宽度将原始HTML文本切分为一系列span进行重排。当然我不会一行行敲代码而是让AI根据这个思路试一下。跟通义千问奋战了一小时没有结果鄙视总是不能将注释排成双行后来通过copilot调用了美帝的AI两分钟出了初步效果看来我们的遥遥领先只能相对于印度吹一吹然后跟它沟通了一下基本上实现了设想效果如下可以看到在浏览器窗口大小变化以及浏览器中文字缩放时都能完美实现自动双行排版。测试代码如下还是很容易看懂的!DOCTYPE html html langzh-CN head meta charsetUTF-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 classcontainer idmain-content p classdual-layoutensp;ensp;ensp;ensp;这是一段专业测试文本。《诗经》是中国古代诗歌的开端其中span classannotation包含一些较长的注释内容我们想要让注释内容以“流式双行绕接”的形式显示。建立一个禁止出现在行首的字符集如 。以及》和禁止出现在行尾的字符集如 《“。/span排版引擎应当自动处理这些复杂的换行边界。/p p classdual-layoutensp;ensp;ensp;ensp;第二段专业测试文本。其中span classannotation包含一些较长的注释内容测试多段落同时排版的效果。这里的注释如果很长很长很长很长很长很长它会跨越多个行间距但依然保持段落感。/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 classdual-up${el.up || \u00A0}/spanspan classdual-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;。避开行首行尾字符的问题也接近解决了。