Files
SSAUGenerator/docx/src/render/document.js
StepanovPlaton bf7816ab7c Rename project to SSAU Generator and restructure content
- 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
2026-10-11 23:25:22 +04:00

464 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};