@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +88 -4
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md +10 -2
  5. package/templates/_opencrew/.opencrew-version +1 -1
  6. package/templates/_opencrew/core/best-practices/_catalog.yaml +5 -0
  7. package/templates/_opencrew/core/best-practices/documento-oficial.md +144 -0
  8. package/templates/_opencrew/core/modelos/documento-oficial.md +42 -0
  9. package/templates/_opencrew/core/prompts/design.prompt.md +1 -0
  10. package/templates/_opencrew/core/prompts/documento.prompt.md +134 -0
  11. package/templates/_opencrew/core/prompts/entrega.prompt.md +5 -4
  12. package/templates/_opencrew/core/scripts/documento/argumentos.mjs +50 -0
  13. package/templates/_opencrew/core/scripts/documento/corpo.mjs +51 -0
  14. package/templates/_opencrew/core/scripts/documento/estilos.mjs +42 -0
  15. package/templates/_opencrew/core/scripts/documento/gravar.mjs +44 -0
  16. package/templates/_opencrew/core/scripts/documento/linha.mjs +58 -0
  17. package/templates/_opencrew/core/scripts/documento/marcacoes.mjs +55 -0
  18. package/templates/_opencrew/core/scripts/documento/markdown.mjs +92 -0
  19. package/templates/_opencrew/core/scripts/documento/pacote.mjs +82 -0
  20. package/templates/_opencrew/core/scripts/documento/perfil.mjs +70 -0
  21. package/templates/_opencrew/core/scripts/documento/png.mjs +20 -0
  22. package/templates/_opencrew/core/scripts/documento/projeto.mjs +60 -0
  23. package/templates/_opencrew/core/scripts/documento/tabelas.mjs +57 -0
  24. package/templates/_opencrew/core/scripts/documento/timbre.mjs +64 -0
  25. package/templates/_opencrew/core/scripts/documento/xml.mjs +95 -0
  26. package/templates/_opencrew/core/scripts/documento/zip.mjs +86 -0
  27. package/templates/_opencrew/core/scripts/documento.mjs +150 -0
  28. package/templates/_opencrew/core/scripts/entrega/canais.mjs +7 -3
  29. package/templates/_opencrew/core/scripts/entrega/comparar.mjs +2 -2
  30. package/templates/_opencrew/core/scripts/entrega/copia.mjs +1 -1
  31. package/templates/_opencrew/core/scripts/entrega/documentos.mjs +104 -0
  32. package/templates/_opencrew/core/scripts/entrega/gravar.mjs +3 -3
  33. package/templates/_opencrew/core/scripts/entrega/leiame.mjs +3 -2
  34. package/templates/_opencrew/core/scripts/entrega/passos.mjs +12 -2
  35. package/templates/_opencrew/core/scripts/entrega/separar.mjs +3 -0
  36. package/templates/_opencrew/core/scripts/entregar.mjs +12 -5
@@ -0,0 +1,57 @@
1
+ // As tabelas do corpo: a tabela de dados (bordas finas, primeira linha em negrito sobre cinza,
2
+ // colunas iguais) e o bloco de assinaturas (sem bordas, duas por linha, a ímpar no centro).
3
+ // Nenhuma linha parte entre páginas. Toda célula termina em parágrafo.
4
+ // Spec: fase-u3b-documento-word.md, §4 e regra 3 (repositório do OpenCrew).
5
+ import { RECUO, borda, paragrafo, twips } from './xml.mjs';
6
+
7
+ const LADOS = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'];
8
+ const BORDAS = `<w:tblBorders>${LADOS.map((lado) => borda(lado, { cor: 'CCCCCC' })).join('')}</w:tblBorders>`;
9
+ const MARGENS = '<w:tblCellMar><w:top w:w="40" w:type="dxa"/><w:left w:w="100" w:type="dxa"/><w:bottom w:w="40" w:type="dxa"/><w:right w:w="100" w:type="dxa"/></w:tblCellMar>';
10
+ export const SEM_MARGENS = '<w:tblCellMar><w:top w:w="0" w:type="dxa"/><w:left w:w="0" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tblCellMar>';
11
+ const FUNDO = '<w:shd w:val="clear" w:color="auto" w:fill="F0F0F0"/>';
12
+ const NA_CELULA = { espaco: { after: 0 }, jc: 'left' };
13
+ /** Espaço em branco para a assinatura à mão, acima da linha: 36 pt. */
14
+ const ANTES_DA_LINHA = 720;
15
+ const RECUO_DA_SOZINHA = twips(4);
16
+
17
+ /**
18
+ * A moldura de uma tabela: propriedades, grade e linhas, nessa ordem.
19
+ * @param {number[]} colunas largura de cada coluna, em twips
20
+ * @param {{ bordas?: string, margens?: string }} partes o que vai em `tblPr`, além da largura e do leiaute fixo
21
+ * @param {string[]} linhas o XML das células de cada linha
22
+ */
23
+ export function tabela(colunas, { bordas = '', margens = '' }, linhas) {
24
+ const largura = colunas.reduce((a, b) => a + b, 0);
25
+ const grade = colunas.map((w) => `<w:gridCol w:w="${w}"/>`).join('');
26
+ const corpo = linhas.map((l) => `<w:tr><w:trPr><w:cantSplit/></w:trPr>${l}</w:tr>`).join('');
27
+ return `<w:tbl><w:tblPr><w:tblW w:w="${largura}" w:type="dxa"/>${bordas}<w:tblLayout w:type="fixed"/>${margens}</w:tblPr><w:tblGrid>${grade}</w:tblGrid>${corpo}</w:tbl>`;
28
+ }
29
+
30
+ /** Uma célula: largura, o que mais vier em `tcPr` (na ordem do formato) e os parágrafos. */
31
+ export const celula = (largura, propriedades, paragrafos) => `<w:tc><w:tcPr><w:tcW w:w="${largura}" w:type="dxa"/>${propriedades}</w:tcPr>${paragrafos}</w:tc>`;
32
+
33
+ /** Tabela de dados, na largura da área de texto, com colunas iguais. */
34
+ export function tabelaDeDados(bloco, larguraDoTexto) {
35
+ const coluna = Math.floor(larguraDoTexto / bloco.linhas[0].length);
36
+ const linhas = bloco.linhas.map((linha, n) => linha.map((pedacos) => celula(coluna, n === 0 ? FUNDO : '', paragrafo(NA_CELULA, pedacos, { b: n === 0 }))).join(''));
37
+ return tabela(bloco.linhas[0].map(() => coluna), { bordas: BORDAS, margens: MARGENS }, linhas);
38
+ }
39
+
40
+ /** Uma assinatura: a linha (borda de cima), o nome em negrito e caixa alta por formatação, e o cargo. */
41
+ function assinatura({ nome, cargo }, recuo) {
42
+ const daLinha = { bordas: borda('top', { sz: 6, cor: '000000', espaco: 1 }), espaco: { before: ANTES_DA_LINHA, after: 0 }, recuo: { left: recuo, right: recuo }, jc: 'center' };
43
+ const doNome = paragrafo(daLinha, [{ texto: nome }], { b: true, caps: true });
44
+ return cargo ? `${doNome}${paragrafo({ espaco: { after: 0 }, jc: 'center' }, [{ texto: cargo }])}` : doNome;
45
+ }
46
+
47
+ /** Bloco de assinaturas: duas por linha, na ordem escrita; a que sobra ocupa a linha inteira. */
48
+ export function tabelaDeAssinaturas(bloco, larguraDoTexto) {
49
+ const coluna = Math.floor(larguraDoTexto / 2);
50
+ const linhas = [];
51
+ for (let i = 0; i < bloco.pessoas.length; i += 2) {
52
+ const par = bloco.pessoas.slice(i, i + 2);
53
+ if (par.length === 2) linhas.push(par.map((p) => celula(coluna, '', assinatura(p, RECUO))).join(''));
54
+ else linhas.push(celula(coluna * 2, '<w:gridSpan w:val="2"/>', assinatura(par[0], RECUO_DA_SOZINHA)));
55
+ }
56
+ return tabela([coluna, coluna], { margens: SEM_MARGENS }, linhas);
57
+ }
@@ -0,0 +1,64 @@
1
+ // O papel timbrado: cabeçalho (`word/header1.xml`: logotipo e até três linhas, com uma linha
2
+ // preta embaixo) e rodapé (`word/footer1.xml`: o texto do perfil e "Página X de Y").
3
+ // Spec: fase-u3b-documento-word.md, §4 e regra 2 (repositório do OpenCrew).
4
+ import { SEM_MARGENS, celula, tabela } from './tabelas.mjs';
5
+ import { DECLARACAO, NS_R, NS_W, borda, pPr, paragrafo, rPr, trecho, twips } from './xml.mjs';
6
+
7
+ const NS_WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing';
8
+ const NS_A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
9
+ const NS_PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture';
10
+ /** Unidades do desenho (EMU) por centímetro. */
11
+ const EMU_POR_CM = 360000;
12
+ /** Folga entre o logotipo e as linhas do cabeçalho: 0,4 cm. */
13
+ const FOLGA = twips(0.4);
14
+ const SEM_ESPACO = { espaco: { after: 0 }, jc: 'left' };
15
+ const LINHAS = [['cabecalho_1', { b: true, sz: 12 }], ['cabecalho_2', { sz: 10 }], ['cabecalho_3', { cor: '666666', sz: 10 }]];
16
+ const DO_RODAPE = { sz: 8.5 };
17
+
18
+ /** O cabeçalho existe quando há logotipo ou alguma das três linhas. */
19
+ export const temCabecalho = (perfil, logotipo) => Boolean(logotipo) || LINHAS.some(([chave]) => perfil[chave]);
20
+ /** O rodapé existe quando há texto de rodapé ou número de página. */
21
+ export const temRodape = (perfil) => Boolean(perfil.rodape) || perfil.numero_pagina;
22
+
23
+ /** A imagem, na largura do perfil e na altura proporcional. `rId`: a relação com `media/logo.png`. */
24
+ function desenho(perfil, logotipo, rId) {
25
+ const cx = Math.round(perfil.logotipo_largura_cm * EMU_POR_CM);
26
+ const cy = Math.round((cx * logotipo.altura) / logotipo.largura);
27
+ const figura = `<pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="logo.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="${rId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>`;
28
+ const moldura = `<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="1" name="Logotipo"/><wp:cNvGraphicFramePr><a:graphicFrameLocks noChangeAspect="1"/></wp:cNvGraphicFramePr>`;
29
+ return `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">${moldura}<a:graphic><a:graphicData uri="${NS_PIC}">${figura}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
30
+ }
31
+
32
+ /**
33
+ * @param {object} perfil o perfil completo
34
+ * @param {number} largura a largura da área de texto, em twips
35
+ * @param {{ largura: number, altura: number }|null} logotipo as medidas do PNG, em pontos da imagem
36
+ * @param {string} rId a relação da imagem, em `word/_rels/header1.xml.rels`
37
+ */
38
+ export function montarCabecalho(perfil, largura, logotipo, rId) {
39
+ const linhas = LINHAS.filter(([chave]) => perfil[chave]).map(([chave, letra]) => paragrafo(SEM_ESPACO, [{ texto: perfil[chave] }], letra));
40
+ const texto = linhas.join('') || paragrafo(SEM_ESPACO);
41
+ const daImagem = logotipo ? twips(perfil.logotipo_largura_cm) + FOLGA : 0;
42
+ const centro = '<w:vAlign w:val="center"/>';
43
+ const imagem = logotipo ? celula(daImagem, centro, `<w:p>${pPr(SEM_ESPACO)}${desenho(perfil, logotipo, rId)}</w:p>`) : '';
44
+ const colunas = logotipo ? [daImagem, largura - daImagem] : [largura];
45
+ const bordas = `<w:tblBorders>${borda('bottom', { sz: 8, cor: '000000' })}</w:tblBorders>`;
46
+ const quadro = tabela(colunas, { bordas, margens: SEM_MARGENS }, [`${imagem}${celula(colunas.at(-1), centro, texto)}`]);
47
+ const espacos = `xmlns:w="${NS_W}" xmlns:r="${NS_R}" xmlns:wp="${NS_WP}" xmlns:a="${NS_A}" xmlns:pic="${NS_PIC}"`;
48
+ return `${DECLARACAO}<w:hdr ${espacos}>${quadro}${paragrafo(SEM_ESPACO)}</w:hdr>`;
49
+ }
50
+
51
+ /** Um campo do Word (`PAGE`, `NUMPAGES`), com "1" como valor até o Word calcular. */
52
+ function campo(instrucao) {
53
+ const marca = (tipo) => `<w:r>${rPr(DO_RODAPE)}<w:fldChar w:fldCharType="${tipo}"/></w:r>`;
54
+ const comando = `<w:r>${rPr(DO_RODAPE)}<w:instrText xml:space="preserve"> ${instrucao} </w:instrText></w:r>`;
55
+ return `${marca('begin')}${comando}${marca('separate')}${trecho('1', DO_RODAPE)}${marca('end')}`;
56
+ }
57
+
58
+ /** @param {object} perfil o perfil completo · @param {number} largura a da área de texto, em twips */
59
+ export function montarRodape(perfil, largura) {
60
+ const props = { bordas: borda('top', { cor: 'AAAAAA', espaco: 4 }), tabs: `<w:tab w:val="right" w:pos="${largura}"/>`, espaco: { after: 0 }, jc: 'left' };
61
+ const texto = trecho(perfil.rodape, { ...DO_RODAPE, i: true, cor: '666666' });
62
+ const pagina = perfil.numero_pagina ? `${trecho('\tPágina ', DO_RODAPE)}${campo('PAGE')}${trecho(' de ', DO_RODAPE)}${campo('NUMPAGES')}` : '';
63
+ return `${DECLARACAO}<w:ftr xmlns:w="${NS_W}"><w:p>${pPr(props)}${texto}${pagina}</w:p></w:ftr>`;
64
+ }
@@ -0,0 +1,95 @@
1
+ // Tijolos do XML do Word: texto seguro (escape e caracteres proibidos), medidas, e as propriedades
2
+ // de parágrafo e de letra com os filhos sempre na ordem que o formato exige.
3
+ // Spec: fase-u3b-documento-word.md, regras 3 (g) e 4 (repositório do OpenCrew).
4
+
5
+ export const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
6
+ export const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
7
+ export const DECLARACAO = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
8
+ /** Um nível de recuo: 0,75 cm. */
9
+ export const RECUO = 425;
10
+
11
+ /** Centímetros → twips (1/20 de ponto). */
12
+ export const twips = (cm) => Math.round(cm * 566.93);
13
+ /** Pontos → meios-pontos (a unidade do tamanho de letra). */
14
+ export const meios = (pt) => Math.round(pt * 2);
15
+
16
+ const ehAlta = (c) => c >= 0xd800 && c <= 0xdbff;
17
+ const ehBaixa = (c) => c >= 0xdc00 && c <= 0xdfff;
18
+ /** Caractere que o XML 1.0 não aceita (as metades de par substituto são vistas à parte). */
19
+ const proibido = (c) => (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) || c === 0xfffe || c === 0xffff || ehAlta(c) || ehBaixa(c);
20
+
21
+ /**
22
+ * Tira o que não pode estar num XML 1.0: controles (menos tabulação e quebras de linha), U+FFFE,
23
+ * U+FFFF e metade solta de par substituto.
24
+ * @returns {{ texto: string, removidos: number }}
25
+ */
26
+ export function limpar(texto) {
27
+ let saida = '';
28
+ let removidos = 0;
29
+ for (let i = 0; i < texto.length; i++) {
30
+ const c = texto.charCodeAt(i);
31
+ if (ehAlta(c) && ehBaixa(texto.charCodeAt(i + 1))) saida += texto[i] + texto[++i];
32
+ else if (proibido(c)) removidos++;
33
+ else saida += texto[i];
34
+ }
35
+ return { texto: saida, removidos };
36
+ }
37
+
38
+ /** Texto para dentro do XML, em conteúdo ou em atributo. */
39
+ export const esc = (texto) => limpar(String(texto)).texto.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
40
+
41
+ const attrs = (o) => Object.entries(o).map(([nome, valor]) => ` w:${nome}="${valor}"`).join('');
42
+
43
+ /** Uma borda simples: `borda('top', { sz: 4, cor: 'CCCCCC' })`. `sz` em oitavos de ponto. */
44
+ export const borda = (lado, { sz = 4, cor, espaco = 0 }) => `<w:${lado} w:val="single" w:sz="${sz}" w:space="${espaco}" w:color="${cor}"/>`;
45
+
46
+ /**
47
+ * Propriedades do parágrafo, na ordem do formato: estilo, keepNext, quebra de página antes, lista,
48
+ * bordas, tabulações, espaço, recuo, alinhamento e nível de tópico.
49
+ * `espaco`: `{ before, after, line, lineRule }` · `recuo`: `{ left, right, hanging }` (twips).
50
+ */
51
+ export function pPr(o = {}) {
52
+ const partes = [
53
+ o.estilo && `<w:pStyle w:val="${o.estilo}"/>`,
54
+ o.keepNext && '<w:keepNext/>',
55
+ o.quebra && '<w:pageBreakBefore/>',
56
+ o.lista != null && `<w:numPr><w:ilvl w:val="${o.lista}"/><w:numId w:val="1"/></w:numPr>`,
57
+ o.bordas && `<w:pBdr>${o.bordas}</w:pBdr>`,
58
+ o.tabs && `<w:tabs>${o.tabs}</w:tabs>`,
59
+ o.espaco && `<w:spacing${attrs(o.espaco)}/>`,
60
+ o.recuo && `<w:ind${attrs(o.recuo)}/>`,
61
+ o.jc && `<w:jc w:val="${o.jc}"/>`,
62
+ o.topico != null && `<w:outlineLvl w:val="${o.topico}"/>`,
63
+ ].filter(Boolean);
64
+ return partes.length ? `<w:pPr>${partes.join('')}</w:pPr>` : '';
65
+ }
66
+
67
+ /** Propriedades da letra, na ordem do formato. `sz` em pontos; `fonte` já validada. */
68
+ export function rPr(o = {}) {
69
+ const partes = [
70
+ o.fonte && `<w:rFonts w:ascii="${esc(o.fonte)}" w:hAnsi="${esc(o.fonte)}" w:cs="${esc(o.fonte)}"/>`,
71
+ o.b && '<w:b/>',
72
+ o.i && '<w:i/>',
73
+ o.caps && '<w:caps/>',
74
+ o.cor && `<w:color w:val="${o.cor}"/>`,
75
+ o.sz && `<w:sz w:val="${meios(o.sz)}"/><w:szCs w:val="${meios(o.sz)}"/>`,
76
+ o.idioma && `<w:lang w:val="${o.idioma}"/>`,
77
+ ].filter(Boolean);
78
+ return partes.length ? `<w:rPr>${partes.join('')}</w:rPr>` : '';
79
+ }
80
+
81
+ /** O texto de um trecho: `w:t` (sempre com `xml:space="preserve"`) e `w:tab` em cada tabulação. */
82
+ const conteudo = (texto) => texto.split('\t').map((parte) => (parte ? `<w:t xml:space="preserve">${esc(parte)}</w:t>` : '')).join('<w:tab/>');
83
+
84
+ /** Um trecho de texto com a sua letra. Texto vazio não gera trecho. */
85
+ export const trecho = (texto, letra = {}) => (texto ? `<w:r>${rPr(letra)}${conteudo(texto)}</w:r>` : '');
86
+
87
+ /**
88
+ * Um parágrafo a partir dos pedaços lidos da linha (`[{ texto, b, i }]`).
89
+ * @param {object} props o que `pPr` recebe
90
+ * @param {object} [letra] letra comum a todos os pedaços (soma com o negrito e o itálico de cada um)
91
+ */
92
+ export function paragrafo(props, pedacos = [], letra = {}) {
93
+ const trechos = pedacos.map((p) => trecho(p.texto, { ...letra, b: letra.b || p.b, i: letra.i || p.i })).join('');
94
+ return `<w:p>${pPr(props)}${trechos}</w:p>`;
95
+ }
@@ -0,0 +1,86 @@
1
+ // Contêiner zip do documento Word, escrito à mão: toda entrada sem compressão (método 0), com
2
+ // CRC-32 próprio e data fixa (1980-01-01 00:00). O mesmo conteúdo dá os mesmos bytes, em qualquer
3
+ // máquina e em qualquer versão do Node. Não importa `node:zlib`.
4
+ // Spec: fase-u3b-documento-word.md, regra 1 (repositório do OpenCrew).
5
+
6
+ const TABELA = Array.from({ length: 256 }, (_, n) => {
7
+ let c = n;
8
+ for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
9
+ return c >>> 0;
10
+ });
11
+
12
+ /** CRC-32 (o do zip e do PNG) de um bloco de bytes. */
13
+ export function crc32(bytes) {
14
+ let c = 0xffffffff;
15
+ for (let i = 0; i < bytes.length; i++) c = TABELA[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
16
+ return (c ^ 0xffffffff) >>> 0;
17
+ }
18
+
19
+ const VERSAO = 20;
20
+ const DATA = 0x0021; // 1980-01-01
21
+ const HORA = 0;
22
+ /** Bit 11 (nome em UTF-8): só quando o nome sai do ASCII. */
23
+ const marcas = (nome) => (nome.length === Buffer.byteLength(nome, 'utf8') ? 0 : 0x0800);
24
+
25
+ function cabecalhoLocal(e) {
26
+ const b = Buffer.alloc(30);
27
+ b.writeUInt32LE(0x04034b50, 0);
28
+ b.writeUInt16LE(VERSAO, 4);
29
+ b.writeUInt16LE(marcas(e.nome), 6);
30
+ b.writeUInt16LE(0, 8); // método 0: sem compressão
31
+ b.writeUInt16LE(HORA, 10);
32
+ b.writeUInt16LE(DATA, 12);
33
+ b.writeUInt32LE(e.crc, 14);
34
+ b.writeUInt32LE(e.bytes.length, 18);
35
+ b.writeUInt32LE(e.bytes.length, 22);
36
+ b.writeUInt16LE(e.nomeEmBytes.length, 26);
37
+ return Buffer.concat([b, e.nomeEmBytes]);
38
+ }
39
+
40
+ function registroCentral(e) {
41
+ const b = Buffer.alloc(46);
42
+ b.writeUInt32LE(0x02014b50, 0);
43
+ b.writeUInt16LE(VERSAO, 4);
44
+ b.writeUInt16LE(VERSAO, 6);
45
+ b.writeUInt16LE(marcas(e.nome), 8);
46
+ b.writeUInt16LE(0, 10);
47
+ b.writeUInt16LE(HORA, 12);
48
+ b.writeUInt16LE(DATA, 14);
49
+ b.writeUInt32LE(e.crc, 16);
50
+ b.writeUInt32LE(e.bytes.length, 20);
51
+ b.writeUInt32LE(e.bytes.length, 24);
52
+ b.writeUInt16LE(e.nomeEmBytes.length, 28);
53
+ b.writeUInt32LE(e.inicio, 42);
54
+ return Buffer.concat([b, e.nomeEmBytes]);
55
+ }
56
+
57
+ function registroFinal(total, tamanho, inicio) {
58
+ const b = Buffer.alloc(22);
59
+ b.writeUInt32LE(0x06054b50, 0);
60
+ b.writeUInt16LE(total, 8);
61
+ b.writeUInt16LE(total, 10);
62
+ b.writeUInt32LE(tamanho, 12);
63
+ b.writeUInt32LE(inicio, 16);
64
+ return b;
65
+ }
66
+
67
+ /**
68
+ * Monta o zip, na ordem recebida.
69
+ * @param {{ nome: string, bytes: Buffer|string }[]} partes nomes com `/`; texto vai em UTF-8
70
+ * @returns {Buffer}
71
+ */
72
+ export function zipar(partes) {
73
+ const locais = [];
74
+ const centrais = [];
75
+ let inicio = 0;
76
+ for (const parte of partes) {
77
+ const bytes = Buffer.isBuffer(parte.bytes) ? parte.bytes : Buffer.from(parte.bytes, 'utf8');
78
+ const e = { nome: parte.nome, nomeEmBytes: Buffer.from(parte.nome, 'utf8'), bytes, crc: crc32(bytes), inicio };
79
+ const local = cabecalhoLocal(e);
80
+ locais.push(local, bytes);
81
+ centrais.push(registroCentral(e));
82
+ inicio += local.length + bytes.length;
83
+ }
84
+ const diretorio = Buffer.concat(centrais);
85
+ return Buffer.concat([...locais, diretorio, registroFinal(partes.length, diretorio.length, inicio)]);
86
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Documento Word: converte um texto em markdown (.md ou .txt) num `.docx`, com as mesmas palavras
3
+ // e os mesmos números, na mesma ordem. Com o perfil de documento oficial do projeto, sai em papel
4
+ // timbrado: cabeçalho com logotipo, rodapé com "Página X de Y", margens e letra do perfil.
5
+ // Uso (na pasta do projeto): node _opencrew/core/scripts/documento.mjs "<arquivo.md>"
6
+ // [--saida <arquivo.docx|pasta>] [--perfil <arquivo>] [--sem-perfil] [--substituir] [--ajuda]
7
+ // node _opencrew/core/scripts/documento.mjs --criar-perfil
8
+ // --saida: onde gravar (padrão: ao lado do texto, com o mesmo nome e `.docx`); a pasta é criada.
9
+ // --perfil: outro arquivo de perfil (padrão: `_opencrew/_memory/documento-oficial.md`, se existir).
10
+ // --sem-perfil: ignora o perfil do projeto (sem timbre, com os padrões).
11
+ // --substituir: autoriza trocar um `.docx` diferente que já existe no destino.
12
+ // --criar-perfil: cria o perfil do projeto a partir do modelo, só se ele não existir.
13
+ // Grava só o `.docx` pedido (por um temporário ao lado, renomeado no fim) e, com --criar-perfil, o
14
+ // perfil que faltava. Nunca apaga nada. Um `.docx` igual ao que seria gravado não é tocado.
15
+ // Última linha da saída: DOCUMENTO:OK (o Word está no destino) · PERFIL:CRIADO ou PERFIL:JA-EXISTE
16
+ // (com --criar-perfil).
17
+ // Código de saída: 0 quando a linha final sai, e em --ajuda · 1 = erro: uma mensagem em PT-BR, sem
18
+ // linha DOCUMENTO: e nada gravado.
19
+ // Para outros scripts: `gerarDocx({ texto, perfil, logotipo })` → `{ bytes, avisos }`, sem tocar o
20
+ // disco; `lerPerfilDoProjeto(raiz, arquivo)` → `{ perfil, logotipo }` ou `{ erro }`.
21
+ // Spec: fase-u3b-documento-word.md (repositório do OpenCrew).
22
+ import { existsSync, promises as fs, statSync } from 'node:fs';
23
+ import path from 'node:path';
24
+ import { MSG as COMUM, dentroDoProjeto, ehPrincipal, relativoAoProjeto } from './comum.mjs';
25
+ import { MSG, USO, lerArgs, limpar } from './documento/argumentos.mjs';
26
+ import { gravarDocx } from './documento/gravar.mjs';
27
+ import { gerarDocx } from './documento/pacote.mjs';
28
+ import { PERFIL, criarPerfil, lerPerfilDoProjeto } from './documento/projeto.mjs';
29
+
30
+ export { gerarDocx } from './documento/pacote.mjs';
31
+ export { lerPerfil } from './documento/perfil.mjs';
32
+ export { PERFIL, lerPerfilDoProjeto } from './documento/projeto.mjs';
33
+
34
+ const TEXTO = /\.(md|txt)$/i;
35
+ const ehPasta = (p) => existsSync(p) && statSync(p).isDirectory();
36
+ const ehArquivo = (p) => existsSync(p) && statSync(p).isFile();
37
+ const erro = (mensagem, comUso = false) => ({ erro: mensagem, comUso });
38
+
39
+ /** O arquivo de texto pedido: um só, dentro do projeto, `.md` ou `.txt`, e que existe. */
40
+ function conferirOrigem(raiz, arquivos) {
41
+ if (arquivos.length === 0) return erro(MSG.semArquivo, true);
42
+ if (arquivos.length > 1) return erro(MSG.maisDeUm(arquivos.length), true);
43
+ if (!ehPasta(path.join(raiz, '_opencrew'))) return erro(COMUM.semRaiz);
44
+ if (!dentroDoProjeto(raiz, arquivos[0])) return erro(COMUM.foraDoProjeto(limpar(arquivos[0])));
45
+ const rel = relativoAoProjeto(raiz, arquivos[0]);
46
+ if (!TEXTO.test(rel)) return erro(MSG.extensao(rel));
47
+ return ehArquivo(path.resolve(raiz, arquivos[0])) ? { rel, abs: path.resolve(raiz, arquivos[0]) } : erro(MSG.naoEncontrei(rel));
48
+ }
49
+
50
+ /** Onde o Word vai: ao lado do texto, ou no arquivo `.docx` ou na pasta de `--saida`. */
51
+ function conferirDestino(raiz, saida, origem) {
52
+ const nome = `${path.basename(origem.abs).replace(TEXTO, '')}.docx`;
53
+ if (saida === undefined) return { destino: path.join(path.dirname(origem.abs), nome) };
54
+ const recusada = erro(MSG.saida(limpar(saida)));
55
+ if (!saida.trim() || !dentroDoProjeto(raiz, saida)) return recusada;
56
+ const alvo = path.resolve(raiz, saida);
57
+ if (/\.docx$/i.test(alvo)) return ehPasta(alvo) ? recusada : { destino: alvo };
58
+ if (ehPasta(alvo)) return { destino: path.join(alvo, nome) };
59
+ return existsSync(alvo) || path.extname(alvo) ? recusada : { destino: path.join(alvo, nome) };
60
+ }
61
+
62
+ /** O arquivo de perfil que vale nesta chamada (`arquivo` fica sem valor quando não há perfil). */
63
+ function conferirPerfil(raiz, args) {
64
+ if (args.semPerfil) return {};
65
+ if (args.perfil === undefined) return ehArquivo(path.join(raiz, PERFIL)) ? { arquivo: PERFIL } : {};
66
+ if (!dentroDoProjeto(raiz, args.perfil)) return erro(COMUM.foraDoProjeto(limpar(args.perfil)));
67
+ return ehArquivo(path.resolve(raiz, args.perfil)) ? { arquivo: args.perfil } : erro(MSG.semPerfil(limpar(args.perfil)));
68
+ }
69
+
70
+ /** Tudo o que a chamada pede, conferido antes de ler ou gravar qualquer coisa. */
71
+ function conferir(raiz, args) {
72
+ const origem = conferirOrigem(raiz, args.arquivos);
73
+ if (origem.erro) return origem;
74
+ const destino = conferirDestino(raiz, args.saida, origem);
75
+ const perfil = conferirPerfil(raiz, args);
76
+ return destino.erro ? destino : perfil.erro ? perfil : { origem, destino: destino.destino, perfil: perfil.arquivo };
77
+ }
78
+
79
+ /** O texto do arquivo, em UTF-8; `null` quando os bytes não são UTF-8. */
80
+ async function lerTexto(arquivo) {
81
+ try {
82
+ return new TextDecoder('utf-8', { fatal: true }).decode(await fs.readFile(arquivo));
83
+ } catch (falha) {
84
+ if (falha instanceof TypeError) return null;
85
+ throw falha;
86
+ }
87
+ }
88
+
89
+ /** Monta o documento na memória: `{ bytes, avisos }`, ou `{ erro }` com a mensagem. */
90
+ async function montar(raiz, pedido) {
91
+ const texto = await lerTexto(pedido.origem.abs);
92
+ if (texto === null) return erro(MSG.naoUtf8(pedido.origem.rel));
93
+ const lido = pedido.perfil ? await lerPerfilDoProjeto(raiz, pedido.perfil) : {};
94
+ if (lido.erro) return erro(lido.erro);
95
+ const documento = gerarDocx({ texto, perfil: lido.perfil, logotipo: lido.logotipo });
96
+ return documento.vazio ? erro(MSG.semTexto(pedido.origem.rel)) : documento;
97
+ }
98
+
99
+ function relatorio(raiz, pedido, estado, avisos) {
100
+ const destino = relativoAoProjeto(raiz, pedido.destino);
101
+ return [
102
+ estado === 'igual' ? MSG.igual(destino) : MSG.gerado(destino),
103
+ pedido.perfil ? MSG.perfil(relativoAoProjeto(raiz, pedido.perfil)) : MSG.nenhumPerfil,
104
+ ...(avisos.length ? [MSG.avisos, ...avisos.map((a) => `- ${a}`)] : []),
105
+ ...MSG.dicas,
106
+ 'DOCUMENTO:OK',
107
+ ];
108
+ }
109
+
110
+ /** Converte e grava; devolve as linhas da saída e o código. */
111
+ async function converter(raiz, args, disco) {
112
+ const pedido = conferir(raiz, args);
113
+ if (pedido.erro) return { linhas: [pedido.erro, ...(pedido.comUso ? [USO] : [])], code: 1 };
114
+ const documento = await montar(raiz, pedido);
115
+ if (documento.erro) return { linhas: [documento.erro], code: 1 };
116
+ const estado = await gravarDocx(pedido.destino, documento.bytes, { substituir: args.substituir, disco });
117
+ const destino = relativoAoProjeto(raiz, pedido.destino);
118
+ if (estado === 'diferente') return { linhas: [MSG.jaExiste(destino)], code: 1 };
119
+ if (estado === 'falha') return { linhas: [MSG.falha(destino)], code: 1 };
120
+ return { linhas: relatorio(raiz, pedido, estado, documento.avisos), code: 0 };
121
+ }
122
+
123
+ async function criar(raiz) {
124
+ if (!ehPasta(path.join(raiz, '_opencrew'))) return { linhas: [COMUM.semRaiz], code: 1 };
125
+ const criado = await criarPerfil(raiz);
126
+ return { linhas: criado ? [MSG.perfilCriado(PERFIL), 'PERFIL:CRIADO'] : [MSG.perfilJaExiste(PERFIL), 'PERFIL:JA-EXISTE'], code: 0 };
127
+ }
128
+
129
+ /**
130
+ * @param {string[]} argv
131
+ * @param {object} [deps] `cwd` (a pasta do projeto), `escrever` e `disco` (as funções de
132
+ * `fs.promises` usadas na gravação do `.docx`)
133
+ * @returns {Promise<number>} 0 = a linha final saiu (ou `--ajuda`) · 1 = erro (uma mensagem em
134
+ * PT-BR, sem linha `DOCUMENTO:`, nada gravado)
135
+ */
136
+ export async function main(argv, { cwd = process.cwd(), escrever = (s) => process.stdout.write(`${s}\n`), disco = fs } = {}) {
137
+ const args = lerArgs(argv);
138
+ const sair = ({ linhas, code }) => linhas.forEach((l) => escrever(l)) ?? code;
139
+ if (args.ajuda) return sair({ linhas: [USO], code: 0 });
140
+ if (args.desconhecida) return sair({ linhas: [MSG.desconhecida(limpar(args.desconhecida)), USO], code: 1 });
141
+ try {
142
+ return sair(args.criarPerfil ? await criar(cwd) : await converter(cwd, args, disco));
143
+ } catch (falha) {
144
+ return sair({ linhas: [`Não consegui gerar o documento: ${limpar(falha?.message ?? falha)}`], code: 1 });
145
+ }
146
+ }
147
+
148
+ if (ehPrincipal(import.meta.url)) {
149
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
150
+ }
@@ -6,7 +6,9 @@ import path from 'node:path';
6
6
  import { lerFrontmatter, lerTexto } from '../verificar/leitura.mjs';
7
7
 
8
8
  /** Pasta do canal → nome no LEIA-ME, na ordem em que as seções aparecem. */
9
- export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube' };
9
+ export const CANAIS = { instagram: 'Instagram', linkedin: 'LinkedIn', blog: 'Blog', email: 'E-mail', whatsapp: 'WhatsApp', twitter: 'X/Twitter', youtube: 'YouTube', documentos: 'Documentos' };
10
+ /** Plataforma cuja pasta tem outro nome (fase-u3b-documento-word.md, regra 12). */
11
+ const PASTA_DA_PLATAFORMA = { documento: 'documentos' };
10
12
  export const OUTROS = 'outros';
11
13
  export const EDITAVEIS = 'editaveis';
12
14
  export const ehCanal = (pasta) => Object.hasOwn(CANAIS, pasta);
@@ -23,13 +25,15 @@ async function plataformaEm(raiz, pasta, formato) {
23
25
  /**
24
26
  * Canal de um formato: o `platform:` de `_opencrew/best-practices.local/<formato>.md`, quando o
25
27
  * arquivo o declara; senão, o do core. Sem formato, sem best-practice, sem `platform:` ou com
26
- * plataforma que não é uma das sete pastas: null (o arquivo vai para `outros/`).
28
+ * plataforma que não é uma das oito pastas: null (o arquivo vai para `outros/`). A plataforma
29
+ * `documento` é a pasta `documentos`.
27
30
  */
28
31
  export async function canalDoFormato(raiz, formato) {
29
32
  if (!formato || !/^[a-z0-9-]+$/.test(formato)) return null;
30
33
  const local = await plataformaEm(raiz, ['best-practices.local'], formato);
31
34
  const plataforma = local ?? (await plataformaEm(raiz, ['core', 'best-practices'], formato));
32
- return plataforma && ehCanal(plataforma) ? plataforma : null;
35
+ const pasta = Object.hasOwn(PASTA_DA_PLATAFORMA, plataforma ?? '') ? PASTA_DA_PLATAFORMA[plataforma] : plataforma;
36
+ return pasta && ehCanal(pasta) ? pasta : null;
33
37
  }
34
38
 
35
39
  const PASTAS_DE_SERVICO = new Set(['entrega', 'entrega.tmp', 'export']);
@@ -11,8 +11,8 @@ import path from 'node:path';
11
11
  const DE_TEXTO = new Set(['.txt', '.md', '.html', '.htm', '.csv', '.json']);
12
12
  export const emLf = (texto) => texto.replace(/\r\n/g, '\n');
13
13
 
14
- /** Os bytes que um arquivo da entrega tem: o texto gerado, ou os do arquivo de origem. */
15
- export const bytesDe = async (a) => (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
14
+ /** Os bytes que um arquivo da entrega tem: os gerados (o Word), o texto gerado, ou os do arquivo de origem. */
15
+ export const bytesDe = async (a) => a.bytes ?? (a.texto != null ? Buffer.from(a.texto, 'utf8') : fs.readFile(a.de));
16
16
 
17
17
  const ehTexto = (nome) => DE_TEXTO.has(path.extname(nome).toLowerCase());
18
18
  const mesmoConteudo = (nome, a, b) => a.equals(b) || (ehTexto(nome) && emLf(a.toString('utf8')) === emLf(b.toString('utf8')));
@@ -17,7 +17,7 @@ const ehPasta = async (p) => fs.stat(p).then((s) => s.isDirectory(), () => false
17
17
  /** Grava um arquivo que ainda não existe ali: se existir, falha (nunca por cima). */
18
18
  async function gravarNovo(alvo, a) {
19
19
  await fs.mkdir(path.dirname(alvo), { recursive: true });
20
- if (a.texto != null) await fs.writeFile(alvo, a.texto, { encoding: 'utf8', flag: 'wx' });
20
+ if ((a.bytes ?? a.texto) != null) await fs.writeFile(alvo, a.bytes ?? a.texto, { encoding: 'utf8', flag: 'wx' });
21
21
  else await fs.copyFile(a.de, alvo, constants.COPYFILE_EXCL);
22
22
  }
23
23
 
@@ -0,0 +1,104 @@
1
+ // Documentos na entrega: o item cujo formato tem a plataforma `documento` vai para a pasta
2
+ // `documentos/`. Texto (`.md` ou `.txt`) vira Word, inteiro, com o perfil de documento oficial do
3
+ // projeto, se existir: os mesmos bytes que o `documento.mjs` grava para o mesmo texto e o mesmo
4
+ // perfil. Outro tipo de arquivo é copiado como está, com aviso. Erro ao converter (inclusive
5
+ // perfil inválido) é pendência de `documentos`, que nunca vira ressalva: os outros canais seguem.
6
+ // Este módulo só lê: quem grava é `gravar.mjs`.
7
+ // Spec: fase-u3b-documento-word.md, decisão 9, regra 12 e §6 (repositório do OpenCrew).
8
+ import { existsSync, promises as fs, statSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { PERFIL, gerarDocx, lerPerfilDoProjeto } from '../documento.mjs';
11
+ import { limpar } from './argumentos.mjs';
12
+
13
+ /** A pasta da entrega e o tipo do arquivo que é (ou vai virar) um Word. */
14
+ export const DOCUMENTOS = 'documentos';
15
+ export const WORD = 'documento';
16
+ const TEXTO = /\.(md|txt)$/i;
17
+
18
+ export const MSG = {
19
+ copiado: (arquivo) => `${arquivo}: só converto .md ou .txt em Word. Copiei o arquivo como está.`,
20
+ erro: (arquivo, motivo) => `Não consegui gerar o Word de ${arquivo}: ${limpar(motivo).replace(/\.+$/, '')}.`,
21
+ aviso: (arquivo, aviso) => `${arquivo}: ${aviso}`,
22
+ semPerfil: 'Sem papel timbrado: este projeto não tem perfil de documento oficial. Para criar o seu: node _opencrew/core/scripts/documento.mjs --criar-perfil',
23
+ semTexto: 'o arquivo não tem texto para converter',
24
+ naoUtf8: 'o arquivo não está em UTF-8',
25
+ };
26
+ /** A linha de "O que não foi conferido" da entrega que tem um Word. */
27
+ export const NAO_CONFERIDO = 'Como o documento abre no Word.';
28
+
29
+ /** O tipo do arquivo em `documentos/`: texto que vira Word, ou cópia como está. */
30
+ export const tipoDoDocumento = (arquivo) => (TEXTO.test(arquivo) ? WORD : 'copia');
31
+ export const naoConferidoDoWord = (arquivos) => (arquivos.some((a) => a.pasta === DOCUMENTOS && a.tipo === WORD) ? [NAO_CONFERIDO] : []);
32
+
33
+ /** O perfil do projeto, lido uma vez: `{}` sem perfil, `{ perfil, logotipo }` ou `{ erro }`. */
34
+ async function perfilDoProjeto(raiz) {
35
+ const arquivo = path.join(raiz, PERFIL);
36
+ if (!existsSync(arquivo) || !statSync(arquivo).isFile()) return {};
37
+ try {
38
+ return await lerPerfilDoProjeto(raiz, PERFIL);
39
+ } catch (falha) {
40
+ return { erro: falha?.message ?? falha };
41
+ }
42
+ }
43
+
44
+ /** O texto do arquivo, em UTF-8; null quando os bytes não são UTF-8. */
45
+ function decodificar(bytes) {
46
+ try {
47
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** O Word de um texto: `{ bytes, avisos }`, ou `{ erro }` com o motivo. */
54
+ async function converter(copia, lido, gerar) {
55
+ if (lido.erro) return { erro: lido.erro };
56
+ try {
57
+ const texto = decodificar(await fs.readFile(copia.de));
58
+ if (texto === null) return { erro: MSG.naoUtf8 };
59
+ const documento = gerar({ texto, perfil: lido.perfil, logotipo: lido.logotipo });
60
+ return documento.vazio ? { erro: MSG.semTexto } : documento;
61
+ } catch (falha) {
62
+ return { erro: falha?.message ?? falha };
63
+ }
64
+ }
65
+
66
+ /** A cópia do texto vira o arquivo Word: cada origem é um grupo, para dois nomes iguais não se perderem. */
67
+ const comoWord = (copia, bytes) => ({ ...copia, nome: copia.nome.replace(TEXTO, '.docx'), de: null, bytes, pastaDeOrigem: copia.de });
68
+ const aviso = (texto) => ({ pasta: DOCUMENTOS, texto });
69
+ const pendencia = (linha) => ({ linha, chave: null, preencher: null });
70
+
71
+ /** Um arquivo de `documentos/` → o que entra em `copias`, `avisos` ou `pendencias`. */
72
+ async function tratar(copia, lido, gerar, saida) {
73
+ if (copia.tipo !== WORD) {
74
+ saida.avisos.push(aviso(MSG.copiado(copia.origem)));
75
+ return saida.copias.push(copia);
76
+ }
77
+ const r = await converter(copia, lido, gerar);
78
+ if (r.erro) return saida.pendencias.push(pendencia(MSG.erro(copia.origem, r.erro)));
79
+ saida.avisos.push(...r.avisos.map((a) => aviso(MSG.aviso(copia.origem, a))));
80
+ return saida.copias.push(comoWord(copia, r.bytes));
81
+ }
82
+
83
+ /**
84
+ * Converte os textos que `separar` pôs em `documentos/`. Sem nenhum, devolve o que recebeu e não
85
+ * lê o perfil.
86
+ * @param {string} raiz a pasta do projeto
87
+ * @param {{ unidades: object[], copias: object[], avisos: object[] }} produtos o que `separar` devolve
88
+ * @param {function} [gerar] quem monta o Word (`gerarDocx`); os testes trocam para injetar um erro
89
+ * @returns {Promise<{ produtos: object, pendencias: object[] }>} `produtos`: os mesmos, com o Word
90
+ * (`{ …, nome: '<nome>.docx', bytes }`) no lugar de cada texto convertido e os avisos da conversão ·
91
+ * `pendencias`: uma por texto que não foi convertido, `{ linha, chave: null, preencher: null }`
92
+ */
93
+ export async function converterDocumentos(raiz, produtos, gerar = gerarDocx) {
94
+ const saida = { copias: [], avisos: [...produtos.avisos], pendencias: [] };
95
+ if (!produtos.copias.some((c) => c.pasta === DOCUMENTOS)) return { produtos, pendencias: [] };
96
+ const lido = produtos.copias.some((c) => c.pasta === DOCUMENTOS && c.tipo === WORD) ? await perfilDoProjeto(raiz) : {};
97
+ for (const copia of produtos.copias) {
98
+ if (copia.pasta === DOCUMENTOS) await tratar(copia, lido, gerar, saida);
99
+ else saida.copias.push(copia);
100
+ }
101
+ // Sem perfil, o Word sai sem cabeçalho: a entrega diz, para ninguém procurar um timbre que não existe.
102
+ if (!lido.perfil && !lido.erro && saida.copias.some((c) => c.pasta === DOCUMENTOS && c.bytes)) saida.avisos.push(aviso(MSG.semPerfil));
103
+ return { produtos: { ...produtos, copias: saida.copias, avisos: saida.avisos }, pendencias: saida.pendencias };
104
+ }
@@ -25,10 +25,10 @@ async function limparSobras({ entrega, tmp, antiga }) {
25
25
  if ((await tipoDe(tmp)) === 'pasta') await apagar(tmp);
26
26
  }
27
27
 
28
- /** Grava um arquivo da entrega na pasta temporária: o texto gerado, ou os mesmos bytes da origem. */
28
+ /** Grava um arquivo da entrega na pasta temporária: os bytes ou o texto gerados, ou os mesmos bytes da origem. */
29
29
  async function gravarArquivo(destino, a) {
30
30
  await fs.mkdir(path.dirname(destino), { recursive: true });
31
- if (a.texto != null) await fs.writeFile(destino, a.texto, 'utf8');
31
+ if ((a.bytes ?? a.texto) != null) await fs.writeFile(destino, a.bytes ?? a.texto, 'utf8');
32
32
  else await fs.copyFile(a.de, destino);
33
33
  }
34
34
 
@@ -51,7 +51,7 @@ async function trocar({ entrega, tmp, antiga }, passo) {
51
51
  /**
52
52
  * Refaz `entrega/` do zero e grava, ao lado, o relatório da verificação.
53
53
  * @param {string} execucao pasta da execução (`crews/<crew>/output/<run>/`), absoluta
54
- * @param {object[]} arquivos `{ pasta, nome, texto | de }`
54
+ * @param {object[]} arquivos `{ pasta, nome, texto | bytes | de }`
55
55
  * @param {{ leiame: string, relatorio: string }} textos
56
56
  * @returns {Promise<string|null>} null quando gravou tudo; senão, o caminho que não foi gravado
57
57
  */