Files
memind/mindspace-docx-export.mjs
T
2026-07-02 19:52:26 +08:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function decodeHtmlEntities(value) {
return String(value ?? '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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],
]);
}