O Uniscribe faz mais trabalho do que a maioria dos chamadores percebe. O ScriptItemize executa análise bidirecional e segmentação por script em uma passada, e o ScriptLayout produz a ordem visual dos runs resultantes. O HarfBuzz, o substituto portátil que as pessoas buscam, não faz nenhum dos dois: ele dá shaping a um único run cuja direção e script já foram decididos por outra pessoa. Então a parte difícil de levar um pipeline de texto PDF do Windows ao Linux ou ao macOS não é vincular um motor de shaping. É fornecer o algoritmo bidirecional que o Uniscribe estava fornecendo em silêncio, e no componente PDFium é para isso que o FPdfBidi existe
A unidade implementa o UAX #9 diretamente: regras P2 e P3 para direção de parágrafo, X1 a X10 para embeddings explícitos e isolates, W1 a W7 para tipos fracos, N0 a N2 para neutros e colchetes, I1 e I2 para níveis implícitos, e L1 e L2 para a reordenação final. Duas funções a carregam: o PdfResolveBidiLevels retorna um nível de embedding por code unit UTF-16, e o PdfBidiVisualOrder transforma esses níveis na permutação que coloca os code units da esquerda para a direita
O que o algoritmo dá a você, e o que não
Ele dá números a você. Níveis pares são da esquerda para a direita, ímpares são da direita para a esquerda, e o nível de cada caractere codifica o aninhamento de runs direcionais dentro do qual aquele caractere está. A partir desses números o L2 deriva uma permutação. O que o algoritmo deliberadamente não faz é decidir qual fonte usar, formar ligaduras ou reordenar glifos dentro de um cluster; essas são questões de shaping e pertencem ao estágio depois deste
uses
FPdfBidi;
var
Levels: TPdfBidiLevels;
Order: TPdfBidiOrder;
ParagraphLevel: Byte;
Text, Visual: WideString;
I: Integer;
begin
Text := SourceLine;
// pbdAuto aplica P2-P3: o primeiro caractere forte decide
if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
begin
Order := PdfBidiVisualOrder(Text, Levels);
SetLength(Visual, Length(Order));
for I := 0 to High(Order) do
Visual[I + 1] := Text[Order[I] + 1];
// Visual agora se lê da esquerda para a direita; Levels[] ainda diz
// quais runs são RTL para que um shaper receba direções corretas
end;
end;
A tabela de classes de caracteres é gerada, não escrita
Todo code point tem uma propriedade Bidi_Class, e o algoritmo a consulta constantemente, então a tabela é a fundação sobre a qual todo o resto se apoia. Ela é gerada a partir do Unicode Character Database em vez de mantida à mão: o campo cinco do UnicodeData.txt dá as classes atribuídas, e as declarações @missing no DerivedBidiClass.txt dão os padrões para os code points que o banco de dados não atribui, que é como blocos não alocados corretamente assumem R, AL, ET ou BN como padrão em vez de L
O truque de compressão é emitir apenas os intervalos cuja classe não é L. Qualquer coisa fora de todo intervalo é L, que é tanto o padrão Unicode quanto a classe da esmagadora maioria dos code points. Isso leva uma tabela que de outra forma correria a milhares de entradas a 745 intervalos e cerca de 6,7 KB. A consequência operacional vale enunciar: quando você migrar para uma nova versão do Unicode, rode o gerador de novo. Editar o arquivo include à mão vai funcionar, e também vai divergir silenciosamente do banco de dados na próxima atualização
O L2 deve reordenar code points, não code units UTF-16
Este é o erro que produz saída genuinamente corrompida, e a primeira implementação o cometeu. O L2 diz para reverter runs contíguos em cada nível do mais alto até o nível ímpar mais baixo. Escrito contra uma string UTF-16, "reverter um run" naturalmente significa reverter os code units nele. Para caracteres no Plano Multilingue Básico isso está bom. Para um caractere RTL em um plano astral, como os dos blocos cipriota ou sul-arábico antigo perto de U+10800, não está: o caractere é um par substituto, reverter o run põe o surrogate baixo antes do alto, e a string agora contém dois surrogates não pareados em vez de um caractere. Nada a jusante consegue recuperá-lo
A correção é fazer o L2 em unidades de code point. A implementação funde code units em unidades de code point, executa as reversões nessas unidades e expande o resultado de volta a índices de code unit no fim. É por isso que o PdfBidiVisualOrder recebe o texto e não só o array de níveis: ele não consegue dizer onde estão as fronteiras de surrogate só pelos níveis. A mesma disciplina de pares substitutos atravessa as APIs de texto em geral, como descrito em o artigo de emoji, CJK e pares substitutos
A descida pelos níveis deve incluir níveis que não ocorrem
O segundo erro é mais sutil e não produz crash, apenas texto que não é reordenado. O L2 diz para começar no nível mais alto presente e descer até o nível ímpar mais baixo. Uma otimização natural é coletar o conjunto de níveis que de fato ocorrem e iterar sobre esse conjunto. Está errado
Considere uma linha de texto latino dentro de um embedding da direita para a esquerda. O nível do parágrafo é 0, o embedding empurra os caracteres latinos ao nível 2, e nenhum caractere está no nível 1. Iterar sobre os níveis que ocorrem encontra apenas 0 e 2, e não há nível ímpar algum, então o loop não executa reversão alguma. Essa resposta está correta, mas por uma razão que a otimização não conhece: uma reversão no nível 2 seguida de uma reversão no nível 1 se cancelaria exatamente, então não executar nenhuma é o desfecho certo. Mude a entrada levemente, de modo que caracteres de nível 1 e nível 3 existam mas o nível 2 não, e o loop baseado em conjunto pula a reversão de nível 2 que o algoritmo exige
// Correto: percorra todo nível do máximo até o nível ímpar mais
// baixo, incluindo níveis que nenhum caractere de fato tem
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
ReverseRunsAtOrAbove(Level); // no-op quando nenhum run se qualifica
Dec(Level);
end;
Escrito como um loop decrescente simples o comportamento sai de graça, e as iterações no-op não custam nada mensurável. Este é um caso em que a otimização óbvia não está levemente errada, está errada de um jeito dependente de entrada que um pequeno corpus de testes jamais revelará
Colchetes: BD16 com uma tabela pragmática
A regra N0 e o algoritmo de pares de colchetes BD16 existem para que um parêntese em texto de direção mista resolva para a direção do que ele cerca em vez de para o que por acaso esteja adjacente. Isso precisa de uma tabela de pares de colchetes. A implementação carrega os pares de uso geral em vez do conteúdo completo do arquivo de colchetes do Unicode: ASCII, CJK, fullwidth, matemáticos e ornamentais
Um colchete não listado não é um erro. Ele resolve como um neutro ordinário por N1 e N2, que é exatamente o comportamento que toda implementação tinha antes de o Unicode 6.3 introduzir o N0. Então o limite é "menos refinado para colchetes raros", não "incorreto". Um detalhe precisa de tratamento explícito: a equivalência canônica entre os colchetes angulares em U+2329 e U+232A e os em U+3008 e U+3009 precisa ser dobrada ao casar pares, ou um colchete de abertura escrito de um jeito não conseguirá se parear com um de fechamento escrito do outro
Como você testa trinta regras que interagem
Não com um corpus grande, ao menos não primeiro. A abordagem produtiva foi dezesseis casos verificados à mão, cada um escolhido para exercitar uma regra específica e cada um conferido contra os níveis que o UAX #9 diz que deve produzir: detecção de direção de parágrafo sob P2 e P3, as regras de tipos fracos W2, W3 e W7, as regras de níveis implícitos I1 e I2, embedding explícito via X2 e X7, isolates via X5a e X6a, o reset L1 de whitespace ao final e separadores, um caso de colchete N0, e um caso com um caractere astral para travar o tratamento de surrogates
Dezesseis casos com níveis esperados de correção conhecida capturam mais que mil e seiscentos casos com saída de aparência plausível, porque o modo de falha de uma implementação bidirecional é texto que quase se lê certo. Uma vez que esses passem, um corpus é útil para achar lacunas de tabela e problemas de desempenho, que são classes diferentes de defeito
Dentro do componente PDFium os níveis alimentam dois consumidores. No lado da escrita eles dizem ao backend de shaping a direção de cada run, que é a entrada de que o HarfBuzz precisa. No lado da leitura informam a geometria de seleção e a ordem de leitura, já que um clique em texto RTL precisa mapear a uma posição lógica em vez de visual; esse mapeamento é coberto em o artigo de seleção de linha visual e o modelo de ordem de leitura em blocos de texto estruturado e ordem de leitura. Os detalhes de suporte de plataforma do componente estão na página de produto do PDFium Delphi component