Notepad Neo

Por que dir="auto" quebra um editor multilíngue

Um atributo deveria resolver o texto da direita para a esquerda. Ele o resolve exatamente uma vez por região editável, o que em um bloco de notas é a granularidade errada — e a falha é invisível até alguém escrever o segundo parágrafo.

DH

— cria e mantém o Notepad Neo

· atualizado em · 12 min de leitura

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.

Uma direção resolvida para um editor inteiro, versus uma por parágrafo À esquerda, dir="auto" na raiz editável resolve uma única direção a partir do primeiro caractere forte da nota — o H de Hello — então os parágrafos em árabe abaixo herdam da esquerda para a direita e se alinham à borda esquerda. À direita, cada bloco é carimbado a partir do seu próprio primeiro caractere forte, então o parágrafo em português continua alinhado à esquerda e os parágrafos em árabe se alinham à direita. ONE DECISION PER EDITOR ONE DECISION PER BLOCK <div contenteditable dir="auto"> <div contenteditable> Hello — a note about travel first strong char: H → level 0 Hello — a note about travel dir="ltr" inherited مرحبا بالعالم ✕ left مرحبا بالعالم dir="rtl" الرحلة الأولى الرحلة الثانية ✕ bullets left الرحلة الأولى الرحلة الثانية bullets right Back to English. Back to English. The whole region resolved once, from the H in “Hello”. Every paragraph after it inherits that answer. Each block resolves from its own first strong character. Mixing scripts in one note costs nothing.
A mesma nota, mesmo conteúdo, mesmo navegador. A única diferença é onde a decisão de direção é tomada. O 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.

Regras P2 e P3 do Algoritmo Bidirecional Unicode Percorrendo uma string da esquerda para a direita, tipos fracos como dígitos, pontuação e espaços são ignorados. A varredura para no primeiro caractere forte. Se esse caractere for de classe R ou AL, o nível do parágrafo é 1 (da direita para a esquerda); se for de classe L, o nível é 0. Se não houver caractere forte, a regra não produz resposta. SCANNING “2024 — مرحبا” 2 0 2 4 EN ␠ WS — ON ␠ WS م AL ر ح skip every weak type… …halt on the first strong one P2 — first L, AL or R found: AL P3 — AL or R ? yes embedding level 1 right-to-left Digits are class EN or AN — weak, never strong. “2024” cannot decide anything, which is why the scan has to keep going until it reaches a letter.
As regras P2 e P3 por inteiro. A metade interessante é o que é ignorado: espaços, pontuação e — a que pega as pessoas — dígitos.
Nunca coloque dir="auto" na raiz editável

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
];
O bloco árabe com suas faixas de dígitos excluídas Uma reta numérica de U+0590 a U+08FF. O hebraico e as faixas de letras árabes estão marcados como fortemente da direita para a esquerda. Dois vãos são recortados do bloco árabe: U+0660 a U+0669, os dígitos arábico-índicos, e U+06F0 a U+06F9, os dígitos arábico-índicos estendidos, ambos tipos fracos que não podem decidir a direção de um parágrafo. U+0590 → U+08FF Hebrew 0590–05FF Arabic letters 0600–065F digits 0660–69 Arabic 066D–06EF digits 06F0–F9 Syriac · Thaana · N'Ko · Samaritan 06FA–08FF class AN / EN — weak, not strong ٠١٢٣٤٥٦٧٨٩ ۰۱۲۳۴۵۶۷۸۹ “2024 مرحبا” must resolve right-to-left, from the م. If the digit ranges were inside the table, “٢٠٢٤ Hello” would resolve RTL — and that is equally wrong.
Três faixas separadas onde uma pareceria mais arrumada. Os dois vãos são a razão inteira de a tabela não ser simplesmente [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.

Quais blocos de uma nota mista recebem um atributo dir Uma árvore do DOM sob o papel do editor. Um parágrafo em português não recebe atributo porque da esquerda para a direita já é o padrão. Um título e um parágrafo em árabe recebem dir="rtl". Uma lista em árabe recebe dir="rtl" tanto no ul quanto nos li filhos, para que o recuo e os marcadores se movam. Um parágrafo carrega data-dir-lock, definido pela pessoa no menu Formatar, e a detecção automática o ignora por completo. #editor-paper <p> Trip notes no attribute — LTR is the default <h2 dir="rtl"> الرحلة first strong char is AL <ul dir="rtl"> flips padding-inline-start <li dir="rtl"> flips the ::marker side <li dir="rtl"> <p dir="ltr" data-dir-lock> pinned by hand — sync skips it
A carimbagem é esparsa de propósito. Só blocos que precisam contrariar o padrão carregam um atributo, e um bloco travado nunca mais é tocado pelo detector.

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.

CSS físico versus lógico em uma lista da direita para a esquerda A mesma lista em árabe estilizada de duas formas. Com padding-left, a lista recua a partir da borda esquerda enquanto seus marcadores ficam à direita, deixando um vão do lado errado. Com padding-inline-start, o recuo acompanha a direção de leitura e a lista fica corretamente encostada na margem direita. padding-left: 2em padding-inline-start: 2em indent العنصر الأول العنصر الثاني العنصر الثالث ✕ dead space on the wrong side indent العنصر الأول العنصر الثاني العنصر الثالث ✓ indent follows the reading edge The markers are on the right in both. Only the box the list sits in is wrong on the left — which is exactly why this survives a casual look at the screenshot.
O 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 editorUse no lugar
padding-left / padding-rightpadding-inline-start / -end
margin-left / margin-rightmargin-inline
border-left / border-rightborder-inline-start / -end
text-align: left / righttext-align: start / end
deslocamentos left / rightinset-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;
  }
}
Sincronização do documento inteiro versus sincronização restrita ao cursor No carregamento e na colagem, todo bloco da nota é re-derivado. A cada tecla, apenas a cadeia de blocos ancestrais acima do cursor é re-derivada, tipicamente dois ou três elementos, independentemente do tamanho da nota. ON LOAD · ON PASTE ON EVERY KEYSTROKE re-derived re-derived re-derived re-derived re-derived applyAutoDirection — every block caret updateDirectionAt — ancestors only Two or three matches() calls per character, and the cost does not grow with the note.
A passagem restrita roda de forma síncrona no 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.

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.

← Todos os artigos de engenharia Experimentar o editor