Pular para o conteúdo principal

Arquitetura

Um mapa da árvore de código agrupado por responsabilidade, não por ordem de import — contexto útil se você está modificando o próprio pacote em vez de só consumir ele.

Editor (src/designer/ + src/components/)​

Desde a 3.0.0 o estado e o layout são arquivos separados. O context/DesignerProvider.tsx guarda o estado de seleção, a barra de abas (Campos/Dados/Estilo/Filtro/Página), clipboard (copiar/colar), atalhos de teclado, e toda mutação em Template/Binding[] (adicionar/remover/reordenar schemas, atualizar um vínculo, redimensionar faixa de página…), expondo tudo por cinco contextos divididos por frequência de mudança — o context/contexts.ts declara os contextos, o context/hooks.ts guarda os cinco acessores mais cinco seletores derivados, e o context/derived.ts as derivações. O useClipboardAndDelete registra Delete/Ctrl+C/Ctrl+V exatamente uma vez, aqui, e não peça por peça.

O Designer.tsx agora é um preset de 101 linhas (era 986): três providers e duas peças num layout de duas colunas. O parts/ guarda as dez peças posicionáveis — um arquivo cada, e cada uma é um par de componentes (um wrapper que chama o useTabGate, depois um *Body com os hooks de verdade, porque nenhum hook pode rodar antes do return null do gate). A maioria reproduz o elemento raiz que o Designer.tsx renderizava, então extrair não adicionou nível de DOM; três embrulham num .jpd-part porque o componente de dentro não tem uma raiz estável só. O parts/DesignerSidebar.tsx é a única peça que importa outras peças — invariante guardado pelo test/designer/partBoundaries.test.ts.

As peças que fazem o trabalho não mudaram e continuam exportadas com as props de hoje, então o caminho headless por props segue funcionando. Debaixo das peças:

  • PageCanvas.tsx — a página de verdade: um <Rnd> (react-rnd) por schema pra arrastar/redimensionar, as faixas de cabeçalho/rodapé/ margem desenhadas em vermelho, a grade, seleção por caixa (marquee), controles de zoom. Delega a aparência de cada campo pra FieldBox/ (um componente pequeno por schema.type).
  • O painel lateral — FieldList.tsx, Toolbar.tsx e, com um campo selecionado, PropertyPanel/index.tsx — um dispatcher fino pra um PropertyPanel<Tipo>.tsx por tipo de schema, cada um dividido em aba "Dados" e "Estilo". BindingEditor.tsx (o editor de vínculo genérico de path/array/seção/gráfico/kpi) e PropertyPanelFields.tsx (inputs compartilhados de X/Y/largura/altura) são reaproveitados por vários deles.

Seleção, edição e vínculo vivem todos na mesma árvore React — sem ponte de módulo, sem API imperativa entre canvas e painel.

Vínculos e templates (src/bindings/, src/fields/table/columns.ts)​

Duas delas valem menção, porque são a mesma operação por pontas opostas. O reindexTableForNewHead reescreve a lista de cabeçalho inteira e re-deriva todo slot casando cada nome novo contra o head antigo; um nome renomeado, por definição, não está no head antigo, então aquele slot cai num branco. O renameColumnInTable é o estreito: muda head[index] e nada mais, que é o que faz um rename preservar o token, o estilo e a largura da coluna.

O table/columnFormula.ts é dono de duas coisas que o resto do pacote não pode duplicar: tokenFor(key), a regra única de como uma chave de JSON vira token, e o columnFormulaFor, que reproduz a precedência do próprio resolver (célula de design com { vence o vínculo) pra o editor não conseguir mostrar coisa diferente do que o PDF vai usar.

O table/normalizeColumns.ts converte coluna de chave crua em {label, formula} num template salvo mais os vínculos dele. Não é um passo do migrateTemplate e não pode ser: a chave da coluna vive nos bindings, que não fazem parte do Template.

bindings.ts é lógica pura sobre strings/objetos simples, sem dependência de terceiros:

  • resolveToken/renderTemplate — avalia um template {token}/ {FUNÇÃO(...)} contra o JSON de verdade. É quem transforma um TextSchema.content ou um KpiSchema.title/subtitle na string que de fato é desenhada. A formatação dentro de DATE/CURRENCY é propositalmente independente do idioma da UI do Designer (prop locale) — é parte do conteúdo do relatório gerado, escrito por quem monta o template, não da casca da ferramenta.
  • buildInputs — transforma o documento JSON inteiro + Binding[] num Record<schemaName, string> plano (ou um array 2D serializado, pra tabelas) que tanto o preview do canvas quanto o generate.ts leem.
  • resolveChartItems/aggregateChartItems — resolve o Binding de um gráfico contra o array de verdade, aplica filters, agrupa o resto em "Outros" a partir de topN.
  • resolveKpiValue — resolve um Binding do tipo kpi num número só agregado (sum/count/avg/min/max).
  • describeBinding/describeBindingShort — resumos legíveis usados só na UI do editor.

fields/table/columns.ts guarda as funções puras que mantêm head/content/ footer/columnStyles de uma TableSchema sincronizados com o Binding dela (array) quando uma coluna é adicionada/removida/ reordenada/reformatada pelo painel.

Degradar ou falhar: onde está a linha​

O src/pdf/textSafety.ts guarda as duas metades de uma decisão, e a divisão vale entender antes de mexer em qualquer uma.

Problema no dado degrada. Caractere de controle ( , , NUL) vira espaço em todo caminho que chega ao papel — o truncateToWidth é o funil de célula de tabela, KPI e rótulo de gráfico, e o renderText.ts sanitiza o próprio valor porque é o único caminho que não trunca. Fonte nenhuma tem glifo pra caractere de controle, nem uma Unicode completa, então isso é a única renderização possível, não perda de conteúdo. Antes disso, um no nome de um cliente fazia o documento inteiro falhar.

Caractere que uma fonte completa renderizaria, mas esta não, falha — e o withGlyphContext é o que torna a falha útil. Ele envolve uma chamada de desenho e, no erro, acha o caractere culpado testando os candidatos um por um contra a fonte, em vez de casar a mensagem de erro do pdf-lib (o mesmo erro que a hierarquia de erros do motor de expressões existe pra evitar). A lista de candidatos é uma função lazy, então resolver os rótulos de um gráfico ou achatar as linhas de uma tabela não custa nada no caminho normal. É aplicado em cinco pontos: o campo de texto, o KPI e o gráfico no render/index.ts, e dentro do drawTableSlice, que é o funil dos três caminhos de tabela (corpo, faixa repetida, aninhada em seção).

O docs/USAGE.pt-BR.md, "O que pode e o que não pode derrubar uma geração", é a versão da mesma tabela voltada pra quem usa — mantenha as duas em sincronia.

O motor de expressões (src/expressions/)​

parse -> AST -> evaluate, em quatro arquivos: tokenize.ts, parse.ts, evaluate.ts, mais o registry functions.ts (os 11 nomes de CUSTOM_FIELD_FUNCTIONS). O dataAccess.ts e o formatters.ts guardam a busca de path, a comparação de valor e a formatação de DATE/CURRENCY — moram aqui, e não em bindings.ts, porque o bindings.ts importa o motor, então o motor não pode importar de volta.

Três decisões que vale conhecer antes de mexer:

  • Um operador só é operador quando cercado de espaço em branco dos dois lados. {my-key} e {my key} são paths; {a - b} é subtração; {a -b} não é nenhum dos dois. Não é uma esquisitice pra limpar — é o que permite uma chave JSON com hífen ou com espaço, template em produção depende disso, e um tokenizador convencional quebraria {my-key} em três tokens e devolveria 0. O tokenize.ts implementa isso explicitamente, com testes.
  • Valores intermediários são string | number, coagidos na fronteira de cada operador. O motor que isto substituiu mantinha todo intermediário como string e reparseava em cada nível de aninhamento — daí ele não ter precedência de operador ({a + b * c} dava 20, não 14), nem agrupamento por parêntese ({(a + b) * c} dava 0), e estourar em {"x" + 1} e {a / 0}: esses dois caíam numa recursão infinita que o guarda de aninhamento pegava, reportando um problema de profundidade que não existia.
  • SUM/COUNT/AVG recebem um path de array cru, não um valor. Em SUM(itens.total), itens.total quer dizer "a coluna total do array itens". É por isso que os nós call guardam argSources (o texto original) ao lado dos args já parseados.

Sem eval/new Function: um template pode vir de fonte não confiável, então o avaliador percorre a AST. O MAX_EXPRESSION_DEPTH (40) protege a call stack do V8 contra template mal-formado ou malicioso.

Os erros são uma hierarquia de dois membros em errors.ts — ExpressionSyntaxError e ExpressionDepthError, os dois sob ExpressionError. A classe base é o que o resolve.ts captura, e isso importa: a geração engole problema de template (o campo renderiza vazio, o editor aponta) e deixa passar o resto, porque engolir tudo esconderia bug do motor. Reconhecer um deles casando a mensagem seria o mesmo erro com outra cara — e foi: o erro de profundidade era um Error cru farejado por regex, e por isso uma expressão aninhada demais derrubava o PDF inteiro.

Geração do PDF (src/pdf/)​

generate.ts é o ponto de entrada (generatePdf(template, data, bindings, options?)) — JS puro, sem DOM, seguro de rodar em Node. É só um orquestrador fino: pergunta pro layout/ onde tudo cai e desenha o que volta. Não contém nenhuma decisão de paginação própria (ver "Layout e desenho" abaixo).

  • layout/ — funções puras, sem pdf-lib nenhum:
    • layoutDocument.ts — A travessia de paginação: entram Template + dado + vínculos, sai um LayoutDocument (páginas de Placements já posicionados e com valor resolvido).
    • layoutTypes.ts — os tipos BodyItem/FlowBounds.
    • bodyLayout.ts — agrupa os schemas de uma página em BodyItems ordenados por Y (buildBodyItems), mais os helpers (boundsOf, gapAfter) que a travessia usa pra percorrer essa sequência.
    • pageLayout.ts — normalizePageDefs (Template de página única vs. Template.pages multi-página).
    • sectionLayout.ts — quantas repetições uma seção vinculada tem e quanto cada uma ocupa depois das tabelas mestre-detalhe crescerem.
  • render/ — o desenho de verdade, um arquivo por tipo de campo, despachado pelo drawFieldOfType de render/index.ts:
    • renderTable.ts — linhas de cabeçalho/corpo/rodapé, override de estilo por coluna, paginação quando a tabela não cabe numa página só.
    • renderSection.ts — repete o grupo de campos membros uma vez por item do array vinculado, crescendo/paginando junto com o resto do corpo.
    • renderChart.ts — pizza/rosca ou barra, posição da legenda (tamanho de fonte configurável), paleta de cores (src/fields/chart/colors.ts).
    • renderKpi.ts — o cartão colorido + o path do ícone Material Symbols (src/materialIcons.ts), tamanhos de fonte/ícone/raio de canto configuráveis (defaults em src/fields/kpi/card.ts).
    • renderText.ts/renderImage.ts — os dois tipos de campo mais simples; renderImage.ts também guarda os limites de segurança de tamanho/contagem de imagem (MAX_IMAGE_BYTES/MAX_DISTINCT_IMAGES).

Layout e desenho: uma travessia, depois pintura​

O layout/layoutDocument.ts decide toda a paginação de um Template + dado

  • vínculos, sem desenhar nada e sem tocar em pdf-lib. Devolve um LayoutDocument: uma entrada por página física, cada uma com Placements já posicionados (yMm vindo do cursor do fluxo) e já resolvidos (o valor do texto, as linhas de uma fatia de tabela). O generate.ts então percorre esse array e pinta; não contém nenhuma decisão de paginação.

Essa forma é recente e vale entender, porque substituiu um risco de verdade. A paginação era calculada duas vezes: o laço de desenho decidia quebra de página e desenhava na mesma passada, e o countBodyPages percorria o corpo uma segunda vez só pra produzir o total — o {pageCount} tem que estar certo já na primeira página física, então a contagem precisa terminar antes do primeiro traço. As duas compartilhavam só as decisões atômicas de pagination.ts (needsNewPageForItem, computeTableSlice); o avanço de cursor, o laço de fatiar tabela e a repetição de seção existiam em duas cópias. Uma mudança em uma delas significava "o dry-run disse 7 páginas, o desenho fez 8" — silencioso e muito difícil de rastrear.

Hoje a contagem de páginas é pages.length, então a divergência é estruturalmente impossível.

Duas consequências que vale manter:

  • O cursor depois de uma tabela avança por computeTableSlice().heightMm, não pelo Y que o drawTableSlice devolve. A geometria de tabela é determinística (TABLE_ROW_HEIGHT_MM é fixo e a célula trunca em vez de quebrar linha), então a altura é função da CONTAGEM de linhas — que é exatamente o que permite o layout saber onde a tabela termina sem desenhá-la.
  • O layout/ não pode importar do render/. A medição de seção mora em layout/sectionLayout.ts e as métricas de tabela em pdf/tableMetrics.ts por esse motivo; o render/renderTable.ts importa rgb do pdf-lib como valor, e arrastar isso pro grafo do layout custaria a independência dele — a propriedade que permite o mesmo LayoutDocument alimentar outra coisa além do pdf-lib mais adiante.

{pageNumber}/{pageCount} são resolvidos pelo renderizador, não pelo layout, e só pras faixas repetidas (cabeçalho/rodapé/margem) — nenhum campo do corpo os usa. É isso que remove o ovo-e-galinha que tornava o dry-run necessário.

Módulos de apoio (continuam direto sob src/pdf/, usados tanto por layout/ quanto por render/): pagination.ts (divide o corpo entre páginas contra headerHeight/footerHeight/marginLeft/marginRight — ver src/page/zones.ts pra como o editor classifica um campo em cabeçalho/ rodapé/margem/corpo só pela posição), fontUtils.ts (embute uma fonte TTF própria via fontkit, normalizeFontBytes), backgroundImage.ts (transforma um PDF/PNG/JPEG enviado no PNG de fundo da página), color.ts, resolvers.ts e pdfWorker.ts (configura o worker do pdf.js, browser-only). O backgroundImage.ts aceita só imagem — ver os entry points abaixo.

Só downloadPdf, Designer, PdfPreview* e os componentes de UI tocam o DOM. Todo o resto sob src/pdf/, src/bindings/ e src/types/ é seguro de importar num backend Node — veja Integração com backend e Uso só no servidor.

Estilo (src/css/theme.css, src/css/reset.css)​

Duas folhas de estilo escritas à mão, e nenhuma delas tem build step — a 3.0.0 tirou o Tailwind do pacote inteiro (tailwindcss e @tailwindcss/cli fora do devDependencies, script build:css removido, src/style.css deletado, npm run build reduzido a tsup). Elas chegam ao dist/ pelo publicDir: "src/css" do tsup e são expostas como json-pdf-designer/theme.css e json-pdf-designer/reset.css.

  • reset.css (236 linhas) — o subconjunto sem aparência: só o que o editor herdava do Preflight do Tailwind. Existe como export público separado pro consumidor que escreve a aparência ele mesmo e não quer a nossa; sem ele, esse consumidor teria de redescobrir por conta que uma dúzia de classes precisa de font: inherit mais appearance: button, e que border: 0 põe border-style: none (então qualquer border-width posterior computa zero — tem de ser border: 0 solid).
  • theme.css (2.770 linhas) — o reset mais a aparência inteira. Ela dá @import no reset.css, então um import basta pro consumidor e existe uma cópia só do reset pra manter em sincronia.

Nenhuma das duas é minificada, de propósito: elas são o contrato público, lidas pra aprender os nomes de classe e de token, e o bundler do consumidor minifica.

Os dois exports têm cobertura de exemplo, e o modo que não usa nenhum também. Os cinco apps em examples/ ficam em cinco pontos diferentes do espectro: theme.css como ele vem (report-builder), theme.css retematizado só por token (composed-layout), theme.css em dark mode (no-preview), reset.css sozinho (headless-designer) e CSS nenhum do pacote (custom-ui). Essa cobertura é o ponto: até a 3.0.0 o reset.css era export público com ZERO exemplo exercitando ele. O test/docsFreshness.test.ts guarda a divisão — ele lê as linhas de import de CSS de verdade de cada exemplo e falha quando uma muda sem a doc mudar junto, e também fixa a pegadinha do modo só-reset: o PdfPreview lê o --jpd-shadow-page-preview inline por canvas.style.boxShadow, então quem fica só no reset.css tem de declarar esse token, senão a sombra da página desaparece sem erro. Ver Tema e CSS.

Nenhum reset global​

Até a 2.1.1 o dist/style.css era Tailwind compilado com o Preflight incluso — um reset que caía no app inteiro do consumidor. Não cai mais. Cada classe .jpd-* carrega o reset de que o elemento dela dependia, e a única regra com * é escopada às raízes do editor e envolta em :where(), ou seja especificidade zero, então qualquer regra do consumidor ganha dela. O box-sizing é o que genuinamente não dá pra resolver por classe: o .jpd-input é width: 100% com padding e borda, e os filhos que o consumidor passa pra dentro de Card/Modal também precisam herdar.

@layer json-pdf-designer​

Toda regra que o pacote envia mora numa layer de cascata só, declarada primeiro (@layer json-pdf-designer, utilities;) antes de qualquer regra. CSS sem layer ganha de CSS com layer, independente de especificidade, então a regra do consumidor ganha por default — o que é justamente o que faz o className que um componente aceita querer dizer alguma coisa. Declarar a ordem antes é o que impede um consumidor que importa o Tailwind dele antes da nossa folha de acabar com o nosso reset vencendo as utilitárias dele (medido, num app de exemplo: um bg-sky-600 computava rgba(0,0,0,0) antes da declaração existir).

Onde a responsabilidade se divide​

A linha divisória corre igual no CSS e na API de componente:

  • A peça é dona do que a faz funcionar. O canvas é dono da geometria da folha (mm→px, transform: scale(zoom)) porque o react-rnd calcula o delta de arrasto contra esse transform. O gridTemplateRows do TabPanel É o mecanismo de colapso.
  • O consumidor é dono do layout. O viewport que rola em volta do canvas, e a largura de 320px da sidebar — que é por isso que essa largura mora no .jpd-designer__main do theme.css, e não na peça.
  • className/style/...rest vão pro elemento que dá o nome ao componente; todo outro elemento que ele renderiza é alcançável por parts, por papel. O cx faz merge de classe (dedupe por token exato, undefined em vez de "" quando nada sobra) e o mergeStyle deixa o style do consumidor ganhar, já que style é a saída de último recurso.

Estado é data-* e não classe em todo o pacote — a regra sendo que se o JSX teria de concatenar ou escolher uma string de classe, vira atributo. Dark mode é [data-jpd-theme="dark"] no :root, com a .dark mantida como alias pros consumidores da 2.x, e de propósito sem media query. A versão disso tudo virada pro usuário é Tema e CSS — mantenha as duas em sincronia.

O que guarda uma folha de estilo escrita à mão​

Não há compilador atrás destes arquivos, então dois arquivos de teste fazem esse papel:

  • test/noTailwind.test.ts — varre todo token em posição de classe no src/ contra uma allowlist: tem de ser jpd-*. Isso pega utilitária do Tailwind que sobrou e, o que uma blacklist não pegaria, classe SEM namespace — que foi exatamente como section-body e section-drag-handle entraram num pacote de biblioteca, onde colidem com o CSS do consumidor. A migração tocou ~285 sites de classe, e meio migrado é o pior estado possível: o theme.css não estiliza as utilitárias que sobraram e o Tailwind não existe mais pra gerá-las, então o pedaço não migrado renderiza sem estilo nenhum e sem erro nenhum.
  • test/theme.test.ts — cobre as quatro coisas da folha que quebram em silêncio: a declaração da @layer, o escopo da regra com *, e os blocos de token (:root, o par [data-jpd-theme="dark"]/.dark, a ilha de light) continuarem concordando. Ele remove comentários antes de varrer, porque o cabeçalho do próprio arquivo documenta a sintaxe que os testes procuram.

Idioma da UI (src/i18n/)​

O texto da própria UI do Designer (botões, abas, avisos, placeholders) vem de um dicionário pequeno — en.ts (canônico, default) e pt-BR.ts (tipado contra ele, então uma chave faltando é erro de compilação, não uma string vazia silenciosa). I18nProvider/useT/ useLocale (contexto React) ligam a prop <Designer locale="en" | "pt-BR"> até cada componente; um componente usado sozinho, sem <Designer> por cima, continua renderizando texto certo (em inglês) via o valor default do contexto. Isso só cobre a casca do editor — nunca muda como {DATE(...)}/{CURRENCY(...)} formatam o conteúdo do relatório gerado.

Três entradas (index.ts, server.ts, preview.ts)​

src/index.ts exporta a API pública completa — componentes React (Designer, o kit de UI) mais geração/vínculos/tipos. src/server.ts espelha só o subconjunto sem React — mesma geração/vínculos/tipos, zero referência a react em qualquer lugar do output compilado — buildado como um bundle separado (dist/server.{js,cjs,d.ts}) exposto pelo subpath json-pdf-designer/server. Manter os dois como listas mantidas à mão (em vez de uma gerada a partir da outra) faz um export adicionado por engano só numa das duas ser fácil de notar em review — ver o comentário no topo de src/index.ts.

O src/preview.ts é a terceira entrada (json-pdf-designer/preview) e segue o mesmo padrão por outro motivo: o pdfjs-dist é peer dependency opcional (~35MB instalado), e o grafo dele é o único que pode importá-lo. Renderizar o preview também é a única coisa pra que o pacote usa pdf.js — e é por isso que o backgroundImage.ts aceita só imagem: rasterizar um PDF enviado teria puxado o pdf.js de volta pro <Designer>, e portanto pro entry principal, pra todo mundo. Um import() lazy também não resolveria: o bundler ainda precisa resolver o specifier em tempo de build.

Os peers opcionais, e por que são opcionais​

Cinco pacotes são peer dependencies com optional: true, e nenhum deles é opcional por acidente:

PeerUsado porCusto se fosse obrigatório
react, react-dom<Designer> e os componentes de UIum backend Node resolvendo React que nunca renderiza
react-rndsó o PageCanvas.tsx (arrastar/redimensionar)ver abaixo — ele puxa o React junto
pdfjs-distsó o grafo do /preview~35MB instalados
wawoff2fontUtils.ts, lazy, pra fonte .woff2caminho WASM só-Node que bundler avisa

O react-rnd é o sutil. Ele era dependency normal até a 2.0.0, então era instalado sempre — e como os peers react/react-dom dele não são opcionais, o npm instalava o stack React inteiro (react, react-dom, react-draggable, re-resizable, scheduler, prop-types, …: cerca de 8,7MB) até num projeto que só importa /server. O nosso próprio optional: true no react era derrotado em silêncio um nível abaixo. Um install de backend hoje resolve fontkit, pdf-lib e tiny-inflate mais as árvores deles, e nada de React.

Como as fronteiras são garantidas​

Um import no arquivo errado quebra uma promessa de empacotamento, não o build: custa megabytes que ninguém pediu pra todo consumidor, ou deixa uma dependência real não declarada — e só apareceria no npm install de outra pessoa. Três checagens guardam isso, cada uma cobrindo um ângulo que as outras não pegam:

  1. test/entryBoundaries.test.ts (roda no npm test) — percorre o grafo de código a partir de src/index.ts, src/server.ts e src/preview.ts seguindo imports relativos, e garante que:

    • o /server não alcança react, react-dom nem react-rnd;
    • nem o entry principal nem o /server alcançam o pdfjs-dist.

    A mais rápida e a mais precisa: ela nomeia o arquivo culpado. Dois casos de controle garantem que a varredura ainda vê o que deveria — pdf.js pelo /preview, React e react-rnd pelo entry principal — pra que um walker quebrado não faça as outras afirmações passarem por vacuidade.

    Ela ignora statements type-only (import type / export type … from), que são apagados na compilação: o src/server.ts legitimamente faz export type { Locale, Dict } from "./i18n", e o ./i18n/index.ts reexporta o I18nProvider, que é React. Nada disso chega no dist/server.* — nem o JS nem o .d.ts, que o tsup inlineia.

  2. examples/no-preview (builda na CI) — um app que nunca instala o pdfjs-dist, cobrindo o node_modules de verdade em vez da árvore de código. Omitir a dependência não basta sozinho: o example linka o pacote com file:../.., e um bundler resolve import bare pelo caminho real do arquivo — então um import vazado ainda acharia o pdf.js no node_modules do repo pai (onde ele existe como devDependency). É por isso que o script build dele termina no check-no-pdfjs.mjs, que varre o bundle gerado procurando símbolos do pdf.js (GlobalWorkerOptions, PDFDocumentLoadingTask, pdf.worker.min.mjs). Nunca adicione o pdfjs-dist nesse example — isso anula a checagem.

  3. A checagem do tarball em .github/workflows/ci.yml — depois de npm install ./pkg.tgz, garante que o node_modules/pdfjs-dist não existe. Essa prova a afirmação de empacotamento: o npm não puxa o peer sozinho, ou seja, ele continua opcional de verdade.