前言
由于自己的项目需要一个能够将HTML
结构实时预览为 PDF
效果图的功能,然后社区里找了很久也没发现有合适的插件能够支持,没办法,只能自己动手造了(痛~)。
分页思路
绝对定位 + 偏移位
首先A4纸每一页的宽高分别是 210mm
和 297mm
,换算成像素是 794px
和 1123px
,如果当前 HTML
内容的高度超过了一页A4
纸的高度,那么就需要创建新的一页,将超出的内容放置到下一页,以此类推。
代码实现
re-render
为需要渲染分页内容的容器jufe-wrapper-page
为 A4 纸大小的容器jufe-wrapper-page-item
为真实渲染区域
const A4_HEIGHT = 1123
// 分割视图 传入一个需要分页的元素
export function splitPage(renderCV: HTMLElement) {
let page = 0,
realHeight = 0
const target = renderCV.clientHeight,
reRender = document.querySelector('.re-render') as HTMLElement
reRender.innerHTML = ''
while (target - realHeight > 0) {
const wrapper = createDIV(),
resumeNode = renderCV.cloneNode(true) as HTMLElement
wrapper.classList.add('jufe-wrapper-page')
// 创建A4纸里面真正需要渲染的内容 且最小化高度
const realRenderHeight = Math.min(target - realHeight, A4_HEIGHT)
const wrapperItem = createDIV()
wrapperItem.classList.add('jufe-wrapper-page-item')
wrapperItem.style.height = realRenderHeight + 'px'
resumeNode.style.position = 'absolute'
// 计算当前偏移位
resumeNode.style.top = -page * A4_HEIGHT + 'px'
resumeNode.style.left = 0 + 'px'
wrapperItem.appendChild(resumeNode)
wrapper.appendChild(wrapperItem)
realHeight += A4_HEIGHT
page++
reRender?.appendChild(wrapper) // 将当前生成的 A4 纸添加到容器中
}
}
不足之处
现在的分页效果已经有了,但是分页过程中会出现一个体验非常差的问题,那就是边界内容会被截断,怎么理解?比如一段文字,它的上半部分显示在第一页,下半部分显示在第二页,这就是内容截断。为什么会出现这种情况?出现这种情况是因为定位造成的,定位只关注具体的像素,而识别不了边界位置是否有元素存在。
解决方案
即然在边界的内容被截断了,那么我们需要想办法找到边界那个被截断的元素,在该元素前面创建一个空白元素,高度为被截断的上半部分内容的高度,然后在分页之前,把该元素挤到下一页去,确保分页的时候内容被完全显示,画个图理解一下。
没有处理边界元素之前
处理了边界元素之后
代码实现
有了思路,试试写出代码
找到处于边界的元素节点
这个节点高度应该尽可能的小,比如一个div它的偏移位加上自己本身的高度,已经超出了当前的页所能容纳的高度,那么它是边界元素,但是如果它里面还有div等块元素存在的话,那么我们需要秉承深度优先的原则,让这个占位符的高度尽可能的最小化
// 处理边界内容截断
export function handlerWhiteBoundary(renderCV: HTMLElement) {
// 首先我们获取到当前页面的边距,方便后续新页也保持这个边距大小
const pt = +getComputedStyle(renderCV).getPropertyValue('padding-top').slice(0, -2)
const pb = +getComputedStyle(renderCV).getPropertyValue('padding-bottom').slice(0, -2)
const children = Array.from(renderCV.children) as HTMLElement[]
// 记录页码 必须为引用 因为该变量涉及参数传递
const pageSize = { value: 1 }
for (const child of children) {
// 子元素的外边距也需要参与计算
const height = calculateElementHeight(child)
// 当前元素距离最外层容器的高度 通过actualTop可以判断元素是否处于容器的边界
const actualTop = getElementTop(child, renderCV)
// 如果总长度已经超出了一页A4纸的高度(除去底部边距的高度) 那么需要找到边界元素
if (actualTop + height > A4_HEIGHT * pageSize.value - pb) {
if (child.children.length) {
// 有子节点 继续往深度查找 最小化空白元素的高度
findBoundaryElement(child, renderCV, pt, pb, pageSize)
} else {
// 没有子节点 计算空白占位符的高度 插入到边界元素的前面
renderCV.insertBefore(
createBoundaryWhiteSpace(A4_HEIGHT * pageSize.value - actualTop + pt),
child
)
// A4 纸页数增加
++pageSize.value
}
}
}
return renderCV
}
// 最小化空白占位符的高度,深度优先查找
function findBoundaryElement(
node: HTMLElement,
target: HTMLElement,
paddingTop: number,
paddingBottom: number,
pageSize: { value: number }
) {
const children = Array.from(node.children) as HTMLElement[]
for (const child of children) {
const totalHeight = calculateElementHeight(child)
const actualTop = getElementTop(child, target)
if (actualTop + totalHeight > A4_HEIGHT * pageSize.value - paddingBottom) {
// 直接排除一行段落文字 因为在一段普通文本中没必要再进行深入,它们内嵌不了什么其他元素
if (child.children.length && !['p', 'li'].includes(child.tagName.toLocaleLowerCase())) {
findBoundaryElement(child, target, paddingTop, paddingBottom, pageSize)
} else {
// 找到了边界 给边界元素前插入空白元素 将内容挤压至下一页
node.insertBefore(
createBoundaryWhiteSpace(A4_HEIGHT * pageSize.value - actualTop + paddingTop),
child
)
pageSize.value++
}
}
}
}
// 创建空白占位符
function createBoundaryWhiteSpace(h: number) {
const whiteSpace = createDIV()
whiteSpace.setAttribute(WHITE_SPACE, 'true')
// 创建边界空白占位符 加上顶部边距
whiteSpace.style.height = h + 'px'
return whiteSpace
}
// 获取元素高度
function calculateElementHeight(element: HTMLElement) {
// 获取样式对象
const styles = getComputedStyle(element)
// 获取元素的内容高度
// const contentHeight = element.getBoundingClientRect().height
const contentHeight = element.clientHeight
// 获取元素的外边距高度
const marginHeight =
+styles.getPropertyValue('margin-top').slice(0, -2) +
+styles.getPropertyValue('margin-bottom').slice(0, -2)
// 计算元素的总高度
const totalHeight = contentHeight + marginHeight
return totalHeight
}
// 获取元素距离目标元素顶部偏移位
function getElementTop(element: HTMLElement, target: HTMLElement) {
let actualTop = element.offsetTop
let current = element.offsetParent as HTMLElement
while (current !== target) {
actualTop += current.offsetTop
current = current.offsetParent as HTMLElement
}
return actualTop
}
到此为止,核心代码已经写完了,现在我们在分页之前先使用 handlerWhiteBoundary
函数把边界元素都处理一下
// 分割视图
export function splitPage(renderCV: HTMLElement) {
// 分页之前先进行边界元素处理
handlerWhiteBoundary(renderCV)
let page = 0,
realHeight = 0
const target = renderCV.clientHeight,
reRender = document.querySelector('.re-render') as HTMLElement
// ...
// 省略后续代码
}
最终效果图
ok,到此为止已经实现了我想要的效果
完整代码
const A4_HEIGHT = 1123
// 分割视图 传入一个需要分页的元素
export function splitPage(renderCV: HTMLElement) {
handlerWhiteBoundary(renderCV)
let page = 0,
realHeight = 0
const target = renderCV.clientHeight,
reRender = document.querySelector('.re-render') as HTMLElement
reRender.innerHTML = ''
while (target - realHeight > 0) {
const wrapper = createDIV(),
resumeNode = renderCV.cloneNode(true) as HTMLElement
wrapper.classList.add('jufe-wrapper-page')
// 创建A4纸里面真正需要渲染的内容 且最小化高度
const realRenderHeight = Math.min(target - realHeight, A4_HEIGHT)
const wrapperItem = createDIV()
wrapperItem.classList.add('jufe-wrapper-page-item')
wrapperItem.style.height = realRenderHeight + 'px'
resumeNode.style.position = 'absolute'
// 计算当前偏移位
resumeNode.style.top = -page * A4_HEIGHT + 'px'
resumeNode.style.left = 0 + 'px'
wrapperItem.appendChild(resumeNode)
wrapper.appendChild(wrapperItem)
realHeight += A4_HEIGHT
page++
reRender?.appendChild(wrapper) // 将当前生成的 A4 纸添加到容器中
}
}
// 处理边界内容截断
export function handlerWhiteBoundary(renderCV: HTMLElement) {
// 首先我们获取到当前页面的边距,方便后续新页也保持这个边距大小
const pt = +getComputedStyle(renderCV).getPropertyValue('padding-top').slice(0, -2)
const pb = +getComputedStyle(renderCV).getPropertyValue('padding-bottom').slice(0, -2)
const children = Array.from(renderCV.children) as HTMLElement[]
// 记录页码 必须为引用 因为该变量涉及参数传递
const pageSize = { value: 1 }
for (const child of children) {
// 子元素的外边距也需要参与计算
const height = calculateElementHeight(child)
// 当前元素距离最外层容器的高度 通过actualTop可以判断元素是否处于容器的边界
const actualTop = getElementTop(child, renderCV)
// 如果总长度已经超出了一页A4纸的高度(除去底部边距的高度) 那么需要找到边界元素
if (actualTop + height > A4_HEIGHT * pageSize.value - pb) {
if (child.children.length) {
// 有子节点 继续往深度查找 最小化空白元素的高度
findBoundaryElement(child, renderCV, pt, pb, pageSize)
} else {
// 没有子节点 计算空白占位符的高度 插入到边界元素的前面
renderCV.insertBefore(
createBoundaryWhiteSpace(A4_HEIGHT * pageSize.value - actualTop + pt),
child
)
// A4 纸页数增加
++pageSize.value
}
}
}
return renderCV
}
// 最小化空白占位符的高度,深度优先查找
function findBoundaryElement(
node: HTMLElement,
target: HTMLElement,
paddingTop: number,
paddingBottom: number,
pageSize: { value: number }
) {
const children = Array.from(node.children) as HTMLElement[]
for (const child of children) {
const totalHeight = calculateElementHeight(child)
const actualTop = getElementTop(child, target)
if (actualTop + totalHeight > A4_HEIGHT * pageSize.value - paddingBottom) {
// 直接排除一行段落文字 因为在一段普通文本中没必要再进行深入,它们内嵌不了什么其他元素
if (child.children.length && !['p', 'li'].includes(child.tagName.toLocaleLowerCase())) {
findBoundaryElement(child, target, paddingTop, paddingBottom, pageSize)
} else {
// 找到了边界 给边界元素前插入空白元素 将内容挤压至下一页
node.insertBefore(
createBoundaryWhiteSpace(A4_HEIGHT * pageSize.value - actualTop + paddingTop),
child
)
pageSize.value++
}
}
}
}
// 创建空白占位符
function createBoundaryWhiteSpace(h: number) {
const whiteSpace = createDIV()
whiteSpace.setAttribute(WHITE_SPACE, 'true')
// 创建边界空白占位符 加上顶部边距
whiteSpace.style.height = h + 'px'
return whiteSpace
}
// 获取元素高度
function calculateElementHeight(element: HTMLElement) {
// 获取样式对象
const styles = getComputedStyle(element)
// 获取元素的内容高度
// const contentHeight = element.getBoundingClientRect().height
const contentHeight = element.clientHeight
// 获取元素的外边距高度
const marginHeight =
+styles.getPropertyValue('margin-top').slice(0, -2) +
+styles.getPropertyValue('margin-bottom').slice(0, -2)
// 计算元素的总高度
const totalHeight = contentHeight + marginHeight
return totalHeight
}
// 获取元素距离目标元素顶部偏移位
function getElementTop(element: HTMLElement, target: HTMLElement) {
let actualTop = element.offsetTop
let current = element.offsetParent as HTMLElement
while (current !== target) {
actualTop += current.offsetTop
current = current.offsetParent as HTMLElement
}
return actualTop
}