我的copilot免费额度出来了,让它对HTML双行夹批进行了进一步完善,经过跟它一番探讨和测试,完成了以下改善:
1、改成使用DocumentFragment进行HTML元素的组合拼接成功后一次性更新页面;
2、解决了双行夹注中的HTML标签丢失问题;
3、测量注释文字宽度的时候不再统一以annoEle的字体为标准测量,而以文本实际所在的元素的字体为标准测量,所以测量误差大大减少,意外换行的概率也变得更低了;
4、代码模块更清晰。
之所以还是只能说接近完善,主要原因是节点太多的时候改变浏览器窗口大小后可能需要刷新页面才能正确完成双行夹注排版(当然,可以在setTimeout()之后调用location.reload()自动刷新)。
这里必须说,在我的copilot免费额度出来之前,我用了deepseek、豆包、通义千问进行改造,居然没一个成功。我的copilot背后似乎调用的是GPT5。感觉那三傻根本就没理解原始代码的逻辑,只会根据你说的大致思路写代码,自己也不测试代码运行结果,混了一周都没搞好,跟copilot交流了大约三小时就弄出了下面的结果,而且我改动的代码可能还不足1%。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>双行夹注排版 - 精确索引版</title>
<style>
:root { --anno-scale: 0.7; }
.container { width: 95%; margin: 0 auto; }
.note { background: #e9f3e3; padding: 10px; margin-bottom: 20px; border-left: 4px solid #4caf50; }
body { background-color: rgba(210,105,30,0.05); }
/* 不支持JS时注释单行流式显示,支持JS时display: inline;会被display: inline-block;覆盖 */
.anno {
display: inline;
font-size: max(calc(1em * var(--anno-scale)), 0.6em);
background-color: rgba(210,105,30,0.15);
border-left: solid 1px rgb(210,105,30);
padding: 0 2px;
line-height: 1.4;
vertical-align: middle;
}
</style>
</head>
<body>
<div class="container" id="main-content">
<h1>双行夹注排版 - 精确索引版</h1>
<div class="note">✅ 基于字符索引定位节点,正确分割 anno,保留内部标签,无嵌套,canvas 测量流畅。</div>
<p class="dual-layout">    这是一段专业测试文本。《诗经》是中国古代诗歌的开端,其中<span
class="anno">包含一些较长的注释内容,我们想要让注释内容以<strong>"流式双行绕接"</strong>的形式显示。建立一个禁止出现在行首的字符集(如。以及,)》)和禁止出现在行尾的字符集(如
(《")。</span>排版引擎应当<span
class="anno">同一行中的第二条注释,我们想要让注释内容以<strong>"流式双行绕接"</strong>的形式显示。建立一个禁止出现在行首的字符集(如。
以及和禁止出现在行尾的字符集(如)。</span>复杂的换行边界。</p>
<p class="dual-layout">    第一个没有注释的段落。</p>
<p class="dual-layout">    第二段专业测试文<a
href="#ch001note27"><sup>[27]</sup></a>本。其中<span class="anno">让注释本身都要超过一行看看效果。包含一些较长的注释内容,测试多段落同时排版的效果。这里的注释如果<sup>很长很长很长很长很长很长</sup>,它会跨越多个行间距,但依然保持段落感。包含一些较长的注释内容,测试多段落同时排版的效果。这里的注释如果很长很长很长很长很长很长,它会跨越多个行间距,但依然保持段落感。</span>核心优化思路:避头尾逻辑、响应式缩放<span
class="anno">短注释</span>、可见区计算。</p>
<p class="dual-layout">    这段文本没有注释,应该直接跳过处理。</p>
</div>
<script>
(function() {
// ---------- 工具函数 ----------
// 使用 canvas 测量文本宽度
function measureTextWidth(text, element) {
if (!text) return 0;
const canvas = measureTextWidth.canvas || (measureTextWidth.canvas = document.createElement('canvas').getContext('2d'));
const style = window.getComputedStyle(element);
canvas.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
canvas.letterSpacing = style.letterSpacing;
return canvas.measureText(text).width;
}
// 构建元素内所有文本节点的字符索引映射 (文本节点 -> 起始索引)
function buildCharIndexMap(element) {
const map = new Map();
let acc = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node;
while (node = walker.nextNode()) {
if (node.textContent.length > 0) {
map.set(node, acc);
acc += node.textContent.length;
}
}
return map;
}
// 在元素内部根据全局字符索引插入 <br>(基于预先构建的索引映射)
function insertBrAtGlobalIndex(element, globalIndex, charIndexMap) {
for (let [node, start] of charIndexMap) {
const end = start + node.textContent.length;
if (globalIndex >= start && globalIndex <= end) {
const parent = node.parentNode;
if (!parent) return false;
let br;
if (globalIndex == start) {
// 在文本节点前插入 <br>
br = document.createElement('br');
parent.insertBefore(br, node);
} else if (globalIndex == end) {
// 在文本节点后插入 <br>
br = document.createElement('br');
parent.insertBefore(br, node.nextSibling);
} else {
// 拆分文本节点
const offset = globalIndex - start;
const text = node.textContent;
const before = text.slice(0, offset);
const after = text.slice(offset);
const beforeNode = document.createTextNode(before);
const afterNode = document.createTextNode(after);
br = document.createElement('br');
parent.insertBefore(beforeNode, node);
parent.insertBefore(br, node);
parent.insertBefore(afterNode, node);
parent.removeChild(node);
}
// 检查br前后是否有文本内容,如果没有则添加不间断空格
let hasTextBefore = false;
let prev = br.previousSibling;
while (prev) {
if (prev.nodeType === Node.TEXT_NODE && prev.textContent.trim()) {
hasTextBefore = true;
break;
}
prev = prev.previousSibling;
}
if (!hasTextBefore) {
parent.insertBefore(document.createTextNode('\u00A0'), br);
}
let hasTextAfter = false;
let next = br.nextSibling;
while (next) {
if (next.nodeType === Node.TEXT_NODE && next.textContent.trim()) {
hasTextAfter = true;
break;
}
next = next.nextSibling;
}
if (!hasTextAfter) {
parent.insertBefore(document.createTextNode('\u00A0'), br.nextSibling);
}
return true;
}
}
return false;
}
// 提取元素中 [startChar, endChar) 范围的内容,保留标签,返回 DocumentFragment
function extractFragment(element, startChar, endChar, charIndexMap) {
const startPos = getNodeAndOffset(startChar, charIndexMap);
const endPos = getNodeAndOffset(endChar, charIndexMap);
if (!startPos || !endPos) return null;
const range = document.createRange();
range.setStart(startPos.node, startPos.offset);
range.setEnd(endPos.node, endPos.offset);
const fragment = range.cloneContents();
return fragment;
}
// 获取字符索引对应的文本节点和偏移
function getNodeAndOffset(charIndex, charIndexMap) {
for (let [node, start] of charIndexMap) {
const end = start + node.textContent.length;
if (charIndex >= start && charIndex <= end) {
return { node, offset: charIndex - start };
}
}
return null;
}
// 计算 anno 前 n 个字符的双行宽度及最佳分割点(基于 anno 自身样式)
function getDualWidthAndSplit(annoElem, n, noHeading, noTailing, charIndexMap, remainW) {
const fullText = annoElem.textContent;
const sub = fullText.slice(0, n);
if (sub.length === 0) return { width: 0, split: 0 };
let bestSplit = Math.floor(sub.length / 2);
if (bestSplit < 1) bestSplit = 1;
let bestDiff = Infinity;
// 辅助函数:计算前 upTo 个字符的宽度,使用文本节点的父元素字体
function getWidthUpTo(upTo) {
let totalWidth = 0;
let remaining = upTo;
for (let [node, start] of charIndexMap) {
const nodeLen = node.textContent.length;
if (remaining <= 0) break;
const take = Math.min(remaining, nodeLen);
const text = node.textContent.slice(0, take);
const parent = node.parentElement;
totalWidth += measureTextWidth(text, parent);
remaining -= take;
}
return totalWidth;
}
const totalW = getWidthUpTo(n);
// 找到最小的 s 使得 wb <= remainW
let minS = 1;
for (let s = 1; s < sub.length; s++) {
const wb = totalW - getWidthUpTo(s);
if (wb <= remainW) {
minS = s;
break;
}
}
// 找到最大的 s 使得 wa <= remainW
let maxS = sub.length - 1;
for (let s = sub.length - 1; s >= minS; s--) {
const wa = getWidthUpTo(s);
if (wa <= remainW) {
maxS = s;
break;
}
}
for (let s = maxS; s >= minS; s--) {
const wa = getWidthUpTo(s);
const wb = totalW - wa;
// since s >= minS and s <= maxS, wa <= remainW and wb <= remainW
let penalty = 0;
if (noTailing.includes(sub[s - 1])) penalty += 100;
if (noHeading.includes(sub[s])) penalty += 100;
const diff = Math.abs(wa - wb) + penalty;
if (diff < bestDiff) {
bestDiff = diff;
bestSplit = s;
}
}
const w1 = getWidthUpTo(bestSplit);
const w2 = totalW - w1;
return { width: Math.max(w1, w2), split: bestSplit };
}
// 主函数
function dualLineAnno(container, option = {}) {
const annoClass = option.annoClass || 'anno';
const sampleChar = option.sampleChar || '中';
const noHeading = option.noHeading || '!%),.:;>?]}¢¨°·ˇˉ―‖'"...‰′″›℃∶、。〃〉》」』】〕〗〞︶︺︾﹀﹄﹚﹜﹞!"%'),.:;?]`|}~¢';
const noTailing = option.noTailing || '$([{£¥·'"〈《「『【〔〖〝﹙﹛﹝$(.[{£¥';
const rightAdjust = option.rightAdjust || 2;
// 注入样式
if (!document.querySelector('#anno-style')) {
const style = document.createElement('style');
style.id = 'anno-style';
style.textContent = `.${annoClass} { display: inline-block !important; font-size: max(calc(1em * var(--anno-scale)), 0.6em); vertical-align: middle; background-color: rgba(210,105,30,0.15); border-radius: 8px; margin: 0 0 1px -1px; border-left: solid 1px rgb(210,105,30); padding: 0 2px; line-height: 1.4; }`;
document.head.appendChild(style);
}
// 创建容器片段
const containerFrag = document.createDocumentFragment();
// 遍历段落
Array.from(container.children).forEach(para => {
if (!para.querySelector('.' + annoClass)) {
// 没有anno的段落,直接克隆
containerFrag.appendChild(para.cloneNode(true));
return;
}
const paraStyle = window.getComputedStyle(para);
const paraRect = para.getBoundingClientRect();
const maxWidth = paraRect.width - parseFloat(paraStyle.paddingLeft) - parseFloat(paraStyle.paddingRight) - rightAdjust;
if (maxWidth <= 0) return;
// 收集所有文本节点(非 anno 内部)和 anno 元素(按文档顺序)
const items = [];
for (let child of para.childNodes) {
if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
items.push({ type: 'text', node: child });
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.classList.contains(annoClass)) {
items.push({ type: 'anno', node: child });
} else {
items.push({ type: 'element', node: child });
}
}
}
// 创建新的DocumentFragment来构建内容
const newPara = document.createDocumentFragment();
let remainW = maxWidth;
let i = 0;
while (i < items.length) {
const item = items[i];
if (item.type === 'text') {
const textNode = item.node;
const parent = textNode.parentElement;
if (!parent) { i++; continue; }
const text = textNode.textContent;
const charWidth = measureTextWidth(sampleChar, parent);
if (charWidth === 0) { i++; continue; }
const maxChars = Math.floor(remainW / charWidth);
if (maxChars >= text.length) {
const w = measureTextWidth(text, parent);
// 计算剩余宽度时多扣除一个字符以防止因为消除测量误差导致本行后续双行夹注文字过多而意外换行
remainW -= (w + charWidth);
newPara.appendChild(textNode.cloneNode(true));
i++;
} else {
// 文本截断,当前行已满,重置宽度继续下一元素(下一行)
remainW = maxWidth;
newPara.appendChild(textNode.cloneNode(true));
i++;
}
} else if (item.type === 'element') {
const elem = item.node;
const rect = elem.getBoundingClientRect();
const w = rect.width;
if (remainW >= w) {
remainW -= w;
newPara.appendChild(elem.cloneNode(true));
i++;
} else {
remainW = maxWidth;
newPara.appendChild(elem.cloneNode(true));
i++;
}
} else { // anno
const annoElem = item.node;
const fullText = annoElem.textContent;
if (!fullText.trim()) { i++; continue; }
const totalLen = fullText.length;
// 获取 anno 内所有文本节点的索引映射
const annoCharMap = buildCharIndexMap(annoElem);
// 尝试整个放入
const { width: wholeWidth, split: wholeSplit } = getDualWidthAndSplit(annoElem, totalLen, noHeading, noTailing, annoCharMap, remainW);
if (remainW >= wholeWidth) {
// 整个放入,克隆anno,插入 <br>
const clonedAnno = annoElem.cloneNode(true);
insertBrAtGlobalIndex(clonedAnno, wholeSplit, buildCharIndexMap(clonedAnno));
newPara.appendChild(clonedAnno);
remainW -= wholeWidth;
i++;
} else {
// 拆分:寻找最大的 n (1 <= n < totalLen) 使得前 n 个字符的双行宽度 <= remainW
let bestN = 0;
for (let n = 1; n < totalLen; n++) {
const { width } = getDualWidthAndSplit(annoElem, n, noHeading, noTailing, annoCharMap, remainW);
if (width <= remainW) bestN = n;
else break;
}
if (bestN === 0) bestN = 1;
const { split: innerSplit } = getDualWidthAndSplit(annoElem, bestN, noHeading, noTailing, annoCharMap, remainW);
// 提取前半部分和后半部分(基于 annoElem 和它的字符映射)
const frontFrag = extractFragment(annoElem, 0, bestN, annoCharMap);
const backFrag = extractFragment(annoElem, bestN, totalLen, annoCharMap);
if (frontFrag) {
const frontSpan = document.createElement('span');
frontSpan.className = annoClass;
frontSpan.appendChild(frontFrag);
insertBrAtGlobalIndex(frontSpan, innerSplit, buildCharIndexMap(frontSpan));
newPara.appendChild(frontSpan);
remainW = maxWidth; // 当前行已满,下一行重置
if (backFrag) {
const backSpan = document.createElement('span');
backSpan.className = annoClass;
backSpan.appendChild(backFrag);
// 为backSpan计算并插入br
const backCharMap = buildCharIndexMap(backSpan);
const { split: backSplit } = getDualWidthAndSplit(backSpan, backSpan.textContent.length, noHeading, noTailing, backCharMap, maxWidth);
insertBrAtGlobalIndex(backSpan, backSplit, backCharMap);
newPara.appendChild(backSpan);
// 检查backSpan之后是否有内容,如果没有则添加不间断空格
// 由于是fragment末尾,暂时不添加,除非需要
}
}
i++;
}
}
}
// 替换段落内容
const paraEle = document.createElement('p');
paraEle.appendChild(newPara);
containerFrag.appendChild(paraEle);
});
container.innerHTML = '';
container.appendChild(containerFrag);
}
window.onload = function() {
const container = document.getElementById('main-content');
const origHTML = container.innerHTML;
dualLineAnno(container, { annoClass: 'anno', sampleChar: '中', rightAdjust: 4 });
window.addEventListener('resize', () => {
clearTimeout(window._resizeTimer);
window._resizeTimer = setTimeout(() => {
container.innerHTML = origHTML;
dualLineAnno(container, { annoClass: 'anno', sampleChar: '中', rightAdjust: 4 });
}, 100);
// location.reload(); // 如页面刷新不及时,重新加载页面
});
// console.log(container.innerHTML);
};
})();
</script>
</body>
</html>
把这个代码用于红楼梦脂批双行夹注的截图:
