Files
note2any/src/xiaohongshu/paginator.ts
2025-10-21 21:47:02 +08:00

203 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 文件xiaohongshu/paginator.ts — 小红书内容分页器:按切图比例自动分页,确保表格和图片不跨页。 */
import { NMPSettings } from '../settings';
/**
* 分页结果
*/
export interface PageInfo {
index: number; // 页码(从 0 开始)
content: string; // 该页的 HTML 内容
height: number; // 该页内容的实际高度(用于调试)
}
/**
* 解析横竖比例字符串为数值
*/
function parseAspectRatio(ratio: string): { width: number; height: number } {
const parts = ratio.split(':').map(p => parseFloat(p.trim()));
if (parts.length === 2 && parts[0] > 0 && parts[1] > 0) {
return { width: parts[0], height: parts[1] };
}
return { width: 3, height: 4 };
}
const PAGE_PADDING = 40; // 与 renderPage 保持一致的页面内边距
/**
* 计算目标页面高度
*/
function getTargetPageHeight(settings: NMPSettings): number {
const ratio = parseAspectRatio(settings.sliceImageAspectRatio);
const height = Math.round((settings.sliceImageWidth * ratio.height) / ratio.width);
return height;
}
/**
* 判断元素是否为不可分割元素(表格、图片、代码块等)
*/
function isIndivisibleElement(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
// 表格、图片、代码块、公式等不应跨页
return ['table', 'img', 'pre', 'figure', 'svg'].includes(tagName) ||
element.classList.contains('math-block') ||
element.classList.contains('mermaid') ||
element.classList.contains('excalidraw');
}
/**
* 将 HTML 内容分页
* @param articleElement 文章预览的 DOM 元素
* @param settings 插件设置
* @returns 分页结果数组
*/
export async function paginateArticle(
articleElement: HTMLElement,
settings: NMPSettings
): Promise<PageInfo[]> {
const pageHeight = getTargetPageHeight(settings);
const pageWidth = settings.sliceImageWidth;
// 创建临时测量容器:与实际页面一致的宽度与内边距
const measureHost = document.createElement('div');
measureHost.style.cssText = `
position: absolute;
left: -9999px;
top: 0;
width: ${pageWidth}px;
visibility: hidden;
box-sizing: border-box;
`;
document.body.appendChild(measureHost);
const measurePage = document.createElement('div');
measurePage.className = 'xhs-page';
measurePage.style.boxSizing = 'border-box';
measurePage.style.width = `${pageWidth}px`;
measurePage.style.padding = `${PAGE_PADDING}px`;
measurePage.style.background = 'white';
measurePage.style.position = 'relative';
measureHost.appendChild(measurePage);
const measureContent = document.createElement('div');
measureContent.className = 'xhs-page-content';
measurePage.appendChild(measureContent);
if (articleElement.classList.length > 0) {
measureContent.classList.add(...Array.from(articleElement.classList));
}
const measuredFontSize = window.getComputedStyle(articleElement).fontSize;
if (measuredFontSize) {
measureContent.style.fontSize = measuredFontSize;
}
const pages: PageInfo[] = [];
let currentPageContent: Element[] = [];
let currentPageHeight = 0;
let pageIndex = 0;
// 克隆文章内容以避免修改原始 DOM
const clonedArticle = articleElement.cloneNode(true) as HTMLElement;
const children = Array.from(clonedArticle.children);
for (const child of children) {
const childClone = child.cloneNode(true) as HTMLElement;
measureContent.appendChild(childClone);
await waitForLayout();
const totalHeight = measurePage.scrollHeight;
const isIndivisible = isIndivisibleElement(child);
const fitsCurrentPage =
totalHeight <= pageHeight ||
(!isIndivisible && totalHeight <= pageHeight * 1.1) ||
currentPageContent.length === 0;
if (fitsCurrentPage) {
currentPageContent.push(child);
currentPageHeight = totalHeight;
continue;
}
// 当前页已放不下:移除刚刚加入的克隆节点
measureContent.removeChild(childClone);
await waitForLayout();
if (currentPageContent.length > 0) {
pages.push({
index: pageIndex++,
content: wrapPageContent(currentPageContent),
height: currentPageHeight
});
}
currentPageContent = [child];
measureContent.innerHTML = '';
const firstClone = child.cloneNode(true) as HTMLElement;
measureContent.appendChild(firstClone);
await waitForLayout();
currentPageHeight = measurePage.scrollHeight;
// 不可分割元素即使超过高度也直接保留在新页
}
if (currentPageContent.length > 0) {
pages.push({
index: pageIndex,
content: wrapPageContent(currentPageContent),
height: currentPageHeight
});
}
document.body.removeChild(measureHost);
return pages;
}
async function waitForLayout(): Promise<void> {
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
/**
* 包装页面内容为完整的 HTML
*/
function wrapPageContent(elements: Element[]): string {
const wrapper = document.createElement('div');
wrapper.className = 'xhs-page-content';
elements.forEach(el => {
wrapper.appendChild(el.cloneNode(true));
});
return wrapper.innerHTML;
}
/**
* 渲染单个页面到容器
* 预览时缩放显示,切图时使用实际尺寸
*/
export function renderPage(
container: HTMLElement,
pageContent: string,
settings: NMPSettings
): void {
// 实际内容尺寸(切图使用)
const actualPageWidth = settings.sliceImageWidth;
const actualPageHeight = getTargetPageHeight(settings);
container.innerHTML = '';
// 直接设置为实际尺寸,用于切图
// 预览时通过外层 CSS 的 max-width 限制显示宽度,浏览器自动缩放
container.style.cssText = `
width: ${actualPageWidth}px;
height: ${actualPageHeight}px;
overflow: hidden;
box-sizing: border-box;
padding: ${PAGE_PADDING}px;
background: #faf1f1;
`;
const contentDiv = document.createElement('div');
contentDiv.className = 'xhs-page-content';
contentDiv.innerHTML = pageContent;
container.appendChild(contentDiv);
}