'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, };