Como construímos modelos personalizados estruturados: deixar os usuários mudar o estilo sem abrir CSS arbitrário
Recebemos sempre o mesmo pedido dos usuários: “O modelo GitHub é legal, mas dá para mudar a cor do título para a cor da nossa marca? E aumentar um pouco o espaçamento entre linhas?”
Parece uma pequena funcionalidade, mas há um trade-off inevitável por trás: abrir CSS arbitrário ou não?
Este post percorre o caminho que acabamos escolhendo — sobreposições estruturadas em cima dos modelos de sistema existentes — e por que isso se encaixa melhor numa ferramenta como md-to.com, que segue o princípio “um conteúdo, vários formatos de exportação”.
Dois extremos comuns que não funcionam direito
A maioria das ferramentas Markdown lida com “estilo personalizado” de uma destas duas formas:
Opção 1: apenas modelos fixos. Simples e estável, mas não chega. O usuário escolhe o modelo mais próximo e logo trava no próximo passo: “queria só mudar a cor do título”.
Opção 2: CSS arbitrário. Liberdade total, dá para ajustar a área de pré-visualização sem limite. O problema: ficar bonito no navegador não significa ficar bonito depois da exportação.
Construímos uma ferramenta que converte Markdown em PDF / Word / HTML / imagem, e várias dessas trilhas de exportação não consomem CSS:
- O Word (
.docx) usa estilos OOXML. Não existe o conceito de “CSS arbitrário” ali. - A impressão de PDF tem suas próprias regras de paginação e margens. Layouts ajustados puramente com CSS de navegador costumam quebrar na hora de imprimir.
- A exportação de imagem passa por captura de canvas, e o suporte a algumas funcionalidades CSS (partes de
filter,backdrop-filter) é limitado.
Se deixarmos os usuários escreverem CSS arbitrário, a pré-visualização fica boa mas as exportações quebram silenciosamente. Esse descompasso “o que você vê não é o que você obtém” é uma péssima experiência.
Por isso pegamos um terceiro caminho.
Nossa abordagem: modelo base + sobreposições estruturadas
A ideia central numa frase: o usuário escolhe primeiro um modelo de sistema, depois muda um pequeno conjunto de campos estruturados em cima dele.
Não armazenamos um modelo completo — armazenamos “ID do modelo base + sobreposições”:
{
version: 1,
baseTemplateId: 'classic',
overrides: {
colors: {
heading: '#0F766E'
},
fonts: {
body: 'Noto Sans SC'
},
fontSizes: {
body: 13
},
spacing: {
lineHeight: 1.7
}
}
}
Por que não armazenar um modelo completo? Três razões:
- Os usuários se beneficiam quando a base melhora. Se mais tarde apertarmos o padding dos blocos de código no Classic, os modelos personalizados pegam o novo valor automaticamente — porque a sobreposição não tocava esse campo.
- Payload pequeno, validação simples nas duas pontas. O backend só precisa validar um punhado de campos da lista branca, e não um objeto de modelo inteiro.
- A semântica “baseado em qual modelo” é preservada explicitamente. A UI pode dizer ao usuário “você fez 3 mudanças em cima do Classic”.
Abaixo está a interface atual do editor, cobrindo quatro grupos de campos de alta frequência: cores, fontes, tamanhos de fonte e espaçamento:

O runtime: de um clique até uma exportação pronta
O sistema todo se resume a três pipelines.
Pipeline 1: pré-visualização ao vivo
O usuário muda uma cor no editor e a pré-visualização precisa atualizar imediatamente:
- O editor escreve os novos valores de sobreposição em
localStorage.customDocumentTemplate. - O editor dispara um evento
template-change. MarkdownPreviewouve o evento e chamagetActiveTemplate().getActiveTemplate()lêdocumentTemplate(a base atual) ecustomDocumentTemplate(as sobreposições). Se baterem, chamacreateCustomTemplate(baseTemplate, overrides)para fundir num modelo completo.generatePreviewCSS(template)regenera os estilos da pré-visualização.
A lógica de fusão é direta — um merge raso por grupo de campos:
return {
...baseTemplate,
id: `custom:${baseTemplate.id}`,
colors: {
...baseTemplate.colors,
...(overrides.colors ?? {}),
},
fonts: {
...baseTemplate.fonts,
...(overrides.fonts ?? {}),
},
fontSizes: {
...baseTemplate.fontSizes,
...(overrides.fontSizes ?? {}),
},
spacing: {
...baseTemplate.spacing,
...(overrides.spacing ?? {}),
},
};
Pipeline 2: salvar como modelo privado
Quando o usuário clica em “Salvar Modelo”, o adapter da ferramenta empacota o estado atual:
{
version: 1,
toolSlug,
documentTemplateId,
customDocumentTemplate
}
Esse payload vai para saved_templates.settings_json. Repare que documentTemplateId e customDocumentTemplate são armazenados separadamente — um modelo privado significa “Y modificações em cima do modelo X”, não um snapshot de estilo desconectado da origem.
Pipeline 3: aplicar um modelo privado
Quando o usuário escolhe um de volta no PrivateTemplateSelector, ou carrega via parâmetro ?tpl=<id>:
- O adapter chama
applySettings(settingsJson). - Se o payload tiver um
customDocumentTemplate, ele é gravado de volta no localStorage; se não, é limpo. documentTemplateIdé aplicado.template-changeé disparado, e os caminhos de pré-visualização / exportação recebem o modelo completo já fundido.
Um detalhe fácil de perder mas crítico: limpar as sobreposições ao escolher um modelo do sistema
Adicionamos uma única linha dentro de setActiveTemplate(templateId):
localStorage.removeItem('customDocumentTemplate');
Por quê? Sem isso, surge um cenário confuso:
- O usuário personaliza o Classic com título verde, e isso fica no localStorage.
- O usuário troca para o Dracula.
- O usuário volta para o Classic — e o título está verde de novo, mas ele não lembra de ter mudado.
Definimos “escolher um modelo de sistema” como um reset explícito: você escolhe um modelo limpo, recebe o modelo limpo. Para personalizar de novo, clique em “Personalizar” novamente. Essa semântica é muito mais clara do que “preservar as últimas edições do usuário sempre que possível”.
TemplatePickerModal faz a mesma limpeza quando “Aplicar” é clicado.
Validação no backend: a lista branca é a rede de segurança
O validateTemplateInput() no backend valida estritamente settingsJson.customDocumentTemplate:
customDocumentTemplateé opcional; se presente, deve ser um objeto.versionprecisa ser1(espaço para futuras migrações).baseTemplateIdprecisa ser uma string não vazia.overridesprecisa ser um objeto.- Campos fora da lista branca são rejeitados.
A lista branca cobre tudo o que a UI expõe hoje, com um pouco de folga para futuros campos:
| Grupo | Campos permitidos |
|---|---|
colors | heading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven |
fonts | heading, body, code |
fontSizes | h1, h2, h3, h4, h5, h6, body, code |
spacing | headingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight |
A lista branca é a rede de segurança de todo este desenho: como não aceitamos chaves arbitrárias, mesmo que mais tarde adicionemos lógica de renderização a um campo ou o renomeemos, chaves antigas salvas pelos usuários não vão se infiltrar no modelo de runtime e poluir a saída.
O ganho: as trilhas de exportação não precisam saber que existem “modelos personalizados”
Depois da fusão, temos um DocumentTemplate completo, então nenhuma das funções de exportação rio abaixo precisa mudar:
- Pré-visualização:
generatePreviewCSS(template) - PDF / impressão:
generatePdfStyles()/generatePrintStyles() - HTML:
generateExportHTML() - Cópia em texto rico:
generateRichTextHTML() - Word:
templateToDocxStyles(template)
É exatamente o ganho que tivemos ao recusar CSS arbitrário desde o começo: enquanto toda saída continuar consumindo DocumentTemplate como representação intermediária unificada, a consistência entre formatos é praticamente de graça. A cor de título escolhida pelo usuário aparece idêntica na pré-visualização do navegador, no PDF, no Word e nas exportações de imagem — não “parecida”, mas literalmente o mesmo valor hex, porque tudo é lido do mesmo modelo fundido.
Limitações atuais
A primeira versão é intencionalmente estreita:
- Apenas campos de alta frequência são expostos; nem toda cor, tamanho de fonte ou valor de espaçamento é editável.
- Não há entrada dedicada para “nomear seu modelo personalizado” — a nomeação ainda passa pela caixa de “Salvar Modelo”.
- Sem import / export JSON para modelos.
- Sem CSS arbitrário.
- A UI do editor é uma primeira passada leve — painéis agrupados, paletas de cores predefinidas e layout mobile ainda têm muito espaço para crescer.
Para onde isso vai
Alguns próximos passos claros para esta estrutura:
- Mais campos editáveis. Cor de borda de tabela, fundo de linhas alternadas, fundo de citação, cor do texto de código — a lista branca já tem os espaços; o que falta é UI.
- Um schema unificado de campos. Que o formulário do frontend e a validação do backend compartilhem uma única definição, evitando que se afastem.
- Uma entrada “Editar” na lista de modelos do workspace. Hoje só dá para salvar uma nova cópia; não dá para continuar editando um modelo privado existente.
- Um fluxo explícito “duplicar modelo do sistema como modelo privado”. Tornar “quero basear um privado no Classic” uma ação mais direta.
- Import / export JSON de modelos personalizados. Útil para compartilhar estilos dentro de uma equipe.
Resumindo
Olhando para trás, a chave deste design não é o que construímos — é o que não construímos:
- Não permitimos CSS arbitrário — o que evitou quebras entre formatos.
- Não armazenamos snapshots completos de modelos — o que preservou o benefício das atualizações de modelo base.
- Não mantivemos silenciosamente as sobreposições ao trocar de modelo de sistema — o que evitou a confusão “não mudei nada, mas o estilo está diferente”.
- O backend não aceita campos fora da lista branca — o que deixa espaço para o design evoluir.
Restrições, na verdade, entregam uma experiência mais previsível. Numa ferramenta que abrange vários formatos de exportação, essa previsibilidade vale mais que liberdade.
Se você também está construindo um produto “um conteúdo, várias exportações”, visite md-to.com e experimente este sistema de modelos personalizados — veja se é o tipo de “liberdade na medida certa” que você estava procurando.
Ferramentas Relacionadas
Converter MD para Word (Docx) Online - Markdown para Word Grátis
Converta Markdown para documentos Word instantaneamente. Conversor online gratuito de MD para DOCX com pré-visualização em tempo real. Mantenha a formatação intacta. Sem cadastro!
Conversor Gratuito de Word para Markdown - Converta DOCX para MD Online
Converta documentos Word para Markdown instantaneamente. Conversor online gratuito de DOCX para MD preserva títulos, tabelas, listas e formatação. Sem cadastro!
Converter MD para PDF Online - Conversor Gratuito Markdown para PDF
Converta Markdown para PDF instantaneamente. Conversor online gratuito de MD para PDF com mais de 20 templates profissionais. Perfeito para relatórios, documentação e impressão. Experimente agora!
Conversor Gratuito de Markdown para LaTeX - Converta MD para TeX Online
Converta Markdown para LaTeX online gratuitamente. Transforme seus documentos MD para formato TeX para artigos acadêmicos e documentos técnicos. Sem cadastro!