Fix DOCX headings, captions and page breaks; polish PPTX layout

- docx: zero paragraph indent for centered headings/captions/figure notes
  so they no longer shift right (empty <w:ind/> inherited 709 twips)
- docx: use pageBreakBefore instead of a separate break paragraph; drop
  the merger page break to remove the blank page after the title page
- docx: heading numbers separated by space instead of tab
- docx: bullet marker is the Symbol dash U+F02D
- pptx: narrower slide number moved left and bold; content title lower;
  section title on the right half like title/final
This commit is contained in:
2026-10-12 01:01:51 +04:00
parent b5b663b023
commit d374c26b09
7 changed files with 60 additions and 47 deletions

View File

@@ -33,6 +33,10 @@ async function buildDocument(config, { check = false } = {}) {
const ctx = buildContext(config); const ctx = buildContext(config);
const state = createState(md, sections); const state = createState(md, sections);
const titlePage = config.document.titlePage;
const hasTitlePage = Boolean(titlePage && fs.existsSync(titlePage));
ctx.hasTitlePage = hasTitlePage;
collectNumbers(sections, ctx, state); collectNumbers(sections, ctx, state);
await resolvePlantUml(ctx, sections, state); await resolvePlantUml(ctx, sections, state);
const children = renderSections(ctx, state); const children = renderSections(ctx, state);
@@ -42,8 +46,7 @@ async function buildDocument(config, { check = false } = {}) {
const doc = buildDocx(ctx, children); const doc = buildDocx(ctx, children);
let buffer = await Packer.toBuffer(doc); let buffer = await Packer.toBuffer(doc);
const titlePage = config.document.titlePage; if (hasTitlePage) {
if (titlePage && fs.existsSync(titlePage)) {
buffer = await mergeDocxBuffers(fs.readFileSync(titlePage), buffer); buffer = await mergeDocxBuffers(fs.readFileSync(titlePage), buffer);
} else if (titlePage) { } else if (titlePage) {
state.warnings.push(`Титульник не найден, пропущен: ${titlePage}`); state.warnings.push(`Титульник не найден, пропущен: ${titlePage}`);

View File

@@ -53,7 +53,8 @@ const DEFAULT_CONFIG = {
}, },
lists: { lists: {
bulletChar: '-', // Тире маркера — глиф Symbol U+F02D (рисуется шрифтом Symbol).
bulletChar: '\uF02D',
letterSuffix: ')', letterSuffix: ')',
numberSuffix: ')', numberSuffix: ')',
forbiddenLetters: ['ё', 'з', 'й', 'о', 'ч', 'ь', 'ы', 'ъ'], forbiddenLetters: ['ё', 'з', 'й', 'о', 'ч', 'ь', 'ы', 'ъ'],

View File

@@ -349,9 +349,9 @@ function renderTokens(ctx, tokens, state, opts = {}) {
const text = tokens[i + 1] ? tokens[i + 1].content : ''; const text = tokens[i + 1] ? tokens[i + 1].content : '';
if (mode === 'numbered') { if (mode === 'numbered') {
if (level <= 1) { if (level <= 1) {
if (!(firstH1 && opts.suppressFirstPageBreak)) out.push(pageBreak()); const breakBefore = !(firstH1 && opts.suppressFirstPageBreak);
firstH1 = false; firstH1 = false;
out.push(sectionHeading(ctx, text)); out.push(sectionHeading(ctx, text, { pageBreakBefore: breakBefore }));
} else if (level === 2) { } else if (level === 2) {
out.push(subsectionHeading(ctx, text)); out.push(subsectionHeading(ctx, text));
} else { } else {
@@ -399,7 +399,9 @@ function renderTokens(ctx, tokens, state, opts = {}) {
function renderSections(ctx, state) { function renderSections(ctx, state) {
resetListInstances(); resetListInstances();
const out = []; const out = [];
let first = true; // При наличии титульного листа первая часть тоже должна начинаться
// с новой страницы (титульник склеивается перед документом).
let first = !ctx.hasTitlePage;
for (const section of state.sections) { for (const section of state.sections) {
const entry = section.entry; const entry = section.entry;

View File

@@ -44,6 +44,10 @@ function alignmentOf(name) {
// ── базовые абзацы ──────────────────────────────────────────────── // ── базовые абзацы ────────────────────────────────────────────────
// Явное обнуление абзацного отступа. Пустой <w:ind/> наследует отступ
// базового стиля, поэтому для центрированных абзацев задаём нули явно.
const NO_INDENT = { left: 0, firstLine: 0 };
function textRun(ctx, text, opts = {}) { function textRun(ctx, text, opts = {}) {
const t = ctx.typography; const t = ctx.typography;
const run = { const run = {
@@ -73,7 +77,7 @@ function paraRuns(ctx, runs, opts = {}) {
return new Paragraph({ return new Paragraph({
children: runs, children: runs,
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: opts.noFirstIndent ? {} : { firstLine: ctx.typography.indentFirst }, indent: opts.noFirstIndent ? NO_INDENT : { firstLine: ctx.typography.indentFirst },
alignment: opts.alignment || AlignmentType.BOTH, alignment: opts.alignment || AlignmentType.BOTH,
...opts.extra, ...opts.extra,
}); });
@@ -87,40 +91,37 @@ function emptyLine(ctx) {
return new Paragraph({ return new Paragraph({
children: [textRun(ctx, ' ')], children: [textRun(ctx, ' ')],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {}, indent: NO_INDENT,
}); });
} }
// ── заголовки ───────────────────────────────────────────────────── // ── заголовки ─────────────────────────────────────────────────────
function structHeading(ctx, title, opts = {}) { function structHeading(ctx, title, opts = {}) {
const elements = [];
if (opts.pageBreakBefore !== false) elements.push(pageBreak());
const cfg = ctx.config.headings.struct; const cfg = ctx.config.headings.struct;
elements.push( return new Paragraph({
new Paragraph({ heading: HeadingLevel.HEADING_1,
heading: HeadingLevel.HEADING_1, pageBreakBefore: opts.pageBreakBefore === true,
children: [textRun(ctx, String(title).toUpperCase())], children: [textRun(ctx, String(title).toUpperCase())],
spacing: { spacing: {
...ctx.typography.lineSpacing, ...ctx.typography.lineSpacing,
before: cfg.spacingBefore, before: cfg.spacingBefore,
after: cfg.spacingAfter, after: cfg.spacingAfter,
}, },
alignment: alignmentOf(cfg.align), alignment: alignmentOf(cfg.align),
indent: {}, indent: NO_INDENT,
}) });
);
return elements;
} }
function structSection(ctx, title, children, opts = {}) { function structSection(ctx, title, children, opts = {}) {
return flatten(structHeading(ctx, title, opts), children); return flatten(structHeading(ctx, title, opts), children);
} }
function sectionHeading(ctx, title) { function sectionHeading(ctx, title, opts = {}) {
const cfg = ctx.config.headings.section; const cfg = ctx.config.headings.section;
return new Paragraph({ return new Paragraph({
heading: HeadingLevel.HEADING_1, heading: HeadingLevel.HEADING_1,
pageBreakBefore: opts.pageBreakBefore === true,
numbering: { reference: REFS.outline, level: 0 }, numbering: { reference: REFS.outline, level: 0 },
children: [textRun(ctx, title)], children: [textRun(ctx, title)],
spacing: { ...ctx.typography.lineSpacing, before: cfg.spacingBefore, after: cfg.spacingAfter }, spacing: { ...ctx.typography.lineSpacing, before: cfg.spacingBefore, after: cfg.spacingAfter },
@@ -152,7 +153,7 @@ function plainHeading(ctx, text, level = 2) {
children: [textRun(ctx, text)], children: [textRun(ctx, text)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 240 }, spacing: { ...ctx.typography.lineSpacing, before: 240, after: 240 },
alignment: AlignmentType.LEFT, alignment: AlignmentType.LEFT,
indent: {}, indent: NO_INDENT,
}); });
} }
@@ -176,7 +177,7 @@ function figureCaption(ctx, label, title) {
children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)], children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: alignmentOf(ctx.config.captions.figure.align), alignment: alignmentOf(ctx.config.captions.figure.align),
indent: {}, indent: NO_INDENT,
}); });
} }
@@ -186,7 +187,7 @@ function tableCaption(ctx, label, title) {
children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)], children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
alignment: alignmentOf(ctx.config.captions.table.align), alignment: alignmentOf(ctx.config.captions.table.align),
indent: {}, indent: NO_INDENT,
}); });
} }
@@ -196,7 +197,7 @@ function tableContinuation(ctx, label, { pageBreakBefore = false } = {}) {
children: [textRun(ctx, `${prefix} ${label}`)], children: [textRun(ctx, `${prefix} ${label}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
alignment: alignmentOf(ctx.config.captions.table.align), alignment: alignmentOf(ctx.config.captions.table.align),
indent: {}, indent: NO_INDENT,
...(pageBreakBefore ? { pageBreakBefore: true } : {}), ...(pageBreakBefore ? { pageBreakBefore: true } : {}),
keepNext: true, keepNext: true,
}); });
@@ -273,7 +274,7 @@ function figureBlock(ctx, { label, title, imagePath, notesLines = [] }) {
new Paragraph({ new Paragraph({
children: [new ImageRun({ type, data, transformation: scaled })], children: [new ImageRun({ type, data, transformation: scaled })],
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
indent: {}, indent: NO_INDENT,
keepNext: true, keepNext: true,
}) })
); );
@@ -285,7 +286,7 @@ function figureBlock(ctx, { label, title, imagePath, notesLines = [] }) {
children: [textRun(ctx, line)], children: [textRun(ctx, line)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
indent: {}, indent: NO_INDENT,
keepNext: true, keepNext: true,
}) })
); );
@@ -419,7 +420,7 @@ function mathPara(ctx, mathChildren, formulaNum, opts = {}) {
children: [textRun(ctx, '\t'), new DocxMath({ children: mathChildren }), textRun(ctx, `\t(${num})`)], children: [textRun(ctx, '\t'), new DocxMath({ children: mathChildren }), textRun(ctx, `\t(${num})`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.LEFT, alignment: AlignmentType.LEFT,
indent: {}, indent: NO_INDENT,
tabStops: [ tabStops: [
{ type: TabStopType.CENTER, position: ctx.page.contentWidth / 2 }, { type: TabStopType.CENTER, position: ctx.page.contentWidth / 2 },
{ type: TabStopType.RIGHT, position: ctx.page.contentWidth }, { type: TabStopType.RIGHT, position: ctx.page.contentWidth },
@@ -447,7 +448,7 @@ function whereBlock(ctx, items) {
return new Paragraph({ return new Paragraph({
children: [prefix, ...symbolRuns, textRun(ctx, ` ${dash} ${item.desc}`)], children: [prefix, ...symbolRuns, textRun(ctx, ` ${dash} ${item.desc}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {}, indent: NO_INDENT,
tabStops: [{ type: TabStopType.LEFT, position: ctx.config.formula.whereTabPosition }], tabStops: [{ type: TabStopType.LEFT, position: ctx.config.formula.whereTabPosition }],
}); });
}); });
@@ -479,7 +480,7 @@ function listParagraphRuns(ctx, runs, reference, level, instance) {
// ── прочее ──────────────────────────────────────────────────────── // ── прочее ────────────────────────────────────────────────────────
function pageBreak() { function pageBreak() {
return new Paragraph({ children: [new PageBreak()], indent: {} }); return new Paragraph({ children: [new PageBreak()], indent: NO_INDENT });
} }
function noteSingle(ctx, text) { function noteSingle(ctx, text) {
@@ -524,13 +525,13 @@ function definitionsBlock(ctx, pairs) {
function appendixStart(ctx, letter, title) { function appendixStart(ctx, letter, title) {
return [ return [
pageBreak(),
new Paragraph({ new Paragraph({
heading: HeadingLevel.HEADING_1, heading: HeadingLevel.HEADING_1,
pageBreakBefore: true,
children: [textRun(ctx, `${ctx.config.appendix.labelPrefix} ${letter.toUpperCase()}`)], children: [textRun(ctx, `${ctx.config.appendix.labelPrefix} ${letter.toUpperCase()}`)],
spacing: { ...ctx.typography.lineSpacing, before: ctx.config.headings.struct.spacingBefore, after: 0 }, spacing: { ...ctx.typography.lineSpacing, before: ctx.config.headings.struct.spacingBefore, after: 0 },
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
indent: {}, indent: NO_INDENT,
}), }),
new Paragraph({ new Paragraph({
children: [textRun(ctx, title)], children: [textRun(ctx, title)],
@@ -540,7 +541,7 @@ function appendixStart(ctx, letter, title) {
after: ctx.config.headings.struct.spacingAfter, after: ctx.config.headings.struct.spacingAfter,
}, },
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
indent: {}, indent: NO_INDENT,
}), }),
]; ];
} }

View File

@@ -63,7 +63,12 @@ function buildNumberingConfig(ctx) {
left: lists.nestedLevelStep * lvl, left: lists.nestedLevelStep * lvl,
firstLine: typography.indentFirst, firstLine: typography.indentFirst,
}); });
const level = (lvl, format, text, { suffix = LevelSuffix.SPACE, indentOverride } = {}) => ({ const level = (
lvl,
format,
text,
{ suffix = LevelSuffix.SPACE, indentOverride, runFont } = {}
) => ({
level: lvl, level: lvl,
format, format,
text, text,
@@ -75,14 +80,14 @@ function buildNumberingConfig(ctx) {
indent: indentOverride || indent(lvl), indent: indentOverride || indent(lvl),
spacing: { ...typography.lineSpacing, before: 0, after: 0 }, spacing: { ...typography.lineSpacing, before: 0, after: 0 },
}, },
run, run: runFont ? { ...run, font: runFont } : run,
}, },
}); });
const outlineLevel = (lvl, text) => ({ const outlineLevel = (lvl, text) => ({
level: lvl, level: lvl,
format: LevelFormat.DECIMAL, format: LevelFormat.DECIMAL,
text, text,
suffix: LevelSuffix.TAB, suffix: LevelSuffix.SPACE,
alignment: AlignmentType.LEFT, alignment: AlignmentType.LEFT,
start: 1, start: 1,
style: { style: {
@@ -95,10 +100,10 @@ function buildNumberingConfig(ctx) {
}); });
const dashRef = [ const dashRef = [
level(0, LevelFormat.BULLET, lists.bulletChar), level(0, LevelFormat.BULLET, lists.bulletChar, { runFont: 'Symbol' }),
level(1, LevelFormat.RUSSIAN_LOWER, `%2${lists.letterSuffix}`), level(1, LevelFormat.RUSSIAN_LOWER, `%2${lists.letterSuffix}`),
level(2, LevelFormat.DECIMAL, `%3${lists.numberSuffix}`), level(2, LevelFormat.DECIMAL, `%3${lists.numberSuffix}`),
level(3, LevelFormat.BULLET, lists.bulletChar), level(3, LevelFormat.BULLET, lists.bulletChar, { runFont: 'Symbol' }),
]; ];
const numberRef = [ const numberRef = [
level(0, LevelFormat.DECIMAL, `%1${lists.numberSuffix}`), level(0, LevelFormat.DECIMAL, `%1${lists.numberSuffix}`),

View File

@@ -177,7 +177,7 @@ async function mergeDocxBuffers(prependBuffer, mainBuffer) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const docx = new DocxMerger({ pageBreak: true }, [prependBin, mainBin]); const docx = new DocxMerger({ pageBreak: false }, [prependBin, mainBin]);
docx.save('nodebuffer', async (data) => { docx.save('nodebuffer', async (data) => {
try { try {
const merged = Buffer.from(data); const merged = Buffer.from(data);

View File

@@ -30,11 +30,12 @@ const DEFAULTS = {
}, },
slideNumber: { slideNumber: {
show: true, show: true,
x: 12.05, x: 11.7,
y: 6.86, y: 6.86,
w: 1.15, w: 0.8,
h: 0.42, h: 0.42,
fontSize: 12, fontSize: 12,
bold: true,
color: "FFFFFF", color: "FFFFFF",
align: "right", align: "right",
}, },
@@ -46,10 +47,10 @@ const DEFAULTS = {
body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" }, body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" },
}, },
section: { section: {
title: { x: 2.0, y: 2.85, w: 9.35, h: 1.8, fontSize: 32, bold: true, align: "center", color: "white" }, title: { x: 6.7, y: 2.85, w: 5.95, h: 1.8, fontSize: 32, bold: true, align: "center", color: "white" },
}, },
content: { content: {
title: { x: 0.6, y: 0.06, w: 12.15, h: 0.66, fontSize: 20, bold: false, align: "center", color: "white" }, title: { x: 0.6, y: 0.14, w: 12.15, h: 0.7, fontSize: 22, bold: false, align: "center", valign: "middle", color: "white" },
body: { x: 0.75, y: 1.0, w: 11.85, h: 5.55, fontSize: 18, align: "left", color: "ink", valign: "top" }, body: { x: 0.75, y: 1.0, w: 11.85, h: 5.55, fontSize: 18, align: "left", color: "ink", valign: "top" },
bulletSize: 18, bulletSize: 18,
subBulletSize: 16, subBulletSize: 16,