feat(crm-ops): estrategia-marca 1.3.0 — SPEC v2.0, versão completa (36 capítulos), redação em paralelo, montagem e PDF A4; funde marketing:brand-strategy (marketing 2.1.0)

This commit is contained in:
AIkTop
2026-10-17 01:39:40 +01:00
parent bce12944ae
commit c18bb90ff6
16 changed files with 662 additions and 590 deletions
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Junta as partes de uma estratégia de marca num único markdown.
Uso:
montar.py --partes DIR --intro intro.md --briefing briefing-llm.md --config marca.json --out estrategia.md
[--glossario glossario.md] [--complementos complementos.md] [--historico historico.md]
- DIR contém parte-I.md ... parte-VIII.md (só os capítulos, `## N. Título`).
- intro.md: resumo executivo + "Sobre este documento" (títulos `# ...`).
- briefing-llm.md: a secção `## BRIEFING-LLM` completa (fica sempre em último).
- marca.json: chaves `frontmatter` (dict) e, opcional, `partes` ([[romano, nome], ...]).
Gera o anexo A (pontos a validar) a partir das marcas `[a validar: ...]`.
"""
import argparse, json, os, re, sys
PARTES = [["I", "Contexto"], ["II", "Fundamentos da marca"], ["III", "Públicos"],
["IV", "Identidade e expressão"], ["V", "Oferta e venda"], ["VI", "Comunicação e canais"],
["VII", "Crescimento"], ["VIII", "Medição e implementação"]]
def yaml_value(v):
if isinstance(v, list):
return "[" + ", ".join(str(x) for x in v) + "]"
s = str(v)
return '"' + s.replace('"', '\\"') + '"' if re.search(r"[:#®·\-]", s) else s
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--partes", required=True)
ap.add_argument("--intro", required=True)
ap.add_argument("--briefing", required=True)
ap.add_argument("--config", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--glossario")
ap.add_argument("--complementos")
ap.add_argument("--historico")
a = ap.parse_args()
cfg = json.load(open(a.config, encoding="utf-8"))
fm = "---\n" + "".join(f"{k}: {yaml_value(v)}\n" for k, v in cfg.get("frontmatter", {}).items()) + "---\n\n"
doc = [fm, open(a.intro, encoding="utf-8").read().rstrip() + "\n\n"]
for rom, nome in cfg.get("partes", PARTES):
p = os.path.join(a.partes, f"parte-{rom}.md")
if not os.path.exists(p):
sys.exit(f"Falta {p}")
t = open(p, encoding="utf-8").read().strip()
if re.search(r"^# ", t, re.M):
sys.exit(f"{p} tem títulos de nível 1; só o montar.py escreve as Partes")
doc.append(f"# Parte {rom}. {nome}\n\n{t}\n\n")
full = "".join(doc)
pend, cap, seen, rows = [], None, set(), []
for line in full.split("\n"):
m = re.match(r"^## (\d+)\. ", line)
if m:
cap = int(m.group(1))
for v in re.findall(r"\[a validar:\s*([^\]]+)\]", line):
pend.append((cap or 0, v.strip().rstrip(".")))
for c, v in pend:
if (c, v.lower()) in seen:
continue
seen.add((c, v.lower()))
rows.append(f"| {c or '—'} | {v[0].upper() + v[1:]} |")
anexos = ["# Anexos\n\n## A. Pontos a validar\n\n",
f"{len(rows)} pontos, por capítulo. Cada ponto fechado corrige-se no capítulo e sai desta lista.\n\n",
"| Cap. | O que falta confirmar |\n| --- | --- |\n", "\n".join(rows) or "| — | Nenhum |", "\n\n"]
extras = [(t, f) for t, f in [("Glossário", a.glossario), ("Complementos do sector", a.complementos),
("Histórico de alterações", a.historico)] if f]
for letra, (titulo, f) in zip("BCD", extras):
anexos.append(f"## {letra}. {titulo}\n\n" + open(f, encoding="utf-8").read().strip() + "\n\n")
full += "".join(anexos)
br = open(a.briefing, encoding="utf-8").read().strip()
if not br.startswith("## BRIEFING-LLM"):
sys.exit("briefing-llm.md tem de começar por '## BRIEFING-LLM'")
full += br + "\n"
open(a.out, "w", encoding="utf-8").write(full)
caps = len(re.findall(r"^## \d+\. ", full, re.M))
print(f"{a.out}: {len(full.split())} palavras, {caps} capítulos, {len(rows)} pontos a validar")
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Estratégia de marca (markdown) -> PDF A4 com a identidade DA MARCA em causa.
Uso: render_pdf.py estrategia.md estrategia.pdf --config marca.json [--html saida.html]
Requer: pandoc, google-chrome (ou chromium), pdfinfo.
O marca.json define cores, fontes, logótipo e textos da capa (ver assets/marca-descomplicar.json).
Regras aprendidas (17-10-2026):
- A4 sempre (nunca Letter). Margens 20/18 mm, numeração no rodapé.
- Fontes web subset (ex.: woff2 só latim básico) não têm €, → ou −; declarar uma
família de recurso SEPARADA (`fallback` no config) e acrescentá-la a todas as pilhas.
Não usar unicode-range na mesma família com intervalos de peso diferentes: o Chrome
escolhe a face pelo peso e perde o resto dos glifos.
- Confirmar com `pdffonts` que não aparecem Liberation/DejaVu (exceto mono) e ver uma
página de conteúdo renderizada, não só a capa.
- A secção BRIEFING-LLM vai no PDF como está (bloco yaml), por ser a última secção.
"""
import argparse, json, re, shutil, subprocess, tempfile, os
TPL = """<!doctype html>
<html lang="$lang$"><head><meta charset="utf-8"><title>$title$</title></head>
<body>
<!--CAPA-->
<nav id="TOC">
$table-of-contents$
</nav>
$body$
</body></html>
"""
def face(f):
s = f"@font-face{{font-family:'{f['family']}';src:url('file://{f['src']}');"
s += f"font-weight:{f.get('weight', '100 900')};font-style:{f.get('style', 'normal')};"
if f.get("unicode_range"):
s += f"unicode-range:{f['unicode_range']};"
return s + "}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("src")
ap.add_argument("out")
ap.add_argument("--config", required=True)
ap.add_argument("--html")
a = ap.parse_args()
c = json.load(open(a.config, encoding="utf-8"))
md = open(a.src, encoding="utf-8").read()
md = re.sub(r"\A---\n.*?\n---\n", "", md, flags=re.S)
with tempfile.TemporaryDirectory() as tmp:
tpl = os.path.join(tmp, "tpl.html")
open(tpl, "w", encoding="utf-8").write(TPL)
body = subprocess.run(
["pandoc", "-f", "gfm+attributes", "-t", "html5", "--toc", "--toc-depth=2", "--standalone",
"--template", tpl, "--metadata", f"title={c['titulo_documento']}", "--metadata", "lang=pt-PT"],
input=md, capture_output=True, text=True, check=True).stdout
marca = c["marca"] # ex.: "Descomplicar®"
if marca.endswith("®"):
base = re.escape(marca[:-1])
body = re.sub(rf"(?<![/\w.]){base}®", f'{marca[:-1]}<sup class="reg">®</sup>', body)
body = re.sub(r"\[a validar:([^\]]*)\]", r'<span class="av">[a validar:\1]</span>', body)
T, X, FB = c["fonte_titulos"], c["fonte_texto"], c.get("fallback", "sans-serif")
k = c["cores"] # escura, clara_texto, primaria, acento_texto, suave, linha, alternada, aviso_fundo, aviso_texto
faces = "\n".join(face(f) for f in c.get("fontes", []))
css = f"""{faces}
@page{{size:A4;margin:20mm 18mm 20mm 18mm;
@bottom-left{{content:"{c['rodape']}";font:8.5pt '{X}',{FB};color:{k['texto_secundario']}}}
@bottom-right{{content:counter(page);font:600 8.5pt '{T}',{FB};color:{k['acento_texto']}}}}}
@page capa{{margin:0;@bottom-left{{content:none}}@bottom-right{{content:none}}}}
html{{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
body{{font-family:'{X}',{FB};font-size:10.2pt;line-height:1.55;color:{k['texto']};margin:0}}
sup.reg{{font-size:.45em;vertical-align:super;line-height:0;margin-left:.02em}}
.capa{{page:capa;height:297mm;width:210mm;background:{k['escura']};color:{k['clara_texto']};position:relative;break-after:page;box-sizing:border-box;padding:34mm 22mm}}
.capa svg,.capa img{{width:56mm;height:auto;display:block}}
.capa .eyebrow{{font:700 9pt '{T}',{FB};letter-spacing:.14em;text-transform:uppercase;color:{k['primaria']};margin-top:52mm}}
.capa h1{{font:300 40pt/1.05 '{T}',{FB};text-transform:uppercase;margin:6mm 0 8mm;color:{k['clara_texto']};border:0;padding:0;break-before:auto}}
.capa .slogan{{font:700 18pt '{T}',{FB};color:{k['primaria']}}}
.capa .meta{{position:absolute;left:22mm;right:22mm;bottom:24mm;border-top:1px solid {k['primaria']};padding-top:5mm;font-size:9.5pt;color:{k['clara_texto']};opacity:.75;display:flex;justify-content:space-between}}
nav#TOC{{break-after:page}}
nav#TOC::before{{content:"Índice";display:block;font:700 22pt '{T}',{FB};margin:0 0 6mm}}
nav#TOC ul{{list-style:none;padding-left:0;margin:0}}
nav#TOC > ul > li{{margin-top:3.2mm;font:700 10pt '{T}',{FB};color:{k['acento_texto']};text-transform:uppercase;letter-spacing:.05em}}
nav#TOC > ul > li > ul > li{{font:400 9.6pt '{X}',{FB};color:{k['texto']};text-transform:none;letter-spacing:0;padding-left:4mm;margin-top:.6mm}}
nav#TOC a{{color:inherit;text-decoration:none}}
h1{{font:300 26pt/1.1 '{T}',{FB};text-transform:uppercase;letter-spacing:.02em;break-before:page;margin:0 0 8mm;padding-bottom:4mm;border-bottom:3px solid {k['primaria']}}}
h2{{font:700 17pt/1.2 '{T}',{FB};margin:9mm 0 3mm;break-after:avoid}}
h3{{font:600 12.5pt/1.25 '{T}',{FB};margin:6mm 0 2mm;break-after:avoid}}
h4{{font:600 10.5pt '{T}',{FB};margin:4mm 0 1.5mm;break-after:avoid;color:{k['acento_texto']}}}
p{{margin:0 0 2.6mm;orphans:3;widows:3}}
ul,ol{{margin:0 0 3mm;padding-left:6mm}} li{{margin:.6mm 0}}
a{{color:{k['acento_texto']}}}
blockquote{{margin:3mm 0;padding:3mm 5mm;background:{k['suave']};border-left:4px solid {k['primaria']};font-size:10.6pt}}
blockquote p:last-child{{margin:0}}
table{{border-collapse:collapse;width:100%;margin:2mm 0 4mm;font-size:8.8pt}}
tr{{break-inside:avoid}}
th{{background:{k['escura']};color:{k['clara_texto']};font:600 8.4pt '{T}',{FB};text-align:left;padding:2mm 2.4mm}}
td{{border-bottom:1px solid {k['linha']};padding:1.8mm 2.4mm;vertical-align:top}}
tr:nth-child(even) td{{background:{k['alternada']}}}
code,pre{{font-size:8.4pt;background:{k['suave']};border-radius:2px}}
code{{padding:0 1mm}} pre{{padding:3mm;white-space:pre-wrap}}
hr{{border:0;border-top:1px solid {k['linha']};margin:5mm 0}}
.av{{background:{k['aviso_fundo']};color:{k['aviso_texto']};padding:0 1mm;border-radius:2px;font-size:.92em}}
"""
logo = ""
if c.get("logo_capa"):
logo = open(c["logo_capa"], encoding="utf-8").read()
logo = re.sub(r"<\?xml[^>]*>", "", logo)
capa = (f'<section class="capa"><div>{logo}</div><div class="eyebrow">{c["capa_eyebrow"]}</div>'
f'<h1>{c["capa_titulo"]}</h1><div class="slogan">{c.get("capa_slogan", "")}</div>'
f'<div class="meta"><span>{c["capa_assinatura"]}</span><span>{c["capa_data"]}</span></div></section>')
if marca.endswith("®"):
capa = capa.replace(marca, f'{marca[:-1]}<sup class="reg">®</sup>')
html = body.replace("</head>", f"<style>{css}</style></head>").replace("<!--CAPA-->", capa)
hp = a.html or os.path.join(tmp, "doc.html")
open(hp, "w", encoding="utf-8").write(html)
chrome = shutil.which("google-chrome") or shutil.which("google-chrome-stable") or shutil.which("chromium")
subprocess.run([chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-pdf-header-footer",
"--generate-pdf-document-outline", "--virtual-time-budget=20000",
f"--print-to-pdf={os.path.abspath(a.out)}", "file://" + os.path.abspath(hp)],
check=True, capture_output=True, timeout=600)
info = subprocess.run(["pdfinfo", a.out], capture_output=True, text=True).stdout
fonts = subprocess.run(["pdffonts", a.out], capture_output=True, text=True).stdout
print("\n".join(l for l in info.splitlines() if l.startswith(("Pages", "Page size"))))
bad = sorted({re.sub(r"^[A-Z]{6}\+", "", l.split()[0]) for l in fonts.splitlines()[2:]
if re.search(r"Liberation|DejaVuSans(?!Mono)|Nimbus", l)})
if bad:
print("AVISO: fontes de recurso do sistema no PDF:", ", ".join(bad))
if __name__ == "__main__":
main()