From d374c26b09e592cc4ad1a598f1a41bbeb6c1e16c Mon Sep 17 00:00:00 2001 From: StepanovPlaton Date: Mon, 12 Oct 2026 01:01:51 +0400 Subject: [PATCH] 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 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 --- docx/src/build.js | 7 ++-- docx/src/config/defaults.js | 3 +- docx/src/render/document.js | 8 +++-- docx/src/render/primitives.js | 63 ++++++++++++++++++----------------- docx/src/render/styles.js | 15 ++++++--- docx/src/render/titlepage.js | 2 +- pptx/src/config.js | 9 ++--- 7 files changed, 60 insertions(+), 47 deletions(-) diff --git a/docx/src/build.js b/docx/src/build.js index 3852529..3deddc7 100644 --- a/docx/src/build.js +++ b/docx/src/build.js @@ -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}`); diff --git a/docx/src/config/defaults.js b/docx/src/config/defaults.js index 29283c3..1ba636f 100644 --- a/docx/src/config/defaults.js +++ b/docx/src/config/defaults.js @@ -53,7 +53,8 @@ const DEFAULT_CONFIG = { }, lists: { - bulletChar: '-', + // Тире маркера — глиф Symbol U+F02D (рисуется шрифтом Symbol). + bulletChar: '\uF02D', letterSuffix: ')', numberSuffix: ')', forbiddenLetters: ['ё', 'з', 'й', 'о', 'ч', 'ь', 'ы', 'ъ'], diff --git a/docx/src/render/document.js b/docx/src/render/document.js index 1ddd63d..4be263a 100644 --- a/docx/src/render/document.js +++ b/docx/src/render/document.js @@ -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; diff --git a/docx/src/render/primitives.js b/docx/src/render/primitives.js index 14763de..818c22c 100644 --- a/docx/src/render/primitives.js +++ b/docx/src/render/primitives.js @@ -44,6 +44,10 @@ function alignmentOf(name) { // ── базовые абзацы ──────────────────────────────────────────────── +// Явное обнуление абзацного отступа. Пустой наследует отступ +// базового стиля, поэтому для центрированных абзацев задаём нули явно. +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, }), ]; } diff --git a/docx/src/render/styles.js b/docx/src/render/styles.js index d1a7361..604cc81 100644 --- a/docx/src/render/styles.js +++ b/docx/src/render/styles.js @@ -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}`), diff --git a/docx/src/render/titlepage.js b/docx/src/render/titlepage.js index 43bd05d..4b4e8d5 100644 --- a/docx/src/render/titlepage.js +++ b/docx/src/render/titlepage.js @@ -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); diff --git a/pptx/src/config.js b/pptx/src/config.js index 2f54763..44f92bc 100644 --- a/pptx/src/config.js +++ b/pptx/src/config.js @@ -30,11 +30,12 @@ const DEFAULTS = { }, slideNumber: { show: true, - x: 12.05, + x: 11.7, y: 6.86, - w: 1.15, + w: 0.8, h: 0.42, fontSize: 12, + bold: true, color: "FFFFFF", 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" }, }, 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: { - 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" }, bulletSize: 18, subBulletSize: 16,