168 lines
6.3 KiB
JavaScript
168 lines
6.3 KiB
JavaScript
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
|
|
const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
</Types>`;
|
|
|
|
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
</Relationships>`;
|
|
|
|
const DOCUMENT_RELS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
|
|
|
|
function escapeXml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function decodeHtmlEntities(value) {
|
|
return String(value ?? '')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/gi, "'")
|
|
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
|
|
}
|
|
|
|
export function extractPlainTextFromHtml(html) {
|
|
return decodeHtmlEntities(
|
|
String(html ?? '')
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<\/(?:p|div|section|article|header|footer|h[1-6]|li|tr|blockquote)>/gi, '\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/[ \t\r\f\v]+/g, ' ')
|
|
.replace(/\n[ \t]+/g, '\n')
|
|
.replace(/[ \t]+\n/g, '\n')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim(),
|
|
);
|
|
}
|
|
|
|
function normalizeParagraphs(content) {
|
|
return String(content ?? '')
|
|
.split(/\n{1,}/)
|
|
.map((line) => line.replace(/^#{1,6}\s+/, '').replace(/[*_`~]/g, '').trim())
|
|
.filter(Boolean)
|
|
.slice(0, 500);
|
|
}
|
|
|
|
function paragraphXml(text, { heading = false } = {}) {
|
|
const style = heading ? '<w:pPr><w:pStyle w:val="Title"/></w:pPr>' : '';
|
|
const runStyle = heading ? '<w:rPr><w:b/><w:sz w:val="32"/></w:rPr>' : '';
|
|
return `<w:p>${style}<w:r>${runStyle}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
|
|
}
|
|
|
|
function documentXml({ title, paragraphs }) {
|
|
const body = [
|
|
title ? paragraphXml(title, { heading: true }) : '',
|
|
...paragraphs.map((paragraph) => paragraphXml(paragraph)),
|
|
].join('');
|
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:document xmlns:w="${W_NS}" xmlns:r="${R_NS}">
|
|
<w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body>
|
|
</w:document>`;
|
|
}
|
|
|
|
const CRC_TABLE = (() => {
|
|
const table = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n += 1) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
table[n] = c >>> 0;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
function crc32(buffer) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function dosDateTime(date = new Date()) {
|
|
const year = Math.max(1980, date.getFullYear());
|
|
const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
|
const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
return { dosTime, dosDate };
|
|
}
|
|
|
|
function writeZipEntry(name, content, offset) {
|
|
const nameBuffer = Buffer.from(name);
|
|
const data = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
|
const checksum = crc32(data);
|
|
const { dosTime, dosDate } = dosDateTime();
|
|
const local = Buffer.alloc(30 + nameBuffer.length);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0, 6);
|
|
local.writeUInt16LE(0, 8);
|
|
local.writeUInt16LE(dosTime, 10);
|
|
local.writeUInt16LE(dosDate, 12);
|
|
local.writeUInt32LE(checksum, 14);
|
|
local.writeUInt32LE(data.length, 18);
|
|
local.writeUInt32LE(data.length, 22);
|
|
local.writeUInt16LE(nameBuffer.length, 26);
|
|
nameBuffer.copy(local, 30);
|
|
|
|
const central = Buffer.alloc(46 + nameBuffer.length);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(20, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0, 8);
|
|
central.writeUInt16LE(0, 10);
|
|
central.writeUInt16LE(dosTime, 12);
|
|
central.writeUInt16LE(dosDate, 14);
|
|
central.writeUInt32LE(checksum, 16);
|
|
central.writeUInt32LE(data.length, 20);
|
|
central.writeUInt32LE(data.length, 24);
|
|
central.writeUInt16LE(nameBuffer.length, 28);
|
|
central.writeUInt32LE(offset, 42);
|
|
nameBuffer.copy(central, 46);
|
|
return { local, data, central };
|
|
}
|
|
|
|
function zip(entries) {
|
|
let offset = 0;
|
|
const locals = [];
|
|
const centrals = [];
|
|
for (const [name, content] of entries) {
|
|
const entry = writeZipEntry(name, content, offset);
|
|
locals.push(entry.local, entry.data);
|
|
centrals.push(entry.central);
|
|
offset += entry.local.length + entry.data.length;
|
|
}
|
|
const centralSize = centrals.reduce((sum, item) => sum + item.length, 0);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralSize, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
return Buffer.concat([...locals, ...centrals, end]);
|
|
}
|
|
|
|
export function generateDocxBuffer({ title = '文档', content = '', contentFormat = 'markdown' } = {}) {
|
|
const plain = contentFormat === 'html' ? extractPlainTextFromHtml(content) : String(content ?? '');
|
|
const paragraphs = normalizeParagraphs(plain);
|
|
return zip([
|
|
['[Content_Types].xml', CONTENT_TYPES],
|
|
['_rels/.rels', ROOT_RELS],
|
|
['word/document.xml', documentXml({ title, paragraphs: paragraphs.length > 0 ? paragraphs : [''] })],
|
|
['word/_rels/document.xml.rels', DOCUMENT_RELS],
|
|
]);
|
|
}
|