Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ sustentável de verdade.
- **Três saídas:** SVG e PDF vetoriais e PNG raster, todas geradas no navegador, com opções de
gráfica — A4/Carta/Etiqueta, marcas de corte, sangria de 3 mm e preto 100% K.
- **14 molduras de impressão**, de rótulo simples a grade recortável e display de mesa.
- **Personalização** com indicador de contraste e logo central, com o teto de área medido.
- **5 formas de módulo** — clássico, arredondado, pontos, losango e **circuito**, com trilhas, pads e
vias de placa eletrônica. Cada forma é verificada por decodificação antes de existir.
- **Personalização** com paletas prontas, cor própria para os marcadores de canto, indicador de
contraste e logo central, com o teto de área medido.
- **Lote por CSV:** uma planilha vira centenas de códigos num ZIP, cada um verificado antes de
entrar no pacote.
- **Histórico local** em IndexedDB, com a configuração inteira — cor, moldura, logo — restaurável
Expand Down Expand Up @@ -203,6 +206,12 @@ que precisariam concordar pixel a pixel. Com uma `Scene` intermediária em milí
escrita **uma vez** como função pura e os renderers viram um `switch` sem regra de negócio. A
divergência entre formatos deixa de ser um risco a mitigar: some por construção.

**Forma de módulo como lista de primitivas, não como desenho por renderer.** Cinco formas × quatro
saídas (SVG, Canvas, PDF e o rasterizador da verificação) seriam vinte implementações — e a que
divergisse seria justamente a da verificação, que é quem diz ao usuário se o código lê. Em vez
disso, cada forma produz retângulos com raio, círculos e polígonos em unidades de módulo; o SVG e o
PDF consomem o **mesmo** caminho, e o rasterizador testa os mesmos limites ponto a ponto.

**Entre conteúdo e código passa só uma string.** Um QR carrega texto; o que faz a câmera abrir a
rede Wi-Fi é a convenção de formato dentro desse texto. Por isso os nove tipos moram num diretório
só, e o décimo não tocará em nenhum renderer, na verificação nem no lote.
Expand All @@ -226,6 +235,10 @@ Detalhes em [`docs/ARQUITETURA.md`](docs/ARQUITETURA.md).

- **Ida e volta** — gerar, compor, rasterizar, decodificar e comparar com a entrada, nos quatro
níveis de correção, nas 14 molduras e nos nove tipos de conteúdo.
- **Centro do módulo em todas as formas** — para cada uma das cinco formas, o centro de cada módulo
e a vizinhança de 0,15 módulo ao redor dele são conferidos um a um. É onde o decodificador
amostra: uma forma pode encolher e arredondar a tinta, nunca deslocar o centro. Foi esse teste que
fixou o teto de arredondamento dos marcadores em 1,05 módulo.
- **Equivalência do merge** — o `<path>` mesclado cobre exatamente os mesmos módulos que um retângulo
por módulo, verificado por comparação de conjuntos e por rasterização contra uma segunda
implementação.
Expand Down
17 changes: 16 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,22 @@ export const viewport: Viewport = {

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className={`${archivo.variable} ${plexSans.variable} ${plexMono.variable}`}>
/*
* `suppressHydrationWarning` só aqui, e só por causa do script de tema
* abaixo: ele grava `data-theme` no `<html>` antes da hidratação, e o
* servidor não tem como saber a preferência que está no navegador de quem
* pede a página. O atributo divergir é o comportamento correto, não um
* defeito — sem ele a página piscaria no tema errado.
*
* A supressão vale para os atributos deste elemento e nada mais: não desce
* um nível sequer da árvore, então nenhuma divergência real dentro da
* aplicação passa a ser escondida por causa disto.
*/
<html
lang="pt-BR"
className={`${archivo.variable} ${plexSans.variable} ${plexMono.variable}`}
suppressHydrationWarning
>
<head>
{/*
* Aplica o tema escolhido antes da primeira pintura. Precisa ser
Expand Down
57 changes: 53 additions & 4 deletions docs/ARQUITETURA.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ conhecimento de QR nem de formato:
```ts
type SceneNode =
| { kind: 'rect'; x; y; w; h; fill?; stroke?; strokeWidth? }
| { kind: 'qr'; x; y; side; artifact; dark; light }
| { kind: 'qr'; x; y; side; artifact; dark; light; forma?; olhos? }
| { kind: 'text'; x; y; text; font; size; weight; tracking; align; fill; rotate? }
| { kind: 'image'; x; y; w; h; href };
```
Expand All @@ -74,6 +74,45 @@ interpretador completo de SVG path para poder verificar a leitura — justamente
sustenta o produto. Guardando o artefato, cada renderer resolve como lhe convém: o SVG emite um
`<path>` único, o rasterizador percorre módulos, o PDF desenha retângulos.

### Forma dos módulos: primitivas, não desenho por renderer

`core/render/formas.ts` estende a mesma ideia um nível abaixo. São 5 formas × 4 consumidores (SVG,
Canvas, PDF e o rasterizador puro) — 20 implementações que precisariam concordar, e a que
divergisse seria a da verificação, que é quem responde ao usuário se o código lê.

A forma produz uma lista de primitivas **em unidades de módulo**, com a quiet zone já somada:

```ts
type Primitiva =
| { tipo: 'rect'; x; y; w; h; raios?; tinta }
| { tipo: 'circulo'; cx; cy; raio; tinta }
| { tipo: 'poligono'; pontos; tinta };
```

Cada consumidor lê a mesma lista: SVG e PDF pelo **mesmo** caminho (`camadasDasPrimitivas`), o
Canvas pelo mesmo caminho via `Path2D`, e o rasterizador por `contemPonto`, teste analítico por
pixel. Três primitivas bastam para as cinco formas, e nenhuma delas exige interpretador de caminho
— que é o que mantém o rasterizador puro e rodando no Node.

Duas regras não negociáveis, ambas de leitura e não de estética:

- **O centro do módulo manda.** É onde o decodificador amostra. Uma forma pode encolher, arredondar
e ligar módulos vizinhos; não pode deslocar nem descobrir o centro. O teste confere o centro e a
vizinhança de 0,15 módulo, para cada módulo, em cada forma.
- **O marcador de localização continua sólido.** O detector procura a razão 1:1:3:1:1 varrendo
linhas retas sobre ele. Arredondar o contorno preserva a razão; trocá-lo por círculos soltos a
destrói. O raio do canto tem teto calculado: acima de 1,707 módulo o arco descobre o centro do
módulo de canto, e com a folga exigida o teto cai para 1,19 — daí o 1,05 usado.

A tinta `claro` existe porque o miolo do marcador é vazado: anel cheio, retângulo claro por dentro,
miolo por cima. Vazar de verdade exigiria regra par-ímpar, que PDF e rasterizador tratariam
diferente. Por isso `camadasDasPrimitivas` preserva a **ordem de pintura** em vez de agrupar por
cor — agrupar por cor devolveria o anel por cima do vazado, e os três marcadores sairiam sólidos.

Quando a forma é a clássica e os marcadores não têm cor própria, todos os renderers seguem pelo
caminho antigo, com fusão de runs: o SVG continua saindo como **um único objeto** e o PDF como
retângulos legíveis pelo teste de geometria.

### Milímetro, não pixel

O produto existe para impressão. "1024 px" só significa alguma coisa depois de escolhido o DPI,
Expand Down Expand Up @@ -146,8 +185,14 @@ molduras que repetem o código — grade recortável e display de mesa — apres
de padrões de localização na mesma imagem, e o decodificador não sabe qual seguir.

Quando falha, o diagnóstico vem de **experimentos controlados**, não de heurística: remove o
logo, devolve as cores ao padrão, aumenta a escala. O primeiro que faz o código voltar a ler é a
causa isolada por eliminação, e o veredito carrega `confirmada: true`.
logo, devolve as cores ao padrão, devolve os módulos à forma quadrada, aumenta a escala. O
primeiro que faz o código voltar a ler é a causa isolada por eliminação, e o veredito carrega
`confirmada: true`.

A forma do módulo entrou nessa lista pelo mesmo motivo que o logo: é personalização capaz de
quebrar a leitura sem estragar nada que se veja. Se o mesmo conteúdo, no mesmo tamanho e com as
mesmas cores, volta a ler com módulo quadrado, então quem derrubou foi a estilização — e não há o
que discutir sobre isso.

Polaridade invertida é checada antes de tudo, sem experimento: inverter as duas cores mantém a
razão de contraste idêntica, então nenhum teste de cor a distinguiria. O decodificador roda com
Expand Down Expand Up @@ -276,7 +321,11 @@ Regras de coerência moram no reducer, e existem para impedir arquivo quebrado:
- **Trocar de unidade preserva o tamanho físico**; trocar o DPI em pixels reconverte o lado, para
o tamanho impresso não mudar sem o usuário pedir.
- **Restaurar do histórico substitui o estado inteiro**, sem merge: um registro é uma configuração
que já funcionou, e misturá-la com a atual produziria uma terceira que ninguém verificou.
que já funcionou, e misturá-la com a atual produziria uma terceira que ninguém verificou. As
chaves ausentes ganham o padrão, e é por isso que um registro gravado antes das formas continua
restaurável — ele volta como clássico.
- **`corOlhos: null` não é uma cor**, é "acompanhe o módulo escuro". Guardar a cor concreta faria o
marcador congelar quando o usuário trocasse a cor dos módulos.

---

Expand Down
6 changes: 6 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ volta, o BR Code é remontado a partir do TLV e o CRC-16 é conferido.
CSV → muitos QRs → ZIP, num Web Worker com progresso e verificação por linha. Histórico local em
IndexedDB, com a configuração inteira restaurável.

## ~~Forma e cor dos módulos~~ CONCLUÍDA

Cinco formas — clássico, arredondado, pontos, losango e circuito — como lista de primitivas em
unidades de módulo, consumida pelos quatro renderizadores. Paletas prontas e cor própria para os
marcadores de canto. A verificação de leitura ganhou o experimento que isola a forma como causa.

---

## O que faria sentido depois
Expand Down
7 changes: 7 additions & 0 deletions src/components/generator/Gerador.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ESTADO_INICIAL, reducer } from '@/state/reducer';
import { FichaTecnica } from './FichaTecnica';
import { PainelConteudo } from './PainelConteudo';
import { PainelCor } from './PainelCor';
import { PainelForma } from './PainelForma';
import { PainelHistorico } from './PainelHistorico';
import { PainelExportacao } from './PainelExportacao';
import { PainelLogo } from './PainelLogo';
Expand Down Expand Up @@ -176,12 +177,18 @@ export function Gerador() {
onDpi={(valor) => despachar({ tipo: 'dpi', valor })}
/>

<PainelForma forma={estado.forma} onForma={(valor) => despachar({ tipo: 'forma', valor })} />

<PainelCor
corEscura={estado.corEscura}
corClara={estado.corClara}
corOlhos={estado.corOlhos}
contraste={derivado.contraste}
contrasteOlhos={derivado.contrasteOlhos}
onCorEscura={(valor) => despachar({ tipo: 'cor-escura', valor })}
onCorClara={(valor) => despachar({ tipo: 'cor-clara', valor })}
onCorOlhos={(valor) => despachar({ tipo: 'cor-olhos', valor })}
onPaleta={(escura, clara) => despachar({ tipo: 'paleta', escura, clara })}
onInverter={() => despachar({ tipo: 'inverter-cores' })}
/>

Expand Down
78 changes: 78 additions & 0 deletions src/components/generator/PainelCor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useId } from 'react';
import type { VeredictoContraste } from '@/lib/contrast';
import { formatarRazao } from '@/lib/contrast';
import { Botao } from '@/components/ui/Botao';
import { Chip } from '@/components/ui/Chip';
import { PALETAS } from '@/state/reducer';

/**
* Personalização de cor com indicador de contraste.
Expand Down Expand Up @@ -43,24 +45,62 @@ function Seletor({ rotulo, valor, onChange }: SeletorProps) {
export interface PainelCorProps {
corEscura: string;
corClara: string;
corOlhos: string | null;
contraste: VeredictoContraste;
/** Contraste dos marcadores, só quando eles têm cor própria. */
contrasteOlhos: VeredictoContraste | null;
onCorEscura: (valor: string) => void;
onCorClara: (valor: string) => void;
onCorOlhos: (valor: string | null) => void;
onPaleta: (escura: string, clara: string) => void;
onInverter: () => void;
}

export function PainelCor({
corEscura,
corClara,
corOlhos,
contraste,
contrasteOlhos,
onCorEscura,
onCorClara,
onCorOlhos,
onPaleta,
onInverter,
}: PainelCorProps) {
const problema = contraste.polaridadeInvertida || contraste.nivel === 'insuficiente';
const problemaOlhos =
contrasteOlhos !== null &&
(contrasteOlhos.polaridadeInvertida || contrasteOlhos.nivel === 'insuficiente');

return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2.5">
<span className="type-caption">Paleta</span>
<div className="flex flex-wrap gap-2.5">
{PALETAS.map((p) => (
<Chip
key={p.nome}
ativo={p.escura === corEscura && p.clara === corClara}
onClick={() => onPaleta(p.escura, p.clara)}
>
<span className="flex items-center gap-2.5">
<span className="border-hairline flex size-4 shrink-0 border" aria-hidden="true">
<span className="w-1/2" style={{ background: p.escura }} />
<span className="w-1/2" style={{ background: p.clara }} />
</span>
{p.nome}
</span>
</Chip>
))}
</div>
<p className="type-small text-fg-muted">
Todas escuras sobre claro, inclusive a de placa eletrônica.{' '}
<strong className="text-fg font-semibold">Código claro sobre fundo escuro é recusado</strong> por
boa parte dos leitores de celular.
</p>
</div>

<div className="grid grid-cols-2 gap-5">
<Seletor rotulo="Módulo escuro" valor={corEscura} onChange={onCorEscura} />
<Seletor rotulo="Módulo claro" valor={corClara} onChange={onCorClara} />
Expand All @@ -87,6 +127,44 @@ export function PainelCor({
) : null}
</div>

{/*
* Cor dos marcadores por último e desligada por padrão: quem só quer um
* código azul troca uma cor, não duas. Ligada, ela ganha medição própria
* — marcador de baixo contraste derruba a leitura antes de qualquer
* módulo, porque é por ele que o detector acha o código.
*/}
<div className="flex flex-col gap-2.5">
<span className="type-caption">Cor dos marcadores</span>
<div className="flex flex-wrap gap-2.5">
<Chip ativo={corOlhos === null} onClick={() => onCorOlhos(null)}>
Mesma dos módulos
</Chip>
<Chip ativo={corOlhos !== null} onClick={() => onCorOlhos(corOlhos ?? corEscura)}>
<span className="flex items-center gap-2.5">
<span
className="border-hairline size-4 shrink-0 border"
style={{ background: corOlhos ?? corEscura }}
aria-hidden="true"
/>
Cor própria
</span>
</Chip>
</div>

{corOlhos === null ? null : (
<>
<Seletor rotulo="Marcadores de canto" valor={corOlhos} onChange={onCorOlhos} />
{contrasteOlhos === null ? null : (
<p className={`type-small ${problemaOlhos ? 'text-warning' : 'text-fg-muted'}`}>
Contraste dos marcadores: {formatarRazao(contrasteOlhos.razao)}.{' '}
{contrasteOlhos.mensagem ??
'Os três cantos são o que o scanner procura primeiro — este número precisa ser tão alto quanto o dos módulos.'}
</p>
)}
</>
)}
</div>

<p className="type-small text-fg-muted">
A razão de contraste é um bom indicador, mas scanners medem refletância. Quem confirma é a verificação
de leitura.
Expand Down
86 changes: 86 additions & 0 deletions src/components/generator/PainelForma.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client';

import { useMemo } from 'react';
import type { FormaModulo, MatrizModulos } from '@/core/render/formas';
import { FORMAS, caminhoDasPrimitivas, formaModulo, primitivasDoCodigo } from '@/core/render/formas';
import { Chip } from '@/components/ui/Chip';

/**
* Escolha da forma dos módulos.
*
* O ícone de cada chip não é um desenho feito à mão: é a própria função de
* geometria rodando sobre uma matriz de amostra. Escolher a forma pelo botão
* errado seria fácil se o ícone fosse aproximado — aqui o que se vê no chip é,
* literalmente, o que vai para o arquivo.
*/

/**
* Matriz de amostra, 5 × 5.
*
* Desenhada para exercitar o que distingue as formas: corrida horizontal,
* corrida vertical, cotovelo, cruzamento e módulo isolado. Numa amostra de
* blocos soltos, `Circuito` e `Pontos` sairiam quase iguais.
*/
const AMOSTRA: readonly (readonly number[])[] = [
[1, 1, 1, 0, 1],
[0, 0, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 0, 1, 0, 0],
[1, 0, 1, 1, 1],
];

const MATRIZ_AMOSTRA: MatrizModulos = {
size: AMOSTRA.length,
quietZone: 0,
isDark: (x, y) => AMOSTRA[y]?.[x] === 1,
};

function IconeForma({ forma }: { forma: FormaModulo }) {
const caminho = useMemo(
() => caminhoDasPrimitivas(primitivasDoCodigo(MATRIZ_AMOSTRA, forma, { marcadores: false }), 'escuro'),
[forma],
);

return (
<svg viewBox="0 0 5 5" className="size-5 shrink-0" aria-hidden="true">
<path d={caminho} fill="currentColor" />
</svg>
);
}

export interface PainelFormaProps {
forma: FormaModulo;
onForma: (forma: FormaModulo) => void;
}

export function PainelForma({ forma, onForma }: PainelFormaProps) {
const definicao = formaModulo(forma);

return (
<div className="flex flex-col gap-2.5">
<span className="type-caption">Forma dos módulos</span>

<div className="flex flex-wrap gap-2.5">
{FORMAS.map((f) => (
<Chip key={f.id} ativo={f.id === forma} onClick={() => onForma(f.id)}>
<span className="flex items-center gap-2.5">
<IconeForma forma={f.id} />
{f.nome}
</span>
</Chip>
))}
</div>

<p className="type-small text-fg-muted">{definicao.descricao}</p>

{forma === 'quadrado' ? null : (
<p className="type-small text-fg-muted">
Os três marcadores de canto continuam sendo peça sólida em qualquer forma — é por eles que o scanner
acha o código.{' '}
<strong className="text-fg font-semibold">A verificação de leitura confirma abaixo</strong>, com o
desenho exato que vai para o arquivo.
</p>
)}
</div>
);
}
Loading
Loading