@aksp/opencrew 1.9.0 → 1.11.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.
- package/CHANGELOG.md +112 -0
- package/README.md +98 -6
- package/package.json +1 -1
- package/src/commands/init.js +4 -5
- package/src/commands/update.js +8 -0
- package/src/lib/resumo.js +5 -1
- package/templates/AGENTS.md +17 -7
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/architect.agent.yaml +25 -16
- package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
- package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
- package/templates/_opencrew/core/formato-da-crew.md +162 -0
- package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +33 -57
- package/templates/_opencrew/core/prompts/design.prompt.md +12 -11
- package/templates/_opencrew/core/prompts/discovery.prompt.md +23 -5
- package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
- package/templates/_opencrew/core/prompts/entrega.prompt.md +5 -4
- package/templates/_opencrew/core/prompts/repair.prompt.md +75 -84
- package/templates/_opencrew/core/runner.pipeline.md +9 -12
- package/templates/_opencrew/core/scripts/conserto/achados.mjs +158 -0
- package/templates/_opencrew/core/scripts/conserto/aplicar.mjs +156 -0
- package/templates/_opencrew/core/scripts/conserto/argumentos.mjs +51 -0
- package/templates/_opencrew/core/scripts/conserto/crew.mjs +126 -0
- package/templates/_opencrew/core/scripts/conserto/edicoes.mjs +133 -0
- package/templates/_opencrew/core/scripts/conserto/gravar.mjs +55 -0
- package/templates/_opencrew/core/scripts/conserto.mjs +82 -0
- package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
- package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
- package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
- package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
- package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
- package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
- package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
- package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
- package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
- package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
- package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
- package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
- package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
- package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
- package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
- package/templates/_opencrew/core/scripts/documento.mjs +150 -0
- package/templates/_opencrew/core/scripts/entrega/canais.mjs +7 -3
- package/templates/_opencrew/core/scripts/entrega/comparar.mjs +2 -2
- package/templates/_opencrew/core/scripts/entrega/copia.mjs +1 -1
- package/templates/_opencrew/core/scripts/entrega/documentos.mjs +104 -0
- package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
- package/templates/_opencrew/core/scripts/entrega/leiame.mjs +3 -2
- package/templates/_opencrew/core/scripts/entrega/passos.mjs +12 -2
- package/templates/_opencrew/core/scripts/entrega/separar.mjs +3 -0
- package/templates/_opencrew/core/scripts/entregar.mjs +12 -5
- package/templates/_opencrew/core/scripts/verificar/proibicoes.mjs +30 -5
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A gravação do conserto: o que impede de gravar é visto antes de tocar em qualquer arquivo; cada
|
|
2
|
+
// arquivo alterado ganha antes a cópia `.bak` (a que já existe fica).
|
|
3
|
+
// Spec: fase-u4a-conserto-de-crews.md, regras 1 e 2 (repositório do OpenCrew).
|
|
4
|
+
import { accessSync, constants, existsSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { relativoAoProjeto } from '../comum.mjs';
|
|
6
|
+
|
|
7
|
+
const MSG = {
|
|
8
|
+
naoUtf8: (arquivo) => `Não altero ${arquivo}: o arquivo não está em UTF-8. Salve-o em UTF-8 e rode de novo.`,
|
|
9
|
+
somenteLeitura: (arquivo) => `Não consigo gravar em ${arquivo} (arquivo protegido ou aberto em outro programa). Nada foi gravado.`,
|
|
10
|
+
parouNoMeio: (gravados, arquivo, motivo) => `A gravação parou em ${arquivo}: ${motivo}. Já gravados: ${gravados.join(', ') || 'nenhum'} (a cópia .bak de cada um tem o texto de antes).`,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const INVALIDO = String.fromCharCode(0xfffd); // o que sobra de um byte que não é UTF-8
|
|
14
|
+
|
|
15
|
+
function podeGravar(arquivo) {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(arquivo, constants.W_OK);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** O que impede a gravação, visto antes de tocar em qualquer arquivo: texto que não é UTF-8, arquivo protegido. */
|
|
25
|
+
export function erroAntesDeGravar(raiz, mudancas) {
|
|
26
|
+
const rel = (m) => relativoAoProjeto(raiz, m.arquivo);
|
|
27
|
+
const estranho = mudancas.find((m) => m.antes?.includes(INVALIDO));
|
|
28
|
+
if (estranho) return MSG.naoUtf8(rel(estranho));
|
|
29
|
+
const fechado = mudancas.find((m) => m.antes !== null && !podeGravar(m.arquivo));
|
|
30
|
+
return fechado ? MSG.somenteLeitura(rel(fechado)) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Grava as mudanças; antes de cada arquivo, a cópia `.bak` do que estava lá (se ainda não existe).
|
|
35
|
+
* @returns {string[]} as linhas do relatório, com os caminhos relativos ao projeto
|
|
36
|
+
*/
|
|
37
|
+
export function gravar(raiz, mudancas) {
|
|
38
|
+
const linhas = [];
|
|
39
|
+
const gravados = [];
|
|
40
|
+
for (const { arquivo, antes, depois } of mudancas) {
|
|
41
|
+
const rel = relativoAoProjeto(raiz, arquivo);
|
|
42
|
+
const copia = `${arquivo}.bak`;
|
|
43
|
+
const jaHavia = existsSync(copia);
|
|
44
|
+
try {
|
|
45
|
+
if (antes !== null && !jaHavia) writeFileSync(copia, antes, 'utf8');
|
|
46
|
+
writeFileSync(arquivo, depois, 'utf8');
|
|
47
|
+
} catch (falha) {
|
|
48
|
+
throw new Error(MSG.parouNoMeio(gravados, rel, falha?.code ?? falha?.message ?? falha), { cause: falha });
|
|
49
|
+
}
|
|
50
|
+
gravados.push(rel);
|
|
51
|
+
linhas.push(`Gravei: ${rel}`);
|
|
52
|
+
if (antes !== null) linhas.push(`Cópia: ${rel}.bak${jaHavia ? ' (mantive a cópia que já existia)' : ''}`);
|
|
53
|
+
}
|
|
54
|
+
return linhas;
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Conserto de uma crew que já existe: mostra o que falta para as melhorias do runtime valerem nela
|
|
3
|
+
// e, com --aplicar, grava um item por vez. Quem grava em `crews/` é este script, não a IA.
|
|
4
|
+
// Uso (na pasta do projeto):
|
|
5
|
+
// node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" só lê
|
|
6
|
+
// node _opencrew/core/scripts/conserto.mjs --crew "crews/<crew>" --aplicar "<item>" grava
|
|
7
|
+
// Itens de --aplicar: manifesto · nome:<agente>=<Nome Sobrenome> · formato:<passo>=<formato> ·
|
|
8
|
+
// fonte:<caminho>=<para que> · proibicao:<n>=<trecho> · proibicao:<n>=revisao-humana ·
|
|
9
|
+
// irreversivel:<passo> (ver --ajuda).
|
|
10
|
+
// Só grava `crew.yaml`, `crew-party.csv`, `agents/*.agent.md`, `pipeline/steps/*.md` e
|
|
11
|
+
// `_memory/memories.md` da crew, e a cópia `<arquivo>.bak` de cada um (a cópia que já existe
|
|
12
|
+
// não é sobrescrita). Nunca apaga.
|
|
13
|
+
// Última linha da saída: CONSERTO:OK (nada a consertar) · CONSERTO:PENDENTE (há achados) ·
|
|
14
|
+
// CONSERTO:APLICADO (tudo o que foi pedido está gravado) · CONSERTO:ERRO (nada foi gravado).
|
|
15
|
+
// Código de saída: 0, menos com CONSERTO:ERRO (1).
|
|
16
|
+
// Spec: fase-u4a-conserto-de-crews.md (repositório do OpenCrew).
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { ehPrincipal, erroDeUso } from './comum.mjs';
|
|
20
|
+
import { diagnosticar } from './conserto/achados.mjs';
|
|
21
|
+
import { planejar } from './conserto/aplicar.mjs';
|
|
22
|
+
import { AJUDA, lerArgs, lerItem, limpar } from './conserto/argumentos.mjs';
|
|
23
|
+
import { lerCrew } from './conserto/crew.mjs';
|
|
24
|
+
import { gravar } from './conserto/gravar.mjs';
|
|
25
|
+
|
|
26
|
+
const MSG = {
|
|
27
|
+
estranho: (arg) => `Argumento desconhecido: ${limpar(arg)}. Veja as opções com --ajuda.`,
|
|
28
|
+
semYaml: (crew) => `A pasta ${limpar(crew)} não tem \`crew.yaml\`: não é uma crew.`,
|
|
29
|
+
itemDesconhecido: (item) => `Item desconhecido em --aplicar: ${limpar(item)}. Veja os itens com --ajuda.`,
|
|
30
|
+
emDia: (nome) => `A crew "${nome}" está em dia: não há o que consertar.`,
|
|
31
|
+
titulo: (nome, n) => `Conserto da crew "${nome}" — ${n} ${n === 1 ? 'achado' : 'achados'}`,
|
|
32
|
+
jaEstava: (item) => `Já estava assim: ${item}`,
|
|
33
|
+
falha: (motivo) => `Não consegui consertar: ${String(motivo).replace(/\s+/g, ' ').trim()}`,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** O que está errado na chamada, antes de ler a crew; ou null. */
|
|
37
|
+
function erroDaChamada(raiz, args) {
|
|
38
|
+
if (args.estranho !== undefined) return MSG.estranho(args.estranho);
|
|
39
|
+
const deUso = erroDeUso({ raiz, faltando: args.crew ? [] : ['--crew'], crew: args.crew });
|
|
40
|
+
if (deUso) return deUso;
|
|
41
|
+
if (!existsSync(path.resolve(raiz, args.crew, 'crew.yaml'))) return MSG.semYaml(args.crew);
|
|
42
|
+
const desconhecido = args.itens.find((i) => !lerItem(i));
|
|
43
|
+
return desconhecido === undefined ? null : MSG.itemDesconhecido(desconhecido);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** As linhas do diagnóstico: um bloco por achado e a linha de situação. */
|
|
47
|
+
function relatorio(crew) {
|
|
48
|
+
const achados = diagnosticar(crew);
|
|
49
|
+
if (!achados.length) return [MSG.emDia(crew.nome), 'CONSERTO:OK'];
|
|
50
|
+
const blocos = achados.flatMap(({ codigo, linhas: [titulo, ...resto] }) => ['', `[${codigo}] ${titulo}`, ...resto.map((l) => ` ${l}`)]);
|
|
51
|
+
return [MSG.titulo(crew.nome, achados.length), ...blocos, '', 'CONSERTO:PENDENTE'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** As linhas de um `--aplicar`: o que foi gravado, as cópias e a linha de situação. */
|
|
55
|
+
function aplicar(raiz, crew, itens) {
|
|
56
|
+
const plano = planejar(crew, itens.map(lerItem));
|
|
57
|
+
if (plano.erro) return [plano.erro, 'CONSERTO:ERRO'];
|
|
58
|
+
return [...gravar(raiz, plano.mudancas), ...plano.iguais.map(MSG.jaEstava), 'CONSERTO:APLICADO'];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string[]} argv
|
|
63
|
+
* @param {object} [deps] `cwd` (a pasta do projeto) e `escrever`
|
|
64
|
+
* @returns {number} 0 = a crew foi lida (e, com --aplicar, gravada) · 1 = CONSERTO:ERRO
|
|
65
|
+
*/
|
|
66
|
+
export function main(argv, deps = {}) {
|
|
67
|
+
const { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`) } = deps;
|
|
68
|
+
const args = lerArgs(argv);
|
|
69
|
+
if (args.ajuda) return AJUDA.forEach((l) => escrever(l)) ?? 0;
|
|
70
|
+
let linhas;
|
|
71
|
+
try {
|
|
72
|
+
const erro = erroDaChamada(cwd, args);
|
|
73
|
+
const crew = erro ? null : lerCrew(cwd, args.crew);
|
|
74
|
+
linhas = erro ? [erro, 'CONSERTO:ERRO'] : args.itens.length ? aplicar(cwd, crew, args.itens) : relatorio(crew);
|
|
75
|
+
} catch (falha) {
|
|
76
|
+
linhas = [MSG.falha(falha?.message ?? falha), 'CONSERTO:ERRO'];
|
|
77
|
+
}
|
|
78
|
+
linhas.forEach((l) => escrever(l));
|
|
79
|
+
return linhas.at(-1) === 'CONSERTO:ERRO' ? 1 : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ehPrincipal(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -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
|
+
}
|