""" Генерирует svc-api-x.docx из results/source-bundle.md Что делает: 1. Читает results/source-bundle.md — все исходники склеены в один MD-файл. 2. Парсит его: разбивает по заголовкам ## (каждый заголовок = один исходный файл). 3. Строит DOCX: - Титульная страница (название, подзаголовок, дата) - Оглавление (поле Word TOC — пользователь жмёт правую кнопку → Обновить поле) - разделы с кодом (заголовок файла + код с подсветкой синтаксиса на сером фоне) - Номер страницы в нижнем колонтитуле Подсветка синтаксиса CFML (4 цвета): синий — CF-теги (, , , ...) зелёный — строковые значения ("...", '...') фиолетовый — выражения в # (#variables#, #func()#) тёмно-голубой — SQL-ключевые слова (SELECT, FROM, WHERE, JOIN, ...) чёрный — всё остальное Запуск: /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 = 'results/source-bundle.md' # входной файл OUTPUT_PATH = '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 # --------------------------------------------------------------------------- # Вспомогательные функции # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Токенизатор CFML # --------------------------------------------------------------------------- # Цвета подсветки (RGB) COL_TAG = (0x1A, 0x5F, 0xB4) # синий — CF-теги COL_STR = (0x1E, 0x7E, 0x34) # зелёный — строки COL_VAR = (0x6F, 0x31, 0x78) # фиолетовый — #переменные# COL_SQL = (0x0D, 0x5C, 0x73) # тёмно-голубой — SQL COL_DEF = None # чёрный (default) _PATTERNS = [ ('tag', re.compile( r'|', re.IGNORECASE)), ('var', re.compile( r'#[^#\n]+#')), ('str', re.compile( r'"[^"\n]*"|' + r"'[^'\n]*'")), ('sql', re.compile( r'\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|' r'INSERT|INTO|UPDATE|DELETE|SET|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|' r'AS|DISTINCT|COUNT|SUM|AVG|MAX|MIN|CAST|COALESCE|CASE|WHEN|THEN|' r'ELSE|END|IS|NULL|IN|EXISTS|UNION|ALL)\b', re.IGNORECASE)), ] _COLOR_MAP = {'tag': COL_TAG, 'str': COL_STR, 'var': COL_VAR, 'sql': COL_SQL, None: COL_DEF} def tokenize_cfml(line): """Разбивает строку кода на список (text, color) с подсветкой CFML.""" tokens = [] pos = 0 length = len(line) while pos < length: best_match = None best_kind = None best_start = length for kind, pat in _PATTERNS: m = pat.search(line, pos) if m and m.start() < best_start: best_match = m best_kind = kind best_start = m.start() if best_match is None: tokens.append((line[pos:], COL_DEF)) break if best_start > pos: tokens.append((line[pos:best_start], COL_DEF)) tokens.append((best_match.group(0), _COLOR_MAP[best_kind])) pos = best_match.end() return tokens def set_font(run, name='Courier New', size=8, 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: br_run = code_para.add_run() br = OxmlElement('w:br') br_run._r.append(br) set_font(br_run) for text, color in tokenize_cfml(line): run = code_para.add_run(text) set_font(run, color=color) doc.save(OUTPUT_PATH) print(f'Готово: {OUTPUT_PATH}') print(f'Файлов в документе: {len(sections)}') if __name__ == '__main__': sections = parse_bundle(BUNDLE_PATH) build(sections)