diff --git a/build_docx.py b/build_docx.py new file mode 100644 index 0000000..8b45111 --- /dev/null +++ b/build_docx.py @@ -0,0 +1,275 @@ +""" +Генерирует svc-api-x.docx из results/source-bundle.md + +Что делает: + 1. Читает results/source-bundle.md — все исходники склеены в один MD-файл. + 2. Парсит его: разбивает по заголовкам ## (каждый заголовок = один исходный файл). + 3. Строит DOCX: + - Титульная страница (название, подзаголовок, дата) + - Оглавление (поле Word TOC — пользователь жмёт правую кнопку → Обновить поле) + - 47 разделов с кодом (заголовок файла + код моноширинным шрифтом на сером фоне) + - Номер страницы в нижнем колонтитуле + +Запуск: + /home/naeel/svc-api-x/.venv/bin/python build_docx.py + +Как получить оглавление с номерами страниц (после открытия в Word): + 1. Открыть svc-api-x.docx в Microsoft Word + 2. Найти страницу с оглавлением (вторая страница, после титула) + 3. Кликнуть ПРАВОЙ кнопкой мыши на текст оглавления + 4. В контекстном меню выбрать «Обновить поле» + 5. В диалоге выбрать «Обновить всё» → OK + 6. Номера страниц проставятся автоматически + 7. Сохранить (Ctrl+S) + + В LibreOffice Writer: правая кнопка на оглавлении → «Обновить указатель» + +Настройки — что и где менять: + BUNDLE_PATH / OUTPUT_PATH — пути к входному MD и выходному DOCX (ниже) + setup_styles() — шрифт и размер заголовков файлов (Heading 1) и базового текста + set_font() — шрифт и размер кода (по умолчанию Courier New 8pt) + build() — отступы/поля страницы (Cm), интервалы (Pt), + цвет заголовков (RGBColor), фон кода ('F2F2F2') + Титульная страница — текст, размер и цвет в блоке «Титульная страница» в build() +""" +import re +from docx import Document +from docx.shared import Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn +from docx.oxml import OxmlElement + +BUNDLE_PATH = '/home/naeel/svc-api-x/results/source-bundle.md' # входной файл +OUTPUT_PATH = '/home/naeel/svc-api-x/results/svc-api-x.docx' # выходной файл + + +# --------------------------------------------------------------------------- +# Парсинг +# --------------------------------------------------------------------------- + +def parse_bundle(path): + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + + # Первый заголовок # — мета-блок + parts = re.split(r'^## (.+)$', content, flags=re.MULTILINE) + meta = parts[0].strip() + + sections = [] + for i in range(1, len(parts), 2): + filename = parts[i].strip() + body = parts[i + 1] if i + 1 < len(parts) else '' + code_match = re.search(r'```(?:\w+)?\n(.*?)```', body, re.DOTALL) + code = code_match.group(1).rstrip('\n') if code_match else body.strip() + sections.append((filename, code)) + + return sections + + +# --------------------------------------------------------------------------- +# Вспомогательные функции +# --------------------------------------------------------------------------- + +def set_font(run, name='Courier New', size=9, bold=False, color=None): + run.font.name = name + run.font.size = Pt(size) + run.font.bold = bold + if color: + run.font.color.rgb = RGBColor(*color) + + +def add_toc(doc): + """Вставляет поле TOC (обновляется при открытии в Word).""" + paragraph = doc.add_paragraph() + paragraph.paragraph_format.space_before = Pt(0) + paragraph.paragraph_format.space_after = Pt(0) + run = paragraph.add_run() + fld_char_begin = OxmlElement('w:fldChar') + fld_char_begin.set(qn('w:fldCharType'), 'begin') + run._r.append(fld_char_begin) + + instr_text = OxmlElement('w:instrText') + instr_text.set(qn('xml:space'), 'preserve') + instr_text.text = ' TOC \\o "1-1" \\h \\z \\u ' + run._r.append(instr_text) + + fld_char_separate = OxmlElement('w:fldChar') + fld_char_separate.set(qn('w:fldCharType'), 'separate') + run._r.append(fld_char_separate) + + no_display = OxmlElement('w:t') + no_display.text = '[Правая кнопка → Обновить поле]' + run._r.append(no_display) + + fld_char_end = OxmlElement('w:fldChar') + fld_char_end.set(qn('w:fldCharType'), 'end') + run._r.append(fld_char_end) + + return paragraph + + +def add_page_break(doc): + p = doc.add_paragraph() + run = p.add_run() + br = OxmlElement('w:br') + br.set(qn('w:type'), 'page') + run._r.append(br) + p.paragraph_format.space_before = Pt(0) + p.paragraph_format.space_after = Pt(0) + + +# --------------------------------------------------------------------------- +# Стили +# --------------------------------------------------------------------------- + +def add_page_numbers_to_footer(doc): + """Добавляет номер страницы в нижний колонтитул.""" + for section in doc.sections: + footer = section.footer + footer.is_linked_to_previous = False + para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph() + para.clear() + para.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = para.add_run() + run.font.name = 'Arial' + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(0x88, 0x88, 0x88) + # Поле PAGE + fld = OxmlElement('w:fldChar') + fld.set(qn('w:fldCharType'), 'begin') + run._r.append(fld) + instr = OxmlElement('w:instrText') + instr.set(qn('xml:space'), 'preserve') + instr.text = ' PAGE ' + run._r.append(instr) + fld2 = OxmlElement('w:fldChar') + fld2.set(qn('w:fldCharType'), 'end') + run._r.append(fld2) + + +def setup_styles(doc): + styles = doc.styles + + # Heading 1 — имя файла + h1 = styles['Heading 1'] + h1.font.name = 'Arial' + h1.font.size = Pt(13) + h1.font.bold = True + h1.font.color.rgb = RGBColor(0x1F, 0x39, 0x64) # тёмно-синий + h1.paragraph_format.space_before = Pt(18) + h1.paragraph_format.space_after = Pt(6) + h1.paragraph_format.keep_with_next = True + + # Normal — базовый + normal = styles['Normal'] + normal.font.name = 'Arial' + normal.font.size = Pt(11) + + +# --------------------------------------------------------------------------- +# Построение документа +# --------------------------------------------------------------------------- + +def build(sections): + doc = Document() + + # Поля страницы + for section in doc.sections: + section.top_margin = Cm(2.5) + section.bottom_margin = Cm(2.5) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.0) + + setup_styles(doc) + add_page_numbers_to_footer(doc) + + # ── Титульная страница ────────────────────────────────────────────────── + title_p = doc.add_paragraph() + title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + title_p.paragraph_format.space_before = Pt(120) + title_p.paragraph_format.space_after = Pt(12) + tr = title_p.add_run('svc-api-x — Исходный код') + tr.font.name = 'Arial' + tr.font.size = Pt(22) + tr.font.bold = True + tr.font.color.rgb = RGBColor(0x1F, 0x39, 0x64) + + sub_p = doc.add_paragraph() + sub_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + sub_p.paragraph_format.space_after = Pt(6) + sr = sub_p.add_run('Прикладной слой · v1/ (без taffy)') + sr.font.name = 'Arial' + sr.font.size = Pt(13) + sr.font.color.rgb = RGBColor(0x55, 0x55, 0x55) + + date_p = doc.add_paragraph() + date_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + dr = date_p.add_run('Дата сборки: 2026-04-30') + dr.font.name = 'Arial' + dr.font.size = Pt(11) + dr.font.color.rgb = RGBColor(0x88, 0x88, 0x88) + + add_page_break(doc) + + # ── Оглавление ────────────────────────────────────────────────────────── + toc_title = doc.add_paragraph() + toc_title.alignment = WD_ALIGN_PARAGRAPH.LEFT + toc_title.paragraph_format.space_before = Pt(0) + toc_title.paragraph_format.space_after = Pt(12) + ttr = toc_title.add_run('ОГЛАВЛЕНИЕ') + ttr.font.name = 'Arial' + ttr.font.size = Pt(14) + ttr.font.bold = True + ttr.font.color.rgb = RGBColor(0x1F, 0x39, 0x64) + + add_toc(doc) + + add_page_break(doc) + + # ── Исходники ──────────────────────────────────────────────────────────── + for filename, code in sections: + # Заголовок файла (Heading 1 — попадает в TOC) + doc.add_heading(filename, level=1) + + if not code: + p = doc.add_paragraph() + r = p.add_run('(файл пуст)') + r.font.name = 'Arial' + r.font.size = Pt(10) + r.font.italic = True + r.font.color.rgb = RGBColor(0x99, 0x99, 0x99) + continue + + # Код — параграф с моноширинным шрифтом, закрашенный фон через XML + # Разбиваем по строкам, одним параграфом с переносами + lines = code.split('\n') + # Шапка блока + code_para = doc.add_paragraph() + code_para.paragraph_format.space_before = Pt(2) + code_para.paragraph_format.space_after = Pt(8) + code_para.paragraph_format.left_indent = Cm(0.5) + + # Серый фон через shading + pPr = code_para._p.get_or_add_pPr() + shd = OxmlElement('w:shd') + shd.set(qn('w:val'), 'clear') + shd.set(qn('w:color'), 'auto') + shd.set(qn('w:fill'), 'F2F2F2') + pPr.append(shd) + + for idx, line in enumerate(lines): + if idx > 0: + run = code_para.add_run() + br = OxmlElement('w:br') + run._r.append(br) + run = code_para.add_run(line) + set_font(run, name='Courier New', size=8) + + doc.save(OUTPUT_PATH) + print(f'Готово: {OUTPUT_PATH}') + print(f'Файлов в документе: {len(sections)}') + + +if __name__ == '__main__': + sections = parse_bundle(BUNDLE_PATH) + build(sections) diff --git a/results/svc-api-x.docx b/results/svc-api-x.docx new file mode 100644 index 0000000..23b2a1d Binary files /dev/null and b/results/svc-api-x.docx differ