This commit is contained in:
2026-10-08 06:49:13 +04:00
parent 41d434ef97
commit 35611e2a91
84 changed files with 3430 additions and 375 deletions

456
docx/src/render/document.js Normal file
View 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,
};