From 6c80239d45348877fcca364745d8a576d642ae65 Mon Sep 17 00:00:00 2001
From: LuckyFBB <976060700@qq.com>
Date: Mon, 29 Jun 2026 17:00:21 +0800
Subject: [PATCH] feat(createpdfdownload): add PDF export DOM helper
---
docs/api/functions/createPDFDownload.md | 58 ++++++++++++++
docs/api/globals.md | 1 +
docs/api/typedoc-sidebar.json | 6 +-
src/createPDFDownload/__test__/index.test.ts | 75 +++++++++++++++++
src/createPDFDownload/index.ts | 84 ++++++++++++++++++++
src/index.ts | 1 +
6 files changed, 224 insertions(+), 1 deletion(-)
create mode 100644 docs/api/functions/createPDFDownload.md
create mode 100644 src/createPDFDownload/__test__/index.test.ts
create mode 100644 src/createPDFDownload/index.ts
diff --git a/docs/api/functions/createPDFDownload.md b/docs/api/functions/createPDFDownload.md
new file mode 100644
index 0000000..b7f3de9
--- /dev/null
+++ b/docs/api/functions/createPDFDownload.md
@@ -0,0 +1,58 @@
+[dt-utils](../globals.md) / createPDFDownload
+
+# Function: createPDFDownload()
+
+> **createPDFDownload**(`container`, `options`): `HTMLElement` \| `null`
+
+Defined in: [createPDFDownload/index.ts:50](https://github.com/DTStack/dt-utils/blob/master/src/createPDFDownload/index.ts#L50)
+
+创建用于 PDF 下载的 DOM 副本,并移除报告内容中的滚动条。
+
+## Parameters
+
+### container
+
+`HTMLElement` \| `null` \| `undefined`
+
+需要导出的报告根节点
+
+### options
+
+`CreatePDFDownloadOptions` = `{}`
+
+导出 DOM 配置
+
+#### options.width
+
+`number` \| `string`
+
+导出根节点宽度。默认使用原始节点的 `getBoundingClientRect().width`。
+
+#### options.maxWidth
+
+`string`
+
+导出根节点最大宽度。默认移除 `max-width` 限制,避免离屏截图时内容被再次收窄。
+
+## Returns
+
+`HTMLElement` \| `null`
+
+可交给 PDF 导出工具的 DOM 副本
+
+## Description
+
+PDF 导出通常会将页面 DOM 转成图片再写入 PDF。如果直接复用预览区域,内部组件的固定高度和 `overflow: auto/scroll` 会被一起截图,导致导出的 PDF 出现滚动条。
+
+该函数会 clone 一份独立 DOM,展开其中的滚动容器,并保留原始节点宽度。原始 DOM 不会被修改。
+
+## Example
+
+```typescript
+import { createPDFDownload } from '@dtinsight/dt-utils';
+
+exportPDF({
+ filename: '分析报告',
+ getExportDom: () => createPDFDownload(reportRef.current),
+});
+```
diff --git a/docs/api/globals.md b/docs/api/globals.md
index 6f98284..ea4616c 100644
--- a/docs/api/globals.md
+++ b/docs/api/globals.md
@@ -10,6 +10,7 @@
## Utils
- [copy](functions/copy.md)
+- [createPDFDownload](functions/createPDFDownload.md)
- [downloadFile](functions/downloadFile.md)
- [generateUniqueId](functions/generateUniqueId.md)
- [generateUrlWithQuery](functions/generateUrlWithQuery.md)
diff --git a/docs/api/typedoc-sidebar.json b/docs/api/typedoc-sidebar.json
index ef46e2b..79116e3 100644
--- a/docs/api/typedoc-sidebar.json
+++ b/docs/api/typedoc-sidebar.json
@@ -29,6 +29,10 @@
"text": "copy",
"link": "/api/functions/copy.md"
},
+ {
+ "text": "createPDFDownload",
+ "link": "/api/functions/createPDFDownload.md"
+ },
{
"text": "downloadFile",
"link": "/api/functions/downloadFile.md"
@@ -149,4 +153,4 @@
}
]
}
-]
\ No newline at end of file
+]
diff --git a/src/createPDFDownload/__test__/index.test.ts b/src/createPDFDownload/__test__/index.test.ts
new file mode 100644
index 0000000..90652a5
--- /dev/null
+++ b/src/createPDFDownload/__test__/index.test.ts
@@ -0,0 +1,75 @@
+import createPDFDownload from '../index';
+
+describe('createPDFDownload', () => {
+ test('return null when container is empty', () => {
+ expect(createPDFDownload(null)).toBeNull();
+ expect(createPDFDownload(undefined)).toBeNull();
+ });
+
+ test('clone dom without changing source element', () => {
+ const container = document.createElement('div');
+ container.innerHTML = '';
+
+ const exportDom = createPDFDownload(container);
+
+ expect(exportDom).not.toBe(container);
+ expect(exportDom?.innerHTML).toBe(container.innerHTML);
+ expect(container.getAttribute('style')).toBeNull();
+ });
+
+ test('expand scroll containers in cloned dom', () => {
+ const container = document.createElement('div');
+ container.innerHTML = `
+
+ `;
+
+ const exportDom = createPDFDownload(container);
+ const sourceSection = container.querySelector('.report-section');
+ const exportSection = exportDom?.querySelector('.report-section');
+ const exportContent = exportDom?.querySelector('.report-content');
+
+ expect(sourceSection?.style.overflow).toBe('auto');
+ expect(sourceSection?.style.height).toBe('120px');
+ expect(exportSection?.style.height).toBe('auto');
+ expect(exportSection?.style.maxHeight).toBe('none');
+ expect(exportSection?.style.overflow).toBe('visible');
+ expect(exportSection?.style.overflowX).toBe('visible');
+ expect(exportSection?.style.overflowY).toBe('visible');
+ expect(exportContent?.style.height).toBe('auto');
+ expect(exportContent?.style.overflowY).toBe('visible');
+ });
+
+ test('set root width from container rect by default', () => {
+ const container = document.createElement('div');
+ jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+ width: 742,
+ height: 100,
+ top: 0,
+ right: 742,
+ bottom: 100,
+ left: 0,
+ x: 0,
+ y: 0,
+ toJSON: jest.fn(),
+ });
+
+ const exportDom = createPDFDownload(container);
+
+ expect(exportDom?.style.width).toBe('742px');
+ expect(exportDom?.style.maxWidth).toBe('none');
+ });
+
+ test('support custom root width and max width', () => {
+ const container = document.createElement('div');
+
+ const exportDom = createPDFDownload(container, {
+ width: '100%',
+ maxWidth: '960px',
+ });
+
+ expect(exportDom?.style.width).toBe('100%');
+ expect(exportDom?.style.maxWidth).toBe('960px');
+ });
+});
diff --git a/src/createPDFDownload/index.ts b/src/createPDFDownload/index.ts
new file mode 100644
index 0000000..437a6bb
--- /dev/null
+++ b/src/createPDFDownload/index.ts
@@ -0,0 +1,84 @@
+export interface CreatePDFDownloadOptions {
+ /**
+ * 导出根节点宽度。默认使用原始节点的 getBoundingClientRect().width。
+ */
+ width?: number | string;
+ /**
+ * 导出根节点最大宽度。默认移除 max-width 限制,避免离屏截图时内容被再次收窄。
+ */
+ maxWidth?: string;
+}
+
+const SCROLL_OVERFLOW_REGEXP = /auto|scroll/;
+
+function isScrollContainer(element: HTMLElement) {
+ const { overflow, overflowX, overflowY } = window.getComputedStyle(element);
+ return [overflow, overflowX, overflowY].some((value) => SCROLL_OVERFLOW_REGEXP.test(value));
+}
+
+function expandScrollContainer(element: HTMLElement) {
+ element.style.setProperty('height', 'auto', 'important');
+ element.style.setProperty('max-height', 'none', 'important');
+ element.style.setProperty('overflow', 'visible', 'important');
+ element.style.setProperty('overflow-x', 'visible', 'important');
+ element.style.setProperty('overflow-y', 'visible', 'important');
+}
+
+/**
+ * 创建用于 PDF 下载的 DOM 副本,并移除报告内容中的滚动条。
+ *
+ * @category Utils
+ * @description
+ * PDF 导出通常会将页面 DOM 转成图片再写入 PDF。如果直接复用预览区域,
+ * 内部组件的固定高度和 `overflow: auto/scroll` 会被一起截图,导致导出的 PDF
+ * 出现滚动条。该函数会 clone 一份独立 DOM,展开其中的滚动容器,并保留原始节点宽度。
+ *
+ * @param {HTMLElement | null | undefined} container - 需要导出的报告根节点
+ * @param {CreatePDFDownloadOptions} options - 导出 DOM 配置
+ * @returns {HTMLElement | null} 可交给 PDF 导出工具的 DOM 副本
+ *
+ * @example
+ * ```typescript
+ * import { createPDFDownload } from '@dtinsight/dt-utils';
+ *
+ * exportPDF({
+ * filename: '分析报告',
+ * getExportDom: () => createPDFDownload(reportRef.current),
+ * });
+ * ```
+ */
+const createPDFDownload = (
+ container?: HTMLElement | null,
+ options: CreatePDFDownloadOptions = {}
+): HTMLElement | null => {
+ if (!container) return null;
+
+ const exportDom = container.cloneNode(true) as HTMLElement;
+ const sourceElements = [container].concat(
+ Array.from(container.querySelectorAll('*'))
+ );
+ const exportElements = [exportDom].concat(
+ Array.from(exportDom.querySelectorAll('*'))
+ );
+
+ sourceElements.forEach((sourceElement, index) => {
+ const exportElement = exportElements[index];
+ if (!exportElement || !isScrollContainer(sourceElement)) return;
+
+ expandScrollContainer(exportElement);
+ });
+
+ const width =
+ options.width === undefined ? container.getBoundingClientRect().width : options.width;
+ if (typeof width === 'number') {
+ exportDom.style.width = `${width}px`;
+ } else if (width) {
+ exportDom.style.width = width;
+ }
+
+ exportDom.style.maxWidth = options.maxWidth || 'none';
+
+ return exportDom;
+};
+
+export default createPDFDownload;
diff --git a/src/index.ts b/src/index.ts
index f2e2ae9..698d1c6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ export * as lodash from 'lodash-es';
export { default as checkBrowserSupport } from './checkBrowserSupport';
export { default as Cookies } from './cookies';
export { default as copy } from './copy';
+export { default as createPDFDownload } from './createPDFDownload';
export { default as downloadFile } from './downloadFile';
export { default as formatBytes } from './formatBytes';
export { DateTimeFormat, default as formatDateTime } from './formatDateTime';