Artigo Técnico

Níveis de embutimento BiDi para texto PDF sem Uniscribe

O Uniscribe faz mais trabalho do que a maioria dos chamadores imagina. O ScriptItemize executa análise bidirecional e segmentação por sistema de escrita numa passagem, e o ScriptLayout produz a ordem visual dos runs resultantes. O HarfBuzz, o substituto portátil a que as pessoas recorrem, não faz nenhum dos dois: molda um único run cuja direção e sistema de escrita já foram decididos por outra pessoa. Por isso a parte difícil de levar uma pipeline de texto PDF Windows ao Linux ou macOS não é vincular um motor de shaping. É fornecer o algoritmo bidirecional que o Uniscribe estava a fornecer silenciosamente, e no componente PDFium é para isso que serve a FPdfBidi

A unidade implementa o UAX #9 diretamente: regras P2 e P3 para a direção do parágrafo, X1 a X10 para embutimentos e isolamentos explícitos, W1 a W7 para tipos fracos, N0 a N2 para neutros e parênteses, I1 e I2 para níveis implícitos, e L1 e L2 para a reordenação final. Duas funções transportam-na: PdfResolveBidiLevels devolve um nível de embutimento por unidade de código UTF-16, e PdfBidiVisualOrder transforma esses níveis na permutação que coloca as unidades de código da esquerda para a direita

O que o algoritmo lhe dá, e o que não dá

Dá-lhe números. Níveis pares são da esquerda para a direita, níveis ímpares são da direita para a esquerda, e o nível de cada carácter codifica o aninhamento de runs direcionais em que esse carácter se senta. A partir desses números a L2 deriva uma permutação. O que o algoritmo deliberadamente não faz é decidir que fonte usar, formar ligaduras, ou reordenar glifos dentro de um cluster; essas são preocupações de shaping e pertencem à etapa a seguir a esta

Pipeline FPdfBidi para texto PDF sem Uniscribe: PdfResolveBidiLevels atribui um nível de embutimento UAX #9 por unidade de código UTF-16 e PdfBidiVisualOrder aplica a regra L2 para produzir a ordem visual
Os níveis codificam o aninhamento de runs, e a regra L2 transforma-os na permutação que se lê da esquerda para a direita
uses
  FPdfBidi;

var
  Levels: TPdfBidiLevels;
  Order: TPdfBidiOrder;
  ParagraphLevel: Byte;
  Text, Visual: WideString;
  I: Integer;
begin
  Text := SourceLine;
  // pbdAuto aplica P2-P3: o primeiro carácter 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 lê-se da esquerda para a direita; Levels[] ainda
    // diz que runs são RTL para que um shaper receba direções corretas
  end;
end;

A tabela de classes de caracteres é gerada, não escrita

Cada ponto de código tem uma propriedade Bidi_Class, e o algoritmo a consulta constantemente, pelo que a tabela é o fundamento sobre que tudo o resto assenta. É gerada a partir da Unicode Character Database em vez de mantida à mão: o campo cinco de UnicodeData.txt dá as classes atribuídas, e as declarações @missing em DerivedBidiClass.txt dão os padrões para os pontos de código que a base de dados não atribui, que é como os blocos não atribuídos ficam corretamente como R, AL, ET ou BN em vez de L

O truque de compressão é emitir só os intervalos cuja classe não é L. Qualquer coisa que caia fora de todos os intervalos é L, que é ao mesmo tempo o padrão Unicode e a classe da esmagadora maioria dos pontos de código. Isso reduz uma tabela que de outra forma correria até milhares de entradas a 745 intervalos e cerca de 6,7 KB. A consequência operacional vale a pena enunciar: quando mudar para uma nova versão do Unicode, corra de novo o gerador. Editar o ficheiro include à mão funcionará, e também divergirá silenciosamente da base de dados na próxima atualização

A L2 tem de reordenar pontos de código, não unidades de código UTF-16

Este é o erro que produz saída genuinamente corrompida, e a primeira implementação cometeu-o. A L2 diz para inverter runs contíguos em cada nível do mais alto até ao nível ímpar mais baixo. Escrito contra uma string UTF-16, "inverter um run" naturalmente significa inverter as unidades de código nele. Para caracteres no Plano Multilingue Básico isso está bem. Para um carácter RTL num plano astral, como os dos blocos cipriota ou sul-arábico antigo perto de U+10800, não está: o carácter é um par substituto, inverter o run põe o substituto baixo antes do alto, e a string passa a conter dois substitutos sem par em vez de um carácter. Nada a jusante o pode recuperar

A correção é fazer a L2 sobre unidades de ponto de código. A implementação funde unidades de código em unidades de ponto de código, executa as inversões sobre essas unidades, e expande o resultado de volta a índices de unidades de código no fim. É por isso que o PdfBidiVisualOrder recebe o texto e não só o array de níveis: não consegue saber de apenas níveis onde estão as fronteiras dos substitutos. A mesma disciplina de pares substitutos atravessa as APIs de texto em geral, como descrito no artigo sobre emoji, CJK e pares substitutos

Corrupção de par substituto na reordenação bidi: inverter unidades de código UTF-16 parte um carácter astral perto de U+10800 em substitutos sem par, enquanto inverter unidades de ponto de código fundidas o mantém intacto
A regra L2 tem de fundir unidades de código em pontos de código antes de inverter, e depois expandi-las de volta

A descida pelos níveis tem de incluir níveis que não ocorrem

O segundo erro é mais subtil e não produz crash nenhum, só texto que não é reordenado. A L2 diz para começar no nível mais alto presente e descer até ao nível ímpar mais baixo. Uma otimização natural é recolher o conjunto de níveis que realmente ocorrem e iterar sobre esse conjunto. Está errada

Considere uma linha de texto latino dentro de um embutimento da direita para a esquerda. O nível do parágrafo é 0, o embutimento empurra os caracteres latinos para o nível 2, e nenhum carácter se senta no nível 1. Iterar sobre os níveis que ocorrem encontra só 0 e 2, e não há nível ímpar nenhum, pelo que o ciclo não executa inversão nenhuma. Essa resposta está correta, mas por uma razão que a otimização não conhece: uma inversão no nível 2 seguida de uma inversão no nível 1 cancelar-se-iam exatamente, pelo que não executar nenhuma é o desfecho certo. Mude a entrada ligeiramente, de modo a que existam caracteres de nível 1 e de nível 3 mas não de nível 2, e o ciclo baseado em conjunto salta a inversão de nível 2 que o algoritmo exige

// Correto: percorrer todos os níveis do máximo até ao nível ímpar
// mais baixo, incluindo níveis que nenhum carácter realmente tem
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
  ReverseRunsAtOrAbove(Level);   // não opera quando nenhum run qualifica
  Dec(Level);
end;

Escrito como um ciclo decrescente simples o comportamento sai de graça, e as iterações sem operação não custam nada mensurável. Este é um caso em que a otimização óbvia não está ligeiramente errada, está errada de um modo dependente da entrada que um pequeno corpus de testes nunca revelará

Armadilha da descida de níveis bidi no UAX #9: iterar só os níveis que ocorrem salta a inversão de nível 2 exigida, enquanto um ciclo decrescente simples de MaxLevel ao nível ímpar mais baixo sempre reordena corretamente
Percorrer todos os níveis até ao ímpar mais baixo não custa nada e nunca salta uma inversão exigida

Parênteses: BD16 com uma tabela pragmática

A regra N0 e o algoritmo de pares de parênteses BD16 existem para que um parêntese em texto de direção mista resolva para a direção do que encerra em vez de para o que por acaso estiver adjacente. Isso precisa de uma tabela de pares de parênteses. A implementação transporta os pares em uso geral em vez do conteúdo completo do ficheiro de parênteses do Unicode: ASCII, CJK, fullwidth, matemáticos e ornamentais

Um parêntese não listado não é um erro. Resolve como um neutro ordinário através de N1 e N2, que é exatamente o comportamento que toda a implementação tinha antes do Unicode 6.3 introduzir a N0. Por isso a fronteira é "menos refinado para parênteses raros", não "incorreto". Um detalhe precisa mesmo de tratamento explícito: a equivalência canónica entre os parênteses angulares em U+2329 e U+232A e os em U+3008 e U+3009 tem de ser dobrada ao fazer corresponder pares, ou um parêntese de abertura escrito de uma forma não conseguirá parear com um de fecho escrito de outra

Como se testam trinta regras em interação

Não com um corpus grande, pelo menos não primeiro. A abordagem produtiva foi dezasseis casos verificados à mão, cada um escolhido para exercitar uma regra específica e cada um verificado contra os níveis que o UAX #9 diz que deve produzir: deteçã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, embutimento explícito via X2 e X7, isolamentos via X5a e X6a, a reposição L1 de espaços em branco e separadores finais, um caso de parêntese N0, e um caso com um carácter astral para fixar o tratamento de substitutos

Dezasseis casos com níveis esperados de correção conhecida apanham mais de mil e seiscentos casos com saída de aparência plausível, porque o modo de falha de uma implementação bidirecional é texto que se lê quase certo. Uma vez esses passarem, um corpus é útil para encontrar buracos na tabela e problemas de desempenho, que são classes de defeito diferentes

Dentro do componente PDFium os níveis alimentam dois consumidores. No lado da escrita 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 tem de mapear para uma posição lógica e não visual; esse mapeamento está coberto no artigo sobre seleção de linhas visuais e o modelo de ordem de leitura em blocos de texto estruturado e ordem de leitura. Os detalhes de suporte de plataformas do componente estão na página de produto do PDFium Delphi component