背景:合同模板场景,css中linear-gradient渐变实现的信纸格式(每行内容不管是否占满,底线都到行末),因在wkhtmltopdf工具把网页转pdf时有各种兼容和效果问题,最后只能用js强行拆分行,每行加下划线效果。
问题描述
起初,汉字按1em宽度计算,其他非汉字类按半个汉字(0.5em)计算,应用了不少合同模板html,没出现问题。直到海南安居房合同,要求打印替换后的内容也要加粗后。即:信纸格式里的内容,不加粗时,显示正常,加粗时,显示就超过原本计算的一行,自动换行到下一行了。见下图:

加粗前.png

加粗.png
也就是说:宋体,加粗前后,字符的实际宽度变了。
字体实际宽度调研
仅对宋体,因为制式合同都是宋体为字体的。针对中文、英文、数字、特殊-英(特殊字符-半角)、特殊-中(特殊字符-全角)这些字符,做了加粗前后的对比。
-
win10系统(windows系统)下效果:
字符宽度-win10.png -
win11系统(windows系统)下效果:
字符宽度-win11.png -
macOS系统(苹果系统)下效果:
字符宽度-苹果系统.png
对比了一下,发现:
- 在windows系统下,不加粗时,符合非汉字字符是半个汉字字符的宽度。在苹果系统下,就比较杂乱了。非汉字字符,有超过半个汉字宽度的,有小于半个汉字宽度。
- 加粗后,不管是windows系统下,还是苹果系统下,,都比较乱。
结论
综上所述,加粗前后都无法精准的算每行字符的实际长度。仍旧只能采用临时调整宽度,比如:每行26个汉字,js计算按26汉字计算,每行样式临时改成26.4em的宽度,以保证正常显示。
附上调研源码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
<title>宋体加粗前后的字符实际宽度</title>
<style>
table {
margin: 0 30px;
font-size: 14px;
font-family: '宋体';
border: 1px solid #aaa;
border-collapse: collapse;
}
th,
td {
border: 1px solid #aaa;
padding: 5px;
}
</style>
</head>
<body>
<div>
<table>
<thead>
<tr>
<th>类型</th>
<th>字符</th>
<th>实际大小<br>(20px为参考)</th>
<th>em<br>实际大小/20)</th>
<th>加粗字符</th>
<th>加粗后实际大小<br>(20px为参考)</th>
<th>em<br>(实际大小/20)</th>
<th>加粗前后对比<br>(加粗后width/加粗前width)</th>
</tr>
</thead>
<tbody>
<!-- <tr>
<td>中文</td>
<td>汉</td>
<td>20px</td>
<td>1</td>
<td style="font-weight: bold;">汉</td>
<td>20px</td>
<td>1</td>
<td>1</td>
</tr> -->
</tbody>
</table>
</div>
<script>
function measureTextWidth(text, font) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.font = font;
return context.measureText(text).width;
}
// 使用示例
// const width = measureTextWidth('中', 'bold 16px SimSun'); // SimSun 是宋体
// console.log(`加粗后的字符宽度为 ${width}px`);
var charObj = {
'中文': '黎妃',
'英文': 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'数字': '0123456789',
'特殊-英': '\,.;$^*""*()-+=|<>&%#@!~',
'特殊-中': ',。、;?!()【】:“”‘’《》¥·',
};
var tbody = document.querySelector('tbody');
var strHtml = '';
Object.keys(charObj).forEach(function (key) {
var str = charObj[key];
str.split('').forEach(function (char) {
var width = measureTextWidth(char, '20px 宋体');
var widthBold = measureTextWidth(char, 'bold 20px 宋体');
var percent = width / 20;
var percent2 = widthBold / 20;
var percent3 = widthBold / width;
strHtml +=`<tr>
<td>${key}</td>
<td>${char}</td>
<td>${width}</td>
<td>${percent}</td>
<td style="font-weight: bold;">${char}</td>
<td>${widthBold}</td>
<td>${percent2}</td>
<td>${percent3}</td>
</tr>`;
})
});
tbody.innerHTML = strHtml;
</script>
</body>
</html>
优化方案
原理:逐字测量宽度,当宽度超过容器时,强制截断并包裹在带下划线的 div 中。
效果图:

信纸格式行.png
源码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS 实现信纸线格式演示</title>
<style>
body {
font-family: "SimSun", "Songti SC", serif; /* 自定义宋体,更像文档 */
background-color: #f0f2f5;
padding: 50px;
}
.container {
width: 800px;
margin: 0 auto;
background: white;
padding: 60px;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
}
h2 {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 15px;
margin-bottom: 30px;
}
.tips {
background: #e6f7ff;
border: 1px solid #91d5ff;
padding: 15px;
border-radius: 4px;
margin-bottom: 30px;
font-size: 14px;
color: #0050b3;
line-height: 1.6;
}
/* 原始样式:未处理前 */
.origin-text {
display: inline-block;
position: relative;
text-indent: 0;
border: none;
min-width: 2em;
text-align: left;
vertical-align: top;
line-height: 28px;
background: linear-gradient(transparent, transparent 27px, #000 1px, #000) 0% 0% / 100% 28px;
}
/* 处理后的通用容器样式 */
.js-letter-paper {
width: 100%;
/* 字体设置稍微大点方便观察 */
font-size: 18px;
line-height: 32px; /* 固定行高非常重要 */
color: #333;
}
/* 核心:JS 生成的每一行 */
.js-line-row {
display: block; /* 必须独占一行 */
width: 100%;
height: 32px; /* 高度等于容器行高 */
line-height: 32px;
border-bottom: 1px solid #000; /* 实线边框 */
box-sizing: border-box; /* 边框算入高度 */
/* 文字内容溢出处理(防止撑开高度) */
white-space: nowrap;
overflow: hidden;
}
/* 模拟空行占位 */
.js-line-row:empty::before {
content: "\00a0";
}
.btn-action {
display: block;
width: 200px;
margin: 20px auto;
padding: 10px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
text-align: center;
}
.btn-action:hover {
background: #40a9ff;
}
</style>
</head>
<body>
<div class="container">
<h2>JS 暴力计算断行实现信纸线</h2>
<div class="tips">
<strong>场景说明:</strong><br>
当 CSS3 `background` 方案在某些旧版打印引擎(如 wkhtmltopdf, 老旧IE)失效时,或者需要对每一行文字进行精确控制(如行尾对齐、最后一行补全)时,使用 JS 暴力计算断行是最稳妥的方案。<br>
<strong>原理:</strong> 逐字测量宽度,当宽度超过容器时,强制截断并包裹在带下划线的 `div` 中。
</div>
<h3>示例一:多行文本自动分割</h3>
<p>原始文本区域(点击下方按钮转换):</p>
<!-- 待处理的容器 -->
<div id="target-content" class="origin-text" style="width: 26em;font-size:18px;">
乙方占比70%,万宁市人民政府占比30%,乙方持有住房满10年后,持有年限每增加1年,可获赠6%的政府持有增值收益份额;乙方持有住房满15年后,乙方获得该住房100%产权。(详见附件九《安居房共有产权协议》)
</div>
<button class="btn-action" onclick="transformToLetterPaper()">执行转换 (JS)</button>
<p>转换结果:</p>
<div id="result-container" class="js-letter-paper" style="width: 26em;font-size:18px;">
<!-- 结果将生成在这里 -->
<div class="js-line-row" style="color:#999; border-bottom:1px dashed #ccc;">(等待生成...)</div>
</div>
</div>
<script>
/**
* 核心转换函数
* 将一段纯文本转换为带下划线的行结构
* @param {string} text - 原始文本
* @param {HTMLElement} container - 目标容器(用于获取宽度和样式)
*/
function splitTextToRows(text, container,sourceDiv) {
// 1. 获取样式的参考对象
// 测量的关键:字体大小、粗细、字体家族必须与显示时完全一致,否则切割会错位。
// 优先使用 sourceDiv (原始内容) 的样式,如果没有则使用 container
const styleRefElement = sourceDiv || container;
const computedStyle = window.getComputedStyle(styleRefElement);
// 2. 获取容器宽度 (这是限制每一行长度的物理边界)
// 注意:必须使用内容盒(content-box)宽度,需减去 padding
const containerStyle = window.getComputedStyle(container);
const paddingLeft = parseFloat(containerStyle.paddingLeft) || 0;
const paddingRight = parseFloat(containerStyle.paddingRight) || 0;
const containerWidth = container.clientWidth - paddingLeft - paddingRight;
// 3. 创建测宽尺
const ruler = document.createElement('span');
// 复制所有可能会影响文字宽度的 CSS 属性
ruler.style.fontFamily = computedStyle.fontFamily;
ruler.style.fontSize = computedStyle.fontSize;
ruler.style.fontWeight = "bold"||computedStyle.fontWeight; // 核心:获取并应用 font-weight
ruler.style.letterSpacing = computedStyle.letterSpacing;
ruler.style.fontStyle = computedStyle.fontStyle;
ruler.style.visibility = 'hidden';
ruler.style.whiteSpace = 'nowrap';
ruler.style.position = 'absolute';
ruler.style.top = '-9999px'; // 移出视口
document.body.appendChild(ruler);
const rows = [];
let currentLine = '';
// 4. 预处理文本
// 将换行符转换为空格,避免测量干扰(视需求而定,信纸通常是将长文本连续排版)
const cleanText = text.replace(/[\r\n]+/g, '');
const chars = cleanText.split('');
// 5. 逐字计算
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
// 试探性加入字符
ruler.innerText = currentLine + char;
// 如果宽度超过容器 (预留 2px Buffer 防止浏览器渲染差异)
if (ruler.offsetWidth > containerWidth - 2) {
rows.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
}
if (currentLine) {
rows.push(currentLine);
}
// 6. 清理
document.body.removeChild(ruler);
// 7. 补全空白行
while (rows.length <= 0) {
rows.push('');
}
console.log('0206-rows:',rows);
// 返回结果同时也返回检测到的样式,以便渲染时应用
return {
rows: rows,
styles: {
fontWeight: 'bold'||computedStyle.fontWeight,
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily
}
};
}
/**
* 触发转换动作
*/
function transformToLetterPaper() {
const sourceDiv = document.getElementById('target-content');
const resultDiv = document.getElementById('result-container');
// 优化:同步宽度,确保结果容器的内容宽度与源容器一致
const sourceComputedStyle = window.getComputedStyle(sourceDiv);
// 注意:getComputedStyle 返回的 width 通常是像素值 (px)
// 赋值给 resultDiv 确保两者的换行基准一致
resultDiv.style.width = sourceComputedStyle.width;
const rawText = sourceDiv.innerText; // 获取纯文本
// 执行分割计算
// minLines 设置为 0,表示行数完全由内容决定,不强制填充空白行
const result = splitTextToRows(rawText, resultDiv, sourceDiv);
const lines = result.rows;
const detectedStyles = result.styles;
// 渲染 DOM
let html = '';
lines.forEach(line => {
const content = line ? line : '';
// 将检测到的 font-weight 等样式应用到每一行,确保视觉一致
html += `<div class="js-line-row" style="font-weight:${detectedStyles.fontWeight}">${escapeHtml(content)}</div>`;
});
resultDiv.innerHTML = html;
}
// 简单的 HTML 转义防止 XSS
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
</script>
</body>
</html>
应用在模板html里的核心代码(es5语法):
function splitLineForPdf(text, sourceDiv, isbold) {
var resultHtml = '';
// 1. 获取样式的参考对象
// 测量的关键:字体大小、粗细、字体家族必须与显示时完全一致,否则切割会错位。
var computedStyle = window.getComputedStyle(sourceDiv);
// 2. 获取容器宽度 (这是限制每一行长度的物理边界)
// 注意:必须使用内容盒(content-box)宽度,需减去 padding
var containerStyle = window.getComputedStyle(sourceDiv);
var paddingLeft = parseFloat(containerStyle.paddingLeft) || 0;
var paddingRight = parseFloat(containerStyle.paddingRight) || 0;
var containerWidth = sourceDiv.clientWidth - paddingLeft - paddingRight;
// 3. 创建测宽尺
var ruler = document.createElement('span');
// 复制所有可能会影响文字宽度的 CSS 属性
ruler.style.fontFamily = computedStyle.fontFamily;
ruler.style.fontSize = computedStyle.fontSize;
//因为加粗后,不同类型字符(中文、英文、特殊符号等等),不同系统对应的字符宽度完全不一样,没有规律了
ruler.style.fontWeight = isbold ? "bolder" : "normal";
ruler.style.letterSpacing = computedStyle.letterSpacing;
ruler.style.fontStyle = computedStyle.fontStyle;
ruler.style.visibility = 'hidden';
ruler.style.whiteSpace = 'nowrap';
ruler.style.position = 'absolute';
ruler.style.top = '-9999px'; // 移出视口
document.body.appendChild(ruler);
var rows = [];
var currentLine = '';
// 4. 预处理文本
// 将换行符转换为空格,避免测量干扰(视需求而定,信纸通常是将长文本连续排版)
var cleanText = text.replace(/[\r\n]+/g, '');
var chars = cleanText.split('');
// 5. 逐字计算
for (var i = 0; i < chars.length; i++) {
var char = chars[i];
// 试探性加入字符
ruler.innerText = currentLine + char;
// 如果宽度超过容器 (预留 2px Buffer 防止浏览器渲染差异)
if (ruler.offsetWidth > containerWidth - 2) {
rows.push(currentLine);
currentLine = char;
} else {
currentLine += char;
}
}
if (currentLine) {
rows.push(currentLine);
}
// 6. 清理
document.body.removeChild(ruler);
// 7. 补全空白行
while (rows.length <= 0) {
rows.push('');
}
console.log('信纸格式-rows:',rows);
$.each(rows, function (index, item) {
resultHtml += '<i class="js-row">' + item + '</i>';
});
return resultHtml;
}
/**
* 转pdf前,替换成js的断行,保证pdf的多下划线显示正常。
*/
function showMultlineToPdf() {
//html结构: <span class="mf-letterline" style="width: 20em;" data-words="20" data-isbold="1"></span>
// 优化后:data-words属性不需要了
$('.mf-letterline').each(function () {
var isbold = ($(this).attr('data-isbold')==1) ? true : false;//是否加粗字体
var curStr = ($(this).text() || '').trim();
var sourceEl = $(this).get(0);//jquery对象转原生dom元素
var replaceStr = splitLineForPdf(curStr,sourceEl,isbold);
$(this).html(replaceStr);//用js断行后的内容替换原来的文本
$(this).addClass('topdf');//避免网页版打印替换后,样式背景线和js断行背景线同时出现
});
}


