/* 文件: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 { 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 { await new Promise(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); }