@aksp/opencrew 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +133 -16
  3. package/package.json +1 -1
  4. package/src/commands/update.js +9 -3
  5. package/src/lib/blocos.js +7 -3
  6. package/src/lib/resumo.js +3 -0
  7. package/templates/AGENTS.md +11 -2
  8. package/templates/_opencrew/.opencrew-version +1 -1
  9. package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
  10. package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
  11. package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
  12. package/templates/_opencrew/core/prompts/design.prompt.md +1 -0
  13. package/templates/_opencrew/core/prompts/discovery.prompt.md +1 -1
  14. package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
  15. package/templates/_opencrew/core/prompts/entrega.prompt.md +93 -18
  16. package/templates/_opencrew/core/prompts/export.prompt.md +5 -81
  17. package/templates/_opencrew/core/runner.pipeline.md +20 -20
  18. package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
  19. package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
  20. package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
  21. package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
  22. package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
  23. package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
  24. package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
  25. package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
  26. package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
  27. package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
  28. package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
  29. package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
  30. package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
  31. package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
  32. package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
  33. package/templates/_opencrew/core/scripts/documento.mjs +150 -0
  34. package/templates/_opencrew/core/scripts/entrega/argumentos.mjs +13 -9
  35. package/templates/_opencrew/core/scripts/entrega/canais.mjs +12 -6
  36. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +104 -0
  37. package/templates/_opencrew/core/scripts/entrega/copia.mjs +138 -0
  38. package/templates/_opencrew/core/scripts/entrega/destino.mjs +98 -0
  39. package/templates/_opencrew/core/scripts/entrega/documentos.mjs +104 -0
  40. package/templates/_opencrew/core/scripts/entrega/fora.mjs +4 -3
  41. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
  42. package/templates/_opencrew/core/scripts/entrega/guardar.mjs +60 -0
  43. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +54 -17
  44. package/templates/_opencrew/core/scripts/entrega/leitor.mjs +16 -3
  45. package/templates/_opencrew/core/scripts/entrega/lembrar.mjs +65 -0
  46. package/templates/_opencrew/core/scripts/entrega/passos.mjs +21 -6
  47. package/templates/_opencrew/core/scripts/entrega/pendencias.mjs +16 -6
  48. package/templates/_opencrew/core/scripts/entrega/ressalvas.mjs +78 -0
  49. package/templates/_opencrew/core/scripts/entrega/resumo.mjs +29 -0
  50. package/templates/_opencrew/core/scripts/entrega/retrato.mjs +42 -0
  51. package/templates/_opencrew/core/scripts/entrega/separar.mjs +9 -3
  52. package/templates/_opencrew/core/scripts/entregar.mjs +76 -44
  53. package/templates/_opencrew/core/scripts/verificar/argumentos.mjs +4 -4
  54. package/templates/_opencrew/core/scripts/verificar/entradas.mjs +26 -0
  55. package/templates/_opencrew/core/scripts/verificar/gravacao.mjs +41 -0
  56. package/templates/_opencrew/core/scripts/verificar/relatorio.mjs +10 -3
  57. package/templates/_opencrew/core/scripts/verificar.mjs +17 -27
  58. package/templates/gitignore +1 -0
@@ -283,19 +283,17 @@ Before executing any step that references an agent:
283
283
  - Apply Voice Guidance (vocabulary always/never use, tone rules)
284
284
  5. **Inject format context**: Check if the current step's frontmatter contains a `format:` field.
285
285
  If present:
286
- a. **Export formats** — if format is one of `pdf`, `csv`, or `formatted-post`:
287
- - Read `_opencrew/core/prompts/export.prompt.md`
288
- - Parse the YAML frontmatter to extract the `name` field
289
- - Extract the Markdown body (everything after the YAML frontmatter closing `---`)
290
- - Append to the agent's context, before skill instructions:
291
- ```
292
- --- EXPORT FORMAT: {format} ---
293
-
294
- {export.prompt.md markdown body}
295
- ```
296
- - The agent must follow the export process for the specified format — read the input file,
297
- transform the content, and write the output file in the target format.
298
- - Skip the best-practices lookup below for export formats.
286
+ a. **Export format** — if format is `csv`:
287
+ - Read `_opencrew/core/prompts/export.prompt.md` and append its Markdown body to the agent's
288
+ context, before skill instructions, under the line `--- EXPORT FORMAT: csv ---`
289
+ - The agent must follow the export process — read the input file, transform the content,
290
+ and write the output file in the target format. Skip the best-practices lookup below.
291
+ a2. **`pdf` or `formatted-post`** (a step of an old crew) — neither is generated any more. Say
292
+ `O formato "{id}" não é mais gerado. O passo segue sem ele e grava o texto em markdown. Para ter um PDF, use Imprimir → Salvar como PDF.`
293
+ (`{id}` = the format) and run it as a common step, with no format injection. For `pdf`, the
294
+ agent writes markdown and the `outputFile` is used with the extension `.md`: that is the
295
+ path that goes to `caminho.mjs` (`saida`, `conferir`) and that the next steps read (their
296
+ `inputFile`, too) — no `.pdf` is created.
299
297
  b. **Content formats** — otherwise, read `_opencrew/best-practices.local/{format}.md` (the user's
300
298
  own version, never touched by `update`) if it exists, else `_opencrew/core/best-practices/{format}.md`
301
299
  (e.g., `_opencrew/core/best-practices/instagram-feed.md`)
@@ -669,10 +667,10 @@ When a step has `on_reject: {step-id}` (a review step):
669
667
  the `format:` of the step that generated that file; a step with no `format:`, with an export
670
668
  format (`pdf`, `csv`, `formatted-post`) or with one outside `[a-z0-9-]+` goes without `=formato`:
671
669
  ```bash
672
- node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…"
670
+ node _opencrew/core/scripts/verificar.mjs --crew "crews/{name}" --arquivo "{path1}={format1},{path2},…" --relatorio "crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md"
673
671
  ```
674
- Save the full output to `crews/{name}/output/{run_id}/verificacao-ciclo-{N}.md` and inject it
675
- into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
672
+ The script writes its report to that file (`{N}` = the cycle; do not save it yourself). Inject
673
+ the output into the reviewer's context as `--- VERIFICAÇÃO AUTOMÁTICA ---`. The reviewer must copy the
676
674
  measured values from it (see best-practices `review.md`). If the checker did not run (no Node,
677
675
  an error, or no `VERIFICACAO:` status line), tell the user, continue with the normal review and
678
676
  repeat it at the final approval: "⚠️ A verificação automática não rodou: {motivo}".
@@ -695,11 +693,12 @@ When a step has `on_reject: {step-id}` (a review step):
695
693
  {any other status} A revisão não aprovou o texto depois de {N} ciclos. Motivo: {parecer resumido}
696
694
 
697
695
  1. Corrigir eu mesmo (eu edito o texto e você verifica de novo)
698
- 2. Aceitar assim mesmo
696
+ 2. Aceitar assim mesmo (fica registrado na entrega)
699
697
  3. Abortar
700
698
  ```
701
699
  5. **Final approval checkpoint** (the checkpoint after the review): show the summary of the last
702
- report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos` — plus the list
700
+ report — `Verificação automática: {N} bloqueios, {M} alertas, {Z} não medidos`, with
701
+ `{P} a preencher` right after the blocks when the report counts any — plus the list
703
702
  of alerts and the {Z} items not measured or not verified (the `Não medido` and `Não verificado`
704
703
  lines under each file, not the "não é texto" line of **Notas**), one per line as
705
704
  `{arquivo} — {motivo}`, then the lines under `**Notas:**` in that report, as they are written,
@@ -707,7 +706,8 @@ When a step has `on_reject: {step-id}` (a review step):
707
706
  every file left unchecked by the safe-name rule. List the same way every file the path script
708
707
  did not check (see Output Path Transformation). If the approved
709
708
  text still contains `[PREENCHER: …]`, ask the user for each missing piece of real information
710
- and write it into the text before approving.
709
+ and write it into the text before approving. If the user does not have it, do not insist and
710
+ never invent: keep the `[PREENCHER]`, say `Sem problema: deixo [PREENCHER: {o que falta}] no texto. Na entrega você escolhe entre preencher depois e entregar assim mesmo, com ressalva.` and go on.
711
711
 
712
712
  ### Step Execution Order (Summary)
713
713
 
@@ -728,7 +728,7 @@ Steps 1 and 4 are binary script gates. If either fails, the pipeline does NOT ad
728
728
 
729
729
  ### Entrega
730
730
 
731
- One script turns the approved files into `crews/{name}/output/{run_id}/entrega/` (a folder per channel, text ready to paste, a `LEIA-ME.md`). Read `_opencrew/core/prompts/entrega.prompt.md` and follow it: how to build `{lista}`, when to add `--vai-publicar`, what to do with `ENTREGA:OK`, with `ENTREGA:INCOMPLETA` and with a script that did not run.
731
+ One script turns the approved files into `crews/{name}/output/{run_id}/entrega/` (a folder per channel, text ready to paste, a `LEIA-ME.md`) and copies what is ready to the folder of the project the user chose. Read `_opencrew/core/prompts/entrega.prompt.md` and follow it: how to build `{lista}`, when to add `--vai-publicar`, what to do with `ENTREGA:OK`, `ENTREGA:COM_RESSALVA` and `ENTREGA:INCOMPLETA`, the question about the folder of the project that keeps a copy (asked once per crew) and what to do with a script that did not run.
732
732
 
733
733
  - **Command** — from the project root, by the safe-name rule (nome seguro), everything between double quotes: `node _opencrew/core/scripts/entregar.mjs --crew "crews/{name}" --run "{run_id}" --arquivo "{lista}"`
734
734
  - **When** — once, after the final approval, immediately before the first step that publishes or sends (`side_effects: irreversible`, in the step or in the agent's skill); with no such step, after the last step. Always before the end-of-run command of the Escritório. If the irreversible step comes before the final approval (a crew built by an old version), the delivery runs at the end.
@@ -0,0 +1,50 @@
1
+ // Linha de comando do `documento.mjs`: as opções, a linha de uso e as mensagens em PT-BR.
2
+ // Spec: fase-u3b-documento-word.md, §3 e §6 (repositório do OpenCrew).
3
+
4
+ const COMANDO = 'node _opencrew/core/scripts/documento.mjs';
5
+ export const USO = `Uso: ${COMANDO} "<arquivo.md>" [--saida <arquivo.docx|pasta>] [--perfil <arquivo>] [--sem-perfil] [--substituir] [--ajuda]\n ${COMANDO} --criar-perfil`;
6
+
7
+ export const MSG = {
8
+ desconhecida: (opcao) => `Opção desconhecida: ${opcao}.`,
9
+ semArquivo: 'Falta o arquivo de texto.',
10
+ maisDeUm: (n) => `Converto um arquivo por vez. Recebi ${n}.`,
11
+ naoEncontrei: (arquivo) => `Não encontrei ${arquivo}.`,
12
+ semTexto: (arquivo) => `${arquivo} não tem texto para converter.`,
13
+ naoUtf8: (arquivo) => `${arquivo} não está em UTF-8. Salve como UTF-8 e tente de novo.`,
14
+ extensao: (arquivo) => `Só converto texto em markdown (.md ou .txt). Recebi: ${arquivo}.`,
15
+ saida: (valor) => `A saída precisa ser um arquivo .docx ou uma pasta, dentro do projeto. Recebi: ${valor}.`,
16
+ jaExiste: (arquivo) => `Já existe ${arquivo}, diferente do que eu ia gravar. Para trocar, rode de novo com --substituir.`,
17
+ falha: (arquivo) => `Não consegui gravar ${arquivo}. Feche o arquivo no Word, ou espere a sincronização da pasta, e rode de novo.`,
18
+ semPerfil: (arquivo) => `Perfil não encontrado: ${arquivo}.`,
19
+ gerado: (arquivo) => `Documento gerado: ${arquivo}`,
20
+ igual: (arquivo) => `${arquivo} já existe e está igual. Nada a fazer.`,
21
+ perfil: (arquivo) => `Perfil: ${arquivo}`,
22
+ nenhumPerfil: `Perfil: nenhum (sem papel timbrado). Para criar o seu: ${COMANDO} --criar-perfil`,
23
+ avisos: 'Avisos:',
24
+ dicas: ['Para ter um PDF: abra o documento no Word e use Arquivo → Salvar como → PDF.', 'O Word é uma cópia do texto. O que você mudar nele não volta sozinho: altere o texto e gere de novo.'],
25
+ perfilCriado: (arquivo) => `Criei ${arquivo}. Abra, preencha o logotipo, o cabeçalho e o rodapé, e gere o documento de novo.`,
26
+ perfilJaExiste: (arquivo) => `${arquivo} já existe. Não mexi nele.`,
27
+ };
28
+
29
+ const COM_VALOR = /^--(saida|perfil)(?:=(.*))?$/s;
30
+ const SEM_VALOR = { '--sem-perfil': 'semPerfil', '--substituir': 'substituir', '--ajuda': 'ajuda', '--criar-perfil': 'criarPerfil' };
31
+
32
+ /** Texto que veio da linha de comando e volta numa mensagem: uma linha só, até 200 caracteres. */
33
+ export const limpar = (valor) => String(valor).replace(/\s+/g, ' ').trim().slice(0, 200);
34
+
35
+ /**
36
+ * `argv` → `{ arquivos, saida, perfil, semPerfil, substituir, ajuda, criarPerfil, desconhecida }`.
37
+ * Opção com valor vale como `--nome valor` e `--nome=valor`; `saida` e `perfil` ficam `undefined`
38
+ * quando a opção não veio. `desconhecida`: a primeira opção que o script não conhece, ou null.
39
+ */
40
+ export function lerArgs(argv) {
41
+ const args = { arquivos: [], semPerfil: false, substituir: false, ajuda: false, criarPerfil: false, desconhecida: null };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const [, nome, colado] = argv[i].match(COM_VALOR) ?? [];
44
+ if (nome) args[nome] = colado ?? (i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : '');
45
+ else if (Object.hasOwn(SEM_VALOR, argv[i])) args[SEM_VALOR[argv[i]]] = true;
46
+ else if (argv[i].startsWith('--')) args.desconhecida ??= argv[i];
47
+ else args.arquivos.push(argv[i]);
48
+ }
49
+ return args;
50
+ }
@@ -0,0 +1,51 @@
1
+ // O corpo do documento (`word/document.xml`): cada bloco lido do markdown vira um parágrafo ou uma
2
+ // tabela, na ordem em que foi escrito; a seção (papel, margens, cabeçalho e rodapé) vai por último.
3
+ // Spec: fase-u3b-documento-word.md, §4 e regra 3 (repositório do OpenCrew).
4
+ import { tabelaDeAssinaturas, tabelaDeDados } from './tabelas.mjs';
5
+ import { DECLARACAO, NS_R, NS_W, RECUO, borda, pPr, paragrafo, twips } from './xml.mjs';
6
+
7
+ /** A4 em pé, em twips. */
8
+ export const PAPEL = { largura: 11906, altura: 16838 };
9
+ /** Distância do cabeçalho e do rodapé até a borda do papel: 1,2 cm. */
10
+ const DA_BORDA = twips(1.2);
11
+ const PENDURADO = 283;
12
+ const VAZIO = '<w:p/>';
13
+
14
+ /** Largura da área de texto, em twips: o papel menos as margens dos lados. */
15
+ export const larguraDoTexto = (perfil) => PAPEL.largura - twips(perfil.margem_esquerda_cm) - twips(perfil.margem_direita_cm);
16
+
17
+ const PARAGRAFO = {
18
+ centro: (b) => paragrafo({ estilo: b.estilo, quebra: b.quebra }, b.pedacos),
19
+ titulo: (b) => paragrafo({ estilo: `Heading${b.nivel}`, quebra: b.quebra }, b.pedacos),
20
+ item: (b) => paragrafo({ quebra: b.quebra, lista: b.nivel, recuo: { left: RECUO * (b.nivel + 1), hanging: PENDURADO } }, b.pedacos),
21
+ paragrafo: (b) => paragrafo({ quebra: b.quebra, recuo: b.recuo ? { left: RECUO } : null }, b.pedacos, { b: b.negrito }),
22
+ regua: (b) => `<w:p>${pPr({ quebra: b.quebra, bordas: borda('bottom', { cor: 'AAAAAA', espaco: 1 }) })}</w:p>`,
23
+ };
24
+ const TABELA = { tabela: tabelaDeDados, assinaturas: tabelaDeAssinaturas };
25
+ const ehTabela = (bloco) => Boolean(bloco && TABELA[bloco.tipo]);
26
+
27
+ /** Tabela: a quebra de página vai num parágrafo vazio antes; no fim, ou antes de outra tabela, um depois. */
28
+ function comTabela(bloco, proximo, largura) {
29
+ const antes = bloco.quebra ? `<w:p>${pPr({ quebra: true })}</w:p>` : '';
30
+ return `${antes}${TABELA[bloco.tipo](bloco, largura)}${!proximo || ehTabela(proximo) ? VAZIO : ''}`;
31
+ }
32
+
33
+ function secao(perfil, referencias) {
34
+ const margens = { top: twips(perfil.margem_superior_cm), right: twips(perfil.margem_direita_cm), bottom: twips(perfil.margem_inferior_cm), left: twips(perfil.margem_esquerda_cm), header: DA_BORDA, footer: DA_BORDA, gutter: 0 };
35
+ const lados = Object.entries(margens).map(([lado, valor]) => ` w:${lado}="${valor}"`).join('');
36
+ const cabecalho = referencias.cabecalho ? `<w:headerReference w:type="default" r:id="${referencias.cabecalho}"/>` : '';
37
+ const rodape = referencias.rodape ? `<w:footerReference w:type="default" r:id="${referencias.rodape}"/>` : '';
38
+ return `<w:sectPr>${cabecalho}${rodape}<w:pgSz w:w="${PAPEL.largura}" w:h="${PAPEL.altura}"/><w:pgMar${lados}/></w:sectPr>`;
39
+ }
40
+
41
+ /**
42
+ * @param {object[]} blocos os de `lerMarkdown`
43
+ * @param {object} perfil o perfil completo (com os padrões)
44
+ * @param {{ cabecalho?: string, rodape?: string }} referencias o `r:id` de cada parte que existe
45
+ * @returns {string} o XML de `word/document.xml`
46
+ */
47
+ export function montarCorpo(blocos, perfil, referencias) {
48
+ const largura = larguraDoTexto(perfil);
49
+ const partes = blocos.map((bloco, i) => (ehTabela(bloco) ? comTabela(bloco, blocos[i + 1], largura) : PARAGRAFO[bloco.tipo](bloco)));
50
+ return `${DECLARACAO}<w:document xmlns:w="${NS_W}" xmlns:r="${NS_R}"><w:body>${partes.join('')}${secao(perfil, referencias)}</w:body></w:document>`;
51
+ }
@@ -0,0 +1,42 @@
1
+ // As partes fixas do documento: estilos (`word/styles.xml`), a lista com marcador
2
+ // (`word/numbering.xml`) e os ajustes (`word/settings.xml`). Do perfil só entram a fonte e o
3
+ // tamanho do corpo; os outros tamanhos e as cores são os do documento oficial de referência.
4
+ // Spec: fase-u3b-documento-word.md, §4 (repositório do OpenCrew).
5
+ import { DECLARACAO, NS_W, RECUO, pPr, rPr } from './xml.mjs';
6
+
7
+ const IDIOMA = 'pt-BR';
8
+ const CINZA = '333333';
9
+ /** Entrelinha 1,15 (276/240), automática, e 5 pt depois de cada parágrafo. */
10
+ const DO_CORPO = { espaco: { after: 100, line: 276, lineRule: 'auto' }, jc: 'both' };
11
+
12
+ /** Os estilos além do `Normal`: `[id, nome, parágrafo, letra]`. Espaços em vigésimos de ponto. */
13
+ const ESTILOS = [
14
+ ['Titulo', 'Título do documento', { espaco: { before: 160, after: 80 }, jc: 'center' }, { b: true, sz: 14 }],
15
+ ['Subtitulo', 'Subtítulo do documento', { espaco: { before: 0, after: 320 }, jc: 'center' }, { i: true, cor: CINZA, sz: 10 }],
16
+ ['Heading1', 'heading 1', { keepNext: true, espaco: { before: 280, after: 80 }, jc: 'left', topico: 0 }, { b: true, caps: true, sz: 12 }],
17
+ ['Heading2', 'heading 2', { keepNext: true, espaco: { before: 200, after: 60 }, jc: 'left', topico: 1 }, { b: true, sz: 11 }],
18
+ ['Heading3', 'heading 3', { keepNext: true, espaco: { before: 160, after: 40 }, jc: 'left', topico: 2 }, { b: true, i: true, cor: CINZA, sz: 10.5 }],
19
+ ];
20
+
21
+ const estilo = ([id, nome, paragrafo, letra]) =>
22
+ `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${nome}"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>${pPr(paragrafo)}${rPr(letra)}</w:style>`;
23
+
24
+ /** @param {{ fonte: string, tamanho_corpo_pt: number }} perfil */
25
+ export function montarEstilos(perfil) {
26
+ const letra = { fonte: perfil.fonte, sz: perfil.tamanho_corpo_pt, idioma: IDIOMA };
27
+ const padroes = `<w:docDefaults><w:rPrDefault>${rPr(letra)}</w:rPrDefault><w:pPrDefault/></w:docDefaults>`;
28
+ const normal = `<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/>${pPr(DO_CORPO)}${rPr(letra)}</w:style>`;
29
+ return `${DECLARACAO}<w:styles xmlns:w="${NS_W}">${padroes}${normal}${ESTILOS.map(estilo).join('')}</w:styles>`;
30
+ }
31
+
32
+ /** Um nível da lista: marcador `•`, recuo de 0,75 cm por nível. */
33
+ const nivel = (n) =>
34
+ `<w:lvl w:ilvl="${n}"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/>${pPr({ recuo: { left: RECUO * (n + 1), hanging: 283 } })}</w:lvl>`;
35
+
36
+ /** A lista com marcador, de dois níveis; é o `numId` 1 de todo item de lista. */
37
+ export const montarNumeracao = () =>
38
+ `${DECLARACAO}<w:numbering xmlns:w="${NS_W}"><w:abstractNum w:abstractNumId="0"><w:multiLevelType w:val="hybridMultilevel"/>${nivel(0)}${nivel(1)}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>`;
39
+
40
+ /** Modo de compatibilidade 15: o Word não abre o arquivo em "Modo de Compatibilidade". */
41
+ export const montarAjustes = () =>
42
+ `${DECLARACAO}<w:settings xmlns:w="${NS_W}"><w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="15"/></w:compat></w:settings>`;
@@ -0,0 +1,44 @@
1
+ // Gravação do documento Word: montado na memória, gravado num temporário ao lado do destino e
2
+ // renomeado. Em falha não fica arquivo pela metade nem temporário. Um Word diferente que já está
3
+ // lá só é trocado quando o usuário autoriza; um igual não é tocado. Nada é apagado.
4
+ // Spec: fase-u3b-documento-word.md, regra 12 (repositório do OpenCrew).
5
+ import { promises as fs } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /** O que já está no destino: os bytes do arquivo, `null` (nada) ou `false` (não é um arquivo legível). */
9
+ async function atual(destino, disco) {
10
+ try {
11
+ return await disco.readFile(destino);
12
+ } catch (erro) {
13
+ return erro.code === 'ENOENT' ? null : false;
14
+ }
15
+ }
16
+
17
+ /** Grava pelo temporário; se algo falha, o temporário (que é deste script) sai e o destino fica como estava. */
18
+ async function trocar(destino, bytes, disco) {
19
+ const temporario = `${destino}.opencrew-tmp`;
20
+ try {
21
+ await disco.mkdir(path.dirname(destino), { recursive: true });
22
+ await disco.writeFile(temporario, bytes);
23
+ await disco.rename(temporario, destino);
24
+ return true;
25
+ } catch {
26
+ await fs.rm(temporario, { force: true }).catch(() => {});
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {string} destino caminho absoluto do `.docx`
33
+ * @param {Buffer} bytes
34
+ * @param {{ substituir?: boolean, disco?: object }} [opcoes] `disco`: as funções de `fs.promises`
35
+ * @returns {Promise<'gravado'|'igual'|'diferente'|'falha'>} `igual`: já existe com os mesmos bytes,
36
+ * nada foi gravado · `diferente`: já existe outro e `substituir` não veio, nada foi gravado
37
+ */
38
+ export async function gravarDocx(destino, bytes, { substituir = false, disco = fs } = {}) {
39
+ const existente = await atual(destino, disco);
40
+ if (existente === false) return 'falha';
41
+ if (existente?.equals(bytes)) return 'igual';
42
+ if (existente && !substituir) return 'diferente';
43
+ return (await trocar(destino, bytes, disco)) ? 'gravado' : 'falha';
44
+ }
@@ -0,0 +1,58 @@
1
+ // O texto de uma linha: negrito, itálico, link e imagem. Devolve os pedaços com a sua letra, sem
2
+ // mudar nenhuma palavra: o que não é marcação em par sai como foi escrito.
3
+ // Spec: fase-u3b-documento-word.md, regra 7 (repositório do OpenCrew).
4
+
5
+ // Guardam, durante a leitura, os sinais que não são marcação (`\*`, `\_` e os de dentro de um
6
+ // endereço). São caracteres de controle: o texto já chega aqui sem nenhum deles.
7
+ const ASTERISCO = String.fromCharCode(1);
8
+ const SUBLINHADO = String.fromCharCode(2);
9
+ const guardar = (texto) => texto.split('*').join(ASTERISCO).split('_').join(SUBLINHADO);
10
+ const devolver = (texto) => texto.split(ASTERISCO).join('*').split(SUBLINHADO).join('_');
11
+
12
+ const LINK = /(!?)\[([^\]]*)\]\(([^)\s]+)\)/g;
13
+ const ENDERECO = /\bhttps?:\/\/\S+/g;
14
+ // Só em par, na mesma linha, colado ao texto, e com espaço, pontuação ou borda do lado de fora.
15
+ const ENFASE = /(?<![\p{L}\p{N}*_\\])(\*\*\*|___|\*\*|__|\*|_)(?=\S)(.+?)(?<=\S)\1(?![\p{L}\p{N}*_])/u;
16
+
17
+ /** `[texto](url)` vira `texto (url)` (ou só a URL, se o texto é ela); imagem fica como está. */
18
+ function semLinks(texto, contagem) {
19
+ return texto.replace(LINK, (tudo, imagem, rotulo, url) => {
20
+ if (imagem) contagem.imagens++;
21
+ if (imagem) return guardar(tudo);
22
+ return rotulo === url ? guardar(url) : `${rotulo} (${guardar(url)})`;
23
+ });
24
+ }
25
+
26
+ /** Parte o texto nos trechos com e sem ênfase; a ênfase de fora vale para a de dentro. */
27
+ function partir(texto, letra) {
28
+ const m = ENFASE.exec(texto);
29
+ if (!m) return texto ? [{ texto, ...letra }] : [];
30
+ const dentro = { b: letra.b || m[1].length >= 2, i: letra.i || m[1].length !== 2 };
31
+ return [...partir(texto.slice(0, m.index), letra), ...partir(m[2], dentro), ...partir(texto.slice(m.index + m[0].length), letra)];
32
+ }
33
+
34
+ /** Junta pedaços vizinhos com a mesma letra. */
35
+ function juntar(pedacos) {
36
+ const saida = [];
37
+ for (const p of pedacos) {
38
+ const ultimo = saida.at(-1);
39
+ if (ultimo && ultimo.b === p.b && ultimo.i === p.i) ultimo.texto += p.texto;
40
+ else saida.push({ ...p });
41
+ }
42
+ return saida;
43
+ }
44
+
45
+ /**
46
+ * Lê uma linha de texto.
47
+ * @param {string} texto a linha, já sem o sinal de título ou de lista
48
+ * @param {{ imagens: number }} contagem soma as imagens que ficaram como texto
49
+ * @returns {{ texto: string, b: boolean, i: boolean }[]}
50
+ */
51
+ export function lerLinha(texto, contagem = { imagens: 0 }) {
52
+ const semEscapes = texto.split('\\*').join(ASTERISCO).split('\\_').join(SUBLINHADO);
53
+ const protegido = semLinks(semEscapes, contagem).replace(ENDERECO, guardar);
54
+ return juntar(partir(protegido, { b: false, i: false })).map((p) => ({ ...p, texto: devolver(p.texto) }));
55
+ }
56
+
57
+ /** O texto como foi escrito, sem ler marcação nenhuma. */
58
+ export const literal = (texto) => (texto ? [{ texto, b: false, i: false }] : []);
@@ -0,0 +1,55 @@
1
+ // As três marcações de documento: linha que começa por `:::` na primeira coluna. Título e
2
+ // subtítulo centralizados, quebra de página e bloco de assinaturas. O que não é uma delas fica
3
+ // como texto, igual ao que foi escrito, com aviso.
4
+ // Spec: fase-u3b-documento-word.md, regra 8 (repositório do OpenCrew).
5
+ import { lerLinha } from './linha.mjs';
6
+
7
+ const MARCACAO = /^:::\s*(\S*)\s*(.*)$/;
8
+ const CENTRO = { titulo: 'Titulo', subtitulo: 'Subtitulo' };
9
+
10
+ export const ehMarcacao = (linha) => linha.startsWith(':::');
11
+
12
+ /** Nome sem diferenciar maiúsculas e acentos: `Quebra-de-Página` → `quebra-de-pagina`. */
13
+ const normalizar = (nome) => nome.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
14
+
15
+ /** `Nome | Cargo` → `{ nome, cargo }`; sem barra, só o nome. Nada mais é interpretado. */
16
+ function pessoa(linha) {
17
+ const barra = linha.indexOf('|');
18
+ if (barra < 0) return { nome: linha.trim(), cargo: '' };
19
+ return { nome: linha.slice(0, barra).trim(), cargo: linha.slice(barra + 1).trim() };
20
+ }
21
+
22
+ /** Bloco de assinaturas: vai até a linha `:::`. Sem ela, a linha de abertura fica como texto. */
23
+ function lerAssinaturas(linhas, i, estado) {
24
+ const fim = linhas.findIndex((l, n) => n > i && l.trim() === ':::');
25
+ if (fim < 0) {
26
+ estado.avisos.semFim++;
27
+ estado.texto(linhas[i]);
28
+ return i + 1;
29
+ }
30
+ const pessoas = linhas.slice(i + 1, fim).filter((l) => l.trim()).map(pessoa);
31
+ if (pessoas.length) estado.por({ tipo: 'assinaturas', pessoas });
32
+ return fim + 1;
33
+ }
34
+
35
+ /**
36
+ * Lê a marcação da linha `i` e devolve a linha em que o próximo bloco começa.
37
+ * @param {string[]} linhas
38
+ * @param {number} i
39
+ * @param {object} estado o de `lerMarkdown`: `por`, `texto`, `quebrar` e `avisos`
40
+ */
41
+ export function lerMarcacao(linhas, i, estado) {
42
+ const [, nomeEscrito, resto] = MARCACAO.exec(linhas[i].trimEnd());
43
+ const nome = normalizar(nomeEscrito);
44
+ if (Object.hasOwn(CENTRO, nome) && resto) {
45
+ estado.por({ tipo: 'centro', estilo: CENTRO[nome], pedacos: lerLinha(resto.trim(), estado.avisos) });
46
+ } else if (nome === 'quebra-de-pagina' && !resto) {
47
+ estado.quebrar();
48
+ } else if (nome === 'assinaturas' && !resto) {
49
+ return lerAssinaturas(linhas, i, estado);
50
+ } else {
51
+ estado.avisos.desconhecidas++;
52
+ estado.texto(linhas[i]);
53
+ }
54
+ return i + 1;
55
+ }
@@ -0,0 +1,92 @@
1
+ // Leitura do texto em markdown: uma linha, um bloco. Devolve os blocos na ordem em que foram
2
+ // escritos (título, parágrafo, item de lista, tabela, linha horizontal, assinaturas) e a contagem
3
+ // dos avisos de conversão. Não numera, não reordena e não corrige nada.
4
+ // Spec: fase-u3b-documento-word.md, regras 4 a 9 (repositório do OpenCrew).
5
+ import { lerLinha, literal } from './linha.mjs';
6
+ import { ehMarcacao, lerMarcacao } from './marcacoes.mjs';
7
+ import { limpar } from './xml.mjs';
8
+
9
+ const REGUA = /^(-{3,}|\*{3,}|_{3,})$/;
10
+ const TITULO = /^(#{1,6})[ \t]+(\S.*)$/;
11
+ const ITEM = /^(\s*)[-*][ \t]+(\S.*)$/;
12
+ const SEPARADORA = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
13
+ const BARRA = /(?<!\\)\|/;
14
+ const CHAVE = /^[A-Za-z_][\w-]*:(\s|$)/;
15
+ const recuado = (inicio) => /^(\t| {2,})/.test(inicio);
16
+
17
+ /** Tira o frontmatter: da primeira linha `---` à próxima, só com `chave: valor` e continuações. */
18
+ function semFrontmatter(linhas) {
19
+ if (linhas[0]?.trimEnd() !== '---') return linhas;
20
+ const fim = linhas.findIndex((l, i) => i > 0 && l.trimEnd() === '---');
21
+ if (fim < 0) return linhas;
22
+ const meio = linhas.slice(1, fim).filter((l) => l.trim());
23
+ return meio.every((l) => CHAVE.test(l) || /^\s+\S/.test(l)) ? linhas.slice(fim + 1) : linhas;
24
+ }
25
+
26
+ const ehSeparadora = (linha) => linha != null && linha.includes('|') && linha.includes('-') && SEPARADORA.test(linha);
27
+
28
+ /** As células de uma linha de tabela: sem as barras das pontas; `\|` dá a barra. */
29
+ function celulas(linha) {
30
+ const miolo = linha.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
31
+ return miolo.split(BARRA).map((c) => c.trim().split('\\|').join('|'));
32
+ }
33
+
34
+ /** Tabela: cabeçalho, linha separadora e as linhas seguintes que têm barra. Vale a linha mais longa. */
35
+ function lerTabela(linhas, i, estado) {
36
+ let fim = i + 2;
37
+ while (fim < linhas.length && linhas[fim].trim() && linhas[fim].includes('|')) fim++;
38
+ const brutas = [linhas[i], ...linhas.slice(i + 2, fim)].map(celulas);
39
+ const colunas = Math.max(...brutas.map((l) => l.length));
40
+ const completas = brutas.map((l) => [...l, ...Array(colunas - l.length).fill('')]);
41
+ estado.por({ tipo: 'tabela', linhas: completas.map((l) => l.map((c) => lerLinha(c, estado.avisos))) });
42
+ return fim;
43
+ }
44
+
45
+ /** Uma linha sozinha: linha horizontal, título, item de lista ou parágrafo. */
46
+ function lerBloco(linha, avisos) {
47
+ const texto = linha.trim();
48
+ if (REGUA.test(texto)) return { tipo: 'regua' };
49
+ const titulo = TITULO.exec(texto);
50
+ if (titulo && titulo[1].length <= 3) return { tipo: 'titulo', nivel: titulo[1].length, pedacos: lerLinha(titulo[2], avisos) };
51
+ if (titulo) return { tipo: 'paragrafo', negrito: true, pedacos: lerLinha(titulo[2], avisos) };
52
+ const item = ITEM.exec(linha);
53
+ if (item) return { tipo: 'item', nivel: recuado(item[1]) ? 1 : 0, pedacos: lerLinha(item[2].trimEnd(), avisos) };
54
+ return { tipo: 'paragrafo', recuo: recuado(linha), pedacos: lerLinha(texto, avisos) };
55
+ }
56
+
57
+ /** Onde os blocos se juntam: a quebra de página pedida vale para o próximo bloco, uma vez só. */
58
+ function novoEstado() {
59
+ const estado = { blocos: [], avisos: { imagens: 0, desconhecidas: 0, semFim: 0, invalidos: 0 }, quebra: false };
60
+ estado.por = (bloco) => {
61
+ estado.blocos.push(estado.quebra ? { ...bloco, quebra: true } : bloco);
62
+ estado.quebra = false;
63
+ };
64
+ estado.texto = (linha) => estado.por({ tipo: 'paragrafo', pedacos: literal(linha.trim()) });
65
+ estado.quebrar = () => { estado.quebra = estado.blocos.length > 0; };
66
+ return estado;
67
+ }
68
+
69
+ /** Consome o bloco que começa na linha `i`; devolve a linha em que o próximo começa. */
70
+ function consumir(linhas, i, estado) {
71
+ const linha = linhas[i];
72
+ if (!linha.trim()) return i + 1;
73
+ if (ehMarcacao(linha)) return lerMarcacao(linhas, i, estado);
74
+ if (linha.includes('|') && ehSeparadora(linhas[i + 1])) return lerTabela(linhas, i, estado);
75
+ estado.por(lerBloco(linha, estado.avisos));
76
+ return i + 1;
77
+ }
78
+
79
+ /**
80
+ * @param {string} bruto o texto do arquivo (CRLF ou LF, com ou sem BOM)
81
+ * @returns {{ blocos: object[], avisos: { imagens: number, desconhecidas: number, semFim: number, invalidos: number } }}
82
+ * bloco: `{ tipo, pedacos | linhas | pessoas, nivel?, recuo?, negrito?, quebra? }`
83
+ */
84
+ export function lerMarkdown(bruto) {
85
+ const semBom = bruto.charCodeAt(0) === 0xfeff ? bruto.slice(1) : bruto;
86
+ const { texto, removidos } = limpar(semBom.replace(/\r\n?/g, '\n'));
87
+ const linhas = semFrontmatter(texto.split('\n'));
88
+ const estado = novoEstado();
89
+ estado.avisos.invalidos = removidos;
90
+ for (let i = 0; i < linhas.length;) i = consumir(linhas, i, estado);
91
+ return { blocos: estado.blocos, avisos: estado.avisos };
92
+ }
@@ -0,0 +1,82 @@
1
+ // O pacote do documento Word: junta as partes, os tipos de conteúdo e as relações, na ordem
2
+ // fixa, e fecha o zip. Não toca o disco: recebe o texto, o perfil já lido e o logotipo em bytes.
3
+ // Spec: fase-u3b-documento-word.md, regras 2 a 4 e 9 (repositório do OpenCrew).
4
+ import { larguraDoTexto, montarCorpo } from './corpo.mjs';
5
+ import { montarAjustes, montarEstilos, montarNumeracao } from './estilos.mjs';
6
+ import { lerMarkdown } from './markdown.mjs';
7
+ import { PADRAO } from './perfil.mjs';
8
+ import { lerPng } from './png.mjs';
9
+ import { montarCabecalho, montarRodape, temCabecalho, temRodape } from './timbre.mjs';
10
+ import { DECLARACAO, NS_R } from './xml.mjs';
11
+ import { zipar } from './zip.mjs';
12
+
13
+ const WORD = 'application/vnd.openxmlformats-officedocument.wordprocessingml';
14
+ const NS_PACOTE = 'http://schemas.openxmlformats.org/package/2006';
15
+ /** As partes de `word/` que têm tipo próprio e são alvo de uma relação do documento, na ordem do zip. */
16
+ const PARTES = [['styles', 'styles'], ['settings', 'settings'], ['numbering', 'numbering'], ['header1', 'header'], ['footer1', 'footer']];
17
+
18
+ const plural = (n, um, varios) => (n === 1 ? um : varios.replace('{n}', n));
19
+ /** Os avisos de conversão, na ordem da spec; cada um diz a quantidade. */
20
+ function avisosDe(c) {
21
+ return [
22
+ c.imagens && plural(c.imagens, '1 imagem não incluída: o Word não leva imagem no texto.', '{n} imagens não incluídas: o Word não leva imagem no texto.'),
23
+ c.desconhecidas && plural(c.desconhecidas, '1 linha com marcação desconhecida (`:::`) ficou como texto.', '{n} linhas com marcação desconhecida (`:::`) ficaram como texto.'),
24
+ c.semFim && plural(c.semFim, 'Bloco de assinaturas sem a linha `:::` no fim: ficou como texto.', '{n} blocos de assinaturas sem a linha `:::` no fim: ficaram como texto.'),
25
+ c.invalidos && plural(c.invalidos, '1 caractere inválido removido.', '{n} caracteres inválidos removidos.'),
26
+ ].filter(Boolean);
27
+ }
28
+
29
+ const relacao = (id, tipo, alvo) => `<Relationship Id="${id}" Type="${NS_R}/${tipo}" Target="${alvo}"/>`;
30
+ const relacoes = (lista) => `${DECLARACAO}<Relationships xmlns="${NS_PACOTE}/relationships">${lista.join('')}</Relationships>`;
31
+
32
+ function tiposDeConteudo(nomes) {
33
+ const padroes = [['rels', 'application/vnd.openxmlformats-package.relationships+xml'], ['xml', 'application/xml'], ['png', 'image/png']];
34
+ const proprios = [['document', 'document.main'], ...PARTES].filter(([nome]) => nomes.includes(nome));
35
+ const porExtensao = padroes.map(([extensao, tipo]) => `<Default Extension="${extensao}" ContentType="${tipo}"/>`).join('');
36
+ const porParte = proprios.map(([nome, tipo]) => `<Override PartName="/word/${nome}.xml" ContentType="${WORD}.${tipo}+xml"/>`).join('');
37
+ return `${DECLARACAO}<Types xmlns="${NS_PACOTE}/content-types">${porExtensao}${porParte}</Types>`;
38
+ }
39
+
40
+ /** O que existe além das partes fixas, e o `rId` de cada parte ligada ao documento. */
41
+ function planejar(perfil, imagem) {
42
+ const existe = { styles: true, settings: true, numbering: true, header1: temCabecalho(perfil, imagem), footer1: temRodape(perfil) };
43
+ const ligadas = PARTES.filter(([nome]) => existe[nome]).map(([nome, tipo], i) => ({ nome, tipo, id: `rId${i + 1}` }));
44
+ const idDe = (nome) => ligadas.find((l) => l.nome === nome)?.id;
45
+ return { ligadas, referencias: { cabecalho: idDe('header1'), rodape: idDe('footer1') } };
46
+ }
47
+
48
+ /** As partes do zip, na ordem da regra 2. */
49
+ function montarPartes(blocos, perfil, logotipo) {
50
+ const imagem = logotipo ? lerPng(logotipo) : null;
51
+ const { ligadas, referencias } = planejar(perfil, imagem);
52
+ const largura = larguraDoTexto(perfil);
53
+ const partes = [
54
+ ['[Content_Types].xml', tiposDeConteudo(['document', ...ligadas.map((l) => l.nome)])],
55
+ ['_rels/.rels', relacoes([relacao('rId1', 'officeDocument', 'word/document.xml')])],
56
+ ['word/document.xml', montarCorpo(blocos, perfil, referencias)],
57
+ ['word/_rels/document.xml.rels', relacoes(ligadas.map((l) => relacao(l.id, l.tipo, `${l.nome}.xml`)))],
58
+ ['word/styles.xml', montarEstilos(perfil)],
59
+ ['word/settings.xml', montarAjustes()],
60
+ ['word/numbering.xml', montarNumeracao()],
61
+ ];
62
+ if (referencias.cabecalho) partes.push(['word/header1.xml', montarCabecalho(perfil, largura, imagem, 'rId1')]);
63
+ if (imagem) partes.push(['word/_rels/header1.xml.rels', relacoes([relacao('rId1', 'image', 'media/logo.png')])], ['word/media/logo.png', Buffer.from(logotipo)]);
64
+ if (referencias.rodape) partes.push(['word/footer1.xml', montarRodape(perfil, largura)]);
65
+ return partes.map(([nome, bytes]) => ({ nome, bytes }));
66
+ }
67
+
68
+ /**
69
+ * Gera o documento Word na memória. O mesmo texto, o mesmo perfil e o mesmo logotipo dão os
70
+ * mesmos bytes.
71
+ * @param {object} entrada
72
+ * @param {string} entrada.texto o markdown (CRLF ou LF, com ou sem BOM)
73
+ * @param {object} [entrada.perfil] o perfil já lido (`lerPerfil`); sem ele, valem os padrões
74
+ * @param {Uint8Array} [entrada.logotipo] os bytes do PNG do cabeçalho; sem eles, não há logotipo
75
+ * @returns {{ bytes: Buffer, avisos: string[], vazio: boolean }} `vazio`: o texto não tem nada a
76
+ * converter (só frontmatter, ou só linhas vazias)
77
+ */
78
+ export function gerarDocx({ texto, perfil, logotipo }) {
79
+ const { blocos, avisos } = lerMarkdown(texto);
80
+ const completo = { ...PADRAO, ...perfil };
81
+ return { bytes: zipar(montarPartes(blocos, completo, logotipo)), avisos: avisosDe(avisos), vazio: blocos.length === 0 };
82
+ }
@@ -0,0 +1,70 @@
1
+ // O perfil de documento oficial: um arquivo de texto do projeto, com linhas `chave: valor`.
2
+ // Aqui ele é lido e validado, sem tocar o disco; o logotipo é conferido em `projeto.mjs`.
3
+ // Spec: fase-u3b-documento-word.md, §3 e regra 10 (repositório do OpenCrew).
4
+
5
+ /** O que vale quando a chave não vem, ou vem vazia. */
6
+ export const PADRAO = Object.freeze({
7
+ logotipo: '', logotipo_largura_cm: 2.5, cabecalho_1: '', cabecalho_2: '', cabecalho_3: '', rodape: '', numero_pagina: true,
8
+ margem_esquerda_cm: 3, margem_direita_cm: 2, margem_superior_cm: 2.5, margem_inferior_cm: 2.5, fonte: 'Arial', tamanho_corpo_pt: 11,
9
+ });
10
+
11
+ export const MSG = {
12
+ chave: (n, chave) => `Perfil, linha ${n}: não conheço a chave ${chave}.`,
13
+ valor: (n, chave, esperado, valor) => `Perfil, linha ${n}: ${chave} precisa ser ${esperado}. Recebi: ${valor}.`,
14
+ };
15
+
16
+ // `chave: valor`; o espaço depois dos dois-pontos é opcional, mas um endereço (`https://…`) não é chave.
17
+ const LINHA = /^([a-z0-9_]+):(?!\/\/)[ \t]*(.*)$/;
18
+ const NUMERO = /^\d+(?:[.,]\d+)?$/;
19
+ const numero = (valor) => (NUMERO.test(valor) ? Number(valor.replace(',', '.')) : NaN);
20
+ const texto = { esperado: '', ler: (valor) => valor };
21
+ const deUmASeis = { esperado: 'um número de 1 a 6', ler: (valor) => (numero(valor) >= 1 && numero(valor) <= 6 ? numero(valor) : undefined) };
22
+ const SIM_OU_NAO = { sim: true, nao: false, 'não': false };
23
+
24
+ /** Como cada chave é lida: `ler` devolve o valor, ou `undefined` quando ele não serve. */
25
+ const CHAVES = {
26
+ logotipo: texto,
27
+ logotipo_largura_cm: deUmASeis,
28
+ cabecalho_1: texto,
29
+ cabecalho_2: texto,
30
+ cabecalho_3: texto,
31
+ rodape: texto,
32
+ numero_pagina: { esperado: 'sim ou nao', ler: (valor) => (Object.hasOwn(SIM_OU_NAO, valor.toLowerCase()) ? SIM_OU_NAO[valor.toLowerCase()] : undefined) },
33
+ margem_esquerda_cm: deUmASeis,
34
+ margem_direita_cm: deUmASeis,
35
+ margem_superior_cm: deUmASeis,
36
+ margem_inferior_cm: deUmASeis,
37
+ fonte: { esperado: 'um nome com até 40 letras, dígitos e espaços', ler: (valor) => (/^[\p{L}\p{N} ]{1,40}$/u.test(valor) ? valor : undefined) },
38
+ tamanho_corpo_pt: { esperado: 'um número de 8 a 14 (aceita meio ponto)', ler: (valor) => (numero(valor) >= 8 && numero(valor) <= 14 && Number.isInteger(numero(valor) * 2) ? numero(valor) : undefined) },
39
+ };
40
+
41
+ /** Tira as aspas em volta do valor, se as duas pontas têm a mesma. */
42
+ function semAspas(valor) {
43
+ const v = valor.trim();
44
+ return v.length >= 2 && (v[0] === '"' || v[0] === "'") && v.at(-1) === v[0] ? v.slice(1, -1).trim() : v;
45
+ }
46
+
47
+ /**
48
+ * Lê o texto do perfil. Só valem as linhas `chave: valor` cuja chave tem letras minúsculas,
49
+ * dígitos e `_`, a partir da primeira coluna; qualquer outra linha é comentário. Valor vazio vale
50
+ * o padrão. Chave repetida: vale a última.
51
+ * @param {string} bruto
52
+ * @returns {{ perfil: object, linhas: Record<string, number>, erro: string|null }} `perfil` com os
53
+ * padrões aplicados; `linhas`: o número da linha de cada chave lida; `erro`: a mensagem em PT-BR
54
+ */
55
+ export function lerPerfil(bruto) {
56
+ const perfil = { ...PADRAO };
57
+ const linhas = {};
58
+ const semBom = bruto.charCodeAt(0) === 0xfeff ? bruto.slice(1) : bruto;
59
+ for (const [i, linha] of semBom.split(/\r\n?|\n/).entries()) {
60
+ const [, chave, escrito = ''] = LINHA.exec(linha.trimEnd()) ?? [];
61
+ if (!chave) continue;
62
+ if (!Object.hasOwn(CHAVES, chave)) return { perfil, linhas, erro: MSG.chave(i + 1, chave) };
63
+ linhas[chave] = i + 1;
64
+ const valor = semAspas(escrito);
65
+ const lido = valor === '' ? PADRAO[chave] : CHAVES[chave].ler(valor);
66
+ if (lido === undefined) return { perfil, linhas, erro: MSG.valor(i + 1, chave, CHAVES[chave].esperado, valor) };
67
+ perfil[chave] = lido;
68
+ }
69
+ return { perfil, linhas, erro: null };
70
+ }