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 praFieldBox/(um componente pequeno porschema.type).- O painel lateral —
FieldList.tsx,Toolbar.tsxe, com um campo selecionado,PropertyPanel/index.tsx— um dispatcher fino pra umPropertyPanel<Tipo>.tsxpor 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) ePropertyPanelFields.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 umTextSchema.contentou umKpiSchema.title/subtitlena string que de fato é desenhada. A formatação dentro deDATE/CURRENCYé propositalmente independente do idioma da UI do Designer (proplocale) — é 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[]numRecord<schemaName, string>plano (ou um array 2D serializado, pra tabelas) que tanto o preview do canvas quanto ogenerate.tsleem.resolveChartItems/aggregateChartItems— resolve oBindingde um gráfico contra o array de verdade, aplicafilters, agrupa o resto em "Outros" a partir detopN.resolveKpiValue— resolve umBindingdo tipokpinum 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. Otokenize.tsimplementa 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/AVGrecebem um path de array cru, não um valor. EmSUM(itens.total),itens.totalquer dizer "a colunatotaldo arrayitens". É por isso que os nóscallguardamargSources(o texto original) ao lado dosargsjá 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, sempdf-libnenhum:layoutDocument.ts— A travessia de paginação: entramTemplate+ dado + vínculos, sai umLayoutDocument(páginas dePlacements já posicionados e com valor resolvido).layoutTypes.ts— os tiposBodyItem/FlowBounds.bodyLayout.ts— agrupa os schemas de uma página emBodyItems ordenados por Y (buildBodyItems), mais os helpers (boundsOf,gapAfter) que a travessia usa pra percorrer essa sequência.pageLayout.ts—normalizePageDefs(Templatede página única vs.Template.pagesmulti-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 pelodrawFieldOfTypederender/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 emsrc/fields/kpi/card.ts).renderText.ts/renderImage.ts— os dois tipos de campo mais simples;renderImage.tstambé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 comPlacements já posicionados (yMmvindo do cursor do fluxo) e já resolvidos (o valor do texto, as linhas de uma fatia de tabela). Ogenerate.tsentã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 odrawTableSlicedevolve. 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 dorender/. A medição de seção mora emlayout/sectionLayout.tse as métricas de tabela empdf/tableMetrics.tspor esse motivo; orender/renderTable.tsimportargbdo pdf-lib como valor, e arrastar isso pro grafo do layout custaria a independência dele — a propriedade que permite o mesmoLayoutDocumentalimentar 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 defont: inheritmaisappearance: button, e queborder: 0põeborder-style: none(então qualquerborder-widthposterior computa zero — tem de serborder: 0 solid).theme.css(2.770 linhas) — o reset mais a aparência inteira. Ela dá@importnoreset.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 oreact-rndcalcula o delta de arrasto contra esse transform. OgridTemplateRowsdoTabPanelÉ 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__maindotheme.css, e não na peça. className/style/...restvão pro elemento que dá o nome ao componente; todo outro elemento que ele renderiza é alcançável porparts, por papel. Ocxfaz merge de classe (dedupe por token exato,undefinedem vez de""quando nada sobra) e omergeStyledeixa ostyledo consumidor ganhar, já questyleé 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 nosrc/contra uma allowlist: tem de serjpd-*. Isso pega utilitária do Tailwind que sobrou e, o que uma blacklist não pegaria, classe SEM namespace — que foi exatamente comosection-bodyesection-drag-handleentraram 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: otheme.cssnã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:
| Peer | Usado por | Custo se fosse obrigatório |
|---|---|---|
react, react-dom | <Designer> e os componentes de UI | um backend Node resolvendo React que nunca renderiza |
react-rnd | só o PageCanvas.tsx (arrastar/redimensionar) | ver abaixo — ele puxa o React junto |
pdfjs-dist | só o grafo do /preview | ~35MB instalados |
wawoff2 | fontUtils.ts, lazy, pra fonte .woff2 | caminho 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:
-
test/entryBoundaries.test.ts(roda nonpm test) — percorre o grafo de código a partir desrc/index.ts,src/server.tsesrc/preview.tsseguindo imports relativos, e garante que:- o
/servernão alcançareact,react-domnemreact-rnd; - nem o entry principal nem o
/serveralcançam opdfjs-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 ereact-rndpelo 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: osrc/server.tslegitimamente fazexport type { Locale, Dict } from "./i18n", e o./i18n/index.tsreexporta oI18nProvider, que é React. Nada disso chega nodist/server.*— nem o JS nem o.d.ts, que o tsup inlineia. - o
-
examples/no-preview(builda na CI) — um app que nunca instala opdfjs-dist, cobrindo onode_modulesde verdade em vez da árvore de código. Omitir a dependência não basta sozinho: o example linka o pacote comfile:../.., e um bundler resolve import bare pelo caminho real do arquivo — então um import vazado ainda acharia o pdf.js nonode_modulesdo repo pai (onde ele existe como devDependency). É por isso que o scriptbuilddele termina nocheck-no-pdfjs.mjs, que varre o bundle gerado procurando símbolos do pdf.js (GlobalWorkerOptions,PDFDocumentLoadingTask,pdf.worker.min.mjs). Nunca adicione opdfjs-distnesse example — isso anula a checagem. -
A checagem do tarball em
.github/workflows/ci.yml— depois denpm install ./pkg.tgz, garante que onode_modules/pdfjs-distnão existe. Essa prova a afirmação de empacotamento: o npm não puxa o peer sozinho, ou seja, ele continua opcional de verdade.