O relato de bug tinha uma frase: "o árabe vai para a esquerda." Era reproduzível em uns
quinze segundos. Abra uma nota, digite Hello, aperte Enter, cole um parágrafo em
árabe. O árabe é renderizado com os glifos moldados e ligados corretamente — o navegador faz essa
parte com perfeição — mas o parágrafo fica colado na margem esquerda, com a borda irregular à
direita. Em todo processador de texto que uma pessoa que lê árabe já usou, seria o contrário.
O editor tinha dir="auto" no elemento contenteditable. Esse atributo existe
justamente para resolver isso. Ele estava fazendo exatamente o que está especificado que faça.
dir="auto" não está quebrado aqui — ele está respondendo a outra pergunta, e não à que um bloco de notas precisa responder.O que o atributo de fato promete
A regra por baixo do dir="auto" não é uma heurística que alguém de um fabricante de
navegador inventou. É o
Anexo Unicode nº 9, o Algoritmo
Bidirecional, e a parte relevante tem três regras. A regra P1 divide o texto em
parágrafos. A regra P2 diz:
Em cada parágrafo, encontre o primeiro caractere do tipo L, AL ou R, ignorando quaisquer caracteres entre um iniciador de isolamento e seu PDI correspondente.
E a regra P3: se esse caractere for AL ou R, o nível de
incorporação do parágrafo é 1 — da direita para a esquerda. Caso contrário é 0.
Essa é a regra do "primeiro caractere forte" inteira, e ela é genuinamente boa. É o que o Word faz.
É o que todo aplicativo de mensagens faz quando inverte um balão de conversa. O problema é a
palavra parágrafo. No UAX #9 um parágrafo é uma unidade real de texto. No HTML, o
dir="auto" resolve uma vez para o elemento em que está, e uma div
contenteditable é um elemento só, não importa quantos parágrafos a pessoa digite
dentro dela.
O navegador resolve uma direção só para todo o contenteditable a partir do seu
primeiro caractere forte. Uma nota que começa com a palavra "Hello" vai manter todo parágrafo em
árabe alinhado à esquerda pelo resto da vida do documento — e, como a moldagem dos glifos
continua perfeita, nada nisso parece um bug de renderização. Parece descuido do app.
Descendo a decisão até o bloco
Uma vez que você aceita que a direção é propriedade de um parágrafo e não de um documento, o
formato da correção é óbvio: rodar você mesmo a regra do primeiro caractere forte, por bloco, e
escrever a resposta no bloco como um atributo dir de verdade. O navegador então faz
todo o resto — moldagem de glifos, pontuação espelhada, posicionamento de marcadores de lista,
movimento do cursor — a partir desse único atributo.
O scanner tem quinze linhas e é o núcleo do módulo:
/** Any letter. Everything that is a letter but not RTL is strong LTR. */
const LETTER = /\p{L}/u;
export function detectDirection(text: string): 'ltr' | 'rtl' | null {
for (const ch of text) {
const cp = ch.codePointAt(0)!;
if (isRtlCodePoint(cp)) return 'rtl';
if (cp === 0x200e) return 'ltr'; // LEFT-TO-RIGHT MARK
if (LETTER.test(ch)) return 'ltr';
}
return null;
}
Três detalhes ali são estruturais e nenhum deles é óbvio.
O for (const ch of text) itera code points, não unidades de código
UTF-16. Um laço for (let i = 0; i < text.length; i++) devolveria um surrogate
solitário para qualquer coisa acima de U+FFFF, e várias das escritas que importam aqui — adlam,
mende kikakui, hebraico antigo, avéstico — vivem nos planos astrais. Com um laço por índice elas
silenciosamente nunca dão match.
O \p{L} com a flag u significa "qualquer letra Unicode", que é o que
faz o ramo LTR funcionar para cirílico, grego, devanágari, tailandês, han e todo o resto sem
enumerar nenhum deles. O conjunto RTL precisa ser enumerado; o conjunto LTR é o complemento, e
complementos são de graça.
E o tipo de retorno tem três estados, não dois. Esse terceiro é assunto de uma seção mais abaixo, porque errá-lo produz um cursor que fica pulando enquanto você digita.
Dígitos não são caracteres fortes, e a tabela de faixas precisa dizer isso
O teste ingênuo de RTL é "este code point está no bloco árabe". O bloco árabe é U+0600–U+06FF, então você escreve essa faixa, e tudo funciona até alguém começar um parágrafo com um numeral arábico-índico.
U+0660–U+0669 são os dígitos ٠١٢٣٤٥٦٧٨٩. U+06F0–U+06F9 são os dígitos arábico-índicos
estendidos ۰۱۲۳۴۵۶۷۸۹ usados em persa e urdu. Os dois ficam dentro do bloco árabe, e nenhum é
caractere forte — o UAX #9 os classifica como AN e EN. O mesmo vale na
outra direção: um parágrafo que começa com "2024" não pode resolver como da esquerda para a direita
só porque dígitos europeus vieram primeiro.
Então a tabela tem buracos, de propósito, e os buracos são a parte interessante:
const RTL_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x0590, 0x05ff], // Hebrew
[0x0600, 0x065f], // Arabic — stops before the Arabic-Indic digits
[0x066a, 0x066a], // Arabic percent sign
[0x066d, 0x06ef], // Arabic — resumes after the digit separators
[0x06fa, 0x08ff], // Arabic ext., Syriac, Thaana, N'Ko, Samaritan, Mandaic
[0x200f, 0x200f], // RIGHT-TO-LEFT MARK
[0x202b, 0x202b], // RIGHT-TO-LEFT EMBEDDING
[0x202e, 0x202e], // RIGHT-TO-LEFT OVERRIDE
[0x2067, 0x2067], // RIGHT-TO-LEFT ISOLATE
[0xfb1d, 0xfdff], // Hebrew + Arabic presentation forms A
[0xfe70, 0xfefc], // Arabic presentation forms B
[0x10800, 0x10fff], // Cypriot, Phoenician, Old Hebrew, Avestan, …
[0x1e800, 0x1efff], // Mende Kikakui, Adlam, Arabic Mathematical
];
[0x0600, 0x06ff].A busca explora o fato de a tabela estar ordenada, então um caractere latino sai na primeira comparação em vez de testar treze faixas:
function isRtlCodePoint(cp: number): boolean {
for (const [lo, hi] of RTL_RANGES) {
if (cp < lo) return false; // ranges are sorted — nothing later can match
if (cp <= hi) return true;
}
return false;
}
Para uma nota em português essa saída antecipada é a diferença entre uma comparação de inteiros por caractere e treze. Isso roda a cada tecla, então importa mais do que parece.
"Nenhum caractere forte" é uma resposta de verdade, não uma resposta ausente
Aperte Enter no fim de um parágrafo em árabe. O novo parágrafo está vazio. Ele não tem primeiro caractere forte, porque não tem caractere nenhum.
Se o detectDirection devolvesse 'ltr' para isso — o padrão óbvio — o
cursor saltaria da borda direita do papel para a esquerda no instante em que você apertasse Enter, e
voltaria para a direita no instante em que você digitasse uma letra árabe. Toda linha nova, duas
vezes. É o tipo de coisa difícil de descrever em um relato de bug e impossível de ignorar depois de
sentir.
Então o terceiro estado existe, e a sincronização no nível do bloco o trata como "mantenha o que você tem":
function syncBlock(el: HTMLElement, root: HTMLElement): void {
if (el.hasAttribute(MANUAL_ATTR)) return;
const dir = detectDirection(el.textContent ?? '');
if (dir === null) return; // undecidable — leave it alone
if (dir === 'rtl') setDir(el, 'rtl');
// Only spell out "ltr" when something above would otherwise make it RTL.
else setDir(el, inheritsRtl(el, root) ? 'ltr' : null);
}
Um novo parágrafo vazio depois de um em árabe herda rtl pela herança normal de
atributos do navegador, mantém isso porque o scanner se recusou a contrariar, e o cursor fica onde o
olho da pessoa já está. No momento em que ela digita uma letra latina, ele inverte, uma vez, de
propósito.
Escreva "rtl" sempre, escreva "ltr" só quando for preciso
A última linha do syncBlock é assimétrica de propósito. O rtl é sempre
escrito. O ltr só é escrito quando o inheritsRtl() encontra um ancestral
RTL que de outro modo desceria em cascata sobre este bloco — caso contrário o atributo é removido
por completo.
A razão é que o dir vai para o HTML salvo. O conteúdo de cada aba é serializado para o
armazenamento como marcação, e um dir="ltr" em cada parágrafo de cada nota em português
é peso puro em cada salvamento, cada exportação e cada colagem. Da esquerda para a direita já é o
padrão; reafirmá-lo só é útil quando algo está ativamente contrariando — um item de lista árabe
dentro de uma lista árabe, com uma entrada em português no meio.
Quais elementos contam como bloco
A lista é mais longa do que parece à primeira vista, e duas das entradas estão ali por motivos que não têm nada a ver com texto:
const BLOCK_SELECTOR =
'p,div,h1,h2,h3,h4,h5,h6,li,ul,ol,blockquote,pre,table,thead,tbody,tr,td,th';
ul e ol estão ali junto de li porque o recuo da lista é
padding-inline-start na lista, não no item. Carimbar só o li coloca o
marcador do lado certo mas deixa a lista inteira recuada pela borda errada.
table está ali junto de td e th porque uma tabela RTL dispõe
as colunas da direita para a esquerda. Carimbe só as células e você tem texto alinhado à direita em
colunas da esquerda para a direita, o que é pior do que qualquer das duas opções consistentes.
O CSS precisa ser lógico, ou o atributo não consegue nada
Carimbar dir="rtl" diz ao navegador para que lado o texto corre. Isso não sobrescreve
uma folha de estilo que fixou em qual lado as coisas ficam. Um único padding-left
dentro do papel do editor basta para recuar toda lista RTL pela borda errada enquanto os marcadores
ficam corretamente à direita — um layout que parece menos um bug e mais uma decisão de design que
ninguém pensou até o fim.
padding-inline-start resolve contra o próprio direction do elemento, então uma declaração está correta nos dois casos. Não existe folha de sobrescrita para RTL em lugar nenhum deste código.| Não use dentro do editor | Use no lugar |
|---|---|
padding-left / padding-right | padding-inline-start / -end |
margin-left / margin-right | margin-inline |
border-left / border-right | border-inline-start / -end |
text-align: left / right | text-align: start / end |
deslocamentos left / right | inset-inline-start / -end |
A regra da citação é o exemplo mais claro. Ela é
border-inline-start: 3px solid …, então em uma citação em português a barra fica à
esquerda e em uma citação em árabe fica à direita, com uma declaração só. A versão física precisa de
duas regras e de um seletor que saiba de direção, e vai ser esquecida na primeira vez que alguém
acrescentar um novo tipo de bloco.
Rodando isso a cada tecla sem percorrer o documento
A direção precisa atualizar enquanto você digita — a primeira letra árabe em um parágrafo vazio deve invertê-lo na hora, não ao perder o foco. Mas re-derivar todo bloco da nota a cada caractere é quadrático de um jeito que aparece em uma nota longa.
Então há dois pontos de entrada com escopos bem diferentes. Uma passagem completa roda no carregamento e na colagem, onde o conteúdo é arbitrário e todo bloco é suspeito. Uma passagem restrita roda no input, subindo apenas do elemento do cursor até a raiz:
export function updateDirectionAt(root: HTMLElement, node: Node | null): void {
if (isUnwrapped(root)) {
applyAutoDirection(root);
return;
}
let el = nearestElement(node);
if (!el || !root.contains(el)) return;
while (el && el !== root) {
if (el.matches(BLOCK_SELECTOR)) syncBlock(el, root);
el = el.parentElement;
}
}
input, deliberadamente fora do debounce de 100 ms de mudança — para que o cursor inverta na primeira tecla em árabe, e não um décimo de segundo depois, e para que a escrita de salvamento automático do HTML já carregue o atributo.O caso do texto solto
Uma aba de texto simples, ou uma colagem recente de texto sem blocos, não tem elemento de bloco algum — só nós de texto diretamente sob o papel. Não há o que carimbar. Esse caso é verificado explicitamente e a direção vai para o próprio elemento do papel. Esta é a única situação em que o comportamento de região inteira está correto, porque a região genuinamente é um parágrafo só.
Deixando a pessoa contrariar a detecção
O primeiro caractere forte acerta quase sempre, e erra em um caso específico e previsível: um
parágrafo que começa com um nome de marca latino, uma URL ou um identificador de código mas que é
árabe no resto. O detector vê a letra latina, responde ltr, e está tecnicamente correto
pela regra enquanto está obviamente errado para quem lê.
Os botões LTR e RTL da barra de ferramentas e o menu Formatar fixam um bloco acrescentando um
atributo sentinela sem valor. O syncBlock faz curto-circuito nele já na primeira linha,
então um bloco fixado nunca é reconsiderado:
/** Marks a block whose direction the user set by hand — auto-detection skips it. */
const MANUAL_ATTR = 'data-dir-lock';
export function setSelectionDirection(root: HTMLElement, dir: 'ltr' | 'rtl' | 'auto'): void {
const blocks = selectedBlocks(root);
for (const el of blocks) {
if (dir === 'auto') {
el.removeAttribute(MANUAL_ATTR);
el.removeAttribute('dir');
} else {
el.setAttribute(MANUAL_ATTR, '');
el.setAttribute('dir', dir);
}
}
if (dir === 'auto') applyAutoDirection(root);
}
Três estados em vez de dois — esquerda, direita e automático. Sem um caminho explícito de volta, quem fixa um parágrafo uma vez não tem como devolvê-lo ao detector, e a terceira opção custa um ramo.
O selectedBlocks usa range.intersectsNode() em vez de percorrer a partir
dos nós âncora e foco, porque uma seleção arrastada por três parágrafos tem pontos de fronteira só
no primeiro e no último. O parágrafo do meio está inteiramente dentro do range e não aparece em
nenhuma das pontas.
O estado que vaza entre abas
Esse não estava na lista de ninguém. O papel do editor é um único elemento do DOM compartilhado por todas as abas abertas — trocar de aba substitui o conteúdo dele, não cria um elemento novo. Então um atributo escrito no próprio papel pertence ao elemento, não à nota.
Fixe a direção em uma aba de texto simples e a trava vai para o papel (o caso do texto solto acima).
Mude para outra aba e a trava continua lá, suprimindo silenciosamente a detecção em uma nota que
nunca pediu isso. A correção é uma única chamada no setContent, que roda a cada troca
de aba:
export function clearRootDirection(root: HTMLElement): void {
root.removeAttribute('dir');
root.removeAttribute(MANUAL_ATTR);
}
Qualquer estado escrito em um elemento reutilizado precisa ser limpo quando o conteúdo por trás dele muda. Óbvio em retrospecto, invisível no momento.
Colagem, e os dois atributos que vale manter
O HTML colado passa por uma lista de permissão antes de entrar no documento — trinta tags, oito propriedades de estilo e exatamente dois atributos:
const ALLOWED_ATTRS = new Set(['dir', 'lang']);
Todo o resto é removido, incluindo id, class e todo manipulador de evento.
O dir sobrevive porque o documento de origem pode ter acertado a direção e não há razão
para jogar isso fora e re-derivar. O lang sobrevive porque orienta a escolha de fonte e
a hifenização, e porque uma tag de idioma é a única coisa que distingue persa de árabe depois que o
texto está no DOM — eles compartilham a escrita e o detector não os diferencia, mas uma pilha de
fontes diferencia.
Depois da inserção, uma passagem completa de applyAutoDirection roda de qualquer jeito.
Marcação colada traz seus próprios blocos, e esses blocos nunca passaram pelo detector.
Exportação é um problema completamente à parte
Tudo acima produz um documento com direção correta na tela. O Word não lê nada disso.
Um .docx não tem conceito de "inferir a direção a partir do texto". As duas metades
precisam ser declaradas explicitamente: <w:bidi/> nas propriedades do parágrafo
para inverter o layout do parágrafo, e <w:rtl/> nas propriedades de cada run para
definir a ordem de leitura daquele run. Emita o primeiro sem o segundo e o texto cai no lugar certo
com os caracteres correndo para o lado errado.
A parte útil é que o exportador não reimplementa a regra. Ele importa o mesmo
detectDirection que o editor usa e o aplica por run, então uma frase quase toda latina
com uma palavra em árabe não é invertida por inteiro, e o arquivo concorda com a tela por
construção, não por coincidência. Isso está descrito em
escrevendo um .docx no navegador sem
dependências.
A exportação direta para PDF contorna o problema recusando-o. O gerador desenha com as fontes
Standard-14 do PDF, que cobrem apenas Latin-1, e escritas complexas ainda precisam de moldagem —
formas contextuais em árabe e urdu, reordenação e conjuntos em bengali e devanágari — que nenhum
arquivo de fonte sozinho oferece. Qualquer coisa fora desse repertório é entregue à impressão do
próprio navegador, que embute fontes Unicode reais, molda corretamente e respeita os atributos
dir que passamos este artigo inteiro carimbando. O raciocínio está em
gerando um PDF no navegador.
O que diríamos a quem estivesse começando isso
Texto bidirecional tem fama de ser assunto de especialista, e as partes de moldagem e reordenação genuinamente são — mas o navegador já faz tudo isso de graça. O que sobra é um problema mais estreito e bem mais tratável: decidir, por parágrafo, para que lado ele corre, e depois escrever CSS que não contrarie a resposta.
- Direção é propriedade de um bloco. Nunca de um documento, e nunca de uma região editável.
-
Nunca coloque
dir="auto"em umcontenteditable. Ele resolve uma vez para a região inteira e a falha é invisível até o segundo parágrafo. -
Dígitos são tipos fracos. Abra buracos de
U+0660–0669eU+06F0–06F9em qualquer faixa árabe que você escrever à mão. - Itere code points, não unidades de código, ou toda escrita RTL dos planos astrais silenciosamente deixa de dar match.
- "Nenhum caractere forte" é uma terceira resposta. Herde nela — não caia para LTR, ou o cursor pula a cada Enter.
-
Escreva
rtlsempre; escrevaltrsó quando um ancestral fosse sobrescrever. O atributo acaba em cada salvamento e cada exportação. -
Proíba CSS físico de
left/rightdentro do editor. Propriedades lógicas são uma declaração correta nas duas direções. - Dê à pessoa uma sobrescrita manual e um caminho de volta ao automático.
- Limpe o estado por elemento na troca de aba. Um nó do DOM compartilhado sobrevive ao conteúdo dentro dele.
- Formatos de exportação não inferem nada. Declare a direção explicitamente, e compartilhe um detector entre a tela e o arquivo para que não possam se afastar.
O módulo inteiro tem 197 linhas. A maior parte é a tabela de faixas e os comentários explicando por que a tabela tem buracos.