1. Posts/

前端实现段落多行文本长度相等的下划线

·阅读预计 6 分钟
css javascript js html React Vanilla JS 记录 Web 笔记
小北
作者
小北
目录

目的 #

在前端实现一段自动换行的文字, 每行都带下划线, 通常主流方法为 JS 分行 + 再为每行加底边。但对于计算文字长度实际会有较高的性能损耗,还无法很好实现自适应布局, 这里记录下我的尝试及解决方案。

之所以有这个需求是因为最近有一个打印申请表的功能, 最终效果要满足打印成 PDF 的效果和申请表趋近。

最终实现效果如下

分析及踩坑过程 #

过程其实就是无数个实现了但是不完美的记录, 不感兴趣的可以跳过本段。

目的是桌面端打印, 所以不用考虑移动端。首先想到的方法是利用 boder, 但不能重复, 因为是一段文字, boder 只能在四周, 所以需要穿过一段文字, 首先想到的从背景图上下手, linear-gradient 通过线性渐变, 查询了下相应的还有一个 repeating-linear-gradient() 可以创建一个由重复线性渐变组成图片, 控制行距来实现重复, 作为背景就能实现:

.underlined-text {
  line-height: 1.5; /* 1.5 行距 (24px) */
  background-image: repeating-linear-gradient(to bottom, 
    transparent, transparent calc(1.5em - 1px), 
    black calc(1.5em - 1px), 
    black 1.5em);
  background-size: 100% 1.5em;
}

生成的预览很完美, 高兴的太早, 然而在调用浏览器的打印功能时, 就发现顶部显示一条额外的线, 这是因为背景渐变从 0 开始渲染, 导致第一段 black 出现在第一行上方边缘, 不过这个还算好办, 向上偏移下应该能遮住:

 background-position: 0 1px;

其实干到这就可以交差了。但细想这条线段还是不对劲:

仔细观察上方那条额外的线, 粗细好像和其他的线段不一样, 通过屏幕放大功能, 印证了比下面的1px还细, 且糊。 也就是说, 以背景图的形式画出来的线, 存在两个问题:

  • 在浏览器打印渲染时, 和浏览器渲染上还是不一样的, 打印时这条线会糊掉
  • 边界在渲染时被模糊或者产生溢出;

虽然偏移了一个像素来解决, 但线条模糊的问题还是存在, 毕竟是图片。 强迫症的心既然存在, 就会一致膈应, 是图片没错, 1px 的线实际渲染起来也明显比其他 1px 的 boder 不一样粗, 为解决这个问题, 自然就想到使用svg来代替,

.svg-underlined-text {
  line-height: 1.5; /* 每行高度为 1.5em */
  font-size: 16px;
  background-image: url("data:image/svg+xml;utf8,\
    <svg xmlns='http://www.w3.org/2000/svg' width='100%' height='1.5em'>\
      <line x1='0' y1='100%' x2='100%' y2='100%' stroke='black' stroke-width='1' />\
    </svg>");
  background-repeat: repeat-y;
  background-size: 100% 1.5em;
}

看了下预览打印和实际打印成 PDF 线条锐利, 也不会出现顶部有线的情况

现在看起来就非常完美了吗?

NO 图样图森, 1px 的 boder 更细, 一头雾水, 查了下原来是: svg 的 <line> 可能有亚像素被抗锯齿模糊处理的可能。 因为, SVG <line> 的 stroke-width 以像素为单位, 居中偏移,

<rect> 它是从一个固定起点开始, 铺一个确定高度的实体块, 在屏幕上就会清晰度更高, 用矩形绘制则更逼近boder的效果:

background-image: url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='24' shape-rendering='crispEdges'>\
<rect x='0' y='23' width='100%' height='1' fill='black' />\
</svg>");
  background-repeat: repeat-y;
  background-size: 100% 24px;

此时浏览器已经能渲染出和 boder 一样粗的线段了, 准备打印成 PDF 欣赏下, 结果看上去还是天塌了:

PDF 经不起放大, 拿 Acrobat 看了下, PDF 文件里的这个 svg 位置居然是位图, 会自动将 svg 转换成位图。

我之所以用 svg 是之前就打印过含有 svg 的网页, 放大过很清晰的, 本以为是 Chrome 更新导致的, 但跑之前的项目了下, 还是很清晰。

唯一不同的是, 之前 svg 是直接使用的 svg 元素。遂得出结论:

DOM <svg> 标签才能高质量打印, background-image: url(xxx.svg) 这种 css 实现的会当作位图嵌入。

这样的话, 如果想高质量打印, 只能使用真正的 <svg> 元素嵌入页面, 既然 background-image 不能高质量打印, 背景图来实现这条路堵死, 只能上 JS 了:

后面准备不拖鞋了, 准备就 JS 分行解决。

吃完饭回来准备开写的时候, 想到 JS 分行的本质是拆分文字, 性能消耗于在拆分文字上, 背景图本质就是在文字下面垫底, 所以文字自动换行, 背景图因为是重复的纵向要多长有多长。

最终方案 #

继续把背景想成是一个独立的层, 可以在这个层里绘制足够多的含有底边的 boder 就好了, 这样就不用计算分行和文字宽度, 溢出的反正可以使用一个父容器 “遮掉”

具体实现如下:

:root {
  --font-size: 1em;
  --line-height: 1.5em;
  --border-height: 1px;
}

.auto-underlined {
    position: relative;
    font-size: var(--font-size);
    line-height: var(--line-height);
    overflow: hidden;
}

.auto-underlined .lines {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 0;
    pointer-events: none;
}

.auto-underlined .line {
    height: calc(var(--line-height) - var(--border-height));
    border-bottom: var(--border-height) solid black;
}

.auto-underlined .text {
    position: relative;
    z-index: 1;
    white-space: normal;
    word-break: break-word;
}

h2 {
    margin-top: 2em;
}
function estimateLines(textEl, wrapperEl) {
    const text = textEl.innerText; // 获取文字内容
    const charCount = text.length; // 获取文本长度

    const style = getComputedStyle(wrapperEl); // 获取文本元素的所有 css
    const fontSize = parseFloat(style.fontSize);
    const width = wrapperEl.clientWidth; // 获取当前元素的宽度

    const avgCharWidth = fontSize * 1; // 平均单个字符宽度, 如果是全是中文则为1
    const charsPerLine = Math.floor(width / avgCharWidth); // 每行能显示的字符数

    const estimatedLines = Math.ceil(charCount / charsPerLine); // 计算需要的行数
    return estimatedLines + 1; // 加 1 行冗余, 如果文字换行比较多, 直接 * 3
}

function renderLinesFor(wrapperEl) {
    const textEl = wrapperEl.querySelector('.text');
    const linesEl = wrapperEl.querySelector('.lines');
    const count = estimateLines(textEl, wrapperEl); // 如果知道最多多少行, 直接输入数字更粗暴好使

    linesEl.innerHTML = '';
    for (let i = 0; i < count; i++) {
      const line = document.createElement('div');
      line.className = 'line';
      linesEl.appendChild(line);
    }
}

function initAutoUnderlined() {
    const wrappers = document.querySelectorAll('.auto-underlined');
    wrappers.forEach(wrapper => {
    renderLinesFor(wrapper);

    // 防抖 resize
    let resizeTimer;
    window.addEventListener('resize', () => {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(() => renderLinesFor(wrapper), 200);
    });

    // 监听内容变化
    const observer = new MutationObserver(() => renderLinesFor(wrapper));
    observer.observe(wrapper.querySelector('.text'), { childList: true, subtree: true, characterData: true });
    });
}

document.addEventListener('DOMContentLoaded', initAutoUnderlined);

完美, 虽然最后还是需要 JS 配合, 但是相比分行来的开销好啊。

Demo #

这里写了一个原生 DEMO , 方便查看 (打开后, 可以通过调整窗口大小观察)

自动下滑线示例


记录代码 #

最后, 记录下简化后的实际应用的代码

import React, { useState, useRef } from 'react';
import {
  IconButton,
  CircularProgress,
  Typography,
  Stack,
  ModalClose,
  Button,
  Tooltip,
  Modal,
  Box
} from '@mui/joy';
import PrintIcon from '@mui/icons-material/Print';
import { useMediaQuery } from '@mui/material';

function PrintApplication({ apply_no }) {
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(true);
  const [htmlUrl, setHtmlUrl] = useState(null);
  const iframeRef = useRef(null);
  const isMobile = useMediaQuery('(max-width: 600px)');
  const [printed, setPrinted] = useState(false);
  const apiUrl = process.env.REACT_APP_API_URL;

  const handlePrint = async () => {
    setOpen(true);
    setLoading(true);
    setPrinted(false);
    try {
      // 获取申请详情
      const response = await fetch(`${apiUrl}/application/detail/${apply_no} `, {
        headers: {
          'Authorization': `${localStorage.getItem('auth')} `,
        },
      });

      if (!response.ok) {
        throw new Error('获取申请详情失败');
      }

      const data = await response.json();

      const htmlContent = generatePrintHTML(data);

      const url = URL.createObjectURL(new Blob([htmlContent], { type: 'text/html' }));
      setHtmlUrl(url);
      setLoading(false);
    } catch (error) {
      console.error('打印申请信息失败:', error);
      setLoading(false);
      setOpen(false);
    }
  };

  const handleIframeLoad = () => {
    if (iframeRef.current && !loading && !printed) {
      try {
        // 调用 iframe 打印
        iframeRef.current.contentWindow.print();
        setPrinted(true);
      } catch (error) {
        console.error('打印触发失败:', error);
      }
    }
  };

  const handleClose = () => {
    setOpen(false);
    setLoading(true);
    setPrinted(false);
    if (htmlUrl) {
      URL.revokeObjectURL(htmlUrl);
      setHtmlUrl(null);
    }
  };

  // 生成打印HTML
  const generatePrintHTML = (data) => {
    return `< !DOCTYPE html >
      <html lang="zh-CN">
        <head>
          <meta charset="UTF-8" />
          <meta name="author" content="WUFEI" />
          <title>申请表</title>
          <style>
            :root {
              --font - size: 1.0625em;
            --line-height: 1.8em;
            --border-height: 1px;
            --paddingx: 0.2em;
            --padding-row-y: 0.5em;
      }
            @page {
              size: A4;
            margin: 0.8cm;
      }
            body {
              font - family: "Microsoft YaHei", sans-serif;
            font-size: 10.5pt;
            line-height: 1.3;
            color: #000;
            overflow: auto;
            scrollbar-width: none;
      }
            html::-webkit-scrollbar,
            body::-webkit-scrollbar {
              display: none;
      }
            .container {
              max - width: 100%;
      }
            .label {
              min - width: 6em;
            font-size: var(--font-size);
            white-space: nowrap;
            margin-right: 8px;
            font-weight: bold;
      }
            .value {
              flex: 1;
            min-width: 100px;
            border-bottom: 1px solid #000;
            padding: 0 var(--paddingx);
            min-height: 16px;
            line-height: var(--line-height);
      }
            .section {
              margin: var(--padding-row-y) 0;
      }
            .half-row {
              display: flex;
            gap: 32px;
      }
      .half-row > div {
              flex: 1;
            display: flex;
            align-items: center;
      }
            .auto-underlined {
              position: relative;
            font-size: var(--font-size);
            line-height: var(--line-height);
            overflow: hidden;
      }
            .auto-underlined .lines {
              position: absolute;
            top: 0;
            left: 0;
            right: 0;
            z-index: 0;
            pointer-events: none;
      }
            .auto-underlined .line {
              height: calc(var(--line-height) - var(--border-height));
            border-bottom: var(--border-height) solid black;
      }
            .auto-underlined .text {
              position: relative;
            z-index: 1;
            padding: 0 var(--paddingx);
            white-space: normal;
            word-break: break-word;
      }

            @media print {
              body {
              -webkit - print - color - adjust: exact;
            print-color-adjust: exact;
        }
      }
          </style>
          <script>
            function estimateLines(textEl, wrapperEl) {
        const text = textEl.innerText;
            const charCount = text.length;

            const style = getComputedStyle(wrapperEl);
            const fontSize = parseFloat(style.fontSize);
            const width = wrapperEl.clientWidth;

            const avgCharWidth = fontSize * 1;
            const charsPerLine = Math.floor(width / avgCharWidth);

            const estimatedLines = Math.ceil(charCount / charsPerLine);
            return estimatedLines * 3;
      }

            function renderLinesFor(wrapperEl) {
        const textEl = wrapperEl.querySelector('.text');
            const linesEl = wrapperEl.querySelector('.lines');
            const count = estimateLines(textEl, wrapperEl);

            linesEl.innerHTML = '';
            for (let i = 0; i < count; i++) {
          const line = document.createElement('div');
            line.className = 'line';
            linesEl.appendChild(line);
        }
      }

            function initAutoUnderlined() {
        const wrappers = document.querySelectorAll('.auto-underlined');
        wrappers.forEach(wrapper => {
              renderLinesFor(wrapper);

            let resizeTimer;
          window.addEventListener('resize', () => {
              clearTimeout(resizeTimer);
            resizeTimer = setTimeout(() => renderLinesFor(wrapper), 200);
          });

          const observer = new MutationObserver(() => renderLinesFor(wrapper));
            observer.observe(wrapper.querySelector('.text'), {childList: true, subtree: true, characterData: true });
        });
      }

            document.addEventListener('DOMContentLoaded', initAutoUnderlined);
          </script>
        </head>
        <body>
          <div class="container">

            <div class="section">
              <div class="half-row">
                <div>
                  <div class="label">名称:</div>
                  <div class="value">${data.cpnt_name || '&nbsp;'}</div>
                </div>
                <div>
                  <div class="label">数量:</div>
                  <div class="value">${data.prod_counts || '&nbsp;'}</div>
                </div>
              </div>
            </div>

            <div class="section">
              <div class="label">方法:</div>
              <div class="auto-underlined">
                <div class="lines"></div>
                <div class="text">${data.request_need || '&nbsp;'}</div>
              </div>
            </div>

          </div>
        </div>
        <footer>
          <div style="text-align: right; font-size: 10pt; color: #777; padding: 5px 0; margin-top: 6px">
            Created by WUFEI
          </div>
        </footer>
      </body>
</html > `;
  };

  return (
    <>
      <Tooltip title="预览打印 - 申请表" variant="outlined">
        <IconButton
          color="primary"
          variant="soft"
          onClick={handlePrint}
          size="sm"
        >
          <PrintIcon />
        </IconButton>
      </Tooltip>

      <Modal
        open={open}
        onClose={handleClose}
        sx={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center'
        }}
      >
        <Box
          sx={{
            backgroundColor: 'background.surface',
            borderRadius: 'md',
            width: isMobile ? '100%' : '80%',
            height: isMobile ? '100%' : '90%',
            display: 'flex',
            flexDirection: 'column',
            overflow: 'hidden',
            boxShadow: 'md'
          }}
        >
          <Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
            <Stack direction="row" justifyContent="space-between" alignItems="center">
              <Typography level="title-lg" endDecorator={
                <Button
                  variant="solid"
                  color="primary"
                  size="sm"
                  sx={{ ml: 2 }}
                  onClick={() => {
                    if (iframeRef.current) {
                      iframeRef.current.contentWindow.print();
                      setPrinted(true);
                    }
                  }}
                  startDecorator={<PrintIcon />}
                  disabled={loading}
                >
                  打印
                </Button>
              }>
                打印申请信息
              </Typography>
              <ModalClose onClick={handleClose} />
            </Stack>
          </Box>

          <Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#fff' }}>
            {loading ? (
              <Stack
                direction="column"
                spacing={2}
                alignItems="center"
                justifyContent="center"
                sx={{ py: 10 }}
              >
                <CircularProgress />
                <Typography>正在准备打印...</Typography>
              </Stack>
            ) : (
              <iframe
                ref={iframeRef}
                src={htmlUrl}
                style={{
                  width: '100%',
                  height: '100%',
                  border: 'none',
                }}
                onLoad={handleIframeLoad}
                title="打印预览"
              />
            )}
          </Box>
        </Box>
      </Modal>
    </>
  );
}

export default PrintApplication;

Related

本地部署并运行 AI 大语言模型详解
·阅读预计 2 分钟
Ollama AI Qwen DeepseekR1 Web 安装 记录 教程 教学 阿里云 Windows Mac Monterey
2025年 WSL 2 安装 Docker 详细记录
·阅读预计 2 分钟
WSL2 Docker Debian 安装 记录 教程 教学 Windows
PaddleOCR M1 MacBook 安装全记录
·阅读预计 4 分钟
PaddleOCR Homebrew Mac 记录