思维导图类应用(如基于DOM渲染的树形结构,或基于canvas、SVG绘制的图谱)经常需要提供导出PNG的能力。开发者通常直接对节点或画布调用截图逻辑,导出的图片在网页上看着还行,一旦放进PPT或送去打印就糊成一团。问题多半出在两处:一是canvas默认按CSS像素导出,没有乘以设备像素比或自定义倍率;二是PNG的DPI元数据根本没有写入,打印机仍然按72DPI解释图片。要在TypeScript项目中系统性地解决这两个问题,第一步就是把导出配置的类型定义设计好,用类型系统约束住所有影响分辨率的参数。

一、为什么导出配置需要专门的类型定义
不少项目里导出功能的参数是散落的:一个scale变量写在渲染函数里,一个quality参数写在toDataURL调用处,DPI则是硬编码的数字。这种写法在需求简单时没有问题,但思维导图导出通常涉及多个可选维度——倍率导出、指定目标宽度、打印级DPI、透明背景、是否包含水印等。一旦参数之间出现互斥关系(例如指定了targetWidth就不应再传scale),松散的参数会让调用方很容易传错。
用TypeScript的接口与联合类型,可以把这些约束固化下来。编译器会在调用点直接报错,比运行时的if判断更可靠。同时,类型定义本身就是一份文档,后续维护者看一眼接口就知道支持哪些导出模式。
二、核心类型定义:分辨率与DPI的完整描述
先定义方向清晰的基础枚举。DPI是打印领域概念,常见取值有72(屏幕默认)、96(Windows标准)、150(普通打印)、300(高清打印)。用字面量联合类型配合数字类型,既允许预设值也允许自定义:
/** PNG导出配置 */
export interface PngExportOptions {
/** 缩放倍率,1为原始尺寸,2相当于Retina清晰度 */
scale?: number;
/** 目标宽度(物理像素),与scale互斥,优先级高于scale */
targetWidth?: number;
/** 目标高度(物理像素),与targetWidth配合使用 */
targetHeight?: number;
/** 输出DPI,默认96,打印建议300 */
dpi?: DpiPreset | number;
/** 是否透明背景,默认true */
transparent?: boolean;
/** JPEG质量,仅format为jpeg时生效,0-1之间 */
quality?: number;
/** 导出格式 */
format?: 'png' | 'jpeg' | 'webp';
}
/** 预设DPI字面量类型 */
export type DpiPreset = 72 | 96 | 150 | 300 | 600;
/** 思维导图节点导出专有配置 */
export interface MindmapNodeExportOptions extends PngExportOptions {
/** 是否包含子节点,默认true */
includeChildren?: boolean;
/** 是否折叠状态下导出,默认false */
collapsed?: boolean;
/** 节点内边距(物理像素) */
padding?: number;
/** 导出文件名,不含扩展名 */
fileName?: string;
}注意DpiPreset的设计技巧:字面量联合会为已知预设值提供智能提示,而尾部的number允许传入任意自定义DPI。如果希望严格限制,可以把number去掉,只保留字面量联合,编译器会拒绝其他值。scale与targetWidth的互斥关系无法靠接口直接表达,需要借助构造函数或工厂函数在运行前归一化处理。
三、运行时校验与类型守卫的配合
类型只在编译期生效,如果配置可能来自用户输入或JSON文件,就需要运行时校验。为关键参数编写类型守卫函数,可以让校验结果自动窄化类型:
function isValidExportOptions(
raw: unknown
): raw is PngExportOptions {
if (typeof raw !== 'object' || raw === null) return false;
const o = raw as Record<string, unknown>;
if (o.scale !== undefined &&
(typeof o.scale !== 'number' || o.scale <= 0 || o.scale > 5)) {
return false;
}
if (o.dpi !== undefined &&
(typeof o.dpi !== 'number' || o.dpi < 24 || o.dpi > 2400)) {
return false;
}
return true;
}
// 使用守卫后自动窄化
function handleConfig(raw: unknown) {
if (isValidExportOptions(raw)) {
// 此处raw已被窄化为PngExportOptions
console.log(raw.scale, raw.dpi);
} else {
throw new Error('导出配置不合法');
}
}限制scale上限为5是实践中的经验值,过大的倍率会导致canvas尺寸超出浏览器上限(Chrome单个canvas边长上限约16384像素,总像素数也有限制),直接抛出异常比生成一张黑图或空白图更友好。DPI的合理范围参考了常见设备能力,24是极低分辨率屏幕的下限,2400则覆盖了专业印刷需求。
四、DPI写入PNG的实现与类型联动
canvas的toDataURL原生不支持DPI元数据,PNG的DPI信息存放在pHYs数据块中。要在导出流程中写入DPI,需要操作二进制数据。下面这段代码展示了如何在toBlob之后修改PNG字节流,并把前面定义的类型参数传进去:
async function exportMindmapNode(
node: HTMLElement,
options: MindmapNodeExportOptions = {}
): Promise<Blob> {
const {
scale = 2,
dpi = 96,
transparent = true,
format = 'png'
} = options;
// 1. 按倍率创建高分辨率canvas
const rect = node.getBoundingClientRect();
const canvas = document.createElement('canvas');
canvas.width = Math.round(rect.width * scale);
canvas.height = Math.round(rect.height * scale);
const ctx = canvas.getContext('2d')!;
ctx.scale(scale, scale);
// 2. 绘制节点内容(示意:使用ForeignObject或图形库渲染)
// drawNode(ctx, node);
// 3. 导出Blob
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('导出失败'))),
`image/${format}`
);
});
// 4. 若为PNG且指定了DPI,改写pHYs数据块
if (format === 'png' && dpi !== 72) {
return injectDpi(blob, dpi);
}
return blob;
}
/** 向PNG二进制流注入pHYs块 */
async function injectDpi(blob: Blob, dpi: number): Promise<Blob> {
const ppm = Math.round(dpi / 0.0254); // 每米像素数
const buffer = new Uint8Array(await blob.arrayBuffer());
// pHYs块固定9字节数据,需在IHDR之后插入
const chunk = new Uint8Array(21);
const view = new DataView(chunk.buffer);
view.setUint32(0, 9); // 数据长度
chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs"
view.setUint32(8, ppm); // X方向像素密度
view.setUint32(12, ppm); // Y方向像素密度
chunk[16] = 1; // 单位:米
view.setUint32(17, crc32(chunk.subarray(4, 17)));
// 找到IHDR结束位置(第8字节起,IHDR数据13字节+4长度+4类型)
const insertPos = 8 + 4 + 4 + 13 + 4;
const merged = new Uint8Array(buffer.length + chunk.length);
merged.set(buffer.subarray(0, insertPos));
merged.set(chunk, insertPos);
merged.set(buffer.subarray(insertPos));
return new Blob([merged], { type: 'image/png' });
}这里的换算关系是关键:PNG的pHYs块以“像素每米”为单位存储密度,而用户习惯的是DPI(像素每英寸),换算公式为ppm等于dpi除以0.0254。CRC32校验可以复用现成实现,不必自己展开多项式计算。需要注意,injectDgi这类修改字节流的操作只适用于PNG,JPEG的DPI写在JFIF头的density字段中,处理方式完全不同,这也是前面把format写进类型定义的原因——调用方能清楚知道不同格式支持的选项。
五、常见踩坑点与类型层面的规避
第一个坑是忽略devicePixelRatio。如果目标只是匹配屏幕清晰度,scale可以默认取window.devicePixelRatio,但导出场景往往需要超过屏幕的清晰度,所以建议默认值设为2并允许调用方提升到3或4。
第二个坑是quality参数只对jpeg和webp生效。可以在类型上用交叉类型精确表达这种条件依赖,或者用函数重载让编译器在format为png时直接拒绝quality参数:
type BaseOptions = Omit<PngExportOptions, 'quality' | 'format'>;
/** PNG导出不接受quality */
function exportImage(
options: BaseOptions & { format?: 'png' }
): Promise<Blob>;
/** JPEG/WebP导出必须或可选提供quality */
function exportImage(
options: BaseOptions & { format: 'jpeg' | 'webp'; quality?: number }
): Promise<Blob>;
function exportImage(options: any): Promise<Blob> {
return exportMindmapNode(document.body, options);
}
// 正确调用
exportImage({ format: 'jpeg', quality: 0.92, dpi: 300 });
// 编译报错:png格式下quality不存在
// exportImage({ format: 'png', quality: 0.9 });第三个坑是超大画布限制。当思维导图节点很多且scale取4时,canvas可能超过浏览器上限。一个稳妥的方案是把最大允许像素数写成一个常量并在归一化函数里自动下调scale,同时通过console.warn提示用户实际使用的倍率,这样既保证导出成功,又不牺牲类型安全。把这套类型定义与校验逻辑封装成独立模块后,思维导图、流程图、白板等各类画布导出场景都能直接复用。
TypeScript类型定义canvas导出PNG图片DPI设置修改时间:2026-08-31 03:30:54