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:
+86
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user