mirror of
https://github.com/StepanovPlaton/SSAUGenerator.git
synced 2026-10-31 10:18:29 +04:00
- Move projects into content/<project>/{docx,pptx} with shared assets/
- Flatten config paths; output = project name in project folder
- Add pptx --init scaffold; remove docx exe build
- Add feature-complete content/example; ignore content/* except example
- Rewrite README and AGENTS.md
464 lines
14 KiB
JavaScript
464 lines
14 KiB
JavaScript
'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,
|
||
...(ctx.typography.language ? { language: { value: ctx.typography.language } } : {}),
|
||
})
|
||
);
|
||
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,
|
||
};
|