字符实际长度计算

背景:合同模板场景,css中linear-gradient渐变实现的信纸格式(每行内容不管是否占满,底线都到行末),因在wkhtmltopdf工具把网页转pdf时有各种兼容和效果问题,最后只能用js强行拆分行,每行加下划线效果。

问题描述

起初,汉字按1em宽度计算,其他非汉字类按半个汉字(0.5em)计算,应用了不少合同模板html,没出现问题。直到海南安居房合同,要求打印替换后的内容也要加粗后。即:信纸格式里的内容,不加粗时,显示正常,加粗时,显示就超过原本计算的一行,自动换行到下一行了。见下图:


加粗前.png
加粗.png

也就是说:宋体,加粗前后,字符的实际宽度变了。

字体实际宽度调研

仅对宋体,因为制式合同都是宋体为字体的。针对中文、英文、数字、特殊-英(特殊字符-半角)、特殊-中(特殊字符-全角)这些字符,做了加粗前后的对比。

  • win10系统(windows系统)下效果:


    字符宽度-win10.png
  • win11系统(windows系统)下效果:


    字符宽度-win11.png
  • macOS系统(苹果系统)下效果:


    字符宽度-苹果系统.png

对比了一下,发现:

  1. 在windows系统下,不加粗时,符合非汉字字符是半个汉字字符的宽度。在苹果系统下,就比较杂乱了。非汉字字符,有超过半个汉字宽度的,有小于半个汉字宽度。
  2. 加粗后,不管是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, "&amp;")
                .replace(/</g, "&lt;")
                .replace(/>/g, "&gt;")
                .replace(/"/g, "&quot;")
                .replace(/'/g, "&#039;");
        }
    </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断行背景线同时出现

        });
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容