339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""
|
||
Генерирует svc-api-x.docx из results/source-bundle.md
|
||
|
||
Что делает:
|
||
1. Читает results/source-bundle.md — все исходники склеены в один MD-файл.
|
||
2. Парсит его: разбивает по заголовкам ## (каждый заголовок = один исходный файл).
|
||
3. Строит DOCX:
|
||
- Титульная страница (название, подзаголовок, дата)
|
||
- Оглавление (поле Word TOC — пользователь жмёт правую кнопку → Обновить поле)
|
||
- разделы с кодом (заголовок файла + код с подсветкой синтаксиса на сером фоне)
|
||
- Номер страницы в нижнем колонтитуле
|
||
|
||
Подсветка синтаксиса CFML (4 цвета):
|
||
синий — CF-теги (<cfif>, <cfset>, </cffunction>, ...)
|
||
зелёный — строковые значения ("...", '...')
|
||
фиолетовый — выражения в # (#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'</cf\w+|<cf\w+|</cfscript>|<cfscript>', 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)
|