Оформление отчёта по ГОСТ из Engee-скрипта
Автор
using ZipFile, JSON, Dates, Base64, TOML, OrderedCollections, Images
# ============================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================
"""
escape_xml(text) -> String
Экранирует специальные символы XML (`&`, `<`, `>`, `"`, `'`) для безопасной вставки
произвольного текста в XML-документ. Используется при формировании содержимого docx-файла,
чтобы пользовательский текст не ломал XML-структуру.
"""
function escape_xml(text)::String
text = replace(text, "&" => "&")
text = replace(text, "<" => "<")
text = replace(text, ">" => ">")
text = replace(text, "\"" => """)
text = replace(text, "'" => "'")
return text
end
"""
reset_image_counter()
Сбрасывает глобальный счётчик изображений в ноль. Вызывается в начале обработки каждого
нового документа, чтобы нумерация файлов изображений начиналась с `image1`.
"""
const image_counter = Ref(0)
reset_image_counter() = image_counter[] = 0
"""
next_image_name(ext::String) -> String
Генерирует уникальное имя файла для очередного изображения в формате `imageN.ext`,
где `N` — автоматически увеличивающийся номер. Гарантирует отсутствие конфликтов имён
при вставке множества изображений в документ.
"""
#next_image_name(ext::String) = "image$(image_counter[] += 1).$(ext)"
next_image_name(ext::String) = "gen_image$(image_counter[] += 1).$(ext)"
const MAX_IMAGE_WIDTH_EMU = 9026
# Счетчик нумерованных списков
const numbered_list_counter = Ref(2) # начинаем с 2 (1 занят bullets)
const MAX_NUMBERED_LISTS = 100
const equation_counter = Ref(0)
"""
reset_equation_counter()
Сбрасывает счётчик формул в ноль. Вызывается в начале обработки документа и при смене
главы первого уровня, так как нумерация формул ведётся в пределах главы.
"""
function reset_equation_counter()
equation_counter[] = 0
end
const table_counter = Ref(0)
"""
reset_table_counter()
Сбрасывает счётчик таблиц в ноль. Вызывается в начале обработки нового документа.
Нумерация таблиц — сквозная через весь документ, не зависит от глав.
"""
function reset_table_counter()
table_counter[] = 0
end
"""
decode_base64_image(data::String) -> Tuple{Vector{UInt8}, String}
Декодирует base64-строку изображения (возможно, с data-URL префиксом) в бинарные данные
и определяет расширение файла. Поддерживает форматы PNG, JPEG, SVG.
Используется для извлечения изображений из вложений Engee-скрипта.
"""
function decode_base64_image(data::String)::Tuple{Vector{UInt8}, String}
if startswith(data, "data:")
mime_match = match(r"data:image/(\w+);base64,", data)
if mime_match !== nothing
ext = mime_match.captures[1]
ext = ext == "jpeg" ? "jpg" : ext
base64_str = replace(data, r"^data:image/\w+;base64," => "")
elseif startswith(data, "data:image/svg+xml;base64,")
ext = "svg"
base64_str = replace(data, r"^data:image/svg\+xml;base64," => "")
else
ext = "png"
base64_str = data
end
else
ext = "png"
base64_str = data
end
return base64decode(base64_str), ext
end
# ============================================================
# ОБРАБОКТА ИЗОБРАЖЕНИЙ
# ============================================================
"""
get_image_dimensions(bytes, ext) -> Tuple{Int, Int}
Определяет размеры изображения (в пикселях) по бинарным данным, конвертирует в EMU
(English Metric Units, 9525 EMU на пиксель при 96 DPI) и возвращает ширину и высоту.
Поддерживает PNG и JPEG. Если определить размеры не удалось, возвращает значения
по умолчанию 600×400 пикселей.
"""
function get_image_dimensions(bytes::Vector{UInt8}, ext::String)::Tuple{Int, Int}
try
width, height = 400, 300
if ext == "png" && length(bytes) > 24
width = Int(bytes[17]) << 24 | Int(bytes[18]) << 16 | Int(bytes[19]) << 8 | Int(bytes[20])
height = Int(bytes[21]) << 24 | Int(bytes[22]) << 16 | Int(bytes[23]) << 8 | Int(bytes[24])
elseif ext in ["jpg", "jpeg"]
i = 2
while i < length(bytes) - 2
if bytes[i] != 0xFF; i += 1; continue; end
marker = bytes[i+1]
if marker in [0xC0, 0xC1, 0xC2]
if i + 8 < length(bytes)
height = Int(bytes[i+5]) << 8 | Int(bytes[i+6])
width = Int(bytes[i+7]) << 8 | Int(bytes[i+8])
break
end
end
if i + 2 < length(bytes)
seg_len = Int(bytes[i+2]) << 8 | Int(bytes[i+3])
i += 2 + seg_len
else
i += 2
end
end
end
# Просто конвертируем пиксели в EMU (96 DPI = 9525 EMU на пиксель)
width_emu = width * 9525
height_emu = height * 9525
return width_emu, height_emu
catch e
# Если не удалось определить размеры, используем значения по умолчанию
return 600 * 9525, 400 * 9525
end
end
"""
create_image_xml(rId, width, height) -> String
Создаёт XML-представление параграфа со встроенным изображением для документа docx.
Формирует корректный OOXML-элемент `<w:drawing>` с указанными размерами и ссылкой
на файл изображения через relationship Id. Автоматически добавляет свойства `keepNext`
и `firstLine=0`, чтобы изображение не отрывалось от подписи и не имело красной строки.
"""
function create_image_xml(rId::String, width::Int, height::Int)::String
docPrId = rand(1000:9999)
return """
<w:p>
<w:pPr>
<w:jc w:val="center"/>
<w:ind w:firstLine="0"/>
<w:keepNext/>
</w:pPr>
<w:r>
<w:drawing>
<wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">
<wp:extent cx="$width" cy="$height"/>
<wp:docPr id="$docPrId" name="Picture$docPrId"/>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr>
<pic:cNvPr id="0" name="Picture$docPrId"/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="$rId"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="$width" cy="$height"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>"""
end
# ============================================================
# ОБРАБОКТА ФОРМУЛ
# ============================================================
"""
latex_symbol_to_omml(cmd) -> Union{String, Nothing}
Преобразует LaTeX-команду (например, `\\alpha`, `\\beta`, `\\sum`) в соответствующий
Unicode-символ или текстовое представление для вставки в OMML-формулу.
Возвращает `nothing` для неизвестных команд.
"""
function latex_symbol_to_omml(cmd)
symbols = Dict(
"\\alpha" => "α", "\\beta" => "β", "\\gamma" => "γ",
"\\delta" => "δ", "\\epsilon" => "ε", "\\zeta" => "ζ",
"\\eta" => "η", "\\theta" => "θ", "\\iota" => "ι",
"\\kappa" => "κ", "\\lambda" => "λ", "\\mu" => "μ",
"\\nu" => "ν", "\\xi" => "ξ", "\\pi" => "π",
"\\rho" => "ρ", "\\sigma" => "σ", "\\tau" => "τ",
"\\upsilon" => "υ", "\\phi" => "φ", "\\chi" => "χ",
"\\psi" => "ψ", "\\omega" => "ω",
"\\varepsilon" => "ε",
"\\varphi" => "φ",
"\\varrho" => "ϱ",
"\\varsigma" => "ς",
"\\Gamma" => "Γ", "\\Delta" => "Δ", "\\Theta" => "Θ",
"\\Lambda" => "Λ", "\\Xi" => "Ξ", "\\Pi" => "Π",
"\\Sigma" => "Σ", "\\Phi" => "Φ", "\\Psi" => "Ψ",
"\\Omega" => "Ω",
"\\infty" => "∞", "\\partial" => "∂", "\\nabla" => "∇",
"\\pm" => "±", "\\mp" => "∓", "\\times" => "×",
"\\div" => "÷", "\\cdot" => "·", "\\leq" => "≤",
"\\geq" => "≥", "\\neq" => "≠", "\\approx" => "≈",
"\\equiv" => "≡", "\\propto" => "∝", "\\sim" => "∼",
"\\rightarrow" => "→", "\\leftarrow" => "←",
"\\Rightarrow" => "⇒", "\\Leftarrow" => "⇐",
"\\leftrightarrow" => "↔", "\\forall" => "∀",
"\\exists" => "∃", "\\in" => "∈", "\\notin" => "∉",
"\\subset" => "⊂", "\\supset" => "⊃",
"\\cup" => "∪", "\\cap" => "∩",
#"\\sqrt" => "√", # обрабатывается отдельно
"\\mathbbR" => "ℝ",
"\\mathbbP" => "ℙ",
"\\mathbbC" => "ℂ",
"\\mathbbN" => "ℕ",
"\\mathbbZ" => "ℤ",
"\\mathbbQ" => "ℚ",
"\\dots" => "…",
"\\vdots" => "⋮",
"\\ddots" => "⋱",
"\\ge" => "≥",
"\\le" => "≤",
# Тригонометрические функции
"\\sin" => "sin", "\\cos" => "cos", "\\tan" => "tan",
"\\cot" => "cot", "\\sec" => "sec", "\\csc" => "csc",
"\\arcsin" => "arcsin", "\\arccos" => "arccos", "\\arctan" => "arctan",
"\\sinh" => "sinh", "\\cosh" => "cosh", "\\tanh" => "tanh",
"\\log" => "log", "\\ln" => "ln", "\\lg" => "lg",
"\\lim" => "lim", "\\max" => "max", "\\min" => "min",
"\\det" => "det", "\\gcd" => "gcd",
"\\mod" => "mod", "\\bmod" => "mod",
)
return get(symbols, cmd, nothing)
end
"""
make_omml_doublestruck(text) -> String
Создаёт OMML-элемент для текста в стиле `\\mathbb` (ажурный/двойной шрифт),
используемый для обозначения математических множеств (ℝ, ℂ, ℕ и т.д.).
"""
function make_omml_doublestruck(text)
# Создаёт текст в стиле \mathbb (ажурный/двойной шрифт)
return "<m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>" * escape_xml(text) * "</m:t></m:r>"
end
"""
process_inline_latex(text) -> String
Находит в тексте inline-формулы, обрамлённые одиночными `\$...\$`, и заменяет их
на OMML-представление для вставки в docx. Окружающий текст остаётся без изменений,
но экранируется для XML.
"""
function process_inline_latex(text)
matches = collect(eachmatch(r"(?<!\\)\$([^\$]+?)(?<!\\)\$", text))
if isempty(matches)
return text
end
result_parts = String[]
last_end = 1
for m in matches
before = text[last_end:prevind(text, m.offset)]
if !isempty(before)
push!(result_parts, "<w:r><w:t xml:space=\"preserve\">" * escape_xml(before) * "</w:t></w:r>")
end
formula = m.captures[1]
# Проверяем, содержит ли формула матрицы
if contains(formula, r"\begin{(p|b|B|v|V)?matrix}") || contains(formula, r"\begin{cases}")
# Выводим как текст — не пытаемся парсить
replacement = """<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:rStyle w:val="CodeChar"/></w:rPr><w:t xml:space="preserve">""" * escape_xml(formula) * "</w:t></w:r></w:p>"
rng = m.offset:nextind(text, m.offset + length(m.match) - 2)
push!(replacements, (rng, replacement))
continue
end
formula = replace(formula, "\\\$" => "\$")
# Преобразуем LaTeX в OMML
omml = latex_to_omml(formula, display_mode=false)
push!(result_parts, omml)
last_end = nextind(text, m.offset + length(m.match) - 1)
end
after = text[last_end:end]
if !isempty(after)
push!(result_parts, """<w:r><w:t xml:space="preserve">""" * escape_xml(after) * "</w:t></w:r>")
end
return join(result_parts, "")
end
"""
process_display_latex(text) -> String
Находит выключные формулы, обрамлённые двойными `\$\$...\$\$`, заменяет их на OMML
с автоматической нумерацией. Номер формулы выравнивается по правому краю,
сама формула центрируется. Поддерживает окружение `cases` для систем уравнений.
"""
function process_display_latex(text)
text = replace(text, r"\[\d+pt]" => "\\")
text = replace(text, r"," => " ")
text = replace(text, r";" => " ")
matches = collect(eachmatch(r"$$(.+?)$$"s, text))
# Сначала вычисляем все замены с правильными номерами
replacements = Tuple{UnitRange{Int}, String}[]
for m in matches
formula = strip(m.captures[1])
# Проверяем, содержит ли формула матрицы
if contains(formula, r"\begin{(p|b|B|v|V)?matrix}")
# Выводим как текст — не пытаемся парсить
replacement = """<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:rStyle w:val="CodeChar"/></w:rPr><w:t xml:space="preserve">""" * escape_xml(formula) * "</w:t></w:r></w:p>"
rng = m.offset:nextind(text, m.offset + length(m.match) - 2)
push!(replacements, (rng, replacement))
continue
end
formula = replace(formula, "\\\$" => "\$")
if contains(formula, "\begin{cases}")
end_match = match(r"\end{cases}", formula)
if end_match !== nothing
formula = formula[1:end_match.offset + length(end_match.match) - 1]
end
end
formula = replace(formula, r"\\qquad" => " ")
formula = replace(formula, r"\\quad" => " ")
formula = replace(formula, "~" => " ")
omml = latex_to_omml(formula, display_mode=true)
# Увеличиваем счётчик
equation_counter[] += 1
eq_number = string(heading_counters[1]) * "." * string(equation_counter[])
replacement = "<w:p>" *
"<w:pPr>" *
"<w:jc w:val=\"center\"/>" *
"<w:tabs>" *
"<w:tab w:val=\"center\" w:pos=\"4513\"/>" *
"<w:tab w:val=\"right\" w:pos=\"9026\"/>" *
"</w:tabs>" *
"</w:pPr>" *
"<w:r><w:tab/></w:r>" *
"<w:r>" * omml * "</w:r>" *
"<w:r><w:tab/></w:r>" *
"<w:r><w:rPr><w:sz w:val=\"24\"/></w:rPr><w:t>(" * eq_number * ")</w:t></w:r>" *
"</w:p>"
rng = m.offset:nextind(text, m.offset + length(m.match) - 2)
push!(replacements, (rng, replacement))
end
# Теперь применяем замены в обратном порядке
result = text
for (rng, replacement) in reverse(replacements)
result = result[1:prevind(result, rng.start)] * replacement * result[nextind(result, rng.stop):end]
end
return result
end
"""
latex_to_omml(latex; display_mode=false) -> String
Конвертирует LaTeX-строку в OMML (Office Math Markup Language) для вставки в docx.
Поддерживает дроби, индексы, степени, корни, матрицы, системы уравнений, греческие
буквы и основные математические операторы. Аргумент `display_mode` определяет,
будет ли формула строчной или выключной.
"""
function latex_to_omml(latex; display_mode = false)
latex = strip(latex)
# Убираем пробелы вокруг операторов
latex = replace(latex, r"\s*=\s*" => "=")
latex = replace(latex, r"\s*\\cdot\s*" => "·")
latex = replace(latex, r"\s*\\times\s*" => "×")
blocks = parse_latex_blocks(latex)
omml_content = join(blocks, "")
if display_mode
return "<m:oMathPara><m:oMath>" * omml_content * "</m:oMath></m:oMathPara>"
else
return "<m:oMath>" * omml_content * "</m:oMath>"
end
end
"""
make_fraction(num_blocks, den_blocks) -> String
Создаёт OMML-элемент дроби из уже обработанных блоков числителя и знаменателя.
"""
function make_fraction(num_blocks, den_blocks)
return "<m:f><m:fPr/><m:num>" * join(num_blocks, "") * "</m:num><m:den>" * join(den_blocks, "") * "</m:den></m:f>"
end
# function find_next_end(chars, start)
# # Ищем \end{...}
# i = start
# while i <= length(chars) - 4
# if chars[i] == '\\' && i + 4 <= length(chars)
# if join(chars[i:min(i+4, end)], "") == "\\end{"
# return i # позиция начала \end
# end
# end
# i += 1
# end
# return nothing
# end
"""
parse_cases(chars, start) -> Tuple{String, Int}
Парсит окружение LaTeX `\begin{cases}...\end{cases}` и создаёт OMML-представление
системы уравнений без скобки (только выравнивание).
Возвращает XML-строку и позицию после `\\end{cases}`.
"""
function parse_cases(chars, start)
text = join(chars[start:end], "")
end_match = match(r"\\end\{cases\}", text)
if end_match !== nothing
cases_text = text[1:prevind(text, end_match.offset)]
end_pos = start + end_match.offset + length(end_match.match) - 1 # позиция после \end{cases}
else
cases_text = text
end_pos = length(chars) + 1
end
cases_text = replace(cases_text, r"\\\\\[\d+pt\]" => "\\\\")
rows = split(cases_text, r"\\\\\s*"; keepempty=false)
result = "<m:eqArr>"
for row in rows
row = strip(row)
if !isempty(row)
row_blocks = parse_latex_blocks(row)
result *= "<m:e>" * join(row_blocks, "") * "</m:e>"
end
end
result *= "</m:eqArr>"
return result, end_pos # возвращаем и результат, и позицию
end
"""
parse_cases_with_pos(chars, start) -> NamedTuple
Парсит окружение `cases` и создаёт OMML-представление системы уравнений с фигурной
скобкой слева. Возвращает именованный кортеж `(xml, end_pos)`.
"""
function parse_cases_with_pos(chars, start)
text = join(chars[start:end], "")
end_match = match(r"\\end\{cases\}", text)
if end_match !== nothing
cases_text = text[1:prevind(text, end_match.offset)]
end_pos = start + end_match.offset + length(end_match.match) - 1
else
cases_text = text
end_pos = length(chars) + 1
end
cases_text = replace(cases_text, r"\\\\\[\d+pt\]" => "\\\\")
rows = split(cases_text, r"\\\\\s*"; keepempty=false)
# Создаём eqArr внутри m:d со скобкой
result = "<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"\"/></m:dPr><m:e><m:eqArr>"
for row in rows
row = strip(row)
if !isempty(row)
row_blocks = parse_latex_blocks(row)
result *= "<m:e>" * join(row_blocks, "") * "</m:e>"
end
end
result *= "</m:eqArr></m:e></m:d>"
return (xml=result, end_pos=end_pos)
end
"""
parse_matrix(chars, start, env) -> String
Парсит матричные окружения LaTeX (`pmatrix`, `bmatrix`, `vmatrix` и т.д.)
и создаёт OMML-представление матрицы с соответствующими скобками.
"""
function parse_matrix(chars, start, env)
text = join(chars[start:end], "")
end_match = match(r"\\end\{" * env * r"\}", text)
if end_match !== nothing
matrix_text = text[1:prevind(text, end_match.offset)]
else
matrix_text = text
end
brackets = Dict(
"pmatrix" => ("(", ")"),
"bmatrix" => ("[", "]"),
"Bmatrix" => ("{", "}"),
"vmatrix" => ("|", "|"),
"Vmatrix" => ("‖", "‖"),
"matrix" => ("", ""),
)
left_br, right_br = get(brackets, env, ("(", ")"))
rows = split(matrix_text, r"\\\\\s*"; keepempty=false)
# Определяем количество столбцов (по первой строке)
num_cols = 1
if !isempty(rows)
first_row = strip(rows[1])
num_cols = max(1, length(split(first_row, "&")))
end
result = "<m:d>"
result *= "<m:dPr>"
if !isempty(left_br)
result *= "<m:begChr m:val=\"" * escape_xml(left_br) * "\"/>"
result *= "<m:endChr m:val=\"" * escape_xml(right_br) * "\"/>"
end
# Указываем количество столбцов
result *= "<m:mc><m:mcPr><m:count m:val=\"$num_cols\"/></m:mcPr></m:mc>"
result *= "</m:dPr>"
for row in rows
row = strip(row)
if !isempty(row)
cells = split(row, "&")
for cell in cells
cell = strip(cell)
result *= "<m:e>"
if !isempty(cell)
cell_blocks = parse_latex_blocks(cell)
result *= join(cell_blocks, "")
end
result *= "</m:e>"
end
end
end
result *= "</m:d>"
return result
end
"""
parse_latex_blocks(text) -> Vector{String}
Разбирает LaTeX-строку на последовательность OMML-блоков. Обрабатывает команды,
символы, фигурные скобки, индексы, степени и операторы. Рекурсивно вызывается
для вложенных конструкций (числитель/знаменатель, аргументы команд).
"""
function parse_latex_blocks(text)
blocks = String[]
chars = collect(text)
i = 1
while i <= length(chars)
c = chars[i]
# Обработка двойного бэкслеша (перенос строки) — игнорируем
if c == '\\' && i + 1 <= length(chars) && chars[i+1] == '\\'
i += 2
continue
end
if c == '\\'
cmd_chars = ['\\']
j = i + 1
while j <= length(chars) && isletter(chars[j])
push!(cmd_chars, chars[j])
j += 1
end
cmd = join(cmd_chars, "")
i = j
# Проверяем команды
if cmd in ["\\cdot", "\\times", "\\div", "\\pm", "\\mp", "\\leq", "\\geq", "\\neq", "\\approx", "\\equiv", "\\propto", "\\sim"]
sym = latex_symbol_to_omml(cmd)
if sym !== nothing
push!(blocks, make_omml_text(sym))
end
elseif cmd in ["\\infty", "\\partial", "\\nabla", "\\forall", "\\exists"]
sym = latex_symbol_to_omml(cmd)
if sym !== nothing
push!(blocks, make_omml_text(sym))
end
elseif cmd in ["\\frac", "\\dfrac"]
# Дробь: \frac{num}{den}
if i <= length(chars)
num, skip1 = parse_braces_chars(chars, i)
den, skip2 = parse_braces_chars(chars, skip1)
num_blocks = parse_latex_blocks(num)
den_blocks = parse_latex_blocks(den)
push!(blocks, make_fraction(num_blocks, den_blocks))
i = skip2
end
elseif cmd == "\\dot"
# Точка над символом
if i <= length(chars)
arg, skip = extract_argument_chars(chars, i)
push!(blocks, "<m:acc><m:accPr><m:chr m:val=\"̇\"/></m:accPr><m:e>" * make_omml_text(arg) * "</m:e></m:acc>")
i = skip
end
elseif cmd == "\\ddot"
# Две точки над символом
if i <= length(chars)
arg, skip = extract_argument_chars(chars, i)
push!(blocks, "<m:acc><m:accPr><m:chr m:val=\"̈\"/></m:accPr><m:e>" * make_omml_text(arg) * "</m:e></m:acc>")
i = skip
end
elseif cmd == "\\sqrt"
if i <= length(chars) && chars[i] == '['
# Корень с показателем: \sqrt[n]{x}
i += 1
deg_text = ""
while i <= length(chars) && chars[i] != ']'
deg_text *= string(chars[i])
i += 1
end
i += 1 # пропускаем ]
arg, skip = parse_braces_chars(chars, i)
deg_blocks = parse_latex_blocks(deg_text)
arg_blocks = parse_latex_blocks(arg)
push!(blocks, "<m:rad><m:radPr><m:deg>" * join(deg_blocks, "") * "</m:deg></m:radPr><m:e>" * join(arg_blocks, "") * "</m:e></m:rad>")
i = skip
else
# Обычный квадратный корень \sqrt{x} — без коробочки степени
arg, skip = parse_braces_chars(chars, i)
arg_blocks = parse_latex_blocks(arg)
push!(blocks, "<m:rad><m:radPr><m:degHide/></m:radPr><m:e>" * join(arg_blocks, "") * "</m:e></m:rad>")
i = skip
end
elseif cmd == "\\text"
if i <= length(chars) && chars[i] == '{'
arg, skip = parse_braces_chars(chars, i)
push!(blocks, make_omml_text(arg)) # текст как есть
i = skip
end
elseif cmd in ["\\mathbb", "\\mathbf", "\\mathcal", "\\mathit", "\\mathrm"]
# Команды смены шрифта
if i <= length(chars) && chars[i] == '{'
arg, skip = parse_braces_chars(chars, i)
if cmd == "\\mathbb"
push!(blocks, make_omml_doublestruck(arg))
elseif cmd == "\\mathbf"
push!(blocks, "<m:r><m:rPr><m:sty m:val=\"b\"/></m:rPr><m:t>" * escape_xml(arg) * "</m:t></m:r>")
elseif cmd == "\\mathcal"
push!(blocks, "<m:r><m:rPr><m:sty m:val=\"s\"/></m:rPr><m:t>" * escape_xml(arg) * "</m:t></m:r>")
else
push!(blocks, make_omml_text(arg))
end
i = skip
end
elseif cmd in ["\\sum", "\\int", "\\iint", "\\iiint", "\\oint", "\\prod"]
# Пропускаем операторы с пределами — они не поддерживаются
# Просто вставляем символ
sym = latex_symbol_to_omml(cmd)
if sym !== nothing
push!(blocks, make_omml_text(sym))
end
# Пропускаем пределы если есть
if i <= length(chars) && (chars[i] == '_' || chars[i] == '^')
# Пропускаем _ и ^ аргументы
if chars[i] == '_'
i += 1
_, i = extract_argument_chars(chars, i)
end
if i <= length(chars) && chars[i] == '^'
i += 1
_, i = extract_argument_chars(chars, i)
end
end
elseif cmd == "\\begin"
env, skip_env = parse_braces_chars(chars, i)
if env in ["cases", "rcases", "dcases"]
cases_result = parse_cases_with_pos(chars, skip_env)
push!(blocks, cases_result.xml)
i = cases_result.end_pos
elseif env in ["pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "matrix"]
matrix_xml = parse_matrix(chars, skip_env, env)
push!(blocks, matrix_xml)
# Ищем \end{env} и перепрыгиваем за него
remaining = join(chars[skip_env:end], "")
end_marker = "\\end{" * env * "}"
end_pos = findfirst(end_marker, remaining)
if end_pos !== nothing
i = skip_env + end_pos.stop
else
i = length(chars) + 1
end
else
i = skip_env
end
else
# список игнорируемых команд
if cmd in ["\\left", "\\right", "\\qquad", "\\quad", "\\,", "\\!", "\\;", "\\:", "\\ ", "\\mathbb", "\\mathbf", "\\mathcal", "\\mathit", "\\mathrm"]
# Ничего не делаем, просто пропускаем
else
# Греческие буквы и другие символы
sym = latex_symbol_to_omml(cmd)
if sym !== nothing
push!(blocks, make_omml_text(sym))
else
# Неизвестная команда — вставляем как текст
push!(blocks, make_omml_text(cmd))
end
end
end
elseif c == '^'
i += 1
sup, skip_chars = extract_argument_chars(chars, i)
sup_blocks = parse_latex_blocks(sup)
if !isempty(blocks)
base_block = pop!(blocks)
if startswith(base_block, "<m:sSub>")
base_block = replace(base_block, "<m:sSub>" => "<m:sSubSup>")
base_block = replace(base_block, "</m:sSub>" => "<m:sup>" * join(sup_blocks, "") * "</m:sup></m:sSubSup>")
push!(blocks, base_block)
else
push!(blocks, "<m:sSup><m:e>" * base_block * "</m:e><m:sup>" * join(sup_blocks, "") * "</m:sup></m:sSup>")
end
else
push!(blocks, "<m:sSup><m:e/><m:sup>" * join(sup_blocks, "") * "</m:sup></m:sSup>")
end
i = skip_chars
elseif c == '_'
i += 1
sub, skip_chars = extract_argument_chars(chars, i)
sub_blocks = parse_latex_blocks(sub)
if !isempty(blocks)
base_block = pop!(blocks)
if startswith(base_block, "<m:sSup>")
base_block = replace(base_block, "<m:sSup>" => "<m:sSubSup>")
base_block = replace(base_block, "</m:sSup>" => "<m:sub>" * join(sub_blocks, "") * "</m:sub></m:sSubSup>")
push!(blocks, base_block)
else
push!(blocks, "<m:sSub><m:e>" * base_block * "</m:e><m:sub>" * join(sub_blocks, "") * "</m:sub></m:sSub>")
end
else
push!(blocks, "<m:sSub><m:e/><m:sub>" * join(sub_blocks, "") * "</m:sub></m:sSub>")
end
i = skip_chars
elseif c == '{'
group, skip_chars = parse_braces_chars(chars, i)
group_blocks = parse_latex_blocks(group)
append!(blocks, group_blocks)
i = skip_chars
elseif c == ' '
# Пропускаем пробелы внутри формул — они не нужны в OMML
i += 1
elseif c == '~'
push!(blocks, make_omml_text(" "))
i += 1
else
push!(blocks, make_omml_text(string(c)))
i += 1
end
end
return blocks
end
# # Старые функции для обратной совместимости (обёртки)
# function parse_braces(text, start)
# chars = collect(text)
# result, skip = parse_braces_chars(chars, start)
# return (result, skip)
# end
# Вспомогательные функции для работы с массивом символов
"""
parse_braces_chars(chars, start) -> Tuple{String, Int}
Извлекает содержимое фигурных скобок `{...}` из массива символов, начиная с позиции
`start`. Учитывает вложенность скобок. Возвращает строку внутри скобок и позицию
после закрывающей скобки.
"""
function parse_braces_chars(chars, start)
if start > length(chars) || chars[start] != '{'
return ("", start)
end
depth = 0
result_chars = Char[]
i = start
while i <= length(chars)
if chars[i] == '{'
depth += 1
end
if chars[i] == '}'
depth -= 1
end
if depth == 0
return (join(result_chars, ""), i + 1)
end
if depth > 0 && i > start
push!(result_chars, chars[i])
end
i += 1
end
return (join(result_chars, ""), length(chars) + 1)
end
# function extract_argument(text, start)
# chars = collect(text)
# result, skip = extract_argument_chars(chars, start)
# return (result, skip)
# end
# function extract_argument(text, start)
# if start > length(text)
# return ("", start)
# end
# if text[start] == '{'
# return parse_braces(text, start)
# else
# # Один символ
# return (string(text[start]), start + 1)
# end
# end
"""
extract_argument_chars(chars, start) -> Tuple{String, Int}
Извлекает аргумент LaTeX-команды: либо одиночный символ, либо содержимое фигурных
скобок `{...}`. Возвращает строку аргумента и следующую позицию.
"""
function extract_argument_chars(chars, start)
if start > length(chars)
return ("", start)
end
if chars[start] == '{'
return parse_braces_chars(chars, start)
else
return (string(chars[start]), start + 1)
end
end
"""
make_omml_text(text) -> String
Создаёт простой OMML-элемент `<m:r><m:t>...</m:t></m:r>` с указанным текстом.
Обрабатывает случай `nothing` (пустой текст). Не экранирует XML внутри,
так как предполагается, что текст уже безопасен.
"""
function make_omml_text(text)
# Не экранируем XML внутри OMML — это plain text
if isnothing( text ) return "<m:r><m:t></m:t></m:r>"
else return "<m:r><m:t>" * text * "</m:t></m:r>"
end
end
# ============================================================
# 1. СОЗДАНИЕ ШАБЛОНА DOCX
# ============================================================
"""
create_template(template_path)
Создаёт файл шаблона docx со всеми необходимыми стилями, колонтитулами,
нумерацией и титульной страницей. Включает стили для обычного текста (`Normal`),
заголовков трёх уровней (`Heading1`–`Heading3`), кода (`Code`), вывода кода
(`CodeOutput`), ячеек таблиц (`TableCell`) и маркированных списков (`ListBullet`).
Титульная страница содержит плейсхолдеры `{{...}}` для подстановки данных
пользователем.
"""
function create_template(template_path::String)
content_types_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Default Extension="jpg" ContentType="image/jpeg"/>
<Default Extension="jpeg" ContentType="image/jpeg"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
<Override PartName="/word/header2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>
<Override PartName="/word/footer2.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
</Types>"""
rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""
doc_rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header2.xml"/>
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer2.xml"/>
<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
</Relationships>"""
styles_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr>
<w:pPr><w:spacing w:line="360" w:lineRule="auto" w:after="160"/><w:ind w:firstLine="720"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="TitlePage">
<w:name w:val="Title Page"/>
<w:basedOn w:val="Normal"/>
<w:pPr><w:ind w:firstLine="0"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="240" w:after="240"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="200" w:after="240"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading3">
<w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="160" w:after="240"/></w:pPr>
</w:style>
<!-- Code: серый фон, маленький отступ снизу -->
<w:style w:type="paragraph" w:styleId="Code">
<w:name w:val="Code"/><w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>
<w:pPr><w:spacing w:line="240" w:lineRule="auto" w:before="60" w:after="160"/><w:ind w:firstLine="0"/><w:shd w:fill="F5F5F5" w:val="clear"/></w:pPr>
</w:style>
<!-- CodeOutput: без отступов между строками -->
<w:style w:type="paragraph" w:styleId="CodeOutput">
<w:name w:val="Code Output"/><w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>
<w:pPr><w:spacing w:line="240" w:lineRule="auto" w:before="0" w:after="0"/><w:ind w:firstLine="0"/></w:pPr>
</w:style>
<w:style w:type="character" w:styleId="CodeChar">
<w:name w:val="Code Char"/>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="character" w:styleId="CodeOutputChar">
<w:name w:val="Code Output Char"/>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="character" w:styleId="InlineCode">
<w:name w:val="Inline Code"/>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="24"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph">
<w:name w:val="List Paragraph"/>
<w:basedOn w:val="Normal"/>
<w:pPr>
<w:ind w:left="720"/>
</w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="ListBullet">
<w:name w:val="List Bullet"/>
<w:basedOn w:val="ListParagraph"/>
</w:style>
<w:style w:type="paragraph" w:styleId="ListNumber">
<w:name w:val="List Number"/>
<w:basedOn w:val="Normal"/>
<w:pPr>
<w:ind w:left="1077" w:firstLine="0"/>
</w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="TableCell">
<w:name w:val="Table Cell"/>
<w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>
<w:pPr><w:spacing w:line="300" w:lineRule="auto" w:before="40" w:after="40"/><w:ind w:firstLine="0"/></w:pPr>
</w:style>
</w:styles>"""
numbering_xml = """<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<!-- Маркированный список (bullets) -->
<w:abstractNum w:abstractNumId="0">
<w:nsid w:val="FFFFFF01"/>
<w:multiLevelType w:val="hybridMultilevel"/>
<w:tmpl w:val="00000001"/>
<w:lvl w:ilvl="0" w:tplc="04190001">
<w:start w:val="1"/>
<w:numFmt w:val="bullet"/>
<w:lvlText w:val=""/>
<w:lvlJc w:val="left"/>
<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol" w:hint="default"/></w:rPr>
</w:lvl>
</w:abstractNum>
<!-- Нумерованный список (decimal) -->
<w:abstractNum w:abstractNumId="1">
<w:nsid w:val="42EF1370"/>
<w:multiLevelType w:val="hybridMultilevel"/>
<w:tmpl w:val="65140BFC"/>
<w:lvl w:ilvl="0" w:tplc="0419000F">
<w:start w:val="1"/>
<w:numFmt w:val="decimal"/>
<w:lvlText w:val="%1."/>
<w:lvlJc w:val="left"/>
<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>
<w:rPr><w:rFonts w:hint="default"/></w:rPr>
</w:lvl>
</w:abstractNum>
<!-- Маркированный список -->
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
<!-- Нумерованные списки (посадочные места 2..MAX_NUMBERED_LISTS) -->"""
# Генерируем 100 посадочных мест для нумерованных списков
for i in 2:MAX_NUMBERED_LISTS+1
numbering_xml *= "<w:num w:numId=\"$i\"><w:abstractNumId w:val=\"1\"/></w:num>\n"
end
numbering_xml *= "</w:numbering>"
header1_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:pPr><w:jc w:val="center"/><w:pBdr><w:bottom w:val="single" w:sz="8" w:space="4" w:color="000000"/></w:pBdr></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/><w:sz w:val="22"/><w:b/></w:rPr><w:t>{{INSTITUTION_NAME}}</w:t></w:r>
</w:p>
</w:hdr>"""
footer1_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:pPr><w:jc w:val="center"/><w:pBdr><w:top w:val="single" w:sz="8" w:space="4" w:color="000000"/></w:pBdr></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/><w:sz w:val="22"/></w:rPr><w:t>{{CITY}}, {{YEAR}}</w:t></w:r>
</w:p>
</w:ftr>"""
header2_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:pPr><w:jc w:val="right"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman"/><w:sz w:val="20"/></w:rPr><w:t>{{TOP_PAGE_INFO}}</w:t></w:r>
</w:p>
</w:hdr>"""
footer2_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:p><w:pPr><w:jc w:val="center"/></w:pPr>
<w:r><w:rPr><w:sz w:val="20"/></w:rPr><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:rPr><w:sz w:val="20"/></w:rPr><w:instrText> PAGE </w:instrText></w:r>
<w:r><w:rPr><w:sz w:val="20"/></w:rPr><w:fldChar w:fldCharType="end"/></w:r>
</w:p>
</w:ftr>"""
document_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
<w:body>
<w:p><w:pPr><w:pStyle w:val="TitlePage"/><w:jc w:val="center"/><w:spacing w:before="2400" w:after="480"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="28"/><w:b/></w:rPr><w:t>{{WORK_TYPE}}</w:t></w:r>
</w:p>
<w:p><w:pPr><w:pStyle w:val="TitlePage"/><w:jc w:val="center"/><w:spacing w:before="600" w:after="600"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="36"/><w:b/></w:rPr><w:t>{{WORK_TITLE}}</w:t></w:r>
</w:p>
<w:p><w:pPr><w:pStyle w:val="TitlePage"/><w:spacing w:before="1200"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="28"/></w:rPr><w:tab/><w:tab/><w:tab/><w:tab/><w:tab/><w:t>{{performed_by}} {{AUTHOR_NAME}}</w:t></w:r>
</w:p>
<w:p><w:pPr><w:pStyle w:val="TitlePage"/><w:spacing w:before="600"/></w:pPr>
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="28"/></w:rPr><w:tab/><w:tab/><w:tab/><w:tab/><w:tab/><w:t>{{accepted_by}} {{SUPERVISOR_NAME}}</w:t></w:r>
</w:p>
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
<w:p><w:r><w:t>{{CONTENT}}</w:t></w:r></w:p>
<w:sectPr>
<w:headerReference r:id="rId2" w:type="first"/>
<w:footerReference r:id="rId3" w:type="first"/>
<w:headerReference r:id="rId4" w:type="default"/>
<w:footerReference r:id="rId5" w:type="default"/>
<w:titlePg/>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="567" w:bottom="1134" w:left="1134" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>"""
writer = ZipFile.Writer(template_path)
files = [
("[Content_Types].xml", content_types_xml),
("_rels/.rels", rels_xml),
("word/_rels/document.xml.rels", doc_rels_xml),
("word/styles.xml", styles_xml),
("word/header1.xml", header1_xml),
("word/footer1.xml", footer1_xml),
("word/header2.xml", header2_xml),
("word/footer2.xml", footer2_xml),
("word/numbering.xml", numbering_xml),
("word/document.xml", document_xml),
]
for (name, content) in files
f = ZipFile.addfile(writer, name)
write(f, content)
end
close(writer)
end
function create_simple_template(template_path::String)
# Используем тот же content_types, rels, styles, что и в create_template
styles_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr>
<w:pPr><w:spacing w:line="360" w:lineRule="auto" w:after="160"/><w:ind w:firstLine="720"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="240" w:after="240"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="200" w:after="240"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading3">
<w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:pPr><w:keepNext/><w:spacing w:before="160" w:after="240"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Code">
<w:name w:val="Code"/><w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>
<w:pPr><w:spacing w:line="240" w:lineRule="auto" w:before="0" w:after="0"/><w:ind w:firstLine="0"/><w:shd w:fill="F5F5F5" w:val="clear"/></w:pPr>
</w:style>
<w:style w:type="character" w:styleId="CodeChar">
<w:name w:val="Code Char"/>
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="CodeOutput">
<w:name w:val="Code Output"/><w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>
<w:pPr><w:spacing w:line="240" w:lineRule="auto" w:before="0" w:after="0"/><w:ind w:firstLine="0"/></w:pPr>
</w:style>
<w:style w:type="character" w:styleId="CodeOutputChar">
<w:name w:val="Code Output Char"/>
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>
</w:style>
<w:style w:type="character" w:styleId="InlineCode">
<w:name w:val="Inline Code"/>
<w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph">
<w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/>
<w:pPr><w:ind w:left="720"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="ListBullet">
<w:name w:val="List Bullet"/><w:basedOn w:val="ListParagraph"/>
</w:style>
<w:style w:type="paragraph" w:styleId="ListNumber">
<w:name w:val="List Number"/><w:basedOn w:val="Normal"/>
<w:pPr><w:ind w:left="1077" w:firstLine="0"/></w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="TableCell">
<w:name w:val="Table Cell"/><w:basedOn w:val="Normal"/>
<w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr>
<w:pPr><w:spacing w:line="300" w:lineRule="auto" w:before="40" w:after="40"/><w:ind w:firstLine="0"/></w:pPr>
</w:style>
</w:styles>"""
# Простой документ: только {{CONTENT}} на первой странице
document_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
<w:body>
<w:p><w:r><w:t>{{CONTENT}}</w:t></w:r></w:p>
<w:sectPr>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar w:top="1134" w:right="567" w:bottom="1134" w:left="1134" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>"""
# Остальные файлы те же, что в create_template
content_types_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Default Extension="jpg" ContentType="image/jpeg"/>
<Default Extension="jpeg" ContentType="image/jpeg"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
</Types>"""
rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""
doc_rels_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
</Relationships>"""
# numbering_xml — такой же, как в create_template (с bullets и нумерованными)
numbering_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:abstractNum w:abstractNumId="0">
<w:nsid w:val="FFFFFF01"/>
<w:multiLevelType w:val="hybridMultilevel"/>
<w:tmpl w:val="00000001"/>
<w:lvl w:ilvl="0" w:tplc="04190001">
<w:start w:val="1"/>
<w:numFmt w:val="bullet"/>
<w:lvlText w:val=""/>
<w:lvlJc w:val="left"/>
<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol" w:hint="default"/></w:rPr>
</w:lvl>
</w:abstractNum>
<w:abstractNum w:abstractNumId="1">
<w:nsid w:val="42EF1370"/>
<w:multiLevelType w:val="hybridMultilevel"/>
<w:tmpl w:val="65140BFC"/>
<w:lvl w:ilvl="0" w:tplc="0419000F">
<w:start w:val="1"/>
<w:numFmt w:val="decimal"/>
<w:lvlText w:val="%1."/>
<w:lvlJc w:val="left"/>
<w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr>
<w:rPr><w:rFonts w:hint="default"/></w:rPr>
</w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
"""
for i in 2:101
numbering_xml *= "<w:num w:numId=\"$i\"><w:abstractNumId w:val=\"1\"/></w:num>\n"
end
numbering_xml *= "</w:numbering>"
writer = ZipFile.Writer(template_path)
files = [
("[Content_Types].xml", content_types_xml),
("_rels/.rels", rels_xml),
("word/_rels/document.xml.rels", doc_rels_xml),
("word/styles.xml", styles_xml),
("word/numbering.xml", numbering_xml),
("word/document.xml", document_xml),
]
for (name, content) in files
f = ZipFile.addfile(writer, name)
write(f, content)
end
close(writer)
end
# ============================================================
# 2. ЗАПОЛНЕНИЕ ШАБЛОНА
# ============================================================
"""
fill_template(template_path, output_path, replacements, images)
Заполняет шаблон docx данными: заменяет плейсхолдеры `{{КЛЮЧ}}` на значения
из словаря `replacements`, вставляет содержимое отчёта вместо `{{CONTENT}}`,
добавляет изображения в папку `word/media` и обновляет ссылки в `document.xml.rels`.
Результат сохраняется в `output_path`. Использует временную директорию для
распаковки/пересборки ZIP-архива docx.
"""
function fill_template(template_path::String, output_path::String, replacements::Dict{String,String}, images::OrderedDict{String, Tuple{Vector{UInt8}, String}})
cp(template_path, output_path, force=true)
mktempdir() do tmpdir
reader = ZipFile.Reader(output_path)
for file in reader.files
file_path = joinpath(tmpdir, file.name)
mkpath(dirname(file_path))
content = read(file, String)
if file.name == "word/document.xml"
# Обновляем rId изображений в отчёте
report = ""
if haskey(replacements, "CONTENT")
report = replacements["CONTENT"]
for (i, (image_name, _)) in enumerate(collect(images))
old_rId = "rIdImg$(i)"
new_rId = "rId$(6 + i)"
report = replace(report, old_rId => new_rId)
end
end
# Заменяем ВСЕ плейсхолдеры универсальным способом
content = replace_all_placeholders(content, replacements, report)
elseif endswith(file.name, ".xml") && file.name != "_rels/.rels"
content = replace_all_placeholders(content, replacements, "")
end
write(file_path, content)
end
close(reader)
# Добавляем изображения
if !isempty(images)
media_dir = joinpath(tmpdir, "word", "media")
mkpath(media_dir)
for (image_name, (bytes, ext)) in images
write(joinpath(media_dir, image_name), bytes)
end
# Обновляем document.xml.rels
doc_rels_path = joinpath(tmpdir, "word", "_rels", "document.xml.rels")
rels_content = read(doc_rels_path, String)
image_rels = String[]
# for (i, (image_name, _)) in enumerate(collect(images))
# rId = "rId$(6 + i)"
# push!(image_rels, """<Relationship Id="$rId" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/$image_name"/>""")
# end
img_list = collect(images)
for (i, (image_name, _)) in enumerate(img_list)
rId = "rId$(6 + i)"
push!(image_rels, """<Relationship Id="$rId" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/$image_name"/>""")
end
rels_content = replace(rels_content, "</Relationships>" => join(image_rels, "\n") * "\n</Relationships>")
write(doc_rels_path, rels_content)
end
# Пересоздаём ZIP с правильным порядком: _rels/.rels должен быть первым
writer = ZipFile.Writer(output_path)
# ВАЖНО: _rels/.rels должен быть первым и без сжатия
rels_path = joinpath(tmpdir, "_rels", ".rels")
if isfile(rels_path)
f = ZipFile.addfile(writer, "_rels/.rels"; method=ZipFile.Store)
write(f, read(rels_path))
end
# Затем [Content_Types].xml
ct_path = joinpath(tmpdir, "[Content_Types].xml")
if isfile(ct_path)
f = ZipFile.addfile(writer, "[Content_Types].xml")
write(f, read(ct_path))
end
# Затем все остальные файлы
for (root, dirs, files) in walkdir(tmpdir)
for file in files
full_path = joinpath(root, file)
rel_path = relpath(full_path, tmpdir)
rel_path = replace(rel_path, "\\" => "/")
# Пропускаем уже добавленные
if rel_path in ["_rels/.rels", "[Content_Types].xml"]
continue
end
f = ZipFile.addfile(writer, rel_path)
write(f, read(full_path))
end
end
close(writer)
end
end
# ============================================================
# 3. ОБРАБОТКА NOTEBOOK
# ============================================================
"""
extract_notebook_content(notebook; no_code=false) -> Tuple{String, OrderedDict}
Извлекает содержимое всех ячеек Engee-скрипта и преобразует его в XML-строку
для вставки в docx. Сбрасывает все счётчики (изображения, таблицы, формулы,
заголовки) в начале обработки. Пропускает ячейки с конфигом.
При `no_code=true` исключает код из кодовых ячеек, оставляя только вывод.
Возвращает кортеж `(xml_строка, словарь_изображений)`.
"""
function extract_notebook_content(notebook::Dict; no_code::Bool = false)::Tuple{String, OrderedDict{String, Tuple{Vector{UInt8}, String}}}
parts = String[]
images = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
reset_image_counter()
image_rId_counter = Ref(0)
# Сбрасываем ВСЕ счётчики
reset_heading_counters()
figure_counter[] = 0
table_counter[] = 0
equation_counter[] = 0
numbered_list_counter[] = 2
for (ci, cell) in enumerate(notebook["cells"])
# Пропускаем ячейки с конфигом
if cell_contains_config(cell)
continue
end
# Если режим no_code и ячейка кодовая — пропускаем саму ячейку
if no_code && cell["cell_type"] == "code"
# Но выводы всё равно обрабатываем
cell_content, cell_images = process_code_content(cell, image_rId_counter, only_output=true)
if !isempty(cell_content)
push!(parts, cell_content)
end
merge!(images, cell_images)
continue
end
# Обычная обработка
cell_content, cell_images = process_cell_content(cell, image_rId_counter, no_code=no_code)
if !isempty(cell_content)
push!(parts, cell_content)
end
merge!(images, cell_images)
end
return join(parts, "\n"), images
end
"""
process_cell_content(cell, image_rId_counter; no_code=false) -> Tuple{String, OrderedDict}
Маршрутизирует обработку ячейки в зависимости от её типа: markdown-ячейки передаются
в `process_markdown_content`, кодовые — в `process_code_content`.
Возвращает XML-представление содержимого и словарь найденных изображений.
"""
function process_cell_content(cell::Dict, image_rId_counter::Ref{Int}; no_code::Bool = false)::Tuple{String, OrderedDict{String, Tuple{Vector{UInt8}, String}}}
cell_type = cell["cell_type"]
if cell_type == "markdown"
return process_markdown_content(cell, image_rId_counter)
elseif cell_type == "code"
return process_code_content(cell, image_rId_counter, no_code=no_code)
else
return "", OrderedDict{String, Tuple{Vector{UInt8}, String}}()
end
end
const heading_counters = Dict{Int, Int}(1 => 0, 2 => 0, 3 => 0)
"""
reset_heading_counters()
Сбрасывает счётчики заголовков всех трёх уровней в ноль.
Вызывается в начале обработки нового документа.
"""
function reset_heading_counters()
heading_counters[1] = 0
heading_counters[2] = 0
heading_counters[3] = 0
end
const figure_counter = Ref(0)
"""
create_figure_caption(image_rId_counter, caption_text, rId, width, height) -> String
Создаёт XML-представление изображения с подписью вида «Рисунок N — Название».
Объединяет вызов `create_image_xml` и параграф подписи с автонумерацией.
"""
function create_figure_caption(image_rId_counter, caption_text, rId, width, height)
figure_counter[] += 1
img_xml = create_image_xml(rId, width, height)
caption_xml = "<w:p><w:pPr><w:jc w:val=\"center\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>Рисунок " * string(figure_counter[]) * " — " * escape_xml(caption_text) * "</w:t></w:r></w:p>"
return img_xml * "\n" * caption_xml
end
"""
check_table_name(code_lines) -> Union{String, Nothing}
Проверяет строки кода на наличие аннотации `DOCX_TABLE_NAME` или `#TABLE_NAME:`
и возвращает название таблицы. Используется для извлечения названия таблицы
из кодовой ячейки перед выводом DataFrame.
"""
function check_table_name(code_lines)
for line in code_lines
stripped = strip(line)
# Проверяем и с решёткой, и без, с двоеточием и без
if startswith(stripped, "#TABLE_NAME:") || startswith(stripped, "DOCX_TABLE_NAME:")
table_name = strip(replace(stripped, r"^#?DOCX_TABLE_NAME:\s*" => ""))
if !isempty(table_name)
return table_name
end
elseif startswith(stripped, "#TABLE_NAME ") || startswith(stripped, "DOCX_TABLE_NAME ")
table_name = strip(replace(stripped, r"^#?DOCX_TABLE_NAME\s+" => ""))
if !isempty(table_name)
return table_name
end
end
end
return nothing
end
"""
html_table_to_docx(html_string, table_name=nothing) -> String
Конвертирует HTML-таблицу (например, вывод DataFrame) в XML-представление
таблицы docx с границами. Автоматически определяет заголовки из `<thead>` или первой
строки. При наличии `table_name` добавляет подпись «Таблица N — Название» перед таблицей
(всегда выводится номер таблицы). Использует стиль `TableCell` для содержимого ячеек.
"""
function html_table_to_docx(html_string::String, table_name = nothing; no_title::Bool = false)::String
table_match = match(r"<table[^>]*>(.*?)</table>"s, html_string)
if table_match === nothing
return ""
end
table_content = table_match.captures[1]
docx = ""
# Добавляем подпись таблицы (если не запрещено)
if !no_title
table_counter[] += 1
if table_name !== nothing
docx *= "<w:p><w:pPr><w:jc w:val=\"right\"/><w:ind w:firstLine=\"0\"/><w:keepNext/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>Таблица " * string(table_counter[]) * "</w:t></w:r><w:r><w:br/></w:r><w:r><w:t>" * escape_xml(table_name) * "</w:t></w:r></w:p>"
else
docx *= "<w:p><w:pPr><w:jc w:val=\"right\"/><w:ind w:firstLine=\"0\"/><w:keepNext/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>Таблица " * string(table_counter[]) * "</w:t></w:r></w:p>"
end
end
docx *= "<w:tbl xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
docx *= "<w:tblPr>"
docx *= "<w:tblW w:w=\"5000\" w:type=\"pct\"/>"
docx *= "<w:tblBorders>"
for border in ["top", "left", "bottom", "right", "insideH", "insideV"]
docx *= "<w:$(border) w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
end
docx *= "</w:tblBorders><w:tblLook w:val=\"04A0\"/></w:tblPr>"
headers = String[]
thead_match = match(r"<thead[^>]*>(.*?)</thead>"s, table_content)
if thead_match !== nothing
th_matches = eachmatch(r"<th[^>]*>(.*?)</th>", thead_match.captures[1])
for m in th_matches
push!(headers, strip(m.captures[1]))
end
end
if isempty(headers)
first_tr = match(r"<tr[^>]*>(.*?)</tr>"s, table_content)
if first_tr !== nothing
th_matches = eachmatch(r"<th[^>]*>(.*?)</th>", first_tr.captures[1])
for m in th_matches
push!(headers, strip(m.captures[1]))
end
end
end
if isempty(headers)
return ""
end
col_width = 9026 ÷ length(headers)
docx *= "<w:tblGrid>"
for _ in headers
docx *= "<w:gridCol w:w=\"$col_width\"/>"
end
docx *= "</w:tblGrid>"
docx *= "<w:tr><w:trPr><w:cantSplit/></w:trPr>" # первая строка (заголовок)
for h in headers
docx *= "<w:tc><w:tcPr><w:tcW w:w=\"$col_width\" w:type=\"dxa\"/></w:tcPr>"
docx *= "<w:p><w:pPr><w:pStyle w:val=\"TableCell\"/><w:jc w:val=\"center\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>$(escape_xml(h))</w:t></w:r></w:p>"
docx *= "</w:tc>"
end
docx *= "</w:tr>"
body_content = table_content
if thead_match !== nothing
body_content = replace(body_content, thead_match.captures[1] => "")
end
row_matches = eachmatch(r"<tr[^>]*>(.*?)</tr>"s, body_content)
for rm in row_matches
row_content = rm.captures[1]
if contains(row_content, "<th")
continue
end
cell_matches = eachmatch(r"<td[^>]*>(.*?)</td>", row_content)
if isempty(cell_matches)
continue
end
docx *= "<w:tr><w:trPr><w:cantSplit/></w:trPr>"
for cm in cell_matches
cell_text = strip(cm.captures[1])
docx *= "<w:tc><w:tcPr><w:tcW w:w=\"$col_width\" w:type=\"dxa\"/></w:tcPr>"
docx *= "<w:p><w:pPr><w:pStyle w:val=\"TableCell\"/></w:pPr><w:r><w:t>$(escape_xml(cell_text))</w:t></w:r></w:p>"
docx *= "</w:tc>"
end
docx *= "</w:tr>"
end
docx *= "</w:tbl>"
return docx
end
"""
process_markdown_content(cell, image_rId_counter) -> Tuple{String, OrderedDict}
Обрабатывает markdown-ячейку: разбирает заголовки (с автонумерацией), списки,
изображения (с подписями «Рисунок N — ...»), таблицы в формате `#|...|#`
(с подписями «Таблица N»), формулы (inline и display), жирный/курсивный текст,
inline-код. Возвращает XML-строку содержимого и словарь найденных изображений.
"""
function process_markdown_content(cell, image_rId_counter)
source = join(cell["source"], "\n")
attachments = get(cell, "attachments", Dict())
images = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
attachment_map = Dict{String, String}()
for (filename, data) in attachments
for fmt in ["image/png", "image/jpeg", "image/svg+xml"]
if haskey(data, fmt)
bytes, ext = decode_base64_image(data[fmt])
image_name = next_image_name(ext)
images[image_name] = (bytes, ext)
attachment_map["attachment:$filename"] = image_name
break
end
end
end
source = process_display_latex(source)
lines = split(source, "\n")
paragraphs = String[]
table_name = nothing
was_numbered_list = false
current_numId = 0
idx = 1
while idx <= length(lines)
line = lines[idx]
line_stripped = strip(line)
if isempty(line_stripped)
was_numbered_list = false # пустая строка разрывает список
idx += 1
continue
end
if startswith(line_stripped, "<w:p>")
push!(paragraphs, line_stripped)
idx += 1
continue
end
# Проверка на DOCX_TABLE_NAME (до проверки на заголовки!)
if startswith(line_stripped, "DOCX_TABLE_NAME:")
table_name = strip(replace(line_stripped, r"^DOCX_TABLE_NAME:\s*" => ""))
idx += 1
continue
elseif startswith(line_stripped, "DOCX_TABLE_NAME ")
table_name = strip(replace(line_stripped, r"^DOCX_TABLE_NAME\s+" => ""))
idx += 1
continue
end
# Обработка fenced code block: ``` ... ```
if startswith(line_stripped, "```")
code_lines_md = String[]
idx += 1
while idx <= length(lines)
if strip(lines[idx]) == "```"
idx += 1
break
end
push!(code_lines_md, lines[idx])
idx += 1
end
for cl in code_lines_md
if !isempty(strip(cl))
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Code\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeChar\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(cl) * "</w:t></w:r></w:p>")
else
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Code\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeChar\"/></w:rPr><w:t xml:space=\"preserve\"></w:t></w:r></w:p>")
end
end
continue
end
# Проверка на таблицу — строго ПЕРВЫМ делом, до заголовков
if line_stripped == "#|"
table_data, next_idx = parse_markdown_table(lines, idx)
if !isempty(table_data)
# Добавляем подпись таблицы
table_counter[] += 1
if table_name !== nothing
push!(paragraphs, "<w:p><w:pPr><w:jc w:val=\"right\"/><w:ind w:firstLine=\"0\"/><w:keepNext/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>Таблица " * string(table_counter[]) * "</w:t></w:r><w:r><w:br/></w:r><w:r><w:t>" * escape_xml(table_name) * "</w:t></w:r></w:p>")
table_name = nothing
else
push!(paragraphs, "<w:p><w:pPr><w:jc w:val=\"right\"/><w:ind w:firstLine=\"0\"/><w:keepNext/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>Таблица " * string(table_counter[]) * "</w:t></w:r></w:p>")
end
num_cols = maximum(length(row) for row in table_data)
table_xml = create_table_xml(table_data, num_cols)
push!(paragraphs, table_xml)
# Добавляем пустую строку после таблицы
push!(paragraphs, "<w:p><w:r><w:t></w:t></w:r></w:p>")
end
idx = next_idx
continue
end
# Простая регулярка для поиска изображений
img_match = match(r"!\[(.*?)\]\((attachment:.*?)\)", line_stripped)
if img_match !== nothing
alt_text = img_match.captures[1]
full_ref = img_match.captures[2]
title_match = match(r"\"(.*?)\"", full_ref)
title_text = title_match !== nothing ? title_match.captures[1] : nothing
caption_text = title_text !== nothing ? title_text : alt_text
ref = replace(full_ref, r"\s*\".*?\"\s*" => "")
ref = replace(ref, r"\s*=\d+x?\d*\s*$" => "")
ref = strip(ref)
if haskey(attachment_map, ref)
image_rId_counter[] += 1
rId = "rIdImg$(image_rId_counter[])"
img_bytes = images[attachment_map[ref]][1]
img_ext = images[attachment_map[ref]][2]
width, height = get_image_dimensions(img_bytes, img_ext)
push!(paragraphs, create_image_xml(rId, width, height))
if !isempty(caption_text)
figure_counter[] += 1
# Добавляем keepNext к параграфу с изображением (предыдущий)
if !isempty(paragraphs)
last_para = paragraphs[end]
if contains(last_para, "<w:drawing>")
# Добавляем keepNext перед </w:pPr> или создаём pPr если нет
if contains(last_para, "<w:pPr>")
last_para = replace(last_para, "<w:pPr>" => "<w:pPr><w:keepNext/>")
else
last_para = replace(last_para, "<w:p>" => "<w:p><w:pPr><w:keepNext/></w:pPr>")
end
paragraphs[end] = last_para
end
end
push!(paragraphs, "<w:p><w:pPr><w:jc w:val=\"center\"/></w:pPr><w:r><w:t>Рисунок " * string(figure_counter[]) * " — " * escape_xml(caption_text) * "</w:t></w:r></w:p>")
end
end
line_stripped = replace(line_stripped, r"!\[.*?\]\(attachment:.*?\)" => "")
line_stripped = strip(line_stripped)
if isempty(line_stripped)
idx += 1
continue
end
end
# Проверка на нумерованный список: "1. ", "2. " и т.д.
is_numbered_list = match(r"^\d+\.\s", line_stripped) !== nothing
# Проверка на ненумерованные заголовки (с тильдой)
is_unnumbered1 = startswith(line_stripped, "# ~") || startswith(line_stripped, "# \\~")
is_unnumbered2 = startswith(line_stripped, "## ~") || startswith(line_stripped, "## \\~")
is_unnumbered3 = startswith(line_stripped, "### ~") || startswith(line_stripped, "### \\~")
if is_numbered_list
if !was_numbered_list
# Начинается новый нумерованный список
if numbered_list_counter[] <= MAX_NUMBERED_LISTS + 1
current_numId = numbered_list_counter[]
numbered_list_counter[] += 1
else
# Превышен лимит — продолжаем использовать последний numId
current_numId = MAX_NUMBERED_LISTS + 1
end
was_numbered_list = true
end
item_text = replace(line_stripped, r"^\d+\.\s" => "")
formatted = process_inline_formatting(item_text)
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"ListNumber\"/><w:numPr><w:ilvl w:val=\"0\"/><w:numId w:val=\"$current_numId\"/></w:numPr></w:pPr>" * formatted * "</w:p>")
idx += 1
continue
end
# Сброс флага нумерованного списка
was_numbered_list = false
# Проверка на обычные нумерованные заголовки
is_heading1 = startswith(line_stripped, "# ") &&
line_stripped != "#|" &&
!is_unnumbered1 &&
!contains(line_stripped, "[template]") &&
!contains(line_stripped, "[config]") &&
!contains(line_stripped, "[title_page]") &&
!contains(line_stripped, "=")
is_heading2 = startswith(line_stripped, "## ") && !startswith(line_stripped, "##|") && !is_unnumbered2
is_heading3 = startswith(line_stripped, "### ") && !is_unnumbered3
is_list_item = startswith(line_stripped, "* ") || startswith(line_stripped, "- ") || match(r"^\d+\.\s", line_stripped) !== nothing
if is_heading1
heading_counters[1] += 1
heading_counters[2] = 0
heading_counters[3] = 0
number = heading_counters[1]
equation_counter[] = 0
was_numbered_list = false
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * string(number) * " " * escape_xml(strip(line_stripped[3:end])) * "</w:t></w:r></w:p>")
elseif is_heading2
heading_counters[2] += 1
heading_counters[3] = 0
number = string(heading_counters[1]) * "." * string(heading_counters[2])
was_numbered_list = false
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * number * " " * escape_xml(strip(line_stripped[4:end])) * "</w:t></w:r></w:p>")
elseif is_heading3
heading_counters[3] += 1
number = string(heading_counters[1]) * "." * string(heading_counters[2]) * "." * string(heading_counters[3])
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading3\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * number * " " * escape_xml(strip(line_stripped[5:end])) * "</w:t></w:r></w:p>")
elseif is_unnumbered1
heading_counters[1] += 1
heading_counters[2] = 0
heading_counters[3] = 0
equation_counter[] = 0
# Убираем "# ~" — 4 символа
# heading_text = strip(line_stripped[4:end])
heading_text = replace(line_stripped, r"^#{2,3}\s+\\?~" => "")
heading_text = strip(heading_text)
was_numbered_list = false
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * escape_xml(heading_text) * "</w:t></w:r></w:p>")
elseif is_unnumbered2
heading_counters[2] += 1
heading_counters[3] = 0
# Убираем "## ~" — 5 символов
#heading_text = strip(line_stripped[5:end])
heading_text = replace(line_stripped, r"^#{2,3}\s+\\?~" => "")
heading_text = strip(heading_text)
was_numbered_list = false
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * escape_xml(heading_text) * "</w:t></w:r></w:p>")
elseif is_unnumbered3
heading_counters[3] += 1
# Убираем "### ~" — 6 символов
#heading_text = strip(line_stripped[6:end])
heading_text = replace(line_stripped, r"^#{2,3}\s+\\?~" => "")
heading_text = strip(heading_text)
was_numbered_list = false
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"Heading3\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr><w:t>" * escape_xml(heading_text) * "</w:t></w:r></w:p>")
# Маркированный список
elseif is_list_item
was_numbered_list = false
if startswith(line_stripped, "* ") || startswith(line_stripped, "- ")
item_text = strip(line_stripped[2:end])
else
item_text = replace(line_stripped, r"^\d+\.\s" => "")
end
formatted = process_inline_formatting(item_text)
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"ListBullet\"/><w:numPr><w:ilvl w:val=\"0\"/><w:numId w:val=\"1\"/></w:numPr></w:pPr>" * formatted * "</w:p>")
# Нумерованный список
elseif match(r"^\d+\.\s", line_stripped) !== nothing
if !was_numbered_list
if numbered_list_counter[] <= MAX_NUMBERED_LISTS + 1
current_numId = numbered_list_counter[]
numbered_list_counter[] += 1
else
current_numId = MAX_NUMBERED_LISTS + 1
end
was_numbered_list = true
end
item_text = replace(line_stripped, r"^\d+\.\s" => "")
formatted = process_inline_formatting(item_text)
push!(paragraphs, "<w:p><w:pPr><w:pStyle w:val=\"ListNumber\"/><w:numPr><w:ilvl w:val=\"0\"/><w:numId w:val=\"$current_numId\"/></w:numPr></w:pPr>" * formatted * "</w:p>")
idx += 1
continue
# Обычный текст
else
was_numbered_list = false
formatted = process_inline_formatting(line_stripped)
if contains(formatted, "<w:r>") || contains(formatted, "<m:oMath")
push!(paragraphs, "<w:p>" * formatted * "</w:p>")
else
push!(paragraphs, "<w:p><w:r><w:t>" * escape_xml(line_stripped) * "</w:t></w:r></w:p>")
end
end
idx += 1
end
return join(paragraphs, "\n"), images
end
# Вспомогательная функция для обработки inline-форматирования
"""
process_inline_formatting(text) -> String
Обрабатывает inline-форматирование в тексте: выделяет **жирный**, *курсив*,
`код`, и строчные формулы \$...\$. Находит ближайший специальный элемент,
извлекает его, окружающий текст экранирует, а содержимое элементов оборачивает
в соответствующие XML-теги docx.
"""
function process_inline_formatting(text)
has_code = contains(text, "`")
has_formula = contains(text, r"\$")
has_bold = contains(text, r"\*\*")
has_italic_star = contains(text, r"(?<!\*)\*(?!\*)")
has_italic_underscore = contains(text, r"(?<!_)_(?!_)")
if !has_code && !has_formula && !has_bold && !has_italic_star && !has_italic_underscore
return "<w:r><w:t>" * escape_xml(text) * "</w:t></w:r>"
end
result_parts = String[]
i = 1
while i <= ncodeunits(text)
next_code = findnext(r"`([^`]+?)`", text, i)
next_formula = findnext(r"(?<!\\)\$([^\$]+?)(?<!\\)\$", text, i)
next_bold = findnext(r"\*\*(.+?)\*\*", text, i)
next_italic_star = findnext(r"(?<!\*)\*([^*]+?)\*(?!\*)", text, i)
# Курсив через подчеркивание: _текст_ (не часть слова)
next_italic_underscore = findnext(r"(?<=[\s\p{P}]|^)_([^_]+?)_(?=[\s\p{P}]|$)", text, i)
candidates = [
(next_code, :code),
(next_formula, :formula),
(next_bold, :bold),
(next_italic_star, :italic_star),
(next_italic_underscore, :italic_underscore)
]
valid = filter(c -> c[1] !== nothing, candidates)
if isempty(valid)
remaining = text[i:end]
if !isempty(remaining)
push!(result_parts, """<w:r><w:t xml:space="preserve">""" * escape_xml(remaining) * "</w:t></w:r>")
end
break
end
nearest = reduce((a, b) -> a[1].start <= b[1].start ? a : b, valid)
rng, match_type = nearest
if rng.start > i
before = text[i:prevind(text, rng.start)]
if !isempty(before)
push!(result_parts, """<w:r><w:t xml:space="preserve">""" * escape_xml(before) * "</w:t></w:r>")
end
end
if match_type == :code
code_text = text[nextind(text, rng.start):prevind(text, rng.stop)]
push!(result_parts, "<w:r><w:rPr><w:rStyle w:val=\"InlineCode\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(code_text) * "</w:t></w:r>")
elseif match_type == :bold
bold_text = text[nextind(text, rng.start + 1):prevind(text, rng.stop - 1)]
push!(result_parts, "<w:r><w:rPr><w:b/><w:rFonts w:ascii=\"Times New Roman\" w:hAnsi=\"Times New Roman\" w:cs=\"Times New Roman\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(bold_text) * "</w:t></w:r>")
elseif match_type == :italic_star
italic_text = text[nextind(text, rng.start):prevind(text, rng.stop)]
push!(result_parts, "<w:r><w:rPr><w:i/><w:rFonts w:ascii=\"Times New Roman\" w:hAnsi=\"Times New Roman\" w:cs=\"Times New Roman\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(italic_text) * "</w:t></w:r>")
elseif match_type == :italic_underscore
# Извлекаем текст между _ и _
italic_text = text[nextind(text, rng.start):prevind(text, rng.stop)]
push!(result_parts, "<w:r><w:rPr><w:i/><w:rFonts w:ascii=\"Times New Roman\" w:hAnsi=\"Times New Roman\" w:cs=\"Times New Roman\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(italic_text) * "</w:t></w:r>")
elseif match_type == :formula
formula = text[nextind(text, rng.start):prevind(text, rng.stop)]
formula = replace(formula, "\\\$" => "\$")
formula = replace(formula, r"\\," => "")
formula = replace(formula, r"\\;" => "")
formula = replace(formula, "~" => " ")
omml = latex_to_omml(formula, display_mode=false)
push!(result_parts, omml)
end
i = nextind(text, rng.stop)
end
return join(result_parts, "")
end
"""
check_code_annotations(code_lines) -> NamedTuple
Проверяет строки кодовой ячейки на наличие управляющих аннотаций:
- `#SKIP` — полностью исключить ячейку из отчёта
- `#CODE_SKIP` — скрыть код, показать только вывод
- `#PLOT_TITLE` — задать подпись для первого графика
- `#TABLE_NAME` — задать название для выводимой таблицы
- `#NO_TITLE` — убрать автоматическую подпись у объекта, выводимого ячейкой
Возвращает именованный кортеж `(only_output, plot_title, table_name, skip_all)`.
"""
function check_code_annotations(code_lines)
only_output = false
plot_title = nothing
table_name = nothing
skip_all = false
no_title = false
for line in code_lines
stripped = strip(line)
if startswith(stripped, "#SKIP")
skip_all = true
elseif startswith(stripped, "#NO_TITLE")
no_title = true
elseif startswith(stripped, "#CODE_SKIP")
only_output = true
elseif startswith(stripped, "#PLOT_TITLE")
title = strip(replace(stripped, r"^#PLOT_TITLE\s*" => ""))
if !isempty(title)
plot_title = title
end
elseif startswith(stripped, "#TABLE_NAME")
name = strip(replace(stripped, r"^#TABLE_NAME[:\s]*" => ""))
if !isempty(name)
table_name = name
end
end
end
return (only_output=only_output, plot_title=plot_title, table_name=table_name, skip_all=skip_all, no_title=no_title)
end
"""
process_code_content(cell, image_rId_counter; no_code=false, only_output=false) -> Tuple{String, OrderedDict}
Обрабатывает кодовую ячейку: извлекает аннотации, опционально выводит исходный код,
обрабатывает выводы (изображения — с подписями «Рисунок N — ...», HTML-таблицы —
с подписями «Таблица N — ...», текстовый вывод). Поддерживает флаги для скрытия кода
и исключения всей ячейки. Возвращает XML-строку и словарь изображений.
"""
function process_code_content(cell::Dict, image_rId_counter::Ref{Int}; no_code::Bool = false, only_output::Bool = false)::Tuple{String, OrderedDict{String, Tuple{Vector{UInt8}, String}}}
parts = String[]
images = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
source_lines = cell["source"]
if length(source_lines) == 1
code_lines = split(source_lines[1], "\n")
else
code_lines = source_lines
end
annotations = check_code_annotations(code_lines)
table_name = check_table_name(code_lines)
# Полностью пропускаем ячейку
if annotations.skip_all
return "", images
end
# === ВЫВОД КОДА ===
if !no_code && !only_output && !annotations.only_output
for line in code_lines
stripped_line = strip(line)
# Пропускаем строки с управляющими тэгами
if startswith(stripped_line, "#TABLE_NAME") ||
startswith(stripped_line, "DOCX_TABLE_NAME") ||
startswith(stripped_line, "#CODE_SKIP") ||
startswith(stripped_line, "#PLOT_TITLE") ||
startswith(stripped_line, "#SKIP") ||
startswith(stripped_line, "#NO_TITLE")
continue
end
if !isempty(line)
push!(parts, "<w:p><w:pPr><w:pStyle w:val=\"Code\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeChar\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(line) * "</w:t></w:r></w:p>")
else
push!(parts, "<w:p><w:pPr><w:pStyle w:val=\"Code\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeChar\"/></w:rPr><w:t xml:space=\"preserve\"></w:t></w:r></w:p>")
end
end
end
# Отступ после каждой кодовой ячейки
if !annotations.only_output && !only_output
push!(parts, "<w:p><w:r><w:t></w:t></w:r></w:p>")
end
# === ОБРАБОТКА ВЫВОДОВ ===
first_image = true
for output in get(cell, "outputs", [])
if haskey(output, "data")
data = output["data"]
# Проверяем HTML-таблицы (DataFrame вывод)
if haskey(data, "text/html") && contains(data["text/html"], "<table")
html = data["text/html"]
# Определяем, нужна ли подпись
# Явное имя таблицы -> подпись нужна (игнорируем no_title)
# Нет имени и no_title -> без подписи
# Нет имени и нет no_title -> подпись "Таблица N"
if annotations.no_title && table_name === nothing
docx_table = html_table_to_docx(html, nothing; no_title=true)
else
docx_table = html_table_to_docx(html, table_name; no_title=false)
end
push!(parts, docx_table)
continue
end
# Проверяем изображения
image_found = false
for fmt in ["image/png", "image/jpeg"]
if haskey(data, fmt)
bytes, ext = decode_base64_image(data[fmt])
image_name = next_image_name(ext)
images[image_name] = (bytes, ext)
image_rId_counter[] += 1
rId = "rIdImg$(image_rId_counter[])"
width, height = get_image_dimensions(bytes, ext)
push!(parts, create_image_xml(rId, width, height))
# Подпись к первому графику
if first_image && !annotations.no_title
if annotations.plot_title !== nothing
figure_counter[] += 1
# Добавляем keepNext к параграфу с изображением
if !isempty(parts)
last_para = parts[end]
if contains(last_para, "<w:drawing>")
if contains(last_para, "<w:pPr>")
last_para = replace(last_para, "<w:pPr>" => "<w:pPr><w:keepNext/>")
else
last_para = replace(last_para, "<w:p>" => "<w:p><w:pPr><w:keepNext/></w:pPr>")
end
parts[end] = last_para
end
end
push!(parts, "<w:p><w:pPr><w:jc w:val=\"center\"/></w:pPr><w:r><w:t>Рисунок " * string(figure_counter[]) * " — " * escape_xml(annotations.plot_title) * "</w:t></w:r></w:p>")
end
end
first_image = false
image_found = true
break
end
end
# Текстовый вывод
if !image_found && haskey(data, "text/plain")
for line in split(strip(data["text/plain"]), "\n")
if !isempty(line)
push!(parts, "<w:p><w:pPr><w:pStyle w:val=\"CodeOutput\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeOutputChar\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(line) * "</w:t></w:r></w:p>")
end
end
end
elseif haskey(output, "text")
text = output["text"]
text = text isa Vector ? join(text, "") : text
for line in split(strip(text), "\n")
if !isempty(line)
push!(parts, "<w:p><w:pPr><w:pStyle w:val=\"CodeOutput\"/></w:pPr><w:r><w:rPr><w:rStyle w:val=\"CodeOutputChar\"/></w:rPr><w:t xml:space=\"preserve\">" * escape_xml(line) * "</w:t></w:r></w:p>")
end
end
end
end
# После обработки всех outputs, если был вывод — добавить отступ
if !isempty(get(cell, "outputs", []))
push!(parts, "<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"120\"/></w:pPr><w:r><w:t></w:t></w:r></w:p>")
end
return join(parts, "\n"), images
end
"""
parse_markdown_table(lines, start_idx) -> Tuple{Vector{Vector{String}}, Int}
Парсит markdown-таблицу в формате:
#|
||Заголовок1 | Заголовок2||
||Ячейка1 | Ячейка2||
|#
Возвращает двумерный массив строк (первая строка — заголовки) и индекс строки
после закрывающего `|#`.
"""
function parse_markdown_table(lines, start_idx)
table_data = []
i = start_idx + 1 # пропускаем #|
while i <= length(lines)
line = lines[i]
line_stripped = strip(line)
# Конец таблицы
if line_stripped == "|#"
i += 1
break
end
# Начало строки с "||" — начинается новая строка таблицы
if startswith(line_stripped, "||")
# Убираем || с начала и конца
row_content = replace(line_stripped, r"^\|\|" => "")
row_content = replace(row_content, r"\|\|$" => "")
# Разбиваем по |
cells = split(row_content, "|")
# Очищаем пробелы
cells = [strip(cell) for cell in cells if !isempty(strip(cell))]
if !isempty(cells)
push!(table_data, cells)
end
end
i += 1
end
return table_data, i
end
"""
create_table_xml(rows, num_cols) -> String
Создаёт XML-представление таблицы docx с границами на основе двумерного массива
строк. Первая строка автоматически форматируется как заголовок (жирный шрифт,
центрирование). Использует стиль `TableCell` для содержимого ячеек.
Добавляет свойство `keepLines` для предотвращения разрыва таблицы между страницами.
"""
function create_table_xml(rows, num_cols)
xml = "<w:tbl>"
xml *= "<w:tblPr>"
xml *= "<w:tblStyle w:val=\"TableGrid\"/>"
xml *= "<w:tblW w:w=\"5000\" w:type=\"pct\"/>" # 100% ширины страницы
xml *= "<w:tblBorders>"
xml *= "<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"
xml *= "</w:tblBorders>"
xml *= "<w:tblLook w:val=\"04A0\"/>"
xml *= "</w:tblPr>"
# Сетка колонок — равная ширина
col_width = 9026 ÷ num_cols
xml *= "<w:tblGrid>"
for c in 1:num_cols
xml *= "<w:gridCol w:w=\"$col_width\"/>"
end
xml *= "</w:tblGrid>"
for (ri, row) in enumerate(rows)
xml *= "<w:tr>"
# Добавляем cantSplit к каждой строке для неразрывности таблицы
xml *= "<w:trPr><w:cantSplit/></w:trPr>"
is_header = (ri == 1)
for cell_text in row
formatted = process_inline_formatting(cell_text)
xml *= "<w:tc>"
xml *= "<w:tcPr><w:tcW w:w=\"$col_width\" w:type=\"dxa\"/></w:tcPr>"
if is_header
xml *= "<w:p><w:pPr><w:pStyle w:val=\"TableCell\"/><w:jc w:val=\"center\"/></w:pPr><w:r><w:rPr><w:b/></w:rPr>" * formatted * "</w:r></w:p>"
else
xml *= "<w:p><w:pPr><w:pStyle w:val=\"TableCell\"/></w:pPr>" * formatted * "</w:p>"
end
xml *= "</w:tc>"
end
xml *= "</w:tr>"
end
xml *= "</w:tbl>"
return xml
end
"""
cell_contains_config(cell) -> Bool
Проверяет, содержит ли ячейка встроенный TOML-конфиг. Ищет комбинацию маркеров
(`[template]`, `[config]`, `[title_page]`) и TOML-синтаксиса (`=` и `[`).
Используется для исключения конфигурационных ячеек из финального отчёта.
"""
function cell_contains_config(cell::Dict)::Bool
source = join(cell["source"], "\n")
known_sections = ["template", "config", "title_page", "extra_labels", "content", "custom_fields"]
has_section = false
has_keyvalue = false
for line in split(source, "\n")
stripped = strip(line)
# Проверка на TOML-секцию
section_match = match(r"^#?\s*\[([^\]]+)\]$", stripped)
if section_match !== nothing && strip(section_match.captures[1]) in known_sections
has_section = true
end
# Проверка на key = value
test_line = stripped
if startswith(test_line, "#") && !startswith(test_line, "##")
test_line = strip(test_line[2:end])
end
if contains(test_line, "=") && !startswith(test_line, "[")
parts = split(test_line, "="; limit=2)
if length(parts) == 2 && !isempty(strip(parts[1]))
has_keyvalue = true
end
end
end
return has_section && has_keyvalue
end
"""
extract_config_from_notebook(notebook) -> Union{String, Nothing}
Извлекает встроенный TOML-конфиг из любой ячейки скрипта (markdown или code).
Ищет маркеры `[template]`, `[config]`, `# CONFIG`. Очищает конфиг от маркдаун-
форматирования и символов комментариев `#`, преобразуя их в валидный TOML.
Возвращает строку конфига или `nothing`, если конфиг не найден.
"""
function extract_config_from_notebook(notebook::Dict)
# Известные имена TOML-секций конфига
known_sections = ["template", "config", "title_page", "extra_labels", "content", "custom_fields"]
for cell in notebook["cells"]
cell_type = cell["cell_type"]
if cell_type == "markdown" || cell_type == "code"
source = join(cell["source"], "\n")
# Ищем начало конфига — строка с [template] или [config]
config_start = nothing
for pattern in ["\n[template]", "\n# [template]", "\n#[template]", "[template]", "# [template]", "[config]", "# CONFIG"]
pos = findfirst(pattern, source)
if pos !== nothing
config_start = pos
break
end
end
if config_start !== nothing
config_text = source[config_start.start:end]
config_text = strip(config_text, '\n')
config_text = strip(config_text)
# Убираем markdown-форматирование ```
config_text = replace(config_text, r"^\s*```(?:toml)?\s*\n?" => "")
config_text = replace(config_text, r"\n?\s*```\s*$" => "")
config_text = strip(config_text)
# Проверяем, что это действительно TOML-конфиг
lines = split(config_text, "\n")
has_section = false
has_keyvalue = false
cleaned_lines = String[]
for line in lines
stripped = strip(line)
if isempty(stripped)
continue
end
# Проверка на TOML-секцию: [известное_имя] (возможно с # перед)
section_match = match(r"^#?\s*\[([^\]]+)\]$", stripped)
is_toml_section = section_match !== nothing &&
strip(section_match.captures[1]) in known_sections
# Проверка на key = value (с возможным # перед)
is_keyvalue = false
cleaned_keyvalue = stripped
if startswith(stripped, "#") && !startswith(stripped, "##")
# Возможно, закомментированная строка с key = value
uncommented = strip(stripped[2:end])
if contains(uncommented, "=") && !startswith(uncommented, "[")
is_keyvalue = true
cleaned_keyvalue = uncommented
end
elseif contains(stripped, "=") && !startswith(stripped, "#") && !startswith(stripped, "[")
is_keyvalue = true
cleaned_keyvalue = stripped
end
is_comment = startswith(stripped, "#") && !is_keyvalue
if is_toml_section
has_section = true
# Убираем # если есть
section_text = stripped
if startswith(section_text, "#")
section_text = strip(section_text[2:end])
end
push!(cleaned_lines, section_text)
elseif is_keyvalue
has_keyvalue = true
push!(cleaned_lines, cleaned_keyvalue)
elseif is_comment
cleaned = strip(stripped[2:end])
if !isempty(cleaned) && !(strip(cleaned) in known_sections || contains(cleaned, "="))
push!(cleaned_lines, "# " * cleaned)
end
end
# Обычные строки без # и без = — пропускаем (это не конфиг)
end
config_text = join(cleaned_lines, "\n")
# Конфиг валиден только если есть и секция, и ключ-значение
if has_section && has_keyvalue && !isempty(config_text)
return config_text
end
end
end
end
return nothing
end
"""
create_default_config(path)
Создаёт файл `config.toml` с настройками по умолчанию, включая пути к шаблону
и выходному файлу, данные титульной страницы, метки полей и настройки содержимого.
Вызывается, если внешний конфиг не найден и встроенный отсутствует.
"""
function create_default_config(path::String)
default_config = """# ============================================================
# НАСТРОЙКИ ГЕНЕРАТОРА ОТЧЁТОВ
# Отредактируйте значения под свой проект
# ============================================================
[template]
# Пути к файлам (относительно скрипта)
template_path = "template.docx"
output_path = "report.docx" # для одного файла
combined_output_path = "combined_report.docx" # для папки
[title_page]
institution_name = "Институт прикладной электродинамики"
work_type = "Божественное откровение"
work_title = "И сказал Максвелл: да будет свет!"
author_name = "Джеймс Клерк Максвелл"
supervisor_name = "Майкл Фарадей"
city = "Эдинбург"
year = "1865"
top_page_info = ""
[extra_labels]
performed_by = "Защитил:"
accepted_by = "Руководитель:"
# Настройки содержимого отчёта
[content]
# Если true — код из ячеек не добавляется, только вывод
no_code = false
"""
write(path, default_config)
println(" Создан файл: $path")
end
"""
create_combined_notebook(content_dir, source_files) -> Dict
Объединяет несколько .ngscript/.ipynb файлов из папки в один скрипт.
Конфиг сохраняется только из первого файла; дублирующиеся конфиги из последующих
файлов игнорируются. Метаданные и формат берутся из первого файла.
Возвращает словарь, готовый для сохранения в JSON.
"""
function create_combined_notebook(content_dir, source_files)
combined_cells = []
first_notebook_metadata = nothing
nbformat = 4
nbformat_minor = 5
config_found = false
for (fi, filename) in enumerate(source_files)
filepath = joinpath(content_dir, filename)
notebook = JSON.parsefile(filepath)
# Сохраняем метаданные и формат первого файла
if fi == 1
first_notebook_metadata = notebook["metadata"]
nbformat = get(notebook, "nbformat", 4)
nbformat_minor = get(notebook, "nbformat_minor", 5)
end
# Добавляем все ячейки
for cell in notebook["cells"]
# Проверяем, содержит ли ячейка конфиг
if !config_found && cell_contains_config(cell)
# Конфиг из первого файла — добавляем и помечаем что нашли
push!(combined_cells, cell)
config_found = true
elseif cell_contains_config(cell)
# Конфиг из последующих файлов — пропускаем
continue
else
# Обычная ячейка — добавляем
push!(combined_cells, cell)
end
end
end
if first_notebook_metadata === nothing
first_notebook_metadata = Dict(
"kernelspec" => Dict(
"display_name" => "Julia",
"language" => "julia",
"name" => "julia"
),
"language_info" => Dict(
"name" => "julia",
"version" => "1.9"
)
)
end
return Dict(
"nbformat" => nbformat,
"nbformat_minor" => nbformat_minor,
"cells" => combined_cells,
"metadata" => first_notebook_metadata
)
end
"""
create_demo_notebook(path)
Создаёт демонстрационный .ngscript файл с шуточным содержанием про уравнения Максвелла.
Включает заголовок, текст с формулами (4 уравнения Максвелла в LaTeX), кодовую ячейку
с графиком «рождения света» и таблицу с «последствиями» уравнений Максвелла.
Используется для демонстрации возможностей генератора, если входная папка пуста.
"""
function create_demo_notebook(path::String)
demo_nb = Dict(
"nbformat" => 4,
"nbformat_minor" => 5,
"metadata" => Dict(
"kernelspec" => Dict(
"display_name" => "Julia 1.9",
"language" => "julia",
"name" => "julia-1.9"
),
"language_info" => Dict(
"name" => "julia",
"version" => "1.9.3"
)
),
"cells" => [
# Заголовок
Dict(
"cell_type" => "markdown",
"source" => [
"# Уравнения Максвелла: первые дни творения\n\n",
"В начале было Слово, и Слово было у Максвелла, ",
"и Слово было «дивергенция». И сказал Максвелл:"
],
"metadata" => Dict()
),
# Четыре уравнения
Dict(
"cell_type" => "markdown",
"source" => [
"## Четыре великих уравнения\n\n",
"**Закон Гаусса для электричества** (не сотвори себе монополя):\n\n",
"\$\$\\nabla \\cdot \\mathbf{E} = \\frac{\\rho}{\\varepsilon_0}\$\$\n\n",
"**Закон Гаусса для магнетизма** (не разлучай север с югом):\n\n",
"\$\$\\nabla \\cdot \\mathbf{B} = 0\$\$\n\n",
"**Закон Фарадея** (ибо всё меняется):\n\n",
"\$\$\\nabla \\times \\mathbf{E} = -\\frac{\\partial \\mathbf{B}}{\\partial t}\$\$\n\n",
"**Закон Ампера—Максвелла** (и ток, и изменение — всё едино):\n\n",
"\$\$\\nabla \\times \\mathbf{B} = \\mu_0 \\mathbf{J} + \\mu_0 \\varepsilon_0 \\frac{\\partial \\mathbf{E}}{\\partial t}\$\$\n\n",
"И увидел Максвелл, что это хорошо. И отделил свет от тьмы: ",
"скорость света \$c = \\frac{1}{\\sqrt{\\mu_0 \\varepsilon_0}}\$."
],
"metadata" => Dict()
),
# График
Dict(
"cell_type" => "code",
"source" => [
"#CODE_SKIP\n",
"#PLOT_TITLE Рождение электромагнитной волны: да будет свет!\n",
"gr(fmt=:png, size=(600, 350))\n",
"\n",
"# Параметры творения\n",
"x = range(0, 4π, length=200)\n",
"t = 0:0.1:2π\n",
"\n",
"# Электрическое поле (компонента y)\n",
"E_y = [cos(k*x[1]) for k in 1:length(t)]\n",
"# Магнитное поле (компонента z)\n",
"B_z = cos.(x)\n",
"\n",
"# Визуализация рождения света\n",
"plot(x, cos.(x), lw=2, label=\"E-поле (свет!)\", c=:gold,\n",
" xlabel=\"Пространство\", ylabel=\"Амплитуда\",\n",
" title=\"Fiat lux! — Рождение электромагнитной волны\",\n",
" legend=:topright)\n",
"plot!(x, sin.(x), lw=2, ls=:dash, label=\"B-поле\", c=:brown)\n",
"hline!([0], lw=0.5, c=:gray, label=\"\")"
],
"outputs" => [],
"metadata" => Dict()
),
# Заголовок табличного сегмента
Dict(
"cell_type" => "markdown",
"source" => [
"## Последствия сотворения электромагнетизма"
],
"metadata" => Dict()
),
# Таблица с последствиями
Dict(
"cell_type" => "code",
"source" => [
"using DataFrames\n",
"#TABLE_NAME Последствия сотворения электромагнетизма\n",
"df = DataFrame(\n",
" День=[1, 2, 3, 4, 5, 6, 7],\n",
" Творение=[\"Электричество\", \"Магнетизм\", \"Проводники\", \n",
" \"Диэлектрики\", \"Радио\", \"Wi-Fi\", \"Суббота\"],\n",
" Результат=[\"Искрит!\", \"Притягивает!\", \n",
" \"Греется!\", \"Не пускает!\",\n",
" \"Передаёт!\", \"Тормозит!\", \"Отдыхает...\"]\n",
")\n",
"show(stdout, MIME(\"text/html\"), df; \n",
" eltypes=false, show_row_number=false, summary=false)"
],
"outputs" => [],
"metadata" => Dict()
),
# Заключение
Dict(
"cell_type" => "markdown",
"source" => [
"## Итоги творения\n\n",
"Так Максвелл создал теорию электромагнетизма. ",
"И был вечер, и было утро: день четвёртый.\n\n",
"* Свет — это электромагнитная волна (и это хорошо)\n",
"* Радио, Wi-Fi и микроволновки — всё оттуда\n",
"* Без уравнений Максвелла этот документ нечем было бы открыть\n",
"* А в субботу электромагнитное поле отдыхает\n\n",
"Формула полного счастья физика:\n\n",
"\$\$S = \\frac{1}{\\mu_0} \\mathbf{E} \\times \\mathbf{B}\$\$\n\n",
"где \$S\$ — вектор Пойнтинга, он же поток энергии, он же «да будет свет» в математической записи."
],
"metadata" => Dict()
)
]
)
write(path, JSON.json(demo_nb))
println(" Создан файл: $path")
end
"""
replace_all_placeholders(xml_content, replacements, report_replacement)
Заменяет все плейсхолдеры `{{КЛЮЧ}}` в XML-документе, даже если Word разбил их
на несколько `<w:r>` элементов. Для `CONTENT` заменяется весь параграф
целиком. Для остальных — заменяется только текст плейсхолдера внутри параграфа.
"""
function replace_all_placeholders(xml_content::String, replacements::Dict{String,String}, report_replacement::String)
# Сначала обрабатываем CONTENT (замена целого параграфа)
if !isempty(report_replacement)
xml_content = replace_placeholder_paragraph(xml_content, "CONTENT", report_replacement)
end
# Для остальных плейсхолдеров — заменяем текст внутри параграфов
for (key, value) in replacements
if key == "CONTENT"
continue # уже обработан
end
escaped_value = escape_xml(value)
xml_content = replace_placeholder_in_text(xml_content, key, escaped_value)
end
return xml_content
end
"""
replace_placeholder_paragraph(xml_content, placeholder_name, replacement)
Находит параграф, содержащий плейсхолдер `{{placeholder_name}}` (даже разбитый
Word'ом на части), и заменяет весь параграф на `replacement`.
"""
function replace_placeholder_paragraph(xml_content::String, placeholder_name::String, replacement::String)
placeholder = "{{$(placeholder_name)}}"
# Находим все параграфы
para_pattern = r"<w:p[ >].*?</w:p>"s
paras = collect(eachmatch(para_pattern, xml_content))
target_para = nothing
for p in paras
para_text = p.match
clean_text = replace(para_text, r"<[^>]+>" => "")
if contains(clean_text, placeholder)
target_para = p
break
end
end
if target_para === nothing
@warn "Плейсхолдер {{$placeholder_name}} не найден в document.xml"
return xml_content
end
# Заменяем весь параграф
result = xml_content[1:prevind(xml_content, target_para.offset)] *
replacement *
xml_content[nextind(xml_content, target_para.offset + length(target_para.match) - 1):end]
return result
end
"""
replace_placeholder_in_text(xml_content, placeholder, replacement)
Заменяет все вхождения плейсхолдера внутри текста документа, даже если Word
разбил его на несколько `<w:r>` элементов с тегами внутри.
Упрощённый подход: находит `{{` и `}}` с любыми тегами между ними,
удаляет теги, и заменяет на значение.
"""
function replace_placeholder_in_text(xml_content::String, placeholder_key::String, replacement::String)
placeholder = "{{$(placeholder_key)}}"
# Если плейсхолдер уже целый — простая замена
if contains(xml_content, placeholder)
return replace(xml_content, placeholder => replacement)
end
# Ищем все вхождения {{ ... }} с возможными XML-тегами внутри
# Паттерн: {{ захватываем всё до }} но только если внутри есть placeholder_key
pattern = Regex("\\{\\{((?:<[^>]+>)*$(placeholder_key)(?:<[^>]+>)*)\\}\\}")
m = match(pattern, xml_content)
if m !== nothing
# Нашли разбитый плейсхолдер — заменяем только первое вхождение
return xml_content[1:prevind(xml_content, m.offset)] *
replacement *
xml_content[nextind(xml_content, m.offset + length(m.match) - 1):end]
end
# Не нашли даже разбитый — возвращаем как есть
return xml_content
end
"""
model_screenshot(component_path, output_name; model_path="", padding=20)
Делает скриншот компонента Engee-модели с обрезкой пустых краёв.
Используется для вставки изображений моделей в генерируемый отчёт.
"""
function model_screenshot(component_path::String, output_name::String; model_path::String="", padding::Int=20)
model_name = split(component_path, "/")[1]
model_file = isempty(model_path) ? model_name * ".engee" : model_path
engee.open(model_file)
rm(output_name, force=true)
engee.screenshot(component_path, output_name)
img = Images.load(output_name)
m = alpha.(img) .> 0.0
r = findall(any.(eachrow(m)))
c = findall(any.(eachcol(m)))
if !isempty(r)
img = img[max(1, r[1]-padding):min(end, r[end]+padding),
max(1, c[1]-padding):min(end, c[end]+padding)]
end
save(output_name, img)
return Images.load(output_name)
end
# ============================================================
# 4. ГЛАВНАЯ ФУНКЦИЯ
# ============================================================
"""
report_compile(notebook_path=""; ...)
Главная функция генерации отчёта. Принимает путь к `.ngscript`/`.ipynb` файлу или папке.
Извлекает конфиг (из внешнего файла или встроенный в скрипт), создаёт шаблон docx
при необходимости, обрабатывает содержимое скрипта и заполняет шаблон данными.
**Приоритет настроек:** аргументы функции > встроенный конфиг > внешний config.toml.
# Режимы запуска
| Вызов | Поведение |
|-------|-----------|
| `report_compile()` | Если в текущей папке один скрипт — использует его. Если несколько — просит выбрать. Если ни одного — создаёт демо. |
| `report_compile("файл.ngscript")` | Обрабатывает указанный файл. |
| `report_compile("./content")` | Обрабатывает папку. Если пуста — создаёт демо с полным шаблоном. |
# Параметры
- `notebook_path` — путь к файлу или папке (по умолчанию автоопределение в текущей папке)
- `config_path` — путь к внешнему TOML-конфигу (по умолчанию `"config.toml"`)
- `template_path` — путь к шаблону docx (по умолчанию `"template.docx"`)
- `output_path` — путь для сохранения отчёта (по умолчанию `"report.docx"`)
- `simple_template` — если `true`, создаётся упрощённый шаблон без титульной страницы и колонтитулов
- `no_code` — если `true`, код из ячеек не включается в отчёт
- `institution_name`, `work_type`, `work_title`, `author_name`, `supervisor_name`, `city`, `year`, `top_page_info` — данные титульной страницы
- `label_performed_by`, `label_accepted_by` — метки перед именами на титульной
- `custom_fields` — словарь дополнительных полей для подстановки `{{КЛЮЧ}}` в шаблон
# Управляющие аннотации в кодовых ячейках
| Тэг | Действие |
|------|----------|
| `#SKIP` | Исключить ячейку из отчёта |
| `#NO_TITLE` | Убрать подписи у таблиц и изображений |
| `#CODE_SKIP` | Скрыть код, показать только вывод |
| `#PLOT_TITLE Название` | Подпись к графику: «Рисунок N — Название» |
| `#TABLE_NAME Название` | Название таблицы: «Таблица N — Название» |
# Настройка шаблона (template.docx)
Шаблон можно создать или доработать вручную в Microsoft Word или LibreOffice Writer.
Основные возможности настройки:
* **Тело отчета**: оно помещается вместо параграфа, содержащего ключ `{{CONTENT}}`
* **Стили абзацев:** в шаблоне определены стили Normal, Heading1–Heading3, Code, CodeOutput, TableCell, ListBullet. Изменяя их в редакторе, можно настроить шрифты, отступы, межстрочный интервал и цвета для всех элементов отчёта.
* **Титульная страница:** содержит плейсхолдеры `{{WORK_TYPE}}`, `{{WORK_TITLE}}`, `{{AUTHOR_NAME}}`, `{{SUPERVISOR_NAME}}` и метки `{{performed_by}}`, `{{accepted_by}}`. Можно менять их расположение, добавлять логотипы, рамки и другие элементы оформления.
* **Колонтитулы:** первая страница — верхний колонтитул с `{{INSTITUTION_NAME}}` и нижний с `{{CITY}}`, `{{YEAR}}`; обычные страницы — верхний с `{{TOP_PAGE_INFO}}` (по умолчанию пустой) и нижний с номером страницы.
* **Пользовательские поля:** любые плейсхолдеры `{{КЛЮЧ}}` в шаблоне заменяются значениями из секции `[custom_fields]` конфига. Например, добавив в шаблон `{{GROUP}}`, можно выводить номер группы на титульном листе.
# Автоматически создаваемые файлы (при запуске в пустой директории)
- `template.docx` — шаблон документа
- `config.toml` — конфигурация (только для демо-режима)
- `DEMO_REPORT_EXAMPLE.ngscript` — демонстрационный скрипт про уравнения Максвелла
"""
function report_compile(
notebook_path::String = "";
config_path::String = "config.toml",
template_path::String = "template.docx",
output_path::String = "report.docx",
institution_name::String = "",
work_type::String = "",
work_title::String = "",
author_name::String = "",
supervisor_name::String = "",
city::String = "",
year::String = "",
top_page_info::String = "",
label_performed_by::String = "",
label_accepted_by::String = "",
custom_fields::Dict{String, String} = Dict{String, String}(),
no_code::Bool = false,
simple_template::Bool = false,
create_config::Bool = false
)
# === ОПРЕДЕЛЯЕМ ВХОДНЫЕ ДАННЫЕ ===
if isempty(notebook_path)
# Ищем .ngscript/.ipynb в текущей директории
current_dir = "."
all_files = readdir(current_dir)
source_files = sort(filter(f ->
(endswith(f, ".ngscript") || endswith(f, ".ipynb")) &&
!startswith(basename(f), "template"),
all_files))
if isempty(source_files)
notebook_path = "./content"
elseif length(source_files) == 1
single_file = source_files[1]
println("(+) Автоматически выбран файл: $single_file")
report_compile(
single_file,
config_path = config_path,
template_path = template_path,
output_path = output_path,
institution_name = institution_name,
work_type = work_type,
work_title = work_title,
author_name = author_name,
supervisor_name = supervisor_name,
city = city,
year = year,
top_page_info = top_page_info,
label_performed_by = label_performed_by,
label_accepted_by = label_accepted_by,
custom_fields = custom_fields,
no_code = no_code,
simple_template = false,
create_config = false
)
return nothing
else
println("(!) В текущей директории найдено $(length(source_files)) файлов:")
for (i, f) in enumerate(source_files)
println(" $i. $f")
end
println("(!) Укажите конкретный файл первым аргументом, например:")
println(" report_compile(\"$(source_files[1])\")")
return nothing
end
end
# === ОБРАБОТКА ПАПКИ ===
if isdir(notebook_path)
content_dir = notebook_path
all_files = readdir(content_dir)
source_files = sort(filter(f ->
(endswith(f, ".ngscript") || endswith(f, ".ipynb")) &&
!startswith(basename(f), "template"),
all_files))
# === ПУСТАЯ ПАПКА — создаём демо-файл ===
if isempty(source_files)
demo_path = joinpath(content_dir, "DEMO_REPORT_EXAMPLE.ngscript")
if isfile(demo_path)
println("(+) Демо-файл уже существует: $demo_path")
else
println("(+) Папка пуста. Создаю тестовый файл $demo_path ...")
create_demo_notebook(demo_path)
end
report_compile(
demo_path,
config_path = config_path,
template_path = template_path,
output_path = output_path,
institution_name = institution_name,
work_type = work_type,
work_title = work_title,
author_name = author_name,
supervisor_name = supervisor_name,
city = city,
year = year,
top_page_info = top_page_info,
label_performed_by = label_performed_by,
label_accepted_by = label_accepted_by,
custom_fields = custom_fields,
no_code = no_code,
simple_template = false,
create_config = true
)
return nothing
end
# === ОДИН ФАЙЛ — обрабатываем напрямую ===
if length(source_files) == 1
single_file = joinpath(content_dir, source_files[1])
println("(+) Найден один файл: $single_file")
println("(+) Обрабатываю его напрямую (без объединения)...")
# Вызываем сами себя с путём к единственному файлу
report_compile(
single_file,
config_path = config_path,
template_path = template_path,
output_path = output_path,
institution_name = institution_name,
work_type = work_type,
work_title = work_title,
author_name = author_name,
supervisor_name = supervisor_name,
city = city,
year = year,
top_page_info = top_page_info,
label_performed_by = label_performed_by,
label_accepted_by = label_accepted_by,
custom_fields = custom_fields,
no_code = no_code,
simple_template = true,
create_config = false
)
return nothing
# === НЕСКОЛЬКО ФАЙЛОВ — просим выбрать ===
else
println("(!) В папке '$content_dir' найдено $(length(source_files)) файлов:")
for (i, f) in enumerate(source_files)
println(" $i. $f")
end
println("(!) Укажите конкретный файл первым аргументом:")
println(" report_compile(\"$(joinpath(content_dir, source_files[1]))\")")
println(" report_compile(\"$(joinpath(content_dir, source_files[2]))\")")
if length(source_files) > 2
println(" ... или любой другой из списка выше")
end
return nothing
end
end
# === АВТОГЕНЕРАЦИЯ ТЕСТОВОГО ФАЙЛА, ЕСЛИ ВООБЩЕ НИЧЕГО НЕТ ===
if !isdir(notebook_path) && !isfile(notebook_path)
println("(-) Папка или файл не найдены: $notebook_path")
demo_path = "DEMO_REPORT_EXAMPLE.ngscript"
if isfile(demo_path)
println("(+) Демо-файл уже существует: $demo_path")
else
println("(+) Создаю тестовый файл $demo_path ...")
create_demo_notebook(demo_path)
end
report_compile(
demo_path,
config_path = config_path,
template_path = template_path,
output_path = output_path,
institution_name = institution_name,
work_type = work_type,
work_title = work_title,
author_name = author_name,
supervisor_name = supervisor_name,
city = city,
year = year,
top_page_info = top_page_info,
label_performed_by = label_performed_by,
label_accepted_by = label_accepted_by,
custom_fields = custom_fields,
no_code = no_code,
simple_template = false,
create_config = true
)
return nothing
end
# === ОБРАБОТКА ОДНОГО ФАЙЛА ===
if !isfile(notebook_path)
println("(-) Файл или папка не найдены: $notebook_path")
return nothing
end
# Автоматически определяем имя выходного файла
explicit_template = template_path
explicit_output = output_path
# Если output_path не задан явно (равен значению по умолчанию)
if explicit_output == "report.docx"
base_name = basename(notebook_path)
output_name = replace(base_name, r"\.(ngscript|ipynb)$" => ".docx")
if output_name != base_name
output_path = output_name
explicit_output = output_name # ← обновляем!
end
end
# === ШАГ 1: Извлекаем конфиг из скрипта ===
notebook = JSON.parsefile(notebook_path)
notebook_config = extract_config_from_notebook(notebook)
if notebook_config !== nothing
println("(+) Найден встроенный конфиг в скрипте (не сохраняется в файл).")
else
# === ШАГ 2: Создаём конфиг только если его нет И это не simple_template ===
if create_config && !isfile(config_path)
println("(-) Файл конфига не найден. Создаю config.toml с настройками по умолчанию.")
create_default_config(config_path)
println(" (!) Вы можете отредактировать config.toml и запустить скрипт снова.")
end
end
# === ШАГ 3: Создаём шаблон если его нет ===
if !isfile(template_path)
println("(-) Шаблон не найден. Создаю новый: $template_path")
if simple_template
create_simple_template(template_path)
println(" (простой шаблон без титульной страницы)")
else
create_template(template_path)
end
println(" (!) Вы можете открыть этот файл в Word и настроить стили.")
end
# === ШАГ 4: Загружаем настройки ===
file_config = Dict{String, Any}()
# Загружаем внешний конфиг если есть
if isfile(config_path)
try
file_config = TOML.parsefile(config_path)
println("(+) Загружен внешний конфиг из $config_path")
catch e
println("(!) Ошибка при загрузке внешнего конфига: $e")
end
end
# Если есть встроенный конфиг — он ПРИОРИТЕТНЕЕ внешнего
if notebook_config !== nothing
try
notebook_parsed = TOML.parse(notebook_config)
println("(+) Встроенный конфиг успешно распарсен")
# Встроенный конфиг перезаписывает внешний
merge!(file_config, notebook_parsed)
catch e
println("(!) Ошибка при парсинге встроенного конфига: $e")
println(" Содержимое конфига:")
println(notebook_config)
end
end
# Отладка: покажем что в итоге
#println(" Итоговый конфиг: ", keys(file_config))
tp = get(file_config, "title_page", Dict())
institution_name = !isempty(institution_name) ? institution_name : get(tp, "institution_name", "Название учебного заведения")
work_type = !isempty(work_type) ? work_type : get(tp, "work_type", "Тип работы")
work_title = !isempty(work_title) ? work_title : get(tp, "work_title", "Название работы")
author_name = !isempty(author_name) ? author_name : get(tp, "author_name", "ФИО исполнителя")
supervisor_name = !isempty(supervisor_name) ? supervisor_name : get(tp, "supervisor_name", "ФИО принимающего")
city = !isempty(city) ? city : get(tp, "city", "Город")
year = !isempty(year) ? year : get(tp, "year", string(Dates.year(Dates.now())))
labels = get(file_config, "extra_labels", Dict())
label_performed_by = !isempty(label_performed_by) ? label_performed_by : get(labels, "performed_by", "Выполнил:")
label_accepted_by = !isempty(label_accepted_by) ? label_accepted_by : get(labels, "accepted_by", "Принял:")
top_page_info = !isempty(top_page_info) ? top_page_info : get(tp, "top_page_info", "")
# Настройки содержимого
content_settings = get(file_config, "content", Dict())
if !no_code
no_code = get(content_settings, "no_code", false)
end
# Пути к файлам
tmpl = get(file_config, "template", Dict())
template_path = haskey(tmpl, "template_path") && explicit_template == "template.docx" ? tmpl["template_path"] : template_path
output_path = haskey(tmpl, "output_path") && explicit_output == "report.docx" ? tmpl["output_path"] : output_path
# Пользовательские поля
if haskey(file_config, "custom_fields")
for (key, value) in file_config["custom_fields"]
if !haskey(custom_fields, key)
custom_fields[key] = string(value)
end
end
end
# === ШАГ 5: Генерируем отчёт ===
notebook = JSON.parsefile(notebook_path)
CONTENT, images = extract_notebook_content(notebook, no_code=no_code)
replacements = Dict{String, String}(
"INSTITUTION_NAME" => institution_name,
"WORK_TYPE" => work_type,
"WORK_TITLE" => work_title,
"AUTHOR_NAME" => author_name,
"SUPERVISOR_NAME" => supervisor_name,
"CITY" => city,
"YEAR" => year,
"TOP_PAGE_INFO" => top_page_info,
"CONTENT" => CONTENT,
"performed_by" => label_performed_by,
"accepted_by" => label_accepted_by,
)
for (key, value) in custom_fields
replacements[key] = value
end
fill_template(template_path, output_path, replacements, images)
println("(+) Отчёт создан: $output_path")
end
################################################################################
# ============================================================
# 5. ГЕНЕРАЦИЯ СЛАЙДОВ POWERPOINT
# ============================================================
"""
create_pptx_template(output_path)
Создаёт файл шаблона PPTX с 4 макетами слайдов, мастер-слайдом, темой,
заметками и одним пустым слайдом. Макеты: титульный (`slideLayout1`),
одноколоночный (`slideLayout2`), двухколоночный (`slideLayout3`),
трехколоночный (`slideLayout4`), четырехколоночный (`slideLayout5`),
слайд-раздел (`slideLayout6`).
"""
function create_pptx_template(output_path::String)
# [Content_Types].xml
ct_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Default Extension="jpg" ContentType="image/jpeg"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout3.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout4.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout5.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout6.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>
<Override PartName="/ppt/tableStyles.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"/>
<Override PartName="/ppt/viewProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml"/>
<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>
</Types>"""
# _rels/.rels
root_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>"""
# ppt/_rels/presentation.xml.rels
ppt_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" Target="presProps.xml"/>
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles" Target="tableStyles.xml"/>
<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps" Target="viewProps.xml"/>
</Relationships>"""
# ppt/presentation.xml
presentation = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" saveSubsetFonts="1">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst><p:sldId id="256" r:id="rId3"/></p:sldIdLst>
<p:sldSz cx="12192000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>"""
# ppt/presProps.xml
pres_props = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:extLst>
<p:ext uri="{E76CE94A-603C-4142-B9EB-6D1370010A27}">
<p14:discardImageEditData xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" val="0"/>
</p:ext>
<p:ext uri="{D31A062A-798A-4329-ABDD-BBA856620510}">
<p14:defaultImageDpi xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" val="32767"/>
</p:ext>
</p:extLst>
</p:presentationPr>"""
# ppt/tableStyles.xml
table_styles = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"/>"""
# ppt/viewProps.xml
view_props = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:viewPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:normalViewPr horzBarState="maximized">
<p:restoredLeft sz="15987" autoAdjust="0"/>
<p:restoredTop sz="94660"/>
</p:normalViewPr>
<p:slideViewPr><p:cSldViewPr snapToGrid="0"><p:cViewPr varScale="1">
<p:scale><a:sx n="91" d="100"/><a:sy n="91" d="100"/></p:scale>
<p:origin x="1195" y="67"/></p:cViewPr><p:guideLst/></p:cSldViewPr></p:slideViewPr>
<p:notesTextViewPr><p:cViewPr>
<p:scale><a:sx n="1" d="1"/><a:sy n="1" d="1"/></p:scale>
<p:origin x="0" y="0"/></p:cViewPr></p:notesTextViewPr>
<p:gridSpacing cx="72008" cy="72008"/>
</p:viewPr>"""
# ppt/slideMasters/slideMaster1.xml
slide_master = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr lIns="91440" tIns="45720" rIns="91440" bIns="45720" anchor="ctr"><a:normAutofit/></a:bodyPr><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Body"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="1825625"/><a:ext cx="10515600" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr lIns="91440" tIns="45720" rIns="91440" bIns="45720"><a:normAutofit/></a:bodyPr><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst>
<p:sldLayoutId id="2147483649" r:id="rId1"/>
<p:sldLayoutId id="2147483650" r:id="rId2"/>
<p:sldLayoutId id="2147483651" r:id="rId3"/>
<p:sldLayoutId id="2147483652" r:id="rId4"/>
<p:sldLayoutId id="2147483653" r:id="rId5"/>
<p:sldLayoutId id="2147483654" r:id="rId6"/>
</p:sldLayoutIdLst>
<p:txStyles>
<!-- Стиль заголовков -->
<p:titleStyle>
<a:lvl1pPr algn="l">
<a:defRPr sz="4400">
<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>
<a:latin typeface="+mj-lt"/><a:ea typeface="+mj-ea"/><a:cs typeface="+mj-cs"/>
</a:defRPr>
</a:lvl1pPr>
</p:titleStyle>
<!-- Стиль обычного текста -->
<p:bodyStyle>
<a:lvl1pPr marL="0" algn="l">
<a:lnSpc><a:spcPct val="100000"/></a:lnSpc>
<a:buNone/>
<a:defRPr sz="1400">
<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>
<a:latin typeface="+mn-lt"/><a:ea typeface="+mn-ea"/><a:cs typeface="+mn-cs"/>
</a:defRPr>
</a:lvl1pPr>
</p:bodyStyle>
<!-- Стиль для кода и вывода -->
<p:otherStyle>
<a:defPPr><a:defRPr lang="ru-RU"/></a:defPPr>
<!-- Код: Consolas 14pt, серый фон -->
<a:lvl1pPr marL="0" algn="l">
<a:lnSpc><a:spcPct val="100000"/></a:lnSpc>
<a:buNone/>
<a:defRPr sz="1400">
<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>
<a:latin typeface="Consolas"/><a:ea typeface="Consolas"/><a:cs typeface="Consolas"/>
</a:defRPr>
</a:lvl1pPr>
<!-- Вывод: Courier New 14pt, без фона -->
<a:lvl2pPr marL="0" algn="l">
<a:lnSpc><a:spcPct val="100000"/></a:lnSpc>
<a:buNone/>
<a:defRPr sz="1400">
<a:solidFill><a:schemeClr val="tx1"/></a:solidFill>
<a:latin typeface="Cascadia Mono"/><a:ea typeface="Cascadia Mono"/><a:cs typeface="Courier New"/>
</a:defRPr>
</a:lvl2pPr>
</p:otherStyle>
</p:txStyles>
</p:sldMaster>"""
notes_master_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Slide Image"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr>
<p:spPr/>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Notes"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
</p:notesMaster>"""
# ppt/slideMasters/_rels/slideMaster1.xml.rels
master_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout2.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout3.xml"/>
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout4.xml"/>
<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout5.xml"/>
<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout6.xml"/>
<Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>"""
notes_master_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>"""
# Общий rels для layout'ов
layout_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>"""
# Титульный макет
layout1 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="title" preserve="1">
<p:cSld name="Title Slide">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="ctrTitle"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="1524000" y="1800000"/><a:ext cx="9144000" cy="1500000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr anchor="b"/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Subtitle"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="1524000" y="3500000"/><a:ext cx="9144000" cy="2000000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr anchor="t"/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# Одноколоночный макет (title + content)
layout2 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="obj" preserve="1">
<p:cSld name="Content Slide">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Content"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="1825625"/><a:ext cx="10515600" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr lIns="91440" tIns="45720" rIns="91440" bIns="45720"/><a:lstStyle/>
<a:p><a:pPr lnSpc="200%"><a:buNone/></a:pPr><a:r><a:rPr lang="ru-RU" sz="1800"/><a:t></a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# Двухколоночный макет (title + left + right)
layout3 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="twoObj" preserve="1">
<p:cSld name="Two Column Slide">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Left"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="half" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="1825625"/><a:ext cx="5181600" cy="4351338"/></a:xfrm>
<a:solidFill><a:srgbClr val="F0F0F0"/></a:solidFill></p:spPr>
<p:txBody><a:bodyPr lIns="91440" tIns="45720" rIns="91440" bIns="45720"/><a:lstStyle/>
<a:p><a:pPr lnSpc="120%"><a:buNone/></a:pPr><a:r><a:rPr lang="ru-RU" sz="1400"><a:latin typeface="Consolas"/><a:ea typeface="Consolas"/></a:rPr><a:t></a:t></a:r></a:p>
</p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="4" name="Right"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="half" idx="2"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="6172200" y="1825625"/><a:ext cx="5181600" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr lIns="91440" tIns="45720" rIns="91440" bIns="45720" anchor="ctr"/><a:lstStyle/>
<a:p><a:pPr lnSpc="200%"><a:buNone/></a:pPr><a:r><a:rPr lang="ru-RU" sz="1800"/><a:t></a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# Трёхколоночный макет
layout4 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="obj" preserve="1">
<p:cSld name="Three Column Slide">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Col1"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="1825625"/><a:ext cx="3400000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="4" name="Col2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="2"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="4400000" y="1825625"/><a:ext cx="3400000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="5" name="Col3"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="3"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="7960000" y="1825625"/><a:ext cx="3400000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# Четырёхколоночный макет
layout5 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="obj" preserve="1">
<p:cSld name="Four Column Slide">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Col1"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="838200" y="1825625"/><a:ext cx="2500000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="4" name="Col2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="2"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="3500000" y="1825625"/><a:ext cx="2500000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="5" name="Col3"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="3"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="6160000" y="1825625"/><a:ext cx="2500000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="6" name="Col4"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph sz="quarter" idx="4"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="8820000" y="1825625"/><a:ext cx="2500000" cy="4351338"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# Макет для слайда-раздела (section header)
layout6 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="secHead" preserve="1">
<p:cSld name="Section Header">
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="831850" y="1709738"/><a:ext cx="10515600" cy="2852737"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr anchor="ctr"/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>"""
# theme
theme = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Default Theme">
<a:themeElements>
<a:clrScheme name="Default">
<a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="Default">
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
</a:fontScheme>
<a:fmtScheme name="Default">
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
<a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
</a:fmtScheme>
</a:themeElements>
<a:objectDefaults/>
</a:theme>"""
# Пустой слайд по умолчанию (титульный)
slide1 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="ctrTitle"/></p:nvPr></p:nvSpPr>
<p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Subtitle"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr lang="ru-RU"/></a:p></p:txBody>
</p:sp>
</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>"""
slide1_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>"""
if isfile(output_path)
rm(output_path, force=true)
end
writer = ZipFile.Writer(output_path)
f = ZipFile.addfile(writer, "_rels/.rels"; method=ZipFile.Store)
write(f, root_rels)
f = ZipFile.addfile(writer, "[Content_Types].xml")
write(f, ct_xml)
files = [
("ppt/presentation.xml", presentation),
("ppt/_rels/presentation.xml.rels", ppt_rels),
("ppt/slideMasters/slideMaster1.xml", slide_master),
("ppt/slideMasters/_rels/slideMaster1.xml.rels", master_rels),
("ppt/notesMasters/notesMaster1.xml", notes_master_xml),
("ppt/notesMasters/_rels/notesMaster1.xml.rels", notes_master_rels),
("ppt/slideLayouts/slideLayout1.xml", layout1),
("ppt/slideLayouts/_rels/slideLayout1.xml.rels", layout_rels),
("ppt/slideLayouts/slideLayout2.xml", layout2),
("ppt/slideLayouts/_rels/slideLayout2.xml.rels", layout_rels),
("ppt/slideLayouts/slideLayout3.xml", layout3),
("ppt/slideLayouts/_rels/slideLayout3.xml.rels", layout_rels),
("ppt/slideLayouts/slideLayout4.xml", layout4),
("ppt/slideLayouts/_rels/slideLayout4.xml.rels", layout_rels),
("ppt/slideLayouts/slideLayout5.xml", layout5),
("ppt/slideLayouts/_rels/slideLayout5.xml.rels", layout_rels),
("ppt/slideLayouts/slideLayout6.xml", layout6),
("ppt/slideLayouts/_rels/slideLayout6.xml.rels", layout_rels),
("ppt/theme/theme1.xml", theme),
("ppt/slides/slide1.xml", slide1),
("ppt/slides/_rels/slide1.xml.rels", slide1_rels),
("ppt/presProps.xml", pres_props),
("ppt/tableStyles.xml", table_styles),
("ppt/viewProps.xml", view_props),
]
for (name, content) in files
f = ZipFile.addfile(writer, name)
write(f, content)
end
close(writer)
end
function process_pptx_content(text::AbstractString)::String
text_str = String(text)
# Убираем markdown-изображения (они обрабатываются отдельно)
text_str = replace(text_str, r"!\[.*?\]\(.*?\)" => "")
text_str = replace(text_str, r"\$\$(.+?)\$\$"s => s"\1")
text_str = replace(text_str, r"\$(.+?)\$" => s"\1")
text_str = replace(text_str, "\\cdot" => "·")
text_str = replace(text_str, "\\times" => "×")
text_str = replace(text_str, "\\nabla" => "∇")
text_str = replace(text_str, "\\partial" => "∂")
text_str = replace(text_str, "\\varepsilon" => "ε")
text_str = replace(text_str, "\\mu" => "μ")
text_str = replace(text_str, "\\rho" => "ρ")
text_str = replace(text_str, "\\mathbf" => "")
text_str = replace(text_str, "`" => "")
text_str = replace(text_str, "\\\\" => " ")
text_str = replace(text_str, "\\{" => "{")
text_str = replace(text_str, "\\}" => "}")
text_str = replace(text_str, "\\" => "")
return text_str
end
function block_content(block)
if block["type"] == "text"
return block["content"]
elseif block["type"] == "image"
return "" # изображения пока игнорируем
elseif startswith(string(block["type"]), "code_")
# Для кодовых блоков: код + вывод
code = get(block, "code", "")
output = get(block, "output", "")
if !isempty(code) && !isempty(output)
return code * "\n\n>>\n" * output
elseif !isempty(code)
return code
elseif !isempty(output)
return output
else
return ""
end
else
return get(block, "content", "")
end
end
"""
extract_notebook_slides(notebook) -> Vector{Dict}
Извлекает содержимое ячеек Engee-скрипта и формирует слайды.
Markdown-ячейки с `#` становятся заголовками, текст — контентом.
Кодовые ячейки создают блоки `code_col`/`code_row` в зависимости от
`codeOutputView` в метаданных. Изображения из вывода выделяются отдельно.
Группировка: заголовок + до 2 блоков = слайд. Остальные блоки → заметки.
"""
function extract_notebook_slides(notebook::Dict; skip_first_heading::Bool=true)::Vector{Dict}
slides = Vector{Dict}()
first_heading_skipped = !skip_first_heading # если не нужно пропускать, считаем что уже пропустили
blocks = Vector{Dict}()
for cell in notebook["cells"]
if cell_contains_config(cell); continue; end
src = join(cell["source"], "")
if isempty(strip(src)); continue; end
if cell["cell_type"] == "markdown"
# Проверяем вложения (изображения)
attachments = get(cell, "attachments", Dict())
if !isempty(attachments)
images = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
for (filename, data) in attachments
for fmt in ["image/png", "image/jpeg"]
if haskey(data, fmt)
bytes, ext = decode_base64_image(data[fmt])
image_name = next_image_name(ext)
images[image_name] = (bytes, ext)
break
end
end
end
if !isempty(images)
push!(blocks, Dict("type" => "image", "images" => images))
continue
end
end
stripped = strip(src)
m = match(r"^(#{1,3})\s+(.+)$"s, stripped)
if m !== nothing
level = length(m.captures[1])
heading = process_pptx_content(strip(m.captures[2]))
# Пропускаем первый заголовок первого уровня (он стал названием)
if !first_heading_skipped && level == 1
first_heading_skipped = true
# Текст после заголовка может идти в notes
lines = split(src, "\n"; limit=2)
if length(lines) >= 2
rest = strip(lines[2])
if !isempty(rest)
push!(blocks, Dict("type" => "notes", "content" => process_pptx_content(rest)))
end
end
continue
end
push!(blocks, Dict("type" => "heading", "title" => heading))
lines = split(src, "\n"; limit=2)
if length(lines) >= 2
rest = strip(lines[2])
if !isempty(rest)
push!(blocks, Dict("type" => "text", "content" => process_pptx_content(rest)))
end
end
#else
# push!(blocks, Dict("type" => "text", "content" => process_pptx_content(stripped)))
#end
else
# Проверяем, есть ли разделитель --- внутри ячейки
if contains(stripped, "\n---\n") || startswith(stripped, "---\n") || endswith(stripped, "\n---")
# Разбиваем на части по ---
parts = split(stripped, r"\n*---\n*"; keepempty=false)
for part in parts
part_stripped = strip(part)
if !isempty(part_stripped)
push!(blocks, Dict("type" => "text", "content" => process_pptx_content(part_stripped)))
end
end
else
push!(blocks, Dict("type" => "text", "content" => process_pptx_content(stripped)))
end
end
elseif cell["cell_type"] == "code"
ann = check_code_annotations(cell["source"])
if ann.skip_all; continue; end
# Определяем расположение вывода из метаданных
code_output_view = "col" # по умолчанию
if haskey(cell, "metadata") && haskey(cell["metadata"], "engee")
engee = cell["metadata"]["engee"]
if haskey(engee, "codeOutputView")
code_output_view = engee["codeOutputView"]
end
end
code_lines = cell["source"]
if length(code_lines) == 1; code_lines = split(code_lines[1], "\n"); end
code_str = String[]
if !ann.only_output
for line in code_lines
stripped = strip(line)
if !startswith(stripped, "#SKIP") &&
!startswith(stripped, "#NO_TITLE") &&
!startswith(stripped, "#CODE_SKIP") &&
!startswith(stripped, "#PLOT_TITLE") &&
!startswith(stripped, "#TABLE_NAME")
push!(code_str, line)
end
end
end
out_str = String[]
images = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
for out in get(cell, "outputs", [])
if haskey(out, "data")
data = out["data"]
image_found = false
for fmt in ["image/png", "image/jpeg"]
if haskey(data, fmt)
bytes, ext = decode_base64_image(data[fmt])
image_name = next_image_name(ext)
images[image_name] = (bytes, ext)
image_found = true
break
end
end
if !image_found
if haskey(data, "text/plain")
#push!(out_str, strip(clean_ansi(data["text/plain"])))
push!(out_str, clean_ansi(data["text/plain"]))
end
end
elseif haskey(out, "text")
t = out["text"]
t = t isa Vector ? join(t, "") : t
t = clean_ansi(t)
if !isempty(strip(t)); push!(out_str, t); end
end
end
code_content = join(code_str, "\n")
output_content = join(out_str, "\n")
# Создаём блоки в зависимости от codeOutputView
if !isempty(out_str) && !isempty(images)
# Есть и текст, и изображение — всё в одном столбце
push!(blocks, Dict(
"type" => "code_col",
"code" => code_content,
"output" => output_content,
"images" => images
))
elseif code_output_view == "row"
# Код и вывод рядом (два столбца)
push!(blocks, Dict(
"type" => "code_row",
"code" => code_content,
"output" => output_content,
"images" => images
))
else
# Код сверху, вывод снизу (один столбец)
push!(blocks, Dict(
"type" => "code_col",
"code" => code_content,
"output" => output_content,
"images" => images
))
end
end
end
# Группировка
i = 1
while i <= length(blocks)
block = blocks[i]
if block["type"] == "heading"
title = block["title"]
i += 1
content_blocks = Vector{Dict}()
notes_blocks = Vector{Dict}()
# До четырех элементов на слайде
while i <= length(blocks) && blocks[i]["type"] != "heading"
if blocks[i]["type"] == "text" && startswith(strip(get(blocks[i], "content", "")), "#NOTE")
# Ячейка с маркером #NOTE — в заметки
note_content = replace(blocks[i]["content"], r"^#NOTE\s*" => "")
push!(notes_blocks, Dict("type" => "notes", "content" => note_content))
elseif length(content_blocks) < 4
push!(content_blocks, blocks[i])
end
i += 1
end
notes_text = join([b["content"] for b in notes_blocks if b["type"] in ("text", "notes")], "\n")
if length(content_blocks) == 0
# Заголовок без контента — слайд-раздел
push!(slides, Dict("title" => title, "layout" => "section_header"))
if !isempty(notes_text) && !isempty(slides)
slides[end]["notes"] = notes_text
end
continue
elseif length(content_blocks) == 1
push_single_block!(slides, title, content_blocks[1])
elseif length(content_blocks) == 2
push_double_block!(slides, title, content_blocks[1], content_blocks[2])
elseif length(content_blocks) == 3
push!(slides, Dict("title" => title, "layout" => "three_text",
"col1" => block_content(content_blocks[1]),
"col2" => block_content(content_blocks[2]),
"col3" => block_content(content_blocks[3])))
elseif length(content_blocks) == 4
push!(slides, Dict("title" => title, "layout" => "four_text",
"col1" => block_content(content_blocks[1]),
"col2" => block_content(content_blocks[2]),
"col3" => block_content(content_blocks[3]),
"col4" => block_content(content_blocks[4])))
end
if !isempty(notes_text) && !isempty(slides)
slides[end]["notes"] = notes_text
end
else
if block["type"] == "image"
push!(slides, Dict("title" => "", "layout" => "image_only", "images" => block["images"]))
elseif startswith(string(block["type"]), "code_")
push_single_block!(slides, "", block)
elseif block["type"] == "notes"
# Заметки — пропускаем (они будут добавлены к следующему слайду)
else
push!(slides, Dict("title" => "", "layout" => "text_only", "content" => get(block, "content", "")))
end
i += 1
end
end
return slides
end
function push_single_block!(slides, title, block)
if block["type"] == "image"
push!(slides, Dict("title" => title, "layout" => "image_only", "images" => block["images"]))
elseif block["type"] == "code_row"
push!(slides, Dict("title" => title, "layout" => "code_row",
"code" => block["code"], "output" => block["output"],
"images" => block["images"]))
elseif block["type"] == "code_col"
push!(slides, Dict("title" => title, "layout" => "code_col",
"code" => block["code"], "output" => block["output"],
"images" => block["images"]))
elseif block["type"] == "text"
push!(slides, Dict("title" => title, "layout" => "text_only", "content" => block["content"]))
else
push!(slides, Dict("title" => title, "layout" => "text_only", "content" => get(block, "content", "")))
end
end
function push_double_block!(slides, title, left_block, right_block)
if left_block["type"] == "text" && right_block["type"] == "text"
push!(slides, Dict("title" => title, "layout" => "two_text",
"left" => left_block["content"], "right" => right_block["content"]))
elseif left_block["type"] == "image" && right_block["type"] == "text"
push!(slides, Dict("title" => title, "layout" => "image_text",
"images" => left_block["images"],
"right_text" => right_block["content"]))
elseif left_block["type"] == "text" && right_block["type"] == "image"
push!(slides, Dict("title" => title, "layout" => "text_image",
"left_text" => left_block["content"],
"images" => right_block["images"]))
elseif startswith(string(left_block["type"]), "code_")
push!(slides, Dict("title" => title, "layout" => "code_left",
"code" => left_block["code"],
"output" => left_block["output"],
"left_images" => get(left_block, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}()),
"right_images" => get(right_block, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}()),
"right_text" => get(right_block, "content", get(right_block, "output", "")),
"right_type" => right_block["type"]))
elseif startswith(string(right_block["type"]), "code_")
push!(slides, Dict("title" => title, "layout" => "code_right",
"left_text" => get(left_block, "content", get(left_block, "output", "")),
"left_type" => left_block["type"],
"code" => right_block["code"],
"output" => right_block["output"],
"left_images" => get(left_block, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}()),
"right_images" => get(right_block, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}())))
else
push_single_block!(slides, title, left_block)
end
end
# function push_multi_block!(slides, title, content_blocks)
# n = length(content_blocks)
# if n == 1
# push_single_block!(slides, title, content_blocks[1])
# elseif n == 2
# push_double_block!(slides, title, content_blocks[1], content_blocks[2])
# elseif n == 3
# push!(slides, Dict("title" => title, "layout" => "three_text",
# "col1" => content_blocks[1]["content"],
# "col2" => content_blocks[2]["content"],
# "col3" => content_blocks[3]["content"]))
# elseif n == 4
# push!(slides, Dict("title" => title, "layout" => "four_text",
# "col1" => content_blocks[1]["content"],
# "col2" => content_blocks[2]["content"],
# "col3" => content_blocks[3]["content"],
# "col4" => content_blocks[4]["content"]))
# end
# end
function make_text_xml(text::String, is_code::Bool=false, is_output::Bool=false)::String
parts = String[]
for line in split(text, "\n")
if is_code || is_output
if !isempty(strip(line))
is_comment = is_code && startswith(strip(line), "#")
color_attr = is_comment ? "<a:solidFill><a:srgbClr val=\"808080\"/></a:solidFill>" : ""
# Только шрифт — размер и цвет из макета
if is_code
font = "<a:latin typeface=\"Consolas\"/><a:ea typeface=\"Consolas\"/><a:cs typeface=\"Consolas\"/>"
else
font = "<a:latin typeface=\"Courier New\"/><a:ea typeface=\"Courier New\"/><a:cs typeface=\"Courier New\"/>"
end
push!(parts, "<a:p><a:pPr lvl=\"0\"><a:buNone/></a:pPr><a:r><a:rPr lang=\"ru-RU\">$(font)$(color_attr)</a:rPr><a:t>" * escape_xml(line) * "</a:t></a:r></a:p>")
else
push!(parts, "<a:p><a:pPr lvl=\"0\"><a:buNone/></a:pPr><a:endParaRPr lang=\"ru-RU\"/></a:p>")
end
else
if !isempty(strip(line))
line_xml = process_inline_pptx(line)
push!(parts, "<a:p><a:pPr><a:buNone/></a:pPr>" * line_xml * "</a:p>")
else
push!(parts, "<a:p><a:pPr><a:buNone/></a:pPr><a:endParaRPr lang=\"ru-RU\"/></a:p>")
end
end
end
return join(parts, "\n")
end
function process_inline_pptx(text)::String
text_str = String(text)
result = String[]
i = firstindex(text_str)
len = lastindex(text_str)
while i <= len
bold_match = match(r"\*\*(.+?)\*\*", text_str, i)
italic_match = match(r"(?<!\*)\*([^*]+?)\*(?!\*)", text_str, i)
next_match = nothing
is_bold = false
if bold_match !== nothing && (italic_match === nothing || bold_match.offset <= italic_match.offset)
next_match = bold_match
is_bold = true
elseif italic_match !== nothing
next_match = italic_match
end
if next_match === nothing
remaining = text_str[i:len]
push!(result, "<a:r><a:rPr lang=\"ru-RU\" sz=\"1800\"/><a:t>" * escape_xml(remaining) * "</a:t></a:r>")
break
end
if next_match.offset > i
before = text_str[i:thisind(text_str, next_match.offset - 1)]
push!(result, "<a:r><a:rPr lang=\"ru-RU\" sz=\"1800\"/><a:t>" * escape_xml(before) * "</a:t></a:r>")
end
inner = next_match.captures[1]
if is_bold
push!(result, "<a:r><a:rPr lang=\"ru-RU\" sz=\"1800\" b=\"1\"/><a:t>" * escape_xml(inner) * "</a:t></a:r>")
else
push!(result, "<a:r><a:rPr lang=\"ru-RU\" sz=\"1800\" i=\"1\"/><a:t>" * escape_xml(inner) * "</a:t></a:r>")
end
# ncodeunits вместо length
i = nextind(text_str, next_match.offset + ncodeunits(next_match.match) - 1)
end
return join(result, "")
end
function clean_ansi(text::AbstractString)::String
text_str = String(text)
# Убираем ANSI escape sequences
text_str = replace(text_str, r"\e\[[0-9;]*m" => "")
# Убираем другие управляющие символы
text_str = replace(text_str, r"\u001b\[[0-9;]*m" => "")
return text_str
end
"""
write_notes_slide(slides_dir, rels_dir, snum, notes_text)
Создаёт XML-файл заметок докладчика для слайда с номером `snum`.
Содержит образ слайда и текст заметок. Сохраняется в `ppt/notesSlides/`.
"""
function write_notes_slide(slides_dir, rels_dir, snum, notes_text)
notes_lines = join(["<a:p><a:r><a:rPr lang=\"ru-RU\"/><a:t>" * escape_xml(line) * "</a:t></a:r></a:p>" for line in split(notes_text, "\n") if !isempty(strip(line))], "\n")
notes_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Slide Image"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr>
<p:spPr/>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Notes"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/>
$(notes_lines)
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:notes>"""
notes_dir = joinpath(dirname(slides_dir), "notesSlides")
mkpath(notes_dir)
write(joinpath(notes_dir, "notesSlide$(snum).xml"), notes_xml)
notes_rels_dir = joinpath(notes_dir, "_rels")
mkpath(notes_rels_dir)
notes_rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide$(snum).xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
</Relationships>"""
write(joinpath(notes_rels_dir, "notesSlide$(snum).xml.rels"), notes_rels)
end
"""
fill_pptx_template(template_path, output_path, title_slide_info, slides)
Заполняет шаблон PPTX: создаёт титульный слайд, затем контентные слайды
из вектора `slides`. Для каждого слайда генерирует XML с фигурами,
сохраняет изображения в `ppt/media/`, создаёт notesSlide при наличии заметок.
В конце обновляет служебные файлы и пересоздаёт ZIP.
"""
function fill_pptx_template(template_path::String, output_path::String,
title_slide_info::Dict{String,String}, slides::Vector{Dict})
if isfile(output_path); rm(output_path, force=true); end
mktempdir() do tmpdir
reader = ZipFile.Reader(template_path)
for file in reader.files
p = joinpath(tmpdir, file.name)
mkpath(dirname(p))
write(p, read(file, String))
end
close(reader)
media_dir = joinpath(tmpdir, "ppt", "media")
mkpath(media_dir)
slides_dir = joinpath(tmpdir, "ppt", "slides"); mkpath(slides_dir)
rels_dir = joinpath(tmpdir, "ppt", "slides", "_rels"); mkpath(rels_dir)
write_title_slide(slides_dir, rels_dir, title_slide_info)
for (i, slide) in enumerate(slides)
snum = i + 1
title = get(slide, "title", "")
layout_type = get(slide, "layout", "")
# Собираем все изображения в один словарь
images = get(slide, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}())
left_imgs = get(slide, "left_images", nothing)
right_imgs = get(slide, "right_images", nothing)
all_imgs = OrderedDict{String, Tuple{Vector{UInt8}, String}}()
merge!(all_imgs, images)
if left_imgs !== nothing; merge!(all_imgs, left_imgs); end
if right_imgs !== nothing; merge!(all_imgs, right_imgs); end
# Копируем все изображения в media
for (iname, (bytes, _)) in all_imgs
write(joinpath(media_dir, iname), bytes)
end
# Передаём все изображения + левые/правые для позиционирования
shapes, layout = build_shapes(slide, title, layout_type, all_imgs, left_imgs, right_imgs)
slide_xml = build_slide_xml(shapes)
srels = build_slide_rels(layout, all_imgs)
write(joinpath(slides_dir, "slide$(snum).xml"), slide_xml)
notes = get(slide, "notes", "")
if !isempty(notes)
write_notes_slide(slides_dir, rels_dir, snum, notes)
end
write(joinpath(rels_dir, "slide$(snum).xml.rels"), srels)
end
finalize_pptx(tmpdir, output_path, length(slides))
end
end
# === ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===
function write_title_slide(slides_dir, rels_dir, info)
# title_text = get(info, "WORK_TITLE", "Презентация")
# sub_parts = String[]
# for (k, prefix) in [("INSTITUTION_NAME", ""), ("WORK_TYPE", ""), ("AUTHOR_NAME", "Автор: "), ("SUPERVISOR_NAME", "Принял: ")]
# v = get(info, k, "")
# if !isempty(v) push!(sub_parts, prefix * v) end
# end
# cty = get(info, "CITY", ""); yr = get(info, "YEAR", "")
# if !isempty(cty) || !isempty(yr) push!(sub_parts, cty * (isempty(cty) || isempty(yr) ? "" : ", ") * yr) end
title_text = get(info, "WORK_TITLE", "Презентация")
sub_parts = String[]
# Добавляем только непустые поля в порядке:
# учебное заведение → тип работы → автор → руководитель → город, год
inst = get(info, "INSTITUTION_NAME", "")
wt = get(info, "WORK_TYPE", "")
auth = get(info, "AUTHOR_NAME", "")
sup = get(info, "SUPERVISOR_NAME", "")
cty = get(info, "CITY", "")
yr = get(info, "YEAR", "")
if !isempty(inst) push!(sub_parts, inst) end
if !isempty(wt) push!(sub_parts, wt) end
if !isempty(auth) push!(sub_parts, "Автор: " * auth) end
if !isempty(sup) push!(sub_parts, "Руководитель: " * sup) end
# Город и год — только если заданы явно (не авто)
city_year = ""
if !isempty(cty) && !isempty(yr)
city_year = cty * ", " * yr
elseif !isempty(cty)
city_year = cty
elseif !isempty(yr)
city_year = yr
end
if !isempty(city_year) push!(sub_parts, city_year) end
xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="ctrTitle"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="ru-RU"/><a:t>$(escape_xml(title_text))</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Subtitle"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/>
$(join(["<a:p><a:r><a:rPr lang=\"ru-RU\"/><a:t>" * escape_xml(p) * "</a:t></a:r></a:p>" for p in sub_parts], "\n"))
</p:txBody>
</p:sp>
</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>"""
write(joinpath(slides_dir, "slide1.xml"), xml)
write(joinpath(rels_dir, "slide1.xml.rels"), slide_rels_xml("slideLayout1"))
end
function title_shape(ph_type, id, text)
"""<p:sp>
<p:nvSpPr><p:cNvPr id="$id" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="$ph_type"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="ru-RU"/><a:t>$(escape_xml(text))</a:t></a:r></a:p></p:txBody></p:sp>"""
end
function subtitle_shape(id, parts)
lines = join(["<a:p><a:r><a:rPr lang=\"ru-RU\"/><a:t>" * escape_xml(p) * "</a:t></a:r></a:p>" for p in parts], "\n")
"""<p:sp>
<p:nvSpPr><p:cNvPr id="$id" name="Subtitle"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="subTitle" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/>$lines</p:txBody></p:sp>"""
end
function slide_rels_xml(layout)
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/$layout.xml"/>
</Relationships>"""
end
"""
build_shapes(slide, title, layout_type, images, left_images=nothing, right_images=nothing) -> (shapes, layout)
Создаёт массив XML-фигур для слайда в зависимости от `layout_type`:
- `text_only` — одноколоночный текст
- `two_text` — двухколоночный текст
- `section_header` — слайд-раздел (только заголовок)
- `code_row`/`code_col`/`code_left`/`code_right` — кодовые раскладки
- `image_only` — только изображения
Возвращает кортеж `(shapes, layout)`.
"""
function build_shapes(slide, title, layout_type, images, left_images=nothing, right_images=nothing)
shapes = String[]
layout = "slideLayout2"
# Заголовок для всех слайдов (кроме section_header, где он добавляется внутри)
if layout_type != "section_header" && !isempty(title)
push!(shapes, content_title_shape(title))
end
if layout_type == "section_header"
layout = "slideLayout6"
if !isempty(title)
push!(shapes, content_title_shape(title))
end
elseif layout_type == "text_only"
content = get(slide, "content", "")
if !isempty(content)
push!(shapes, text_shape("3", "Content", "idx=\"1\"", content, false, "ctr"))
end
elseif layout_type == "two_text"
layout = "slideLayout3"
left = get(slide, "left", ""); right = get(slide, "right", "")
if !isempty(left) push!(shapes, text_shape("3", "Left", "sz=\"half\" idx=\"1\"", left, false, "ctr")) end
if !isempty(right) push!(shapes, text_shape("4", "Right", "sz=\"half\" idx=\"2\"", right, false, "ctr")) end
elseif layout_type == "three_text"
layout = "slideLayout4"
for (idx, key) in enumerate(["col1", "col2", "col3"])
text = get(slide, key, "")
if !isempty(text)
push!(shapes, text_shape(string(2 + idx), "Col$idx", "sz=\"quarter\" idx=\"$idx\"", text, false, "ctr"))
end
end
elseif layout_type == "four_text"
layout = "slideLayout5"
for (idx, key) in enumerate(["col1", "col2", "col3", "col4"])
text = get(slide, key, "")
if !isempty(text)
push!(shapes, text_shape(string(2 + idx), "Col$idx", "sz=\"quarter\" idx=\"$idx\"", text, false, "ctr"))
end
end
elseif layout_type == "image_only"
append_images!(shapes, images, :center)
elseif layout_type == "image_text"
layout = "slideLayout3"
append_images!(shapes, images, :left)
right_text = get(slide, "right_text", "")
if !isempty(right_text)
push!(shapes, text_shape("4", "Text", "sz=\"half\" idx=\"2\"", right_text, false, "ctr"))
end
elseif layout_type == "text_image"
layout = "slideLayout3"
left_text = get(slide, "left_text", "")
if !isempty(left_text)
push!(shapes, text_shape("3", "Text", "sz=\"half\" idx=\"1\"", left_text, false, "ctr"))
end
append_images!(shapes, images, :right)
#elseif layout_type in ("code_row", "code_col", "code_cell", "code_left", "code_right")
# shapes, layout = build_code_shapes(slide, layout_type, shapes, layout)
elseif layout_type in ("code_row", "code_col", "code_cell", "code_left", "code_right")
shapes, layout = build_code_shapes(slide, layout_type, shapes, layout, left_images, right_images)
else
# Старый формат
left = get(slide, "left", ""); right = get(slide, "right", "")
left_type = get(slide, "left_type", "text"); right_type = get(slide, "right_type", "text")
is_two = !isempty(right)
layout = is_two ? "slideLayout3" : "slideLayout2"
if is_two
if !isempty(left) push!(shapes, text_shape("3", "Left", "sz=\"half\" idx=\"1\"", left, left_type=="code", "")) end
if !isempty(right) push!(shapes, text_shape("4", "Right", "sz=\"half\" idx=\"2\"", right, right_type=="code", "")) end
else
if !isempty(left) push!(shapes, text_shape("3", "Content", "idx=\"1\"", left, left_type=="code", left_type=="code" ? "" : "ctr")) end
end
append_images!(shapes, images, is_two)
end
return shapes, layout
end
"""
build_code_shapes(slide, layout_type, shapes, layout, left_images=nothing, right_images=nothing)
Создаёт фигуры для кодовых слайдов в зависимости от `layout_type`:
* `code_col` — код и вывод в одном столбце (друг под другом).
Если код скрыт (#CODE_SKIP), выводится только вывод.
Изображения справа, либо по центру если код и вывод пустые.
* `code_row` — код слева, вывод справа (двухколоночный слайд).
Изображения в правой колонке.
* `code_left` — код слева, произвольный контент (текст/вывод/изображение) справа.
Используется когда кодовая ячейка + текстовая/кодовая ячейка.
* `code_right` — произвольный контент слева, код справа.
Используется когда текстовая/кодовая ячейка + кодовая ячейка.
Разделитель `>>` добавляется между кодом и выводом только если код не скрыт.
Изображения добавляются через `append_images!` с позиционированием :left, :center, :right.
Возвращает кортеж `(shapes, layout)`.
"""
function build_code_shapes(slide, layout_type, shapes, layout, left_images=nothing, right_images=nothing)
code = get(slide, "code", "")
output = get(slide, "output", "")
images = get(slide, "images", OrderedDict{String, Tuple{Vector{UInt8}, String}}())
is_row = layout_type in ("code_row", "code_cell")
is_code_left = layout_type in ("code_left", "code_row", "code_cell")
is_two_column = layout_type in ("code_row", "code_cell", "code_left", "code_right")
layout = is_two_column ? "slideLayout3" : "slideLayout2"
function join_code_output(code, output)
if isempty(code); return output
elseif isempty(output); return code
else return code * "\n\n>>\n" * output
end
end
# Левая часть (код) — только для code_row и code_cell (не code_left)
if is_code_left && layout_type != "code_left"
combined = code
if !isempty(combined)
push!(shapes, text_shape("3", "Code", "sz=\"half\" idx=\"1\"", combined, true, ""))
end
end
if layout_type == "code_left"
combined = join_code_output(code, output)
if !isempty(combined)
push!(shapes, text_shape("3", "Code", "sz=\"half\" idx=\"1\"", combined, true, ""))
end
right_text = get(slide, "right_text", "")
left_imgs = left_images !== nothing ? left_images : get(slide, "left_images", OrderedDict{String, Tuple{Vector{UInt8}, String}}())
right_imgs = right_images !== nothing ? right_images : get(slide, "right_images", OrderedDict{String, Tuple{Vector{UInt8}, String}}())
if !isempty(right_text)
push!(shapes, text_shape("4", "Text", "sz=\"half\" idx=\"2\"", right_text, false, "ctr"))
elseif !isempty(output) && isempty(code)
push!(shapes, text_shape("4", "Output", "sz=\"half\" idx=\"2\"", output, true, "ctr"))
end
# Вот здесь ключевое исправление:
next_rId = 2 # Начинаем с rId2 (rId1 занят макетом)
if !isempty(left_imgs)
append_images!(shapes, left_imgs, :left, next_rId)
next_rId += length(left_imgs)
end
if !isempty(right_imgs)
append_images!(shapes, right_imgs, :right, next_rId)
end
elseif layout_type == "code_right"
left_text = get(slide, "left_text", "")
if !isempty(left_text)
push!(shapes, text_shape("3", "Text", "sz=\"half\" idx=\"1\"", left_text, false, "ctr"))
end
combined = join_code_output(code, output)
if !isempty(combined)
push!(shapes, text_shape("4", "Code", "sz=\"half\" idx=\"2\"", combined, true, ""))
end
if !isempty(images)
append_images!(shapes, images, :right, 2) # Здесь просто 2, так как изображения только справа
end
elseif is_row
if !isempty(output)
push!(shapes, text_shape("4", "Output", "sz=\"half\" idx=\"2\"", output, true, "ctr"))
end
if !isempty(images)
append_images!(shapes, images, :right)
end
else
# code_col
combined = join_code_output(code, output)
if !isempty(combined)
push!(shapes, text_shape("3", "Code", "idx=\"1\"", combined, true, ""))
end
if !isempty(images)
if isempty(code) && isempty(output)
append_images!(shapes, images, :center)
else
append_images!(shapes, images, :right)
end
end
end
return shapes, layout
end
function content_title_shape(title)
title_line = split(title, "\n")[1]
"""<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="ru-RU"/><a:t>$(escape_xml(title_line))</a:t></a:r></a:p></p:txBody></p:sp>"""
end
function text_shape(id, name, ph_attr, text, is_code, anchor)
anchor_attr = isempty(anchor) ? "" : " anchor=\"$anchor\""
content = make_text_xml(text, is_code, false)
"""<p:sp>
<p:nvSpPr><p:cNvPr id="$id" name="$name"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph $ph_attr/></p:nvPr></p:nvSpPr>
<p:spPr/>
<p:txBody><a:bodyPr$anchor_attr/><a:lstStyle/>$content</p:txBody></p:sp>"""
end
function append_images!(shapes, images, position::Symbol, start_rId::Int=0)
start_id = 10 + length(shapes)
for (j, (iname, (bytes, ext))) in enumerate(images)
width_emu, height_emu = get_image_dimensions(bytes, ext)
if position == :left
col_x = 838200
col_width = 5181600
elseif position == :right
col_x = 6172200
col_width = 5181600
else # :center
col_x = 838200
col_width = 10515600
end
if width_emu > col_width
ratio = col_width / width_emu
width_emu = col_width
height_emu = round(Int, height_emu * ratio)
end
x_offset = round(Int, col_x + (col_width - width_emu) / 2)
y_center = 4000000
y_offset = round(Int, y_center - height_emu / 2)
# Ключевое изменение: используем start_rId если он задан
rId_num = start_rId > 0 ? start_rId + j - 1 : j + 1
push!(shapes, """<p:pic>
<p:nvPicPr><p:cNvPr id="$(start_id + j)" name="Image"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>
<p:blipFill><a:blip r:embed="rId$rId_num"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>
<p:spPr><a:xfrm><a:off x="$x_offset" y="$y_offset"/><a:ext cx="$width_emu" cy="$height_emu"/></a:xfrm><a:prstGeom prst="rect"/></p:spPr></p:pic>""")
end
end
function build_slide_xml(shapes)
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
$(join(shapes, "\n"))
</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>"""
end
function build_slide_rels(layout, images)
rels = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/$layout.xml"/>"""
for (j, (iname, _)) in enumerate(images)
rels *= """
<Relationship Id="rId$(j + 1)" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/$iname"/>"""
end
rels *= "\n</Relationships>"
return rels
end
"""
finalize_pptx(tmpdir, output_path, slides_count)
Обновляет служебные файлы презентации: `[Content_Types].xml`,
`presentation.xml`, `presentation.xml.rels`, добавляет `notesMaster`
при наличии заметок, и пересоздаёт ZIP-архив.
"""
function finalize_pptx(tmpdir, output_path, slides_count)
total = slides_count + 1
# Собираем информацию о notesSlides
notes_dir = joinpath(tmpdir, "ppt", "notesSlides")
has_notes = isdir(notes_dir)
notes_slides = String[]
if has_notes
notes_slides = filter(f -> startswith(f, "notesSlide"), readdir(notes_dir))
end
# Content_Types
ct_path = joinpath(tmpdir, "[Content_Types].xml")
ct = read(ct_path, String)
for i in 2:total
ov = """<Override PartName="/ppt/slides/slide$(i).xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>"""
if !contains(ct, ov); ct = replace(ct, "</Types>" => ov * "\n</Types>"); end
end
# Добавляем notesSlides в Content_Types
for ns in notes_slides
ov = """<Override PartName="/ppt/notesSlides/$ns" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>"""
if !contains(ct, ov); ct = replace(ct, "</Types>" => ov * "\n</Types>"); end
end
write(ct_path, ct)
# presentation.xml
refs = ["<p:sldId id=\"256\" r:id=\"rId3\"/>"]
for i in 2:total
push!(refs, """<p:sldId id="$(255 + i)" r:id="rId$(i + 2)"/>""")
end
pres_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" saveSubsetFonts="1">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>$(join(refs, "\n "))</p:sldIdLst>
<p:sldSz cx="12192000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>"""
write(joinpath(tmpdir, "ppt", "presentation.xml"), pres_xml)
# presentation.xml.rels
pr_path = joinpath(tmpdir, "ppt", "_rels", "presentation.xml.rels")
pr = read(pr_path, String)
new_rels = String[]
for i in 1:total
push!(new_rels, """<Relationship Id="rId$(i + 2)" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide$(i).xml"/>""")
end
# Добавляем notesMaster если есть заметки
next_rid = total + 2
if has_notes
push!(new_rels, """<Relationship Id="rId$next_rid" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>""")
end
#pr = replace(pr, r"<Relationship[^>]*Id=\"rId([4-9]|\d{2,})\"[^>]*/>" => "")
pr = replace(pr, r"<Relationship[^>]*Id=\"rId(3|[4-9]|\d{2,})\"[^>]*/>" => "")
pr = replace(pr, "</Relationships>" => join(new_rels, "\n") * "\n</Relationships>")
write(pr_path, pr)
# Пересоздание ZIP
writer = ZipFile.Writer(output_path)
f = ZipFile.addfile(writer, "_rels/.rels"; method=ZipFile.Store)
write(f, read(joinpath(tmpdir, "_rels", ".rels")))
f = ZipFile.addfile(writer, "[Content_Types].xml")
write(f, read(joinpath(tmpdir, "[Content_Types].xml")))
for (root, _, files) in walkdir(tmpdir)
for file in files
full = joinpath(root, file)
rel = relpath(full, tmpdir) |> r -> replace(r, "\\" => "/")
if rel in ["_rels/.rels", "[Content_Types].xml"]; continue; end
f = ZipFile.addfile(writer, rel)
write(f, read(full))
end
end
close(writer)
end
########
"""
slides_compile(notebook_path; output_path, template_path, config_path,
work_title, institution_name, work_type, author_name,
supervisor_name, city, year)
Главная функция генерации презентации PowerPoint из Jupyter/Engee-скриптов
(`.ipynb`). Обрабатывает расширенную структуру ноутбука с метаданными Engee
(`metadata.engee.*`), специальными маркерами в кодовых ячейках (`#CODE_SKIP`,
`#SKIP`, `#NO_TITLE`, `#PLOT_TITLE`, `#TABLE_NAME`, `#NOTE`) и формирует
многослайдовую презентацию `.pptx` с титульным листом.
## Основные возможности
1. **Автоматическое извлечение слайдов**
Каждый markdown-заголовок уровня 1–3 (`#`, `##`, `###`) начинает новый слайд.
Следующие за заголовком ячейки (markdown-текст, код, изображения) группируются
в контент слайда. Если контента нет — создаётся слайд-раздел.
2. **Группировка контента**
До четырёх блоков контента могут быть размещены на одном слайде:
- 1 блок: одноколоночный макет
- 2 блока: двухколоночный макет
- 3 блока: трёхколоночный текстовый/кодовый макет
- 4 блока: четырёхколоночный текстовый/кодовый макет
3. **Обработка кодовых ячеек**
- `#CODE_SKIP` — код не отображается, только вывод (текст или изображение)
- `#SKIP` — ячейка полностью пропускается
- `#NO_TITLE` — убирает автоматическое добавление заголовка из `metadata.name`
- `#PLOT_TITLE` — задаёт пользовательский заголовок для графика
- `#TABLE_NAME` — задаёт имя таблицы
- `#NOTE` в markdown-ячейке — текст отправляется в заметки докладчика
4. **Расположение кода и вывода**
Управляется метаданными `engee.codeOutputView`:
- `"col"` (по умолчанию) — код сверху, вывод снизу
- `"row"` — код слева, вывод справа
5. **Изображения**
Автоматически извлекаются из:
- Вложений markdown-ячеек (`attachments`)
- Вывода кодовых ячеек (`outputs.data["image/png"]` или `"image/jpeg"`)
Масштабируются под ширину колонки с сохранением пропорций.
Размещаются в колонках слайда в соответствии с раскладкой.
6. **Заметки докладчика**
Текст, идущий после заголовка в markdown-ячейке (всё, что после первой строки),
и ячейки с маркером `#NOTE` попадают в заметки соответствующего слайда.
7. **Титульный слайд**
Формируется из аргументов функции или конфигурационного файла `config.toml`
(или встроенного TOML-блока в начале ноутбука). Приоритет источников:
аргументы функции > встроенный TOML в ноутбуке > внешний config.toml > автоопределение
Если название работы не задано, используется первый markdown-заголовок ноутбука.
8. **Поддержка разметки**
- Markdown: `**жирный**`, `*курсив*`
- LaTeX-подобные символы: `\\cdot` → `·`, `\\times` → `×`, `\\nabla` → `∇` и др.
- Формульные блоки: `\$...\$` и `\$\$...\$\$` (ограниченная поддержка)
## Параметры
**Обязательные:**
- `notebook_path::String` — путь к исходному `.ipynb`/`.ngscript` файлу
**Именованные:**
- `output_path::String=""` — путь к выходному `.pptx` файлу.
По умолчанию: имя входного файла с расширением `.pptx`
- `template_path::String="pptx_template.pptx"` — путь к файлу шаблона.
Если не существует, создаётся автоматически
- `config_path::String="config.toml"` — путь к внешнему конфигурационному
TOML-файлу для титульного слайда
**Параметры титульного слайда** (приоритет над конфигурационными файлами):
- `work_title::String=""` — название работы/презентации
- `institution_name::String=""` — название учебного заведения/организации
- `work_type::String=""` — тип работы (например, "Лабораторная работа №1")
- `author_name::String=""` — имя автора
- `supervisor_name::String=""` — имя руководителя/принимающего
- `city::String=""` — город
- `year::String=""` — год
## Пример использования
```julia
# Простой вызов — конфигурация из config.toml
slides_compile("presentation.ipynb")
# С явным указанием титульной информации
slides_compile(
"lab_report.ipynb",
output_path = "Отчёт.pptx",
work_title = "Исследование колебаний маятника",
author_name = "Иванов И.И.",
supervisor_name = "Петров П.П.",
city = "Москва",
year = "2024"
)
```
## С пользовательским шаблоном и конфигом
```julia
slides_compile(
"project.ipynb",
template_path = "custom_template.pptx",
config_path = "project_config.toml"
)
```
# Формат конфигурационного файла (config.toml)
```TOML
[title_page]
work_title = "Название работы"
institution_name = "Название учебного заведения"
work_type = "Лабораторная работа"
author_name = "Автор"
supervisor_name = "Руководитель"
city = "Город"
year = "2024"
```
Этот же блок может быть размещён в первой markdown-ячейке скрипта, обёрнутый
в тройные обратные кавычки с указанием языка toml.
## Примечания
Функция создаёт PPTX-файл, соответствующий стандарту Office Open XML
(ISO/IEC 29500)
Изображения кодируются в Base64 и извлекаются в исходном формате (PNG/JPEG)
без перекодирования
При отсутствии шаблона создаётся минимальный шаблон с базовой темой
и 6 макетами слайдов
Шаблон не удаляется после использования для возможности повторного применения
Все текстовые стили (шрифты, размеры, цвета) задаются в мастер-слайде
и наследуются макетами
Для кода используется моноширинный шрифт Consolas 14pt,
для вывода — Cascadia Mono 14pt
"""
function slides_compile(
notebook_path::String;
output_path::String = "",
template_path::String = "pptx_template.pptx",
config_path::String = "config.toml",
# Параметры титульного слайда
work_title::String = "",
institution_name::String = "",
work_type::String = "",
author_name::String = "",
supervisor_name::String = "",
city::String = "",
year::String = ""
)
if isempty(output_path)
output_path = replace(basename(notebook_path), r"\.(ngscript|ipynb)$" => ".pptx")
end
notebook = JSON.parsefile(notebook_path)
reset_image_counter()
# Загружаем внешний конфиг
file_config = Dict{String, Any}()
if isfile(config_path)
try
file_config = TOML.parsefile(config_path)
catch
@warn "Не удалось загрузить $config_path"
end
end
# Загружаем встроенный конфиг из ноутбука
notebook_config = extract_config_from_notebook(notebook)
if notebook_config !== nothing
try
notebook_parsed = TOML.parse(notebook_config)
merge!(file_config, notebook_parsed) # встроенный приоритетнее
catch
end
end
tp = get(file_config, "title_page", Dict())
# Ищем первый заголовок для названия
first_heading = ""
for cell in notebook["cells"]
if cell["cell_type"] == "markdown" && !cell_contains_config(cell)
src = strip(join(cell["source"], ""))
m = match(r"^#{1,3}\s+(.+)$"m, src)
if m !== nothing
first_heading = strip(m.captures[1])
first_heading = replace(first_heading, r"\*\*(.+?)\*\*" => s"\1")
first_heading = replace(first_heading, r"\*(.+?)\*" => s"\1")
first_heading = replace(first_heading, "`" => "")
break
end
end
end
# Приоритет: аргументы функции > встроенный конфиг > внешний config.toml > авто
title_slide_info = Dict(
"WORK_TITLE" => !isempty(work_title) ? work_title :
get(tp, "work_title", isempty(first_heading) ? "Презентация" : first_heading),
"INSTITUTION_NAME" => !isempty(institution_name) ? institution_name : get(tp, "institution_name", ""),
"WORK_TYPE" => !isempty(work_type) ? work_type : get(tp, "work_type", ""),
"AUTHOR_NAME" => !isempty(author_name) ? author_name : get(tp, "author_name", ""),
"SUPERVISOR_NAME" => !isempty(supervisor_name) ? supervisor_name : get(tp, "supervisor_name", ""),
"CITY" => !isempty(city) ? city : get(tp, "city", ""),
"YEAR" => !isempty(year) ? year : get(tp, "year", ""),
)
slides = extract_notebook_slides(notebook)
println("(+) Извлечено слайдов: $(length(slides))")
temp = template_path
if !isfile(temp)
println("(-) Шаблон не найден. Создаю новый: $temp")
create_pptx_template(temp)
else
println("(+) Использую существующий шаблон: $temp")
end
fill_pptx_template(temp, output_path, title_slide_info, slides)
# rm(temp) — НЕ УДАЛЯЕМ ШАБЛОН
println("(+) Презентация создана: $output_path")
println(" Количество слайдов: $(length(slides) + 1)")
end