defmodule CA.TeX do @moduledoc """ Generates LaTeX files based on the security-profiles.tex template. """ require EEx @template """ \\documentclass{article} \\usepackage[utf8]{inputenc} \\usepackage[T1]{fontenc} \\usepackage{helvet} \\usepackage{geometry} \\usepackage{xcolor} \\usepackage{eso-pic} \\usepackage{fancyhdr} \\usepackage{parskip} \\usepackage{microtype} \\usepackage[english, ukrainian]{babel} \\usepackage{url} \\usepackage{amsmath} \\usepackage{amssymb} \\usepackage{amsthm} \\usepackage{longtable} \\usepackage{booktabs} \\usepackage{hyperref} \\usepackage{titlesec} \\usepackage{tocloft} \\renewcommand{\\cftsecfont}{\\small\\bfseries} \\renewcommand{\\cftsubsecfont}{\\footnotesize} \\renewcommand{\\cftsubsubsecfont}{\\scriptsize} \\renewcommand{\\cftsecpagefont}{\\small\\bfseries} \\renewcommand{\\cftsubsecpagefont}{\\footnotesize} \\renewcommand{\\cftsubsubsecpagefont}{\\scriptsize} \\definecolor{nato}{HTML}{293757} \\definecolor{footergray}{gray}{0.65} \\geometry{a4paper,left=3cm,right=3cm,top=3cm,bottom=3cm} \\renewcommand{\\familydefault}{\\sfdefault} \\setcounter{secnumdepth}{3} \\titleformat{\\section} {\\color{nato}\\Huge\\bfseries\\raggedright}{\\thesection.}{1em}{} \\titlespacing*{\\section}{0pt}{30pt}{12pt} \\titleformat{\\subsection} {\\color{nato}\\LARGE\\bfseries\\raggedright}{\\thesubsection.}{1em}{} \\titlespacing*{\\subsection}{0pt}{20pt}{8pt} \\titleformat{\\subsubsection} {\\color{nato}\\Large\\bfseries\\raggedright}{\\thesubsubsection.}{1em}{} \\titlespacing*{\\subsubsection}{0pt}{10pt}{6pt} \\fancyhf{} \\fancyhead[R]{\\small\\color{footergray} <%= @date %>} \\fancyfoot[L]{\\small\\color{footergray} <%= @subtitle %>. <%= @title %>.} \\fancyfoot[R]{\\small\\color{footergray}\\thepage} \\newcommand\\HUGE[1]{\\fontsize{40}{40}\\selectfont #1} \\renewcommand{\\headrulewidth}{0pt} \\renewcommand{\\footrulewidth}{0.4pt} \\renewcommand{\\footrule}{\\color{footergray}\\hrule width \\textwidth height 0.4pt} \\begin{document} \\pagestyle{fancy} \\begin{titlepage} \\AddToShipoutPictureBG*{\\AtPageLowerLeft{\\color{nato}\\rule{\\paperwidth}{\\paperheight}}} \\color{white}\\thispagestyle{empty}\\vspace*{2cm}\\centering {\\Huge \\textbf{<%= @subtitle %>} \\par} \\vspace{1cm} {\\HUGE \\textbf{<%= @title %>} \\par} \\vspace{1cm} \\vfill {\\large <%= @year %> \\copyright\\ <%= @copyright %> \\par} \\end{titlepage} \\newpage \\begin{center} {\\Huge\\color{nato}\\bfseries <%= @toc_title %>\\par} \\end{center} \\vspace{40pt} \\tableofcontents \\begin{abstract} <%= @abstract %> \\end{abstract} <%= @body %> \\end{document} """ EEx.function_from_string(:def, :render, @template, [:assigns]) @doc """ Generates a LaTeX document string. ## Options * `:date` - document date (default: "31 березня 2026") * `:subtitle` - subtitle on title page and footer (default: "ПК КЗІ КЗЗІ") * `:title` - title on title page and footer (default: "Профілі безпеки") * `:year` - copyright year (default: "2026") * `:copyright` - copyright owner (default: "ДП «УСС»") * `:toc_title` - title for Table of Contents (default: "Зміст") * `:abstract` - abstract content * `:body` - document body content ## Example CA.TeX.generate( title: "Профіль безпеки", subtitle: "Комплексна система захисту інформації", abstract: "...", body: "\\\\section{Вступна частина} ..." ) """ def generate(opts \\ []) do opts = Keyword.merge( [ date: "2 червня 2026", subtitle: "Комплексна система захисту інформації", title: "Профіль безпеки", year: "2026", copyright: "ДП «ІСС»", toc_title: "Зміст", abstract: "", body: "" ], opts ) render(Enum.into(opts, %{})) end def gen do :lists.flatten([ # В Шаблоні Держспецзв'язку legal_l1_profile_1(), legal_l1_profile_2(), legal_l2_profile(), legal_l3_profile(CA.L3.ERP), # В Шаблоні ДП gen_bible(), # Типові накази підприємства (перенесені до CA.NPA) CA.NPA.gen_order_szi_establishment(), CA.NPA.gen_order_kszi_development(), CA.NPA.gen_order_at_ir_ma_ps_pe(), CA.NPA.gen_order_admin_appointment(), CA.NPA.gen_order_physical_access() ]) end def gen_bible(opts \\ []) do all_oids = Enum.map(CA.Profile.Data.specs(), &CA.SPE.oid(&1.id)) {body, count} = generate_body(all_oids) opts = Keyword.merge( [ title: "Повний каталог заходів із захисту інформації (#{count})", subtitle: "Система технічного захисту інформації", abstract: "Повний каталог (Bible) — містить абсолютно всі заходи, посилення та субконтролі згідно з повним скоупом НД ТЗІ.", body: body ], opts ) content = generate(opts) File.write!("priv/bible_profile.tex", content) "priv/bible_profile.tex" end defp generate_body(controls) do profile_specs = unfold(controls) all_specs = CA.Profile.Data.specs() oid_to_spec = Map.new(all_specs, fn spec -> {CA.SPE.oid(spec.id), spec} end) {sections, _} = Enum.map_reduce(profile_specs, nil, fn spec, last_fam -> parts = spec.id |> to_string() |> String.split("-") family = Enum.at(parts, 2) |> String.upcase() latex_level = case length(parts) do 4 -> "subsection" 5 -> "subsubsection" _ -> "paragraph" end spec = if latex_level == "subsubsection" do short_title = String.split(spec.title, " - ", parts: 2) |> List.last() %{spec | title: short_title} else spec end params = Map.get(spec, :parameters, []) formatted = format_control(spec, params, latex_level) if family != last_fam do family_atom = String.to_atom("id-spe-#{String.downcase(family)}") family_spec = Map.get(oid_to_spec, CA.SPE.oid(family_atom)) family_desc = if family_spec && family_spec.description != "", do: escape_latex(family_spec.description) <> "\n\n", else: "" ai_summary = CA.FamilyDescriptions.get_summary(family_atom) ai_text = if ai_summary != "", do: "\\paragraph{Опис} " <> escape_latex(ai_summary) <> "\n\n", else: "" children_text = CA.FamilyDescriptions.get_children_text(family_atom, profile_specs) children_str = if children_text != "", do: "\\paragraph{Перелік заходів захисту} " <> escape_latex(children_text) <> "\n\n", else: "" {"\\section{#{family}}\n#{family_desc}#{ai_text}#{children_str}" <> formatted, family} else {formatted, last_fam} end end) {Enum.join(sections, "\n\n"), length(profile_specs)} end def unfold([%{subcontrols: _} | _] = controls) do Enum.flat_map(controls, fn fam -> Enum.map(fam.subcontrols, fn sc -> %{ id: String.to_atom("id-spe-" <> String.downcase(sc.id)), title: sc.name, description: sc.text, parameters: Map.get(sc, :parameters, []) } end) end) end def unfold(oids) do all_specs = CA.Profile.Data.specs() oid_to_spec = Map.new(all_specs, fn spec -> {CA.SPE.oid(spec.id), spec} end) oids |> Enum.reject(fn oid -> tuple_size(oid) == 9 end) |> Enum.uniq() |> Enum.sort_by(&Tuple.to_list/1) |> Enum.map(&Map.get(oid_to_spec, &1)) |> Enum.reject(&is_nil/1) end defp format_control(spec, [], latex_level) do """ \\#{latex_level}{#{escape_latex(spec.title)}} #{escape_latex(spec.description)} \\vspace{10pt} \\textit{Немає параметрів для цього контролю.} \\vspace{10pt} """ end defp format_control(spec, params, latex_level) do params_paragraphs = params |> Enum.with_index(1) |> Enum.map(fn {param, idx} -> {name, desc, opts} = case param do {n, d, o} -> {n, d, o} {n, d} -> {n, d, []} end type = Keyword.get(opts, :type, "") |> to_string() |> escape_latex() default = Keyword.get(opts, :default, "") |> inspect() |> escape_latex() """ {\\footnotesize\\bfseries No: #{idx}\\\\ Name: #{escape_latex(to_string(name))}\\\\ Type: #{type}\\\\ Default: #{default} } {\\footnotesize #{escape_latex(desc)}} """ end) |> Enum.join("\n") """ \\#{latex_level}{#{escape_latex(spec.title)}} #{escape_latex(spec.description)} \\vspace{10pt} #{params_paragraphs} """ end defp escape_latex(text) do text |> to_string() |> String.replace(<<0xF02D::utf8>>, "-") |> String.replace(<<0x2013::utf8>>, "--") |> String.replace(<<0x2014::utf8>>, "---") |> String.replace(<<0x02BC::utf8>>, "'") |> String.replace(<<0x2019::utf8>>, "'") |> String.replace("\\", "\\textbackslash{}") |> String.replace("{", "\\{") |> String.replace("}", "\\}") |> String.replace("$", "\\$") |> String.replace("&", "\\&") |> String.replace("%", "\\%") |> String.replace("#", "\\#") |> String.replace("_", "\\_\\allowbreak{}") |> String.replace("~", "\\textasciitilde{}") |> String.replace("^", "\\textasciicircum{}") |> String.replace("\"", "\\textquotedbl{}") |> String.replace("\n", "\\\\ \n") end def escape_val(list) when is_list(list) do Enum.map(list, &escape_val/1) end def escape_val(map) when is_map(map) do Map.new(map, fn {k, v} -> {k, escape_val(v)} end) end def escape_val(binary) when is_binary(binary) do escape_latex(binary) end def escape_val(other), do: other def tryzub_def(style \\ :mono) do CA.NPA.tryzub_def(style) end @legal_template_l2 """ \\documentclass[10pt]{article} \\usepackage[T1,T2A]{fontenc} \\usepackage[utf8]{inputenc} \\usepackage[english,ukrainian]{babel} \\usepackage{geometry} \\usepackage{longtable} \\usepackage{array} \\usepackage{multirow} \\usepackage{hyperref} \\usepackage{mathptmx} \\renewcommand{\\familydefault}{\\rmdefault} \\geometry{a4paper,left=2cm,right=2cm,top=2cm,bottom=2cm} \\begin{document} \\begin{flushright} ЗАТВЕРДЖЕНО\\\\ Наказ <%= @org_name %>\\\\ \\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_ 2026 року №\\_\\_\\_\\_ \\end{flushright} \\begin{center} \\textbf{\\large <%= @title %>} \\end{center} \\footnotesize \\begin{longtable}{|c|>{\\raggedright\\arraybackslash}p{4cm}|>{\\raggedright\\arraybackslash}p{6cm}|>{\\raggedright\\arraybackslash}p{1.5cm}|>{\\raggedright\\arraybackslash}p{3.5cm}|} \\hline \\textbf{№ з/п} & \\textbf{Назва дії з безпеки інформації} & \\textbf{Зміст дії} & \\textbf{Заходи захисту} & \\textbf{Мінімальні необхідні параметри}\\\\ \\hline \\endfirsthead \\hline \\textbf{№ з/п} & \\textbf{Назва дії з безпеки інформації} & \\textbf{Зміст дії} & \\textbf{Заходи захисту} & \\textbf{Мінімальні необхідні параметри}\\\\ \\hline \\endhead \\hline \\endfoot \\hline \\endlastfoot <%= @table_body %> \\end{longtable} \\end{document} """ @legal_template_l3 """ \\documentclass[10pt]{article} \\usepackage[T1,T2A]{fontenc} \\usepackage[utf8]{inputenc} \\usepackage[english,ukrainian]{babel} \\usepackage{geometry} \\usepackage{longtable} \\usepackage{array} \\usepackage{multirow} \\usepackage{hyperref} \\usepackage{mathptmx} \\renewcommand{\\familydefault}{\\rmdefault} \\geometry{a4paper,left=2cm,right=2cm,top=2cm,bottom=2cm} \\begin{document} \\begin{flushright} ЗАТВЕРДЖЕНО\\\\ Наказом <%= @org_name %>\\\\ від \\_\\_ \\_\\_\\_\\_\\_\\_\\_\\_\\_ 2026 р. № \\_\\_\\_\\\\ (в редакції наказу <%= @org_name %>\\\\ від \\_\\_ \\_\\_\\_\\_\\_\\_\\_\\_\\_ 2026 р. № \\_\\_) \\end{flushright} \\vspace{2cm} \\begin{center} \\textbf{\\Large <%= @system_desc %>}\\\\ \\textbf{\\Large «<%= @system_name %>»}\\\\ \\vspace{1cm} \\textbf{\\Large ЦІЛЬОВИЙ ПРОФІЛЬ БЕЗПЕКИ}\\\\ \\vspace{0.5cm} \\large <%= @doc_id %> \\end{center} \\newpage \\footnotesize \\begin{longtable}{|>{\\centering\\arraybackslash}p{0.5cm}|>{\\raggedright\\arraybackslash}p{4cm}|>{\\raggedright\\arraybackslash}p{5cm}|>{\\raggedright\\arraybackslash}p{2cm}|>{\\raggedright\\arraybackslash}p{3cm}|} \\hline \\textbf{№} & \\textbf{Вимога з безпеки інформації} & \\textbf{Вимога ГПБ} & \\multicolumn{2}{c|}{\\textbf{ЦПБ}} \\\\ \\cline{4-5} & & & \\textbf{Захід захисту} & \\textbf{Налаштований зміст заходу захисту}\\\\ \\hline \\endfirsthead \\hline \\textbf{№} & \\textbf{Вимога з безпеки інформації} & \\textbf{Вимога ГПБ} & \\multicolumn{2}{c|}{\\textbf{ЦПБ}} \\\\ \\cline{4-5} & & & \\textbf{Захід захисту} & \\textbf{Налаштований зміст заходу захисту}\\\\ \\hline \\endhead \\hline \\endfoot \\hline \\endlastfoot <%= @table_body %> \\end{longtable} \\end{document} """ EEx.function_from_string(:def, :render_legal_l2, @legal_template_l2, [:assigns]) EEx.function_from_string(:def, :render_legal_l3, @legal_template_l3, [:assigns]) def legal_l1_profile_1(controls \\ CA.L1.Base84.groups(), opts \\ []) do {body, _count} = generate_legal_l2_table_body(controls) opts = Keyword.merge( [ org_name: "Держспецзв’язок України", title: "Базовий профіль безпеки системи, де обробляється відкрита або конфіденційна інформація, затверджений наказом Адміністрації Держспецзв’язку від 30.06.2025 № 409", table_body: body ], opts ) content = render_legal_l2(Enum.into(opts, %{})) File.write!("priv/legal_l1_profile_409.tex", content) "priv/legal_l1_profile_409.tex" end def legal_l1_profile_2(controls \\ CA.L1.Base97.groups(), opts \\ []) do {body, _count} = generate_legal_l2_table_body(controls) opts = Keyword.merge( [ org_name: "Держспецзв’язок України", title: "Базовий профіль безпеки системи, де обробляється службова інформація, затверджений наказом Адміністрації Держспецзв’язку від 02.07.2025 № 419", table_body: body ], opts ) content = render_legal_l2(Enum.into(opts, %{})) File.write!("priv/legal_l1_profile_419.tex", content) "priv/legal_l1_profile_419.tex" end def legal_l2_profile(opts \\ []) do controls = CA.L2.Court.controls() # -- CA.L1.controls() {body, _count} = generate_legal_l2_table_body(controls) opts = Keyword.merge( [ org_name: "Державна Судова Адміністрація України", title: "Галузевий профіль безпеки судової системи, що використовуються для надання хмарних послуг та/або послуг центру обробки даних публічним користувачам та/або критично важливим об’єктам інфраструктури", table_body: body ], opts ) content = render_legal_l2(Enum.into(opts, %{})) File.write!("priv/legal_l2_court_profile.tex", content) "priv/legal_l2_court_profile.tex" end def generate_doc_id(opts \\ []) do base = Keyword.get(opts, :base, "47850061") dept = Keyword.get(opts, :dept, "СЗІ") sub = Keyword.get(opts, :sub, "ДП") seq = Keyword.get_lazy(opts, :seq, fn -> Enum.random(1..99) end) "UA.#{base}.#{dept}.#{sub}-#{String.pad_leading(to_string(seq), 2, "0")}" end def legal_l3_profile(module, opts \\ []) do name = module |> Module.split() |> List.last() controls = module.controls() doc_id = generate_doc_id() {body, _count} = generate_legal_l3_table_body(controls) opts = Keyword.merge( [ org_name: module.org_name(), system_desc: module.system_desc(), system_name: module.system_name(), doc_id: doc_id, table_body: body ], opts ) content = render_legal_l3(Enum.into(opts, %{})) filename = "priv/legal_l3_profile_#{String.downcase(name)}.tex" File.write!(filename, content) filename end defp generate_legal_l2_table_body(controls_or_groups) do all_specs = CA.Profile.Data.specs() is_grouped = Enum.any?(controls_or_groups, fn {group_name, list} when is_binary(group_name) and is_list(list) -> true _ -> false end) chunked_specs = if is_grouped do # It's already grouped (a list of {name, controls} or {:category, name}) Enum.map(controls_or_groups, fn {:category, name} -> {:category, name} {name, group} -> {name, unfold(group)} end) |> Enum.reject(fn {:category, _} -> false {_, specs} -> specs == [] end) else # It's a flat list, use default grouping specs = unfold(controls_or_groups) {policy_specs, other_specs} = Enum.split_with(specs, fn spec -> base_id = spec.id |> to_string() |> String.split("-") |> Enum.take(4) |> Enum.join("-") Regex.match?(~r/^id-spe-[a-z]+-1$/, base_id) end) chunked_others = other_specs |> Enum.chunk_by(fn spec -> spec.id |> to_string() |> String.split("-") |> Enum.take(4) |> Enum.join("-") end) |> Enum.map(fn sp -> {hd(sp).title, sp} end) policy_chunk = if policy_specs != [], do: [{"Політики та процедури з безпеки", policy_specs}], else: [] policy_chunk ++ chunked_others end {rows, {final_idx, _last_fam}} = Enum.map_reduce(chunked_specs, {1, nil}, fn {:category, name}, {idx, _last_fam} -> # Explicit category provided, don't increment idx prefix = "\\multicolumn{5}{|>{\\raggedright\\arraybackslash}p{16cm}|}{\\textbf{#{escape_latex(name)}}} \\\\* \\noalign{\\hrule height 0.4pt}\n" {prefix, {idx, name}} {group_name, specs}, {idx, last_fam} -> is_policy_chunk = Enum.all?(specs, fn s -> base_id = s.id |> to_string() |> String.split("-") |> Enum.take(4) |> Enum.join("-") Regex.match?(~r/^id-spe-[a-z]+-1$/, base_id) end) family = if is_policy_chunk do "ПОЛІТИКИ ТА ПРОЦЕДУРИ" else base_spec = hd(specs) parts = base_spec.id |> to_string() |> String.split("-") Enum.at(parts, 2) |> String.upcase() end title = escape_latex(group_name) row_str = specs |> Enum.with_index() |> Enum.map(fn {spec, spec_idx} -> p = spec.id |> to_string() |> String.split("-") fam = Enum.at(p, 2) |> String.upcase() cid = fam <> "-" <> (Enum.at(p, 3) || "") cid = if length(p) > 4, do: cid <> "(" <> Enum.at(p, 4) <> ")", else: cid {desc_list, params_list} = Map.get(spec, :parameters, []) |> Enum.reject(fn {_name, _pdesc, opts} -> Keyword.get(opts, :default) in [nil, "", "nil"] end) |> Enum.map(fn {name_atom, pdesc, opts} -> default_val = Keyword.get(opts, :default, "") type = Keyword.get(opts, :type, "unknown") default_str = if is_list(default_val), do: Enum.join(default_val, ", "), else: to_string(default_val) default = escape_latex(default_str) desc_str = escape_latex(pdesc) formatted_param = "Параметр: #{escape_latex(to_string(name_atom))}\\newline Тип: #{escape_latex(to_string(type))}\\newline Значення: \\textbf{#{default}}" {desc_str, formatted_param} end) |> Enum.uniq() |> Enum.reduce([], fn {desc1, def1} = item, acc -> if Enum.any?(acc, fn {desc2, def2} -> def1 == def2 and (String.contains?(desc1, desc2) or String.contains?(desc2, desc1)) end) do Enum.map(acc, fn {desc2, def2} = existing -> if def1 == def2 and (String.contains?(desc1, desc2) or String.contains?(desc2, desc1)) do if String.length(desc1) < String.length(desc2), do: item, else: existing else existing end end) |> Enum.uniq() else [item | acc] end end) |> Enum.reverse() |> Enum.unzip() col1 = if spec_idx == 0, do: "#{idx}", else: "" col2 = if spec_idx == 0, do: "#{title}", else: "" is_last = spec_idx == length(specs) - 1 line_cmd = if is_last, do: "\\hline", else: "\\cline{3-5}" params = Enum.zip(desc_list, params_list) safe_desc = spec.description |> escape_latex() |> String.replace("\\\\ \n", "\\newline ") if params == [] do "#{col1} & #{col2} & #{safe_desc} & #{escape_latex(cid)} & \\\\ #{line_cmd}\n" else control_row = "#{col1} & #{col2} & #{safe_desc} & #{escape_latex(cid)} & \\\\ \\cline{3-5}\n" param_rows = params |> Enum.with_index() |> Enum.map(fn {{d, p}, p_idx} -> lcmd = if p_idx == length(params) - 1, do: line_cmd, else: "\\cline{3-5}" chunks = String.split(d, "; ") chunks |> Enum.with_index() |> Enum.map(fn {chunk, chunk_idx} -> cx5 = if chunk_idx == 0, do: p, else: "" chunk_text = if chunk_idx == length(chunks) - 1, do: chunk, else: chunk <> ";" lcmd_inner = if chunk_idx == length(chunks) - 1, do: lcmd, else: "" " & & #{chunk_text} & & #{cx5} \\\\ #{lcmd_inner}\n" end) |> Enum.join("") end) |> Enum.join("") control_row <> param_rows end end) |> Enum.join("") if !is_grouped and family != last_fam do family_atom = String.to_atom("id-spe-#{String.downcase(family)}") family_spec = Enum.find(all_specs, &(&1.id == family_atom)) family_title = if family_spec, do: escape_latex(family_spec.title), else: escape_latex(family) prefix = "\\multicolumn{5}{|>{\\raggedright\\arraybackslash}p{16cm}|}{\\textbf{#{family_title}}} \\\\* \\noalign{\\hrule height 0.4pt}\n" {prefix <> row_str, {idx + 1, family}} else {row_str, {idx + 1, family}} end end) {Enum.join(rows, ""), final_idx - 1} end defp generate_legal_l3_table_body(controls) do profile_specs = case controls do [%{subcontrols: _} | _] -> Enum.flat_map(controls, fn fam -> Enum.map(fam.subcontrols, fn sc -> %{ id: String.to_atom("id-spe-" <> String.downcase(sc.id)), title: sc.name, description: sc.text, parameters: Map.get(sc, :parameters, []) } end) end) _ -> cmdb_lookup = CA.L3.ERP.controls() |> Enum.flat_map(& &1.subcontrols) |> Enum.map(&{String.to_atom("id-spe-" <> String.downcase(&1.id)), &1}) |> Enum.into(%{}) unfold(controls) |> Enum.map(fn base_spec -> case Map.get(cmdb_lookup, base_spec.id) do nil -> base_spec cmdb_sc -> %{ base_spec | description: cmdb_sc.text, parameters: Map.get(cmdb_sc, :parameters, []) } end end) end all_specs = CA.Profile.Data.specs() {rows, _state} = profile_specs |> Enum.with_index(1) |> Enum.map_reduce({nil, 0, 0}, fn {spec, row_idx}, {last_fam, fam_idx, ctrl_idx} -> parts = spec.id |> to_string() |> String.split("-") family = Enum.at(parts, 2) |> String.upcase() control_id = family <> "-" <> (Enum.at(parts, 3) || "") control_id = if length(parts) > 4, do: control_id <> "(" <> Enum.at(parts, 4) <> ")", else: control_id title = escape_latex(spec.title) # escape_latex(spec.description) desc_list = Map.get(spec, :parameters, []) |> Enum.reject(fn %{default: default_val} -> default_val in [nil, "", "nil"] {_name, _desc, opts} -> Keyword.get(opts, :default) in [nil, "", "nil"] _ -> false end) |> Enum.map(fn %{name: name_atom, type: type, default: default_val} -> default_str = if is_list(default_val), do: Enum.join(default_val, ", "), else: to_string(default_val) default = escape_latex(default_str) "Параметр: #{escape_latex(name_atom)}\\newline Тип: #{escape_latex(type)}\\newline Значення: \\textbf{#{default}}" {_name_atom, pdesc, opts} -> default_val = Keyword.get(opts, :default, "") type = Keyword.get(opts, :type, "unknown") default_str = if is_list(default_val), do: Enum.join(default_val, ", "), else: to_string(default_val) default = escape_latex(default_str) "Параметр: #{escape_latex(pdesc)}\\newline Тип: #{escape_latex(type)}\\newline Значення: \\textbf{#{default}}" end) |> Enum.uniq() original_spec = Enum.find(all_specs, &(&1.id == spec.id)) gpb_text = if original_spec, do: escape_latex(original_spec.description), else: "" t_text = String.replace(gpb_text, "\\\\ \n", "\\newline ") custom_text_raw = escape_latex(spec.description) |> String.replace("\\\\ \n", "\\newline ") custom_text = if custom_text_raw == t_text, do: "", else: custom_text_raw t_chunks = if t_text == "", do: [""], else: [t_text] grouped_params = Enum.chunk_every(desc_list, 4) |> Enum.map(fn chunk -> Enum.join(chunk, "\\vspace{1mm}\\newline\\noindent\\rule{\\linewidth}{0.4pt}\\vspace{1mm}\n") end) p_chunks = if custom_text == "" do if grouped_params == [], do: [""], else: grouped_params else case grouped_params do [] -> [custom_text] [first | rest] -> [custom_text <> "\\vspace{1mm}\\newline\\noindent\\rule{\\linewidth}{0.4pt}\\vspace{1mm}\n" <> first | rest] end end max_chunks = max(length(t_chunks), length(p_chunks)) t_chunks = t_chunks ++ List.duplicate("", max_chunks - length(t_chunks)) p_chunks = p_chunks ++ List.duplicate("", max_chunks - length(p_chunks)) {fam_idx_new, ctrl_idx_new, prefix} = if family != last_fam do family_atom = String.to_atom("id-spe-#{String.downcase(family)}") family_spec = Enum.find(all_specs, &(&1.id == family_atom)) family_title = if family_spec, do: escape_latex(family_spec.title), else: escape_latex(family) new_fam_idx = fam_idx + 1 pref = "\\multicolumn{5}{|>{\\raggedright\\arraybackslash}p{16cm}|}{\\textbf{#{new_fam_idx}. #{family_title}}} \\\\* \\noalign{\\hrule height 0.4pt}\n" {new_fam_idx, 1, pref} else {fam_idx, ctrl_idx + 1, ""} end row_str = Enum.zip(t_chunks, p_chunks) |> Enum.with_index() |> Enum.map(fn {{t, p}, chunk_idx} -> c1 = if chunk_idx == 0, do: "#{row_idx}", else: "" c2 = if chunk_idx == 0, do: "#{title}", else: "" c4 = if chunk_idx == 0, do: "#{escape_latex(control_id)}", else: "" line_cmd = if chunk_idx == max_chunks - 1, do: "\\hline\n", else: "\n" "#{c1} & #{c2} & #{t} & #{c4} & #{p} \\\\ #{line_cmd}" end) |> Enum.join("") {prefix <> row_str, {family, fam_idx_new, ctrl_idx_new}} end) {Enum.join(rows, ""), length(profile_specs)} end end