Lição 16 - Recursos avançados de texto
Na lição anterior, apresentamos os princípios da renderização de texto baseada em SDF, experimentamos ESDT e MSDF para melhorar a qualidade da renderização e mencionamos os recursos avançados de renderização de texto que o CanvasKit oferece em comparação com o Canvas.
Nesta lição, vamos primeiro olhar métodos de renderização além do SDF e, em seguida, discutir e tentar implementar recursos como: linhas decorativas, sombras e texto que segue caminhos. Por fim, o texto não deve apenas poder ser renderizado, mas também ter uma boa interação. Vamos discutir temas como caixas de entrada, seleção de texto e A11y.
Vamos primeiro ver quais métodos de renderização de texto existem além do SDF.
Renderizar texto com curvas de Bézier
Usando o recurso de exportação SVG do Figma, dá para ver que o texto dele também é renderizado com Paths. Se não considerarmos o desempenho da renderização nem os caracteres CJK, usar curvas de Bézier para renderizar texto é, de fato, uma boa escolha. Para obter informações vetoriais dos caracteres em um ambiente de navegador, podemos usar:
- opentype.js
- o use-gpu usa use-gpu-text, que é um wrapper baseado em ab-glyph
- Cada vez mais aplicativos usam harfbuzzjs; veja: State of Text Rendering 2024. Por exemplo, font-mesh-pipeline é uma demonstração simples
Abaixo mostramos exemplos de renderização de texto com opentype.js e harfbuzzjs, ambos com suporte ao formato de fonte ttf.
opentype.js
O opentype.js oferece o método getPath, que completa o Shaping e obtém path-commands de SVG a partir do conteúdo do texto, da posição e do tamanho da fonte.
opentype.load('fonts/Roboto-Black.ttf', function (err, font) {
const path = font.getPath('Hello, World!', 0, 0, 32); // x, y, fontSize
// convert to svg path definition
});harfbuzzjs
Primeiro inicialize o WASM do harfbuzzjs usando a sintaxe ?init do Vite. Em seguida, carregue o arquivo de fonte e crie um objeto de fonte.
import init from 'harfbuzzjs/hb.wasm?init';
import hbjs from 'harfbuzzjs/hbjs.js';
const instance = await init();
hb = hbjs(instance);
const data = await (
await window.fetch('/fonts/NotoSans-Regular.ttf')
).arrayBuffer();
blob = hb.createBlob(data);
face = hb.createFace(blob, 0);
font = hb.createFont(face);
font.setScale(32, 32); // Set font sizeDepois crie um objeto buffer e adicione o conteúdo do texto. Como mencionado antes, o harfbuzz não trata BiDi, então precisamos definir manualmente a direção do texto. Por fim, chame o método hb.shape para executar o cálculo de Shaping.
buffer = hb.createBuffer();
buffer.addText('Hello, world!');
buffer.guessSegmentProperties();
// TODO: use BiDi
// buffer.setDirection(segment.direction);
hb.shape(font, buffer);
const result = buffer.json(font);Agora temos os dados do glifo e podemos usar Path para desenhá-lo
result.forEach(function (x) {
const d = font.glyphToPath(x.g);
const path = new Path({
d,
fill: '#F67676',
});
});Renderização de matemática TeX
Podemos usar MathJax para renderizar fórmulas matemáticas TeX, convertê-las em SVG e, em seguida, renderizá-las com Path. Aqui seguimos a abordagem de LaTeX in motion-canvas para obter o SVGElement:
const JaxDocument = mathjax.document('', {
InputJax: new TeX({ packages: AllPackages }),
OutputJax: new SVG({ fontCache: 'local' }),
});
const svg = Adaptor.innerHTML(JaxDocument.convert(formula));
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const $svg = doc.documentElement;Em seguida, use o método apresentado em Lição 10 - De SVGElement ao nó serializado para converter o SVGElement em gráficos e adicioná-los à tela.
const root = await deserializeNode(fromSVGElement($svg));Traço de texto
Tanto strokeText no Canvas quanto -webkit-text-stroke no CSS oferecem traços de texto. A boa notícia é que traços são naturalmente fáceis de implementar em SDF:
if (strokeWidth > 0.0 && strokeColor.a > 0.0) {
float fillAlpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist);
float strokeThreshold = buff - strokeWidth / fontSize;
float strokeAlpha = smoothstep(strokeThreshold - gamma_scaled, strokeThreshold + gamma_scaled, dist);
vec4 finalColor = mix(strokeColor, fillColor, fillAlpha);
outputColor = finalColor;
opacity *= strokeAlpha;
}A seguir estão renderizações baseadas em SDF e MSDF, e dá para ver que o MSDF permanece nítido mesmo com traço:
Decoração de texto
No CSS, os navegadores antigos tinham uma implementação tosca de text-decoration, exemplificada por underline, de onde vem a imagem a seguir: Crafting link underlines on Medium

O sublinhado perfeito deve ser visível, mas discreto — permitindo que as pessoas percebam o que é clicável, sem chamar atenção demais para si. Ele deve ficar a uma distância exatamente certa do texto, repousando com conforto atrás dele quando os descendentes quiserem ocupar o mesmo espaço:

Os navegadores atuais aperfeiçoaram essa implementação. Além disso, o Canvas não oferece esse recurso, e o CanvasKit o amplia com um conjunto de propriedades que correspondem ao text-decoration do CSS:
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/text-decoration#constituent_properties
// @see https://skia.org/docs/dev/design/text_shaper/#principles
const paraStyle = new CanvasKit.ParagraphStyle({
textStyle: {
decoration: CanvasKit.UnderlineDecoration,
decorationColor,
decorationThickness,
decorationStyle: CanvasKit.DecorationStyle.Solid,
},
});Outra implementação interessante, underlineJS, é baseada em Canvas.
Usar polilinha
O estilo da linha de decoração é controlado pela propriedade decorationStyle:
export type TextDecorationStyle =
| 'solid'
| 'double'
| 'dotted'
| 'dashed'
| 'wavy';
: TextDecorationStyle;No estilo solid mais simples, por exemplo, passamos decorationColor e decorationThickness como strokeColor e strokeWidth:
if (instance instanceof Text) {
const {
decorationColorRGB,
decorationThickness,
decorationStyle,
metrics,
} = instance;
u_StrokeColor = [
decorationColorRGB.r / 255,
decorationColorRGB.g / 255,
decorationColorRGB.b / 255,
fo,
];
u_ZIndexStrokeWidth[1] = decorationThickness;
}Estilo de decoração
A própria polilinha oferece suporte a strokeDasharray, então tanto dotted quanto dashed podem ser obtidos com ela. Aqui nos referimos à implementação do Skia para definir a proporção entre dash e gap:
// @see https://github.com/google/skia/blob/main/modules/skparagraph/src/Decorations.cpp#L187
SkScalar scaleFactor = textStyle.getFontSize() / 14.f;
switch (textStyle.getDecorationStyle()) {
case TextDecorationStyle::kDotted: {
dashPathEffect.emplace(1.0f * scaleFactor, 1.5f * scaleFactor);
break;
}
case TextDecorationStyle::kDashed: {
dashPathEffect.emplace(4.0f * scaleFactor, 2.0f * scaleFactor);
break;
}
default: break;
}De forma específica, precisamos calcular e amostrar as linhas wavy, o que faremos aqui gerando um Path SVG e usando o método descrito em Lição 13 - Amostragem em uma curva:
// @see https://github.com/google/skia/blob/main/modules/skparagraph/src/Decorations.cpp#L215
let d = 'M 0 0';
while (x_start + quarterWave * 2 < line.width) {
d += ` Q ${x_start + quarterWave} ${
wave_count % 2 != 0 ? quarterWave : -quarterWave
} ${x_start + quarterWave * 2} 0`;
x_start += quarterWave * 2;
++wave_count;
}O efeito é o seguinte:
Calcular a posição
A posição é controlada pela propriedade decorationLine:
export type TextDecorationLine =
| 'underline'
| 'overline'
| 'line-through'
| 'none';Referimo-nos à implementação Decorations::calculatePosition do Skia, usando underline como exemplo:
void Decorations::calculatePosition(TextDecoration decoration, SkScalar ascent) {
switch (decoration) {
case TextDecoration::kUnderline:
if ((fFontMetrics.fFlags & SkFontMetrics::FontMetricsFlags::kUnderlinePositionIsValid_Flag) &&
fFontMetrics.fUnderlinePosition > 0) {
fPosition = fFontMetrics.fUnderlinePosition;
} else {
fPosition = fThickness;
}
fPosition -= ascent;
break;
}
}Exportar SVG
Infelizmente, até o momento (2025.7.9), o SVG não oferece suporte completo a text-decoration. Na imagem da direita abaixo, text-decoration-color não respeita a cor azul que definimos e é substituída pela cor do texto, e text-decoration-style não tem suporte algum. Veja: Text decoration of a text svg in html.
Aparentemente, text-decoration-color não se aplica a elementos de texto SVG
O Figma contorna isso exportando como <path>. Ainda queremos manter <text>, então, ao exportar SVGs, só podemos usar wrappers <foreignObject>, como à esquerda abaixo:
<foreignObject width="50" height="20">
<span style="text-decoration: underline; text-decoration-color: blue;">
Text
</span>
</foreignObject>Sombras
O Pixi.js oferece DropShadowFilter, mas podemos implementar a sombra diretamente em SDF, sem pós-processamento. Use shadowOffset e shadowBlurRadius para controlar o deslocamento e o desfoque da textura SDF.
// @see https://github.com/soimy/pixi-msdf-text/blob/master/src/msdf.frag#L49
vec3 shadowSample = texture2D(uSampler, vTextureCoord - shadowOffset).rgb;
float shadowDist = median(shadowSample.r, shadowSample.g, shadowSample.b);
float distAlpha = smoothstep(0.5 - shadowSmoothing, 0.5 + shadowSmoothing, shadowDist);
vec4 shadow = vec4(shadowColor, shadowAlpha * distAlpha);
gl_FragColor = mix(shadow, text, text.a);Texto ao longo de um caminho
Na comunidade do Figma, muitos usuários esperam por esse recurso, por exemplo: Make text follow a path or a circle. Ele passou a ter suporte oficial recentemente: Type text on a path.
Em SVG, isso pode ser obtido com textPath; veja: Curved Text Along a Path
<path
id="curve"
d="M73.2,148.6c4-6.1,65.5-96.8,178.6-95.6c111.3,1.2,170.8,90.3,175.1,97"
/>
<text width="500">
<textPath xlink:href="#curve"> Dangerous Curves Ahead </textPath>
</text>O Skia oferece o método MakeOnPath; veja Draw text along a path:
const textblob = CanvasKit.TextBlob.MakeOnPath(text, skPath, skFont);
canvas.drawTextBlob(textblob, 0, 0, textPaint);No Mapbox, posicionar rótulos ao longo de estradas e rios é um cenário comum; veja Map Label Placement in Mapbox GL

O Kittl oferece uma ferramenta Easily Type Text On Any Path.
Veja fabricjs - text on path para outra implementação. Posicione cada glifo pelo comprimento de arco no centro da caixa de avanço e, em seguida, use a tangente nessa mesma distância para a orientação. Um parâmetro de Bézier t em geral não é uma fração do comprimento: a amostragem do ponto e da tangente precisa usar o mesmo mapeamento de comprimento de arco, como descrito em Lição 13 - Amostragem em uma curva.
const centerDistance =
startOffset + alignmentOffset + advanceBefore + advance / 2;
const ratio = centerDistance / totalPathLength;
const point = curve.getPointAt(ratio);
const tangent = curve.getTangentAt(ratio);A largura de avanço posiciona o glifo seguinte. Ela difere da largura da textura, dos apoios de tinta e do preenchimento do SDF. letterSpacing se aplica apenas entre glifos adjacentes.
Ajustar a rotação
A implementação gira cada Quad na CPU em torno da origem da linha de base do glifo e, em seguida, o renderiza com SDF/MSDF. Girar em torno da borda inferior da textura deslocaria o texto de forma diferente para cada fonte e tamanho.
const rotation = Math.atan2(tangent[1], tangent[0]);
const x =
point[0] -
(Math.cos(rotation) * advance) / 2 -
Math.sin(rotation) * pathOffset;
const y =
point[1] -
(Math.sin(rotation) * advance) / 2 +
Math.cos(rotation) * pathOffset;Este exemplo usa a linha de base alphabetic; outros ajustes de textBaseline também seguem a normal local. Os limites são a união dos retângulos de tinta dos glifos rotacionados, então mudar o conteúdo, o tamanho, o caminho ou os deslocamentos também atualiza os limites de seleção.
| Atributo | Comportamento |
|---|---|
textAlign | start / left, center ou end / right, em relação ao comprimento disponível do caminho |
startOffset | Move o texto alinhado ao longo da direção de leitura, em unidades do documento |
side | left segue o caminho; right percorre a partir da outra extremidade e gira os glifos preservando a ordem de leitura |
pathOffset | Move a linha de base ao longo da normal local; valores positivos ficam 90° no sentido horário a partir da tangente da direção de leitura |
letterSpacing | Acrescenta distância entre glifos adjacentes |
Em caminhos abertos, glifos cujo centro cai além de qualquer extremidade são omitidos, em vez de dar a volta ou se empilhar em uma extremidade. Um único contorno fechado pode cruzar a emenda com deslocamentos positivos ou negativos, com no máximo uma volta por linha. Subcaminhos separados concatenam seus comprimentos sem inventar uma linha sobre as lacunas de M. Linhas adicionais seguem a normal local no espaçamento da altura de linha.
Arraste o texto ou a alça azul ao longo do caminho, e arraste os pontos de controle laranja para remodelar a curva. Alterne entre uma curva, um círculo e uma linha, mude os lados e o alinhamento, ou ajuste o espaçamento e a distância da linha de base. As alças aceitam as setas do teclado, Shift para passos maiores e Esc para cancelar; um gesto de toque cancelado restaura o estado inicial.
Drag the text or blue handle along the path. Drag orange control points to reshape the curve. Handles support arrow keys; Esc cancels a drag.
Este exemplo usa o renderizador de texto SDF do core. As melhorias de layout e edição do caminho preservam o pipeline de grafemas existente; elas não acrescentam shaping OpenType completo nem suporte a ligaduras.
O ECS também oferece suporte a esses atributos de caminho, com gestos do transformer, picking de glifos, desfazer/refazer e edição de texto. Experimente o exemplo de texto em caminho no ECS; a edição usa uma caixa de texto reta e, ao confirmar, o layout curvo é restaurado.
Exportar SVG
O SVG nativo oferece suporte a textPath, como mostrado abaixo; veja Curved Text Along a Path. O exportador compartilha o layout da tela e emite elementos <tspan> posicionados com x, y e rotate para preservar mudanças de lado, recorte de transbordo e emendas fechadas em navegadores com suporte diferente a textPath side. A serialização JSON preserva o caminho, o lado, o alinhamento e os deslocamentos para edição posterior depois da importação.
<path
id="MyPath"
fill="none"
stroke="red"
d="M10,90 Q90,90 90,45 Q90,10 50,10 Q10,10 10,40 Q10,70 45,70 Q70,70 75,50"
></path>
<text>
<textPath href="#MyPath">Quick brown fox jumps over the lazy dog.</textPath>
</text>Interação mais amigável
Os <textarea> nativos do navegador oferecem recursos convenientes, como cursores piscando, seleções, controle por teclado, copiar e colar e outros. Se você quiser implementar esses recursos do zero com base em <canvas>, a tarefa seria muito tediosa, por exemplo fabricjs - loading custom fonts e o Google Docs, então não vamos escolher essa opção.
Entrada de texto
Abaixo, a partir do Figma, dá para ver que o elemento nativo <textarea> é usado para se posicionar na tela e, quando o Text recebe um clique duplo, a caixa de entrada é exibida:

Isso também é usado no excalidraw; veja: textWysiwyg.tsx.

Também adicionamos um elemento <ic-text-editor> para que ele se ajuste o máximo possível à renderização de Text na tela. O elemento nativo <textarea> tem o estilo redefinido, por exemplo sem mostrar bordas nem fundos. As propriedades fontFamily, fontSize e color têm propriedades CSS correspondentes que podem ser definidas diretamente, mas há uma série de fatores a considerar ao usar posicionamento absoluto:
@customElement('ic-text-editor')
export class TextEditor extends LitElement {
static styles = css`
:host {
position: absolute;
}
textarea {
position: absolute;
display: none;
margin: 0;
padding: 0;
border: 0;
outline: 0;
resize: none;
background: transparent;
}
`;
@query('textarea')
editable: HTMLTextAreaElement;
render() {
return html`<textarea></textarea>`;
}
}Primeiro é preciso converter as coordenadas da posição do evento de mouse do clique duplo para o sistema de coordenadas do viewport:
const { x, y } = this.api.canvas2Viewport({
x: this.node.x,
y: this.node.y,
});
this.editable.style.left = `${x}px`;
this.editable.style.top = `${y}px`;Em seguida, é preciso considerar o nível de zoom atual da câmera:
const { zoom } = this.api.getCamera().read(ComputedCamera);
this.editable.style.transform = `scale(${zoom})`;
this.editable.style.transformOrigin = `left top`;Por fim, queremos que a rolagem no elemento <textarea> não dispare o comportamento padrão do navegador, e sim atravesse o elemento e dispare no elemento <canvas>, que executa as operações de pan e zoom da câmera:
handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
const newWheelEvent = new WheelEvent('wheel', {});
$canvas.dispatchEvent(newWheelEvent);
};O efeito é o seguinte:

Calcular o tamanho quando o texto muda
A largura e a altura de <textarea> precisam ser recalculadas e definidas em tempo real ao digitar e colar texto.
Tratar Tab
O comportamento padrão de pressionar a tecla Tab em <textarea> é mudar o foco para o elemento seguinte. Queremos que ele seja igual ao do editor de código.
Seleção de texto
Quando há suporte para sobrepor o textarea ao Text, é fácil implementar esse recurso.
Efeitos especiais
Carregar fontes web
Para soluções que usam a API Canvas2D para gerar SDF, basta usar webfontloader para carregar as fontes primeiro e, em seguida, especificar a fonte com fontFamily.
import WebFont from 'webfontloader';
WebFont.load({
google: {
families: ['Gaegu'], // specify font
},
active: () => {
const text = new Text({
x: 150,
y: 150,
content: 'Hello, world',
fontFamily: 'Gaegu', // specify font
fontSize: 55,
fill: '#F67676',
});
},
});Você também pode mudar para o modo Text e selecionar a fonte personalizada Gaegu no exemplo da página inicial:

Material Design na GPU
Material Design on the GPU apresenta um efeito de material baseado em texto SDF, usando mapas de normal e iluminação para simular tinta se espalhando no papel. Não precisamos considerar a iluminação; basta usar ruído simplex para implementar o efeito e adicionar vários efeitos de absorção:
import { simplex_2d } from './simplex-2d';
import { aastep } from './aastep';
export const absorb = /* wgsl */ `
${aastep}
${simplex_2d}
float absorb(float sdf, vec2 uv, float scale, float falloff) {
float distort = sdf + snoise(uv * scale) * falloff;
return aastep(0.5, distort);
}
`;