前端实现段落多行文本长度相等的下划线
目录
目的 #
在前端实现一段自动换行的文字, 每行都带下划线, 通常主流方法为 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 || ' '}</div>
</div>
<div>
<div class="label">数量:</div>
<div class="value">${data.prod_counts || ' '}</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 || ' '}</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;