lei-mercados-digitais/scripts/render_entregaveis.py
Francisco Solla 86b90017ec Continue (#1)
Co-authored-by: Francisco Solla <fsolla@pm.me>
Reviewed-on: #1
2026-08-20 15:33:53 +00:00

573 lines
20 KiB
Python

#!/usr/bin/env python3
"""Render the project deliverables as polished DOCX/PDF files."""
from __future__ import annotations
import argparse
import subprocess
import tempfile
from pathlib import Path
from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "rascunho" / "entregaveis"
PANDOC = Path.home() / ".local" / "bin" / "pandoc"
NAVY = "234D79"
BLUE = "2F6FAF"
LIGHT_BLUE = "EAF2F8"
MID_GRAY = "66717D"
LIGHT_GRAY = "E5E9ED"
TEXT = "20262E"
DOCUMENTS = {
"briefing": {
"kind": "brief",
"source": OUT_DIR / "briefing-dep-solla.md",
"docx": OUT_DIR / "briefing-dep-solla.docx",
"title": "Briefing legislativo: interoperabilidade no PL 4675/2025",
"break_before": {
"3. O que o Cade poderá exigir",
"5. O que a emenda de interoperabilidade acrescenta",
},
"font_size": 10.2,
"title_size": 17.5,
"margin": 1.75,
},
"memorando": {
"kind": "brief",
"source": OUT_DIR / "memorando-relator-aliel.md",
"docx": OUT_DIR / "memorando-relator-aliel.docx",
"title": "Memorando ao relator: interoperabilidade no PL 4675/2025",
"break_before": {"5. Competição e inovação"},
"font_size": 10.3,
"title_size": 16.5,
"margin": 1.85,
},
"emenda": {
"kind": "legislative",
"source": ROOT / "rascunho" / "emenda-pl-4675-interoperabilidade.md",
"justification": ROOT / "rascunho" / "justificativa-emenda.md",
"docx": OUT_DIR / "emenda-aditiva-pl4675-interoperabilidade.docx",
"title": "Emenda aditiva ao PL 4675/2025: interoperabilidade",
"break_before": {
"JUSTIFICAÇÃO",
"EMENDA DE PLENÁRIO A PROJETO COM URGÊNCIA",
},
"font_size": 11.5,
"title_size": 12,
"margin": 2.0,
},
}
def set_paragraph_border(paragraph, *, side: str, color: str, size: int = 8) -> None:
p_pr = paragraph._p.get_or_add_pPr()
borders = p_pr.find(qn("w:pBdr"))
if borders is None:
borders = OxmlElement("w:pBdr")
p_pr.append(borders)
border = borders.find(qn(f"w:{side}"))
if border is None:
border = OxmlElement(f"w:{side}")
borders.append(border)
border.set(qn("w:val"), "single")
border.set(qn("w:sz"), str(size))
border.set(qn("w:space"), "4")
border.set(qn("w:color"), color)
def set_run_font(run, name: str, size: float | None = None) -> None:
run.font.name = name
run._element.rPr.rFonts.set(qn("w:eastAsia"), name)
if size is not None:
run.font.size = Pt(size)
def add_field(run, instruction: str) -> None:
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
text = OxmlElement("w:instrText")
text.set(qn("xml:space"), "preserve")
text.text = instruction
separate = OxmlElement("w:fldChar")
separate.set(qn("w:fldCharType"), "separate")
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
run._r.extend([begin, text, separate, end])
def configure_styles(doc: Document, font_size: float, title_size: float) -> None:
styles = doc.styles
normal = styles["Normal"]
normal.font.name = "Arial"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Arial")
normal.font.size = Pt(font_size)
normal.font.color.rgb = RGBColor.from_string(TEXT)
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
normal.paragraph_format.line_spacing = 1.12
normal.paragraph_format.space_after = Pt(4.5)
normal.paragraph_format.widow_control = True
title = styles["Title"]
title.font.name = "Arial"
title._element.rPr.rFonts.set(qn("w:eastAsia"), "Arial")
title.font.size = Pt(title_size)
title.font.bold = True
title.font.color.rgb = RGBColor.from_string(NAVY)
title.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
title.paragraph_format.space_before = Pt(2)
title.paragraph_format.space_after = Pt(10)
title.paragraph_format.keep_with_next = True
heading_specs = {
"Heading 1": (14, NAVY, 13, 5),
"Heading 2": (12.2, BLUE, 10, 4),
"Heading 3": (10.5, NAVY, 7, 2),
}
for style_name, (size, color, before, after) in heading_specs.items():
style = styles[style_name]
style.font.name = "Arial"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Arial")
style.font.size = Pt(size)
style.font.bold = True
style.font.color.rgb = RGBColor.from_string(color)
style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
style.paragraph_format.space_before = Pt(before)
style.paragraph_format.space_after = Pt(after)
style.paragraph_format.keep_with_next = True
style.paragraph_format.widow_control = True
for style_name in ("Compact", "List Bullet", "List Number"):
if style_name not in styles:
continue
style = styles[style_name]
style.font.name = "Arial"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Arial")
style.font.size = Pt(font_size)
style.paragraph_format.line_spacing = 1.08
style.paragraph_format.space_after = Pt(2.5)
style.paragraph_format.widow_control = True
if "Block Text" in styles:
block = styles["Block Text"]
block.font.name = "Arial"
block._element.rPr.rFonts.set(qn("w:eastAsia"), "Arial")
block.font.size = Pt(font_size)
block.font.color.rgb = RGBColor.from_string(NAVY)
block.paragraph_format.left_indent = Cm(0.35)
block.paragraph_format.right_indent = Cm(0.25)
block.paragraph_format.space_before = Pt(4)
block.paragraph_format.space_after = Pt(9)
block.paragraph_format.line_spacing = 1.08
def configure_page(doc: Document, margin: float) -> None:
for section in doc.sections:
section.page_width = Cm(21)
section.page_height = Cm(29.7)
section.top_margin = Cm(1.65)
section.bottom_margin = Cm(1.55)
section.left_margin = Cm(margin)
section.right_margin = Cm(margin)
section.header_distance = Cm(0.65)
section.footer_distance = Cm(0.65)
def add_header_footer(doc: Document, short_title: str) -> None:
for section in doc.sections:
header = section.header
paragraph = header.paragraphs[0]
paragraph.clear()
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
paragraph.paragraph_format.space_after = Pt(4)
run = paragraph.add_run(
"CÂMARA DOS DEPUTADOS | GABINETE DO DEPUTADO JORGE SOLLA"
)
set_run_font(run, "Arial", 8)
run.font.bold = True
run.font.color.rgb = RGBColor.from_string(NAVY)
set_paragraph_border(paragraph, side="bottom", color=BLUE, size=10)
footer = section.footer
table = footer.add_table(rows=1, cols=2, width=Cm(17))
table.autofit = False
table.columns[0].width = Cm(13.5)
table.columns[1].width = Cm(3.5)
left = table.cell(0, 0).paragraphs[0]
left.paragraph_format.space_before = Pt(3)
left_run = left.add_run(short_title)
set_run_font(left_run, "Arial", 7.5)
left_run.font.color.rgb = RGBColor.from_string(MID_GRAY)
right = table.cell(0, 1).paragraphs[0]
right.alignment = WD_ALIGN_PARAGRAPH.RIGHT
page_run = right.add_run("p. ")
set_run_font(page_run, "Arial", 7.5)
page_run.font.color.rgb = RGBColor.from_string(MID_GRAY)
number_run = right.add_run()
set_run_font(number_run, "Arial", 7.5)
number_run.font.color.rgb = RGBColor.from_string(MID_GRAY)
add_field(number_run, "PAGE")
def style_document(doc: Document, config: dict) -> None:
configure_styles(doc, config["font_size"], config["title_size"])
configure_page(doc, config["margin"])
add_header_footer(doc, config["title"])
source_mode = False
first_nonempty = True
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if not text:
continue
if first_nonempty:
paragraph.style = doc.styles["Title"]
set_paragraph_border(paragraph, side="bottom", color=LIGHT_GRAY, size=8)
first_nonempty = False
if text in config["break_before"]:
paragraph.paragraph_format.page_break_before = True
if text in {"Fontes primárias", "Fonte legislativa"}:
source_mode = True
if text.startswith(
("Destinatário:", "Finalidade:", "Texto analisado:", "Ao:", "De:", "Assunto:", "Data:")
):
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
paragraph.paragraph_format.line_spacing = 1.0
paragraph.paragraph_format.space_after = Pt(1)
if source_mode and paragraph.style.name not in {
"Heading 1",
"Heading 2",
"Heading 3",
"Title",
}:
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
paragraph.paragraph_format.line_spacing = 1.0
paragraph.paragraph_format.space_after = Pt(2)
for run in paragraph.runs:
set_run_font(run, "Arial", 8.3)
run.font.color.rgb = RGBColor.from_string(MID_GRAY)
if paragraph.style.name == "Block Text":
p_pr = paragraph._p.get_or_add_pPr()
shading = p_pr.find(qn("w:shd"))
if shading is None:
shading = OxmlElement("w:shd")
p_pr.append(shading)
shading.set(qn("w:fill"), LIGHT_BLUE)
set_paragraph_border(paragraph, side="left", color=BLUE, size=18)
paragraph.paragraph_format.keep_together = True
is_list = paragraph._p.pPr is not None and paragraph._p.pPr.numPr is not None
if is_list:
paragraph.paragraph_format.space_after = Pt(2.5)
paragraph.paragraph_format.line_spacing = 1.06
paragraph.paragraph_format.widow_control = True
for run in paragraph.runs:
set_run_font(run, "Arial")
for table in doc.tables:
for row in table.rows:
row.height_rule = WD_ROW_HEIGHT_RULE.AT_LEAST
row._tr.get_or_add_trPr().append(OxmlElement("w:cantSplit"))
for cell in row.cells:
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
for paragraph in cell.paragraphs:
paragraph.paragraph_format.space_after = Pt(2)
paragraph.paragraph_format.line_spacing = 1.0
for run in paragraph.runs:
set_run_font(run, "Arial", 9)
def configure_legislative_styles(doc: Document, config: dict) -> None:
styles = doc.styles
font_size = config["font_size"]
normal = styles["Normal"]
normal.font.name = "Times New Roman"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
normal.font.size = Pt(font_size)
normal.font.color.rgb = RGBColor.from_string("000000")
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
normal.paragraph_format.line_spacing = 1.15
normal.paragraph_format.space_after = Pt(4)
normal.paragraph_format.widow_control = True
title = styles["Title"]
title.font.name = "Times New Roman"
title._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
title.font.size = Pt(config["title_size"])
title.font.bold = True
title.font.color.rgb = RGBColor.from_string("000000")
title.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
title.paragraph_format.space_before = Pt(4)
title.paragraph_format.space_after = Pt(8)
title.paragraph_format.keep_with_next = True
for style_name, size, alignment in (
("Heading 1", 12, WD_ALIGN_PARAGRAPH.CENTER),
("Heading 2", 11.5, WD_ALIGN_PARAGRAPH.LEFT),
("Heading 3", 11.5, WD_ALIGN_PARAGRAPH.LEFT),
):
style = styles[style_name]
style.font.name = "Times New Roman"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
style.font.size = Pt(size)
style.font.bold = True
style.font.color.rgb = RGBColor.from_string("000000")
style.paragraph_format.alignment = alignment
style.paragraph_format.space_before = Pt(10)
style.paragraph_format.space_after = Pt(5)
style.paragraph_format.keep_with_next = True
for style_name in ("Compact", "List Bullet", "List Number"):
if style_name not in styles:
continue
style = styles[style_name]
style.font.name = "Times New Roman"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
style.font.size = Pt(font_size)
style.paragraph_format.line_spacing = 1.08
style.paragraph_format.space_after = Pt(2)
def style_legislative_document(doc: Document, config: dict) -> None:
configure_legislative_styles(doc, config)
configure_page(doc, config["margin"])
add_header_footer(doc, config["title"])
first_nonempty = True
in_justification = False
in_sources = False
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if not text:
continue
if first_nonempty:
paragraph.style = doc.styles["Title"]
first_nonempty = False
if text in config["break_before"]:
paragraph.paragraph_format.page_break_before = True
if text == "JUSTIFICAÇÃO":
in_justification = True
if text == "Fontes":
in_sources = True
in_justification = False
if text == text.upper() and any(
text.startswith(prefix)
for prefix in ("TÍTULO", "CAPÍTULO", "DA ", "DAS ", "DO ", "DOS ")
):
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.paragraph_format.keep_with_next = True
paragraph.paragraph_format.space_before = Pt(7)
paragraph.paragraph_format.space_after = Pt(5)
for run in paragraph.runs:
run.font.bold = True
if text.startswith(
("(Do Sr.", "Sala das Sessões", "Deputado ", "PT/BA", "(Chancela")
):
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
if text.startswith("Gabinete do Deputado"):
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
if text.startswith("Altera a Lei nº 12.529"):
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.paragraph_format.left_indent = Cm(3.0)
paragraph.paragraph_format.right_indent = Cm(3.0)
heading_style_names = {"Heading 1", "Heading 2", "Heading 3", "Title"}
is_heading = paragraph.style.name in heading_style_names
is_list = paragraph._p.pPr is not None and paragraph._p.pPr.numPr is not None
is_article = text.startswith(("Art. ", "§ ", "Parágrafo único."))
is_incise = any(text.startswith(f"{roman} -") for roman in (
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
"XI", "XII", "XIII", "XIV", "XV",
))
if is_incise:
paragraph.paragraph_format.left_indent = Cm(0.75)
paragraph.paragraph_format.first_line_indent = Cm(-0.5)
elif in_justification and not is_heading and not is_list and not is_article:
paragraph.paragraph_format.first_line_indent = Cm(1.25)
if (
in_sources
and not is_heading
and not text.startswith(
("Sala das Sessões", "Deputado ", "PT/BA", "(Chancela", "(Do Sr.")
)
):
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
paragraph.paragraph_format.first_line_indent = Cm(0)
paragraph.paragraph_format.line_spacing = 1.0
for run in paragraph.runs:
set_run_font(run, "Times New Roman", 9)
paragraph.paragraph_format.widow_control = True
for run in paragraph.runs:
set_run_font(run, "Times New Roman")
def build_amendment_source(config: dict, destination: Path) -> Path:
amendment = config["source"].read_text(encoding="utf-8").strip()
justification = config["justification"].read_text(encoding="utf-8").strip()
content = f"""# CÂMARA DOS DEPUTADOS
**Gabinete do Deputado JORGE SOLLA - PT/BA**
# PROJETO DE LEI Nº 4.675, DE 2025
Altera a Lei nº 12.529, de 30 de novembro de 2011, dispõe sobre os processos
de designação de agentes econômicos de relevância sistêmica em mercados
digitais e de estabelecimento de obrigações especiais e cria unidade
especializada no Conselho Administrativo de Defesa Econômica.
{amendment}
{justification}
Sala das Sessões, em 19 de agosto de 2026.
**Deputado JORGE SOLLA**
**PT/BA**
# EMENDA DE PLENÁRIO A PROJETO COM URGÊNCIA
*(Do Sr. Jorge Solla)*
Assinaram eletronicamente o documento, nesta ordem:
1. Dep. Jorge Solla (PT/BA)
2. ............................................
3. ............................................
4. ............................................
5. ............................................
6. ............................................
7. ............................................
8. ............................................
9. ............................................
10. ............................................
*(Chancela eletrônica do parlamentar, nos termos do Ato da Mesa nº 25, de
2015.)*
"""
destination.write_text(content, encoding="utf-8")
return destination
def render(key: str) -> None:
config = DOCUMENTS[key]
source = config["source"]
target = config["docx"]
pdf = target.with_suffix(".pdf")
if not source.exists():
raise FileNotFoundError(source)
if not PANDOC.exists():
raise FileNotFoundError(PANDOC)
with tempfile.TemporaryDirectory(prefix="render-entregavel-") as temp_dir:
temp = Path(temp_dir)
raw_docx = temp / target.name
render_source = source
if config["kind"] == "legislative":
render_source = build_amendment_source(config, temp / "emenda-completa.md")
subprocess.run(
[
str(PANDOC),
str(render_source),
"--from=gfm",
"--to=docx",
"-o",
str(raw_docx),
],
check=True,
)
doc = Document(raw_docx)
doc.core_properties.title = config["title"]
doc.core_properties.author = "Gabinete do Deputado Jorge Solla"
doc.core_properties.subject = "PL 4675/2025"
if config["kind"] == "legislative":
style_legislative_document(doc, config)
else:
style_document(doc, config)
doc.save(target)
if pdf.exists():
pdf.unlink()
profile = temp / "libreoffice-profile"
subprocess.run(
[
"soffice",
"--headless",
f"-env:UserInstallation=file://{profile}",
"--convert-to",
"pdf",
"--outdir",
str(OUT_DIR),
str(target),
],
check=True,
)
if config["kind"] == "legislative":
legacy = target.with_suffix(".doc")
if legacy.exists():
legacy.unlink()
subprocess.run(
[
"soffice",
"--headless",
f"-env:UserInstallation=file://{profile}",
"--convert-to",
'doc:MS Word 97',
"--outdir",
str(OUT_DIR),
str(target),
],
check=True,
)
if not pdf.exists():
raise RuntimeError(f"PDF not generated: {pdf}")
print(f"Rendered {target.relative_to(ROOT)} and {pdf.relative_to(ROOT)}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"documents",
nargs="*",
choices=sorted(DOCUMENTS),
)
args = parser.parse_args()
for key in args.documents or DOCUMENTS:
render(key)
if __name__ == "__main__":
main()