@aksp/opencrew 1.9.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.
- package/CHANGELOG.md +58 -0
- package/README.md +88 -4
- package/package.json +1 -1
- package/templates/AGENTS.md +10 -2
- package/templates/_opencrew/.opencrew-version +1 -1
- 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/modelos/documento-oficial.md +42 -0
- package/templates/_opencrew/core/prompts/design.prompt.md +1 -0
- package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
- package/templates/_opencrew/core/prompts/entrega.prompt.md +5 -4
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Leitura mínima de um PNG: a assinatura de 8 bytes e a largura e a altura do bloco IHDR, para a
|
|
2
|
+
// proporção do logotipo. A imagem não é aberta nem regravada: vai para o documento como veio.
|
|
3
|
+
// Spec: fase-u3b-documento-word.md, §3 (repositório do OpenCrew).
|
|
4
|
+
|
|
5
|
+
const ASSINATURA = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
6
|
+
/** Tamanho máximo do logotipo: 2 MB. */
|
|
7
|
+
export const MAXIMO = 2 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Uint8Array} bytes
|
|
11
|
+
* @returns {{ largura: number, altura: number }|null} null quando não é um PNG
|
|
12
|
+
*/
|
|
13
|
+
export function lerPng(bytes) {
|
|
14
|
+
const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
15
|
+
if (b.length < 24 || ASSINATURA.some((valor, i) => b[i] !== valor)) return null;
|
|
16
|
+
if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
|
|
17
|
+
const largura = b.readUInt32BE(16);
|
|
18
|
+
const altura = b.readUInt32BE(20);
|
|
19
|
+
return largura > 0 && altura > 0 ? { largura, altura } : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// O perfil de documento oficial no disco do projeto: onde mora, como nasce do modelo (e nunca é
|
|
2
|
+
// sobrescrito) e a leitura do logotipo que ele cita. Perfil inválido não gera documento: o erro
|
|
3
|
+
// diz a linha.
|
|
4
|
+
// Spec: fase-u3b-documento-word.md, regras 10 e 11 (repositório do OpenCrew).
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dentroDoProjeto } from '../comum.mjs';
|
|
9
|
+
import { lerPerfil } from './perfil.mjs';
|
|
10
|
+
import { MAXIMO, lerPng } from './png.mjs';
|
|
11
|
+
|
|
12
|
+
/** Onde o perfil do projeto mora (o `update` não toca esta pasta). */
|
|
13
|
+
export const PERFIL = '_opencrew/_memory/documento-oficial.md';
|
|
14
|
+
/** O modelo do pacote, ao lado dos scripts: `_opencrew/core/modelos/documento-oficial.md`. */
|
|
15
|
+
const MODELO = fileURLToPath(new URL('../../modelos/documento-oficial.md', import.meta.url));
|
|
16
|
+
|
|
17
|
+
export const MSG = {
|
|
18
|
+
semLogotipo: (n, arquivo) => `Perfil, linha ${n}: não encontrei o logotipo ${arquivo}.`,
|
|
19
|
+
logotipo: (n, arquivo) => `Perfil, linha ${n}: o logotipo precisa ser um arquivo PNG de até 2 MB, dentro do projeto. Recebi: ${arquivo}.`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Os bytes do logotipo citado no perfil: `{ bytes }`, ou `{ erro }` com a mensagem da linha. */
|
|
23
|
+
async function lerLogotipo(raiz, arquivo, linha) {
|
|
24
|
+
if (!dentroDoProjeto(raiz, arquivo)) return { erro: MSG.logotipo(linha, arquivo) };
|
|
25
|
+
const info = await fs.stat(path.resolve(raiz, arquivo)).catch(() => null);
|
|
26
|
+
if (!info) return { erro: MSG.semLogotipo(linha, arquivo) };
|
|
27
|
+
if (!info.isFile() || info.size > MAXIMO) return { erro: MSG.logotipo(linha, arquivo) };
|
|
28
|
+
const bytes = await fs.readFile(path.resolve(raiz, arquivo));
|
|
29
|
+
return lerPng(bytes) ? { bytes } : { erro: MSG.logotipo(linha, arquivo) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lê o perfil de um arquivo do projeto, com o logotipo que ele cita.
|
|
34
|
+
* @param {string} raiz a pasta do projeto
|
|
35
|
+
* @param {string} arquivo o perfil, relativo à raiz ou absoluto (já conferido: existe e fica no projeto)
|
|
36
|
+
* @returns {Promise<{ perfil?: object, logotipo?: Buffer, erro?: string }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function lerPerfilDoProjeto(raiz, arquivo) {
|
|
39
|
+
const { perfil, linhas, erro } = lerPerfil(await fs.readFile(path.resolve(raiz, arquivo), 'utf8'));
|
|
40
|
+
if (erro) return { erro };
|
|
41
|
+
if (!perfil.logotipo) return { perfil };
|
|
42
|
+
const logotipo = await lerLogotipo(raiz, perfil.logotipo, linhas.logotipo);
|
|
43
|
+
return logotipo.erro ? { erro: logotipo.erro } : { perfil, logotipo: logotipo.bytes };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cria o perfil do projeto a partir do modelo, só se ele não existir.
|
|
48
|
+
* @returns {Promise<boolean>} true quando criou; false quando já existia (e não foi tocado)
|
|
49
|
+
*/
|
|
50
|
+
export async function criarPerfil(raiz) {
|
|
51
|
+
const destino = path.join(raiz, PERFIL);
|
|
52
|
+
await fs.mkdir(path.dirname(destino), { recursive: true });
|
|
53
|
+
try {
|
|
54
|
+
await fs.writeFile(destino, await fs.readFile(MODELO), { flag: 'wx' });
|
|
55
|
+
return true;
|
|
56
|
+
} catch (erro) {
|
|
57
|
+
if (erro.code === 'EEXIST') return false;
|
|
58
|
+
throw erro;
|
|
59
|
+
}
|
|
60
|
+
}
|