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

View File

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

View File

@@ -349,9 +349,9 @@ function renderTokens(ctx, tokens, state, opts = {}) {
const text = tokens[i + 1] ? tokens[i + 1].content : '';
if (mode === 'numbered') {
if (level <= 1) {
if (!(firstH1 && opts.suppressFirstPageBreak)) out.push(pageBreak());
const breakBefore = !(firstH1 && opts.suppressFirstPageBreak);
firstH1 = false;
out.push(sectionHeading(ctx, text));
out.push(sectionHeading(ctx, text, { pageBreakBefore: breakBefore }));
} else if (level === 2) {
out.push(subsectionHeading(ctx, text));
} else {
@@ -399,7 +399,9 @@ function renderTokens(ctx, tokens, state, opts = {}) {
function renderSections(ctx, state) {
resetListInstances();
const out = [];
let first = true;
// При наличии титульного листа первая часть тоже должна начинаться
// с новой страницы (титульник склеивается перед документом).
let first = !ctx.hasTitlePage;
for (const section of state.sections) {
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 = {}) {
const t = ctx.typography;
const run = {
@@ -73,7 +77,7 @@ function paraRuns(ctx, runs, opts = {}) {
return new Paragraph({
children: runs,
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,
...opts.extra,
});
@@ -87,40 +91,37 @@ function emptyLine(ctx) {
return new Paragraph({
children: [textRun(ctx, ' ')],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {},
indent: NO_INDENT,
});
}
// ── заголовки ─────────────────────────────────────────────────────
function structHeading(ctx, title, opts = {}) {
const elements = [];
if (opts.pageBreakBefore !== false) elements.push(pageBreak());
const cfg = ctx.config.headings.struct;
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [textRun(ctx, String(title).toUpperCase())],
spacing: {
...ctx.typography.lineSpacing,
before: cfg.spacingBefore,
after: cfg.spacingAfter,
},
alignment: alignmentOf(cfg.align),
indent: {},
})
);
return elements;
return new Paragraph({
heading: HeadingLevel.HEADING_1,
pageBreakBefore: opts.pageBreakBefore === true,
children: [textRun(ctx, String(title).toUpperCase())],
spacing: {
...ctx.typography.lineSpacing,
before: cfg.spacingBefore,
after: cfg.spacingAfter,
},
alignment: alignmentOf(cfg.align),
indent: NO_INDENT,
});
}
function structSection(ctx, title, children, opts = {}) {
return flatten(structHeading(ctx, title, opts), children);
}
function sectionHeading(ctx, title) {
function sectionHeading(ctx, title, opts = {}) {
const cfg = ctx.config.headings.section;
return new Paragraph({
heading: HeadingLevel.HEADING_1,
pageBreakBefore: opts.pageBreakBefore === true,
numbering: { reference: REFS.outline, level: 0 },
children: [textRun(ctx, title)],
spacing: { ...ctx.typography.lineSpacing, before: cfg.spacingBefore, after: cfg.spacingAfter },
@@ -152,7 +153,7 @@ function plainHeading(ctx, text, level = 2) {
children: [textRun(ctx, text)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 240 },
alignment: AlignmentType.LEFT,
indent: {},
indent: NO_INDENT,
});
}
@@ -176,7 +177,7 @@ function figureCaption(ctx, label, title) {
children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
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}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
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}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
alignment: alignmentOf(ctx.config.captions.table.align),
indent: {},
indent: NO_INDENT,
...(pageBreakBefore ? { pageBreakBefore: true } : {}),
keepNext: true,
});
@@ -273,7 +274,7 @@ function figureBlock(ctx, { label, title, imagePath, notesLines = [] }) {
new Paragraph({
children: [new ImageRun({ type, data, transformation: scaled })],
alignment: AlignmentType.CENTER,
indent: {},
indent: NO_INDENT,
keepNext: true,
})
);
@@ -285,7 +286,7 @@ function figureBlock(ctx, { label, title, imagePath, notesLines = [] }) {
children: [textRun(ctx, line)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.CENTER,
indent: {},
indent: NO_INDENT,
keepNext: true,
})
);
@@ -419,7 +420,7 @@ function mathPara(ctx, mathChildren, formulaNum, opts = {}) {
children: [textRun(ctx, '\t'), new DocxMath({ children: mathChildren }), textRun(ctx, `\t(${num})`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.LEFT,
indent: {},
indent: NO_INDENT,
tabStops: [
{ type: TabStopType.CENTER, position: ctx.page.contentWidth / 2 },
{ type: TabStopType.RIGHT, position: ctx.page.contentWidth },
@@ -447,7 +448,7 @@ function whereBlock(ctx, items) {
return new Paragraph({
children: [prefix, ...symbolRuns, textRun(ctx, ` ${dash} ${item.desc}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {},
indent: NO_INDENT,
tabStops: [{ type: TabStopType.LEFT, position: ctx.config.formula.whereTabPosition }],
});
});
@@ -479,7 +480,7 @@ function listParagraphRuns(ctx, runs, reference, level, instance) {
// ── прочее ────────────────────────────────────────────────────────
function pageBreak() {
return new Paragraph({ children: [new PageBreak()], indent: {} });
return new Paragraph({ children: [new PageBreak()], indent: NO_INDENT });
}
function noteSingle(ctx, text) {
@@ -524,13 +525,13 @@ function definitionsBlock(ctx, pairs) {
function appendixStart(ctx, letter, title) {
return [
pageBreak(),
new Paragraph({
heading: HeadingLevel.HEADING_1,
pageBreakBefore: true,
children: [textRun(ctx, `${ctx.config.appendix.labelPrefix} ${letter.toUpperCase()}`)],
spacing: { ...ctx.typography.lineSpacing, before: ctx.config.headings.struct.spacingBefore, after: 0 },
alignment: AlignmentType.CENTER,
indent: {},
indent: NO_INDENT,
}),
new Paragraph({
children: [textRun(ctx, title)],
@@ -540,7 +541,7 @@ function appendixStart(ctx, letter, title) {
after: ctx.config.headings.struct.spacingAfter,
},
alignment: AlignmentType.CENTER,
indent: {},
indent: NO_INDENT,
}),
];
}

View File

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

View File

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