mirror of
https://github.com/StepanovPlaton/SSAUGenerator.git
synced 2026-10-31 10:18:29 +04:00
add pptx
This commit is contained in:
456
docx/src/render/document.js
Normal file
456
docx/src/render/document.js
Normal file
@@ -0,0 +1,456 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Document, ExternalHyperlink, TextRun } = require('docx');
|
||||
|
||||
const {
|
||||
pageBreak,
|
||||
paraRuns,
|
||||
textRun,
|
||||
sectionHeading,
|
||||
subsectionHeading,
|
||||
plainHeading,
|
||||
pointParagraph,
|
||||
structSection,
|
||||
appendixStart,
|
||||
tocSection,
|
||||
figureBlock,
|
||||
tableSto,
|
||||
formulaFromText,
|
||||
whereBlock,
|
||||
noteSingle,
|
||||
notesBlock,
|
||||
definitionsBlock,
|
||||
bibliographyList,
|
||||
listParagraphRuns,
|
||||
nextListInstance,
|
||||
resetListInstances,
|
||||
defaultSectionProps,
|
||||
pageNumberFooter,
|
||||
REFS,
|
||||
} = require('./primitives');
|
||||
const { isPlantUmlSource } = require('../plantuml/render');
|
||||
|
||||
function createState(md, sections) {
|
||||
return {
|
||||
md,
|
||||
sections,
|
||||
labelMap: new Map(),
|
||||
biblioCount: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function firstInlineText(tokens) {
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'inline' && String(token.content || '').trim()) {
|
||||
return token.content.trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── inline ────────────────────────────────────────────────────────
|
||||
|
||||
function citationRun(ctx, meta, state) {
|
||||
const { bracketOpen, bracketClose, pagePrefix } = ctx.config.citation;
|
||||
if (meta.n < 1 || (state.biblioCount > 0 && meta.n > state.biblioCount)) {
|
||||
state.errors.push(
|
||||
`Ссылка [@${meta.n}] выходит за пределы списка источников (${state.biblioCount})`
|
||||
);
|
||||
return textRun(ctx, `[@${meta.n}]`);
|
||||
}
|
||||
const text = meta.page
|
||||
? `${bracketOpen}${meta.n}, ${pagePrefix} ${meta.page}${bracketClose}`
|
||||
: `${bracketOpen}${meta.n}${bracketClose}`;
|
||||
return textRun(ctx, text);
|
||||
}
|
||||
|
||||
function xrefRun(ctx, meta, state) {
|
||||
const key = `${meta.kind}:${meta.label}`;
|
||||
const value = state.labelMap.get(key);
|
||||
if (value == null) {
|
||||
state.errors.push(`Не найдена метка @${key}`);
|
||||
return textRun(ctx, `@${key}`);
|
||||
}
|
||||
return textRun(ctx, String(value));
|
||||
}
|
||||
|
||||
function renderInline(ctx, inlineToken, state) {
|
||||
const runs = [];
|
||||
const flags = { bold: false, italics: false };
|
||||
const linkStack = [];
|
||||
if (!inlineToken || !inlineToken.children) return runs;
|
||||
|
||||
const push = (run) => {
|
||||
if (linkStack.length) linkStack[linkStack.length - 1].children.push(run);
|
||||
else runs.push(run);
|
||||
};
|
||||
const closeLink = () => {
|
||||
const link = linkStack.pop();
|
||||
const hyper = new ExternalHyperlink({ children: link.children, link: link.href });
|
||||
if (linkStack.length) linkStack[linkStack.length - 1].children.push(hyper);
|
||||
else runs.push(hyper);
|
||||
};
|
||||
|
||||
for (const child of inlineToken.children) {
|
||||
switch (child.type) {
|
||||
case 'text':
|
||||
push(textRun(ctx, child.content, flags));
|
||||
break;
|
||||
case 'softbreak':
|
||||
push(textRun(ctx, ' '));
|
||||
break;
|
||||
case 'hardbreak':
|
||||
push(new TextRun({ break: 1, font: ctx.typography.font, size: ctx.typography.sizeBody }));
|
||||
break;
|
||||
case 'strong_open':
|
||||
flags.bold = true;
|
||||
break;
|
||||
case 'strong_close':
|
||||
flags.bold = false;
|
||||
break;
|
||||
case 'em_open':
|
||||
flags.italics = true;
|
||||
break;
|
||||
case 'em_close':
|
||||
flags.italics = false;
|
||||
break;
|
||||
case 's_open':
|
||||
case 's_close':
|
||||
break;
|
||||
case 'code_inline':
|
||||
push(textRun(ctx, child.content, { size: ctx.typography.sizeSmall }));
|
||||
break;
|
||||
case 'link_open':
|
||||
linkStack.push({ href: child.attrGet('href') || '', children: [] });
|
||||
break;
|
||||
case 'link_close':
|
||||
if (linkStack.length) closeLink();
|
||||
break;
|
||||
case 'citation':
|
||||
push(citationRun(ctx, child.meta, state));
|
||||
break;
|
||||
case 'xref':
|
||||
push(xrefRun(ctx, child.meta, state));
|
||||
break;
|
||||
case 'image':
|
||||
state.warnings.push(
|
||||
'Инлайн-изображение пропущено; используйте директиву ::: figure'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (linkStack.length) closeLink();
|
||||
return runs;
|
||||
}
|
||||
|
||||
// ── списки ────────────────────────────────────────────────────────
|
||||
|
||||
function renderList(ctx, tokens, startIndex, depth, listCtx, state) {
|
||||
const open = tokens[startIndex];
|
||||
const ordered = open.type === 'ordered_list_open';
|
||||
const current = depth === 0 ? { ref: ordered ? REFS.number : REFS.dash } : listCtx;
|
||||
if (depth === 0) current.instance = nextListInstance(current.ref);
|
||||
|
||||
const elements = [];
|
||||
const level = Math.min(depth, 3);
|
||||
let i = startIndex + 1;
|
||||
const closeType = ordered ? 'ordered_list_close' : 'bullet_list_close';
|
||||
|
||||
while (i < tokens.length && tokens[i].type !== closeType) {
|
||||
if (tokens[i].type !== 'list_item_open') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
let firstParagraph = true;
|
||||
while (i < tokens.length && tokens[i].type !== 'list_item_close') {
|
||||
const token = tokens[i];
|
||||
if (token.type === 'paragraph_open') {
|
||||
const runs = renderInline(ctx, tokens[i + 1], state);
|
||||
if (firstParagraph) {
|
||||
elements.push(listParagraphRuns(ctx, runs, current.ref, level, current.instance));
|
||||
firstParagraph = false;
|
||||
} else {
|
||||
elements.push(
|
||||
paraRuns(ctx, runs, {
|
||||
extra: {
|
||||
indent: {
|
||||
left: ctx.config.lists.nestedLevelStep * (depth + 1),
|
||||
firstLine: ctx.typography.indentFirst,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
i += 3;
|
||||
} else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
|
||||
const nested = renderList(ctx, tokens, i, depth + 1, current, state);
|
||||
elements.push(...nested.elements);
|
||||
i = nested.next;
|
||||
} else if (token.type === 'directive') {
|
||||
elements.push(...renderDirective(ctx, token, state));
|
||||
i += 1;
|
||||
} else if (token.type === 'table_open') {
|
||||
const table = readTable(tokens, i);
|
||||
elements.push(...tableSto(ctx, { label: '', title: '', rows: table.rows }));
|
||||
i = table.next;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return { elements, next: i + 1 };
|
||||
}
|
||||
|
||||
// ── таблицы из markdown ───────────────────────────────────────────
|
||||
|
||||
function readTable(tokens, startIndex) {
|
||||
const rows = [];
|
||||
let current = null;
|
||||
let i = startIndex + 1;
|
||||
for (; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token.type === 'tr_open') {
|
||||
current = [];
|
||||
} else if (token.type === 'tr_close') {
|
||||
if (current) rows.push(current);
|
||||
current = null;
|
||||
} else if (token.type === 'inline') {
|
||||
if (current) current.push(token.content);
|
||||
} else if (token.type === 'table_close') {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { rows, next: i };
|
||||
}
|
||||
|
||||
// ── директивы ─────────────────────────────────────────────────────
|
||||
|
||||
function parseWhereBody(body) {
|
||||
return body
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, '').trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+[—–-]\s+/);
|
||||
if (parts.length < 2) return { symbol: line, desc: '' };
|
||||
return { symbol: parts[0].trim(), desc: parts.slice(1).join(' — ').trim() };
|
||||
});
|
||||
}
|
||||
|
||||
function splitNoteItems(body) {
|
||||
return body
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseDefinitions(body) {
|
||||
return parseWhereBody(body).map(({ symbol, desc }) => ({ term: symbol, definition: desc }));
|
||||
}
|
||||
|
||||
function findTableRows(md, body) {
|
||||
const tokens = md.parse(body, {});
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].type === 'table_open') return readTable(tokens, i).rows;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderDirective(ctx, token, state) {
|
||||
const { name, attrs, body } = token.info;
|
||||
switch (name) {
|
||||
case 'pagebreak':
|
||||
return [pageBreak()];
|
||||
case 'figure': {
|
||||
const cap = token.__caption || { label: '?' };
|
||||
const notes = body
|
||||
? body.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
let imagePath = attrs.file;
|
||||
if (isPlantUmlSource(attrs.file)) {
|
||||
imagePath = attrs.__renderedImage || null;
|
||||
} else if (attrs.file) {
|
||||
const resolved = path.isAbsolute(attrs.file)
|
||||
? attrs.file
|
||||
: path.resolve(ctx.assetsRoot, attrs.file);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
state.warnings.push(`Рисунок "${attrs.title || ''}": файл не найден: ${attrs.file}`);
|
||||
}
|
||||
}
|
||||
return figureBlock(ctx, {
|
||||
label: cap.label,
|
||||
title: attrs.title || '',
|
||||
imagePath,
|
||||
notesLines: notes,
|
||||
});
|
||||
}
|
||||
case 'table': {
|
||||
const cap = token.__caption || { label: '?' };
|
||||
const rows = findTableRows(state.md, body);
|
||||
if (!rows) {
|
||||
state.errors.push(
|
||||
`Директива ::: table "${attrs.title || ''}": не найдена markdown-таблица`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return tableSto(ctx, {
|
||||
label: cap.label,
|
||||
title: attrs.title || '',
|
||||
rows,
|
||||
note: attrs.note,
|
||||
});
|
||||
}
|
||||
case 'formula': {
|
||||
const cap = token.__caption || { label: '?' };
|
||||
return formulaFromText(ctx, body.trim(), cap.label);
|
||||
}
|
||||
case 'where':
|
||||
return whereBlock(ctx, parseWhereBody(body));
|
||||
case 'note':
|
||||
return [noteSingle(ctx, body.trim().replace(/\s*\n\s*/g, ' '))];
|
||||
case 'notes':
|
||||
return notesBlock(ctx, splitNoteItems(body));
|
||||
case 'definitions':
|
||||
return definitionsBlock(ctx, parseDefinitions(body));
|
||||
default:
|
||||
state.warnings.push(`Неизвестная директива ::: ${name}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── общий обход токенов ───────────────────────────────────────────
|
||||
|
||||
function renderTokens(ctx, tokens, state, opts = {}) {
|
||||
const mode = opts.mode || 'plain';
|
||||
const out = [];
|
||||
let firstH1 = true;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
switch (token.type) {
|
||||
case 'heading_open': {
|
||||
const level = Number(token.tag.slice(1));
|
||||
const text = tokens[i + 1] ? tokens[i + 1].content : '';
|
||||
if (mode === 'numbered') {
|
||||
if (level <= 1) {
|
||||
if (!(firstH1 && opts.suppressFirstPageBreak)) out.push(pageBreak());
|
||||
firstH1 = false;
|
||||
out.push(sectionHeading(ctx, text));
|
||||
} else if (level === 2) {
|
||||
out.push(subsectionHeading(ctx, text));
|
||||
} else {
|
||||
out.push(pointParagraph(ctx, text));
|
||||
}
|
||||
} else {
|
||||
out.push(plainHeading(ctx, text, level));
|
||||
}
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
case 'paragraph_open':
|
||||
out.push(paraRuns(ctx, renderInline(ctx, tokens[i + 1], state)));
|
||||
i += 2;
|
||||
break;
|
||||
case 'directive':
|
||||
out.push(...renderDirective(ctx, token, state));
|
||||
break;
|
||||
case 'bullet_list_open':
|
||||
case 'ordered_list_open': {
|
||||
const res = renderList(ctx, tokens, i, 0, null, state);
|
||||
out.push(...res.elements);
|
||||
i = res.next - 1;
|
||||
break;
|
||||
}
|
||||
case 'table_open': {
|
||||
const res = readTable(tokens, i);
|
||||
out.push(...tableSto(ctx, { label: '', title: '', rows: res.rows }));
|
||||
i = res.next - 1;
|
||||
break;
|
||||
}
|
||||
case 'fence':
|
||||
state.warnings.push('Блок кода проигнорирован (функция листингов удалена)');
|
||||
break;
|
||||
case 'hr':
|
||||
out.push(pageBreak());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderSections(ctx, state) {
|
||||
resetListInstances();
|
||||
const out = [];
|
||||
let first = true;
|
||||
|
||||
for (const section of state.sections) {
|
||||
const entry = section.entry;
|
||||
|
||||
if (entry.type === 'toc') {
|
||||
if (!ctx.config.toc.enabled) continue;
|
||||
out.push(...tocSection(ctx, [], { isFirstInDocument: first }));
|
||||
first = false;
|
||||
} else if (entry.type === 'structural') {
|
||||
const title = entry.title || section.meta.title || firstInlineText(section.tokens) || '';
|
||||
const body = renderTokens(ctx, section.tokens, state, { mode: 'plain' });
|
||||
out.push(...structSection(ctx, title, body, { pageBreakBefore: !first }));
|
||||
first = false;
|
||||
} else if (entry.type === 'numbered') {
|
||||
out.push(
|
||||
...renderTokens(ctx, section.tokens, state, {
|
||||
mode: 'numbered',
|
||||
suppressFirstPageBreak: first,
|
||||
})
|
||||
);
|
||||
first = false;
|
||||
} else if (entry.type === 'bibliography') {
|
||||
const body = bibliographyList(ctx, section.biblioEntries || []);
|
||||
out.push(
|
||||
...structSection(ctx, ctx.config.bibliography.title, body, { pageBreakBefore: !first })
|
||||
);
|
||||
first = false;
|
||||
} else if (entry.type === 'appendix') {
|
||||
out.push(...appendixStart(ctx, entry.letter, entry.title || ''));
|
||||
out.push(...renderTokens(ctx, section.tokens, state, { mode: 'plain' }));
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDocx(ctx, children) {
|
||||
resetListInstances();
|
||||
return new Document({
|
||||
features: ctx.config.document.features,
|
||||
numbering: ctx.numberingConfig,
|
||||
styles: ctx.documentStyles,
|
||||
sections: [
|
||||
{
|
||||
properties: defaultSectionProps(ctx),
|
||||
footers: { default: pageNumberFooter(ctx) },
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createState,
|
||||
renderSections,
|
||||
renderTokens,
|
||||
renderInline,
|
||||
readTable,
|
||||
parseWhereBody,
|
||||
buildDocx,
|
||||
};
|
||||
Reference in New Issue
Block a user