dootask/electron/lib/pdf-export.js
kuaifan f0982d7d9a efactor: 拆分 electron 主进程代码为独立模块
将 electron.js 中的 PDF 导出、渲染器辅助函数和工具函数拆分为独立模块:
  - electron/lib/pdf-export.js: PDF 导出相关功能
  - electron/lib/renderer.js: 渲染器辅助函数
  - electron/lib/other.js: 平台检测和 URL 验证常量

  此重构提高了代码可维护性,减少了主文件的复杂度。
2026-01-08 13:54:55 +00:00

440 lines
18 KiB
JavaScript
Vendored

const path = require('path')
const {BrowserWindow, ipcMain} = require('electron')
const loger = require("electron-log");
const crc = require("crc");
const zlib = require('zlib');
const PDFDocument = require('pdf-lib').PDFDocument;
const config = require('../package.json');
const MICRON_TO_PIXEL = 264.58 //264.58 micron = 1 pixel
const PIXELS_PER_INCH = 100.117 // Usually it is 100 pixels per inch but this give better results
const PNG_CHUNK_IDAT = 1229209940;
const LARGE_IMAGE_AREA = 30000000;
const pdfExport = {
//NOTE: Key length must not be longer than 79 bytes (not checked)
writePngWithText(origBuff, key, text, compressed, base64encoded) {
let isDpi = key == 'dpi';
let inOffset = 0;
let outOffset = 0;
let data = text;
let dataLen = isDpi ? 9 : key.length + data.length + 1; //we add 1 zeros with non-compressed data, for pHYs it's 2 of 4-byte-int + 1 byte
//prepare compressed data to get its size
if (compressed) {
data = zlib.deflateRawSync(encodeURIComponent(text));
dataLen = key.length + data.length + 2; //we add 2 zeros with compressed data
}
let outBuff = Buffer.allocUnsafe(origBuff.length + dataLen + 4); //4 is the header size "zTXt", "tEXt" or "pHYs"
try {
let magic1 = origBuff.readUInt32BE(inOffset);
inOffset += 4;
let magic2 = origBuff.readUInt32BE(inOffset);
inOffset += 4;
if (magic1 != 0x89504e47 && magic2 != 0x0d0a1a0a) {
throw new Error("PNGImageDecoder0");
}
outBuff.writeUInt32BE(magic1, outOffset);
outOffset += 4;
outBuff.writeUInt32BE(magic2, outOffset);
outOffset += 4;
} catch (e) {
loger.error(e.message, {stack: e.stack});
throw new Error("PNGImageDecoder1");
}
try {
while (inOffset < origBuff.length) {
let length = origBuff.readInt32BE(inOffset);
inOffset += 4;
let type = origBuff.readInt32BE(inOffset)
inOffset += 4;
if (type == PNG_CHUNK_IDAT) {
// Insert zTXt chunk before IDAT chunk
outBuff.writeInt32BE(dataLen, outOffset);
outOffset += 4;
let typeSignature = isDpi ? 'pHYs' : (compressed ? "zTXt" : "tEXt");
outBuff.write(typeSignature, outOffset);
outOffset += 4;
if (isDpi) {
let dpm = Math.round(parseInt(text) / 0.0254) || 3937; //One inch is equal to exactly 0.0254 meters. 3937 is 100dpi
outBuff.writeInt32BE(dpm, outOffset);
outBuff.writeInt32BE(dpm, outOffset + 4);
outBuff.writeInt8(1, outOffset + 8);
outOffset += 9;
data = Buffer.allocUnsafe(9);
data.writeInt32BE(dpm, 0);
data.writeInt32BE(dpm, 4);
data.writeInt8(1, 8);
} else {
outBuff.write(key, outOffset);
outOffset += key.length;
outBuff.writeInt8(0, outOffset);
outOffset++;
if (compressed) {
outBuff.writeInt8(0, outOffset);
outOffset++;
data.copy(outBuff, outOffset);
} else {
outBuff.write(data, outOffset);
}
outOffset += data.length;
}
let crcVal = 0xffffffff;
crcVal = crc.crcjam(typeSignature, crcVal);
crcVal = crc.crcjam(data, crcVal);
// CRC
outBuff.writeInt32BE(crcVal ^ 0xffffffff, outOffset);
outOffset += 4;
// Writes the IDAT chunk after the zTXt
outBuff.writeInt32BE(length, outOffset);
outOffset += 4;
outBuff.writeInt32BE(type, outOffset);
outOffset += 4;
origBuff.copy(outBuff, outOffset, inOffset);
// Encodes the buffer using base64 if requested
return base64encoded ? outBuff.toString('base64') : outBuff;
}
outBuff.writeInt32BE(length, outOffset);
outOffset += 4;
outBuff.writeInt32BE(type, outOffset);
outOffset += 4;
origBuff.copy(outBuff, outOffset, inOffset, inOffset + length + 4);// +4 to move past the crc
inOffset += length + 4;
outOffset += length + 4;
}
} catch (e) {
loger.error(e.message, {stack: e.stack});
throw e;
}
},
//TODO Create a lightweight html file similar to export3.html for exporting to vsdx
exportVsdx(event, args, directFinalize) {
let win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
preload: path.join(__dirname, 'electron-preload.js'),
webSecurity: true,
nodeIntegration: true,
contextIsolation: true,
},
})
let loadEvtCount = 0;
function loadFinished() {
loadEvtCount++;
if (loadEvtCount == 2) {
win.webContents.send('export-vsdx', args);
ipcMain.once('export-vsdx-finished', (evt, data) => {
let hasError = false;
if (data == null) {
hasError = true;
}
//Set finalize here since it is call in the reply below
function finalize() {
win.destroy();
}
if (directFinalize === true) {
event.finalize = finalize;
} else {
//Destroy the window after response being received by caller
ipcMain.once('export-finalize', finalize);
}
if (hasError) {
event.reply('export-error');
} else {
event.reply('export-success', data);
}
});
}
}
//Order of these two events is not guaranteed, so wait for them async.
//TOOD There is still a chance we catch another window 'app-load-finished' if user created multiple windows quickly
ipcMain.once('app-load-finished', loadFinished);
win.webContents.on('did-finish-load', loadFinished);
},
async mergePdfs(pdfFiles, xml) {
//Pass throgh single files
if (pdfFiles.length == 1 && xml == null) {
return pdfFiles[0];
}
try {
const pdfDoc = await PDFDocument.create();
pdfDoc.setCreator(config.name);
if (xml != null) {
//Embed diagram XML as file attachment
await pdfDoc.attach(Buffer.from(xml).toString('base64'), config.name + '.xml', {
mimeType: 'application/vnd.jgraph.mxfile',
description: config.name + ' Content'
});
}
for (let i = 0; i < pdfFiles.length; i++) {
const pdfFile = await PDFDocument.load(pdfFiles[i].buffer);
const pages = await pdfDoc.copyPages(pdfFile, pdfFile.getPageIndices());
pages.forEach(p => pdfDoc.addPage(p));
}
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
} catch (e) {
throw new Error('Error during PDF combination: ' + e.message);
}
},
exportDiagram(event, args, directFinalize) {
if (args.format == 'vsdx') {
pdfExport.exportVsdx(event, args, directFinalize);
return;
}
let browser = null;
try {
browser = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'electron-preload.js'),
backgroundThrottling: false,
contextIsolation: true,
disableBlinkFeatures: 'Auxclick' // Is this needed?
},
show: false,
frame: false,
enableLargerThanScreen: true,
transparent: args.format == 'png' && (args.bg == null || args.bg == 'none'),
});
if (serverUrl) {
browser.loadURL(serverUrl + 'drawio/webapp/export3.html').then(_ => { }).catch(_ => { })
} else {
browser.loadFile('./public/drawio/webapp/export3.html').then(_ => { }).catch(_ => { })
}
const contents = browser.webContents;
let pageByPage = (args.format == 'pdf' && !args.print), from, to, pdfs;
if (pageByPage) {
from = args.allPages ? 0 : parseInt(args.from || 0);
to = args.allPages ? 1000 : parseInt(args.to || 1000) + 1; //The 'to' will be corrected later
pdfs = [];
args.from = from;
args.to = from;
args.allPages = false;
}
contents.on('did-finish-load', function () {
//Set finalize here since it is call in the reply below
function finalize() {
browser.destroy();
}
if (directFinalize === true) {
event.finalize = finalize;
} else {
//Destroy the window after response being received by caller
ipcMain.once('export-finalize', finalize);
}
function renderingFinishHandler(evt, renderInfo) {
if (renderInfo == null) {
event.reply('export-error');
return;
}
let pageCount = renderInfo.pageCount, bounds = null;
//For some reason, Electron 9 doesn't send this object as is without stringifying. Usually when variable is external to function own scope
try {
bounds = JSON.parse(renderInfo.bounds);
} catch (e) {
bounds = null;
}
let pdfOptions = {pageSize: 'A4'};
let hasError = false;
if (bounds == null || bounds.width < 5 || bounds.height < 5) //very small page size never return from printToPDF
{
//A workaround to detect errors in the input file or being empty file
hasError = true;
} else {
pdfOptions = {
printBackground: true,
pageSize: {
width: bounds.width / PIXELS_PER_INCH,
height: (bounds.height + 2) / PIXELS_PER_INCH //the extra 2 pixels to prevent adding an extra empty page
},
margins: {
top: 0,
bottom: 0,
left: 0,
right: 0
} // no margin
}
}
let base64encoded = args.base64 == '1';
if (hasError) {
event.reply('export-error');
} else if (args.format == 'png' || args.format == 'jpg' || args.format == 'jpeg') {
//Adds an extra pixel to prevent scrollbars from showing
let newBounds = {
width: Math.ceil(bounds.width + bounds.x) + 1,
height: Math.ceil(bounds.height + bounds.y) + 1
};
browser.setBounds(newBounds);
//TODO The browser takes sometime to show the graph (also after resize it takes some time to render)
// 1 sec is most probably enough (for small images, 5 for large ones) BUT not a stable solution
setTimeout(function () {
browser.capturePage().then(function (img) {
//Image is double the given bounds, so resize is needed!
let tScale = 1;
//If user defined width and/or height, enforce it precisely here. Height override width
if (args.h) {
tScale = args.h / newBounds.height;
} else if (args.w) {
tScale = args.w / newBounds.width;
}
newBounds.width *= tScale;
newBounds.height *= tScale;
img = img.resize(newBounds);
let data = args.format == 'png' ? img.toPNG() : img.toJPEG(args.jpegQuality || 90);
if (args.dpi != null && args.format == 'png') {
data = pdfExport.writePngWithText(data, 'dpi', args.dpi);
}
if (args.embedXml == "1" && args.format == 'png') {
data = pdfExport.writePngWithText(data, "mxGraphModel", args.xml, true,
base64encoded);
} else {
if (base64encoded) {
data = data.toString('base64');
}
}
event.reply('export-success', data);
});
}, bounds.width * bounds.height < LARGE_IMAGE_AREA ? 1000 : 5000);
} else if (args.format == 'pdf') {
if (args.print) {
pdfOptions = {
scaleFactor: args.pageScale,
printBackground: true,
pageSize: {
width: args.pageWidth * MICRON_TO_PIXEL,
//This height adjustment fixes the output. TODO Test more cases
height: (args.pageHeight * 1.025) * MICRON_TO_PIXEL
},
marginsType: 1 // no margin
};
contents.print(pdfOptions, (success, errorType) => {
//Consider all as success
event.reply('export-success', {});
});
} else {
contents.printToPDF(pdfOptions).then(async (data) => {
pdfs.push(data);
to = to > pageCount ? pageCount : to;
from++;
if (from < to) {
args.from = from;
args.to = from;
ipcMain.once('render-finished', renderingFinishHandler);
contents.send('render', args);
} else {
data = await pdfExport.mergePdfs(pdfs, args.embedXml == '1' ? args.xml : null);
event.reply('export-success', data);
}
})
.catch((error) => {
event.reply('export-error', error);
});
}
} else if (args.format == 'svg') {
contents.send('get-svg-data');
ipcMain.once('svg-data', (evt, data) => {
event.reply('export-success', data);
});
} else {
event.reply('export-error', 'Error: Unsupported format');
}
}
ipcMain.once('render-finished', renderingFinishHandler);
if (args.format == 'xml') {
ipcMain.once('xml-data', (evt, data) => {
event.reply('export-success', data);
});
ipcMain.once('xml-data-error', () => {
event.reply('export-error');
});
}
args.border = args.border || 0;
args.scale = args.scale || 1;
contents.send('render', args);
});
} catch (e) {
if (browser != null) {
browser.destroy();
}
event.reply('export-error', e);
console.log('export-error', e);
}
},
}
const onExport = () => {
ipcMain.on('export', pdfExport.exportDiagram);
}
module.exports = {pdfExport, onExport};