wandres.dev
LORO · texto rico y árboles

Texto enriquecido: cuando el formato y el contenido chocan

Si dos personas ponen negrita y escriben en el mismo sitio a la vez, el formato y el texto se fusionan con reglas distintas; Loro resuelve el choque convirtiendo cada marca en anclas que son contenido.

⏱ 23 min

Todo lo visto hasta aquí sobre secuencias trataba el texto como una sarta de caracteres, y ese modelo basta mientras el documento sea plano. En cuanto aparece el formato, el problema cambia de naturaleza: la negrita no es un carácter, es una afirmación sobre un rango de caracteres, y un rango es una entidad frágil porque sus extremos se mueven cuando alguien inserta o borra dentro de él o junto a él. La pregunta incómoda es qué debe ocurrir cuando dos personas actúan a la vez sobre la misma zona con intenciones de tipo distinto —una pone negrita, la otra escribe— y la respuesta correcta no se deduce de ninguna regla de convergencia, porque hay varias fusiones matemáticamente válidas y solo una que respeta lo que las dos personas querían. Esta lección explica el problema tal como lo formuló la literatura, y desarma el mecanismo con el que Loro lo resuelve: convertir el formato en contenido para que herede la solución que el texto ya tenía.

🎯 Al terminar esta lección sabrás
  • Formular con precisión por qué el formato no puede tratarse como un atributo de mapa sobre un rango de índices.
  • Reconocer los casos canónicos de Peritext y qué fusión considera correcta cada uno.
  • Entender el mecanismo de las anclas de estilo y por qué las hace independientes del algoritmo de lista subyacente.
  • Manejar las reglas de expansión, anulación y solapamiento que la biblioteca expone al programador.

El problema: un rango no sobrevive a la concurrencia

Empecemos por el caso que hace visible la dificultad, que es el segundo criterio de Peritext y el que la propia documentación de Loro usa para ilustrar su algoritmo. El texto de partida es Hello World. La autora A pone en negrita Hello; el autor B, en paralelo, pone en negrita lo World. Los dos rangos se solapan y entre ambos cubren la frase entera. La fusión que respeta la intención de ambos es que toda la frase quede en negrita. La documentación de Loro señala que un motor que trate las marcas como caracteres de control sin emparejar puede producir el resultado incorrecto de dejar en negrita solo Hello, y que fusionar dos versiones de Markdown a ciegas produce algo aún peor.

El primer criterio es todavía más elocuente sobre la mezcla de formato y contenido. Sobre Hello World, A pone toda la frase en negrita mientras B inserta una palabra en medio y la convierte en Hello New World. La intención conjunta es evidente: la frase entera, incluida la palabra nueva, debe quedar en negrita. Pero si el formato se guardó como el rango de índices del cero al once, ese rango ya no significa lo mismo en el documento fusionado, porque la inserción de B desplazó todo lo que venía después.

Hay un tercer caso que el artículo original también recoge y que suele pasar desapercibido porque parece el más inocente. Sobre la misma frase, A pone en negrita las dos primeras palabras mientras B sustituye la segunda por otra distinta. La fusión deseada mantiene la negrita sobre las dos palabras resultantes, incluida la nueva. Es decir, el formato tiene que sobrevivir no solo a inserciones ajenas sino a que el contenido exacto que se marcó deje de existir. Cualquier representación que ancle el formato a los caracteres concretos que estaban ahí en el momento de marcarlos falla en este caso.

Los dos casos canonicos y lo que deberia salir

  caso 1  formato e insercion concurrentes
    origen ..... Hello World
    A .......... toda la frase en negrita
    B .......... Hello New World
    esperado ... Hello New World entero en negrita

  caso 2  formatos solapados
    origen ..... Hello World
    A .......... Hello en negrita
    B .......... lo World en negrita
    esperado ... Hello World entero en negrita
📝
Por qué un mapa de atributos no sirve aquí

La tentación natural es guardar el formato como un mapa de rango a valor y resolver los choques con la regla de que gana la última escritura. Falla por dos motivos independientes. El primero es que los extremos del rango son índices, y los índices dejan de ser válidos en cuanto alguien inserta antes de ellos. El segundo es más profundo: la regla de que gana la última escritura descarta una de las dos intenciones, y en los dos casos de arriba la fusión correcta no descarta ninguna, sino que las combina. El formato no es un valor en disputa, es una afirmación acumulable.

Anclas de estilo: convertir el formato en contenido

La solución de Loro es un cambio de categoría. En lugar de guardar el formato aparte del texto, cada operación de marcado inserta en la secuencia dos caracteres de control invisibles —una ancla de inicio y una ancla de fin— que quedan emparejadas por proceder de la misma operación. Cada ancla lleva consigo el identificador de la operación de estilo, el par de clave y valor, la marca de tiempo de Lamport y el comportamiento de expansión declarado para esa clave.

Convertido en contenido, el formato hereda gratis toda la maquinaria de la secuencia. Las anclas se integran con el mismo algoritmo de lista que los caracteres —Fugue, el del nivel 34— y por tanto convergen con las mismas garantías. Esto tiene una consecuencia que la documentación destaca con orgullo: la lógica de texto enriquecido es independiente del algoritmo de lista concreto, de modo que el mismo diseño podría montarse sobre cualquier otra secuencia convergente.

flowchart TB
M[operacion de marcado negrita] --> A1[ancla de inicio]
M --> A2[ancla de fin]
A1 --> L[secuencia integrada con Fugue]
A2 --> L
T[caracteres de texto] --> L
L --> R[estado interno de texto enriquecido]
R --> D[representacion en formato delta]
style M fill:#cba6f7,color:#11111b
style L fill:#a6e3a1,color:#11111b

Las anclas son invisibles desde fuera: su longitud es cero medida en caracteres Unicode, de modo que ningún índice de la aplicación las percibe. Internamente, en cambio, existe una segunda métrica —la longitud de entidad— en la que cada ancla cuenta como una unidad, y es la que el motor usa para posicionarse con exactitud. El detalle es lo que resuelve el caso solapado: como las anclas conservan el identificador de la operación que las creó, el motor sabe que hay dos tramos de negrita distintos, uno de A y otro de B, y puede unirlos en lugar de dejar que uno tape al otro.

La misma frase vista con las dos metricas

  vista del usuario ..... Hello World
  longitud Unicode ...... 11

  vista interna ......... [ini negrita A] Hello [ini negrita B] lo [fin A] World [fin B]
  longitud de entidad ... 11 caracteres mas 4 anclas

  el usuario indexa sobre la primera, el motor coloca sobre la segunda

Esa doble contabilidad es también la fuente de la ambigüedad que la biblioteca tiene que resolver por su cuenta. Si en una posición dada hay varias anclas apiladas, un mismo índice del usuario corresponde a varios puntos de inserción internos distintos, y la elección entre ellos es exactamente lo que decide si el texto nuevo hereda la negrita o no. Loro fija ese criterio con un conjunto de reglas ordenadas por prioridad, y el orden importa: la regla que impide crear un estilo por accidente al escribir justo antes de un inicio de marca tiene precedencia sobre las que gobiernan el final de las marcas.

Las reglas: expansión, anulación y solapamiento

Con las anclas dentro de la secuencia, quedan por decidir tres cosas que ninguna regla de convergencia determina y que por tanto la biblioteca expone al programador. Merece la pena subrayar por qué tienen que salir a la superficie: no son decisiones que un algoritmo pueda tomar bien, porque dependen de lo que la clave signifique en tu aplicación, y una negrita, un enlace y un comentario quieren comportarse de tres maneras distintas ante exactamente el mismo gesto del usuario.

Este es además el punto donde la biblioteca deja de ser un detalle de infraestructura y empieza a definir el comportamiento visible del producto. Un editor que expande los enlaces al escribir detrás de ellos se siente roto, y ninguna cantidad de corrección matemática compensa esa sensación.

La primera es la expansión, y es la que gobierna el caso cotidiano de escribir justo en el borde de una marca. Si escribes al final de un fragmento en negrita, esperas seguir en negrita; si escribes al final de un enlace, no esperas que el enlace se estire para tragarse lo nuevo. Loro exige declarar ese comportamiento por clave antes de usar texto enriquecido, con cuatro valores posibles: after, que es el valor por omisión y expande hacia adelante; before, que expande hacia atrás; none, que no expande; y both, que expande por los dos lados.

// El comportamiento de borde se declara por clave, no se adivina
doc.configTextStyle({
  bold: { expand: "after" },
  link: { expand: "none" },
  comment: { expand: "none", overlap: true },
});

const texto = doc.getText("texto");
texto.insert(0, "The fox jumped.");
texto.mark({ start: 0, end: 7 }, "comment:alice", "Hola");
texto.mark({ start: 4, end: 14 }, "comment:bob", "Salta");

La segunda es la anulación. Quitar la negrita no borra las anclas anteriores: añade un par nuevo con la misma clave y valor nulo. El valor efectivo de una clave sobre un carácter es el del par de anclas que lo cubre con la marca de tiempo de Lamport más alta, desempatando por identificador de participante si coinciden. Es la misma disciplina de todo el resto del track: no se destruye información, se añade una afirmación que la supera.

Ese detalle de la anulación tiene una consecuencia que sorprende a quien viene de editores tradicionales: quitar la negrita hace crecer el documento en lugar de encogerlo, porque añade dos anclas más. Es el precio de que la operación sea conmutativa con cualquier cosa que otro esté haciendo en paralelo, y es exactamente el mismo intercambio que el track lleva presentando desde el principio: se cambia espacio por la capacidad de fusionar sin coordinación.

La tercera es el solapamiento, y su solución es la más pragmática de las tres. El formato de delta que se usa para exponer el resultado no admite dos valores distintos para la misma clave sobre el mismo carácter, lo cual es un problema real cuando dos personas comentan fragmentos que se cruzan. La convención de Loro es usar un prefijo con dos puntos y un sufijo único elegido por la aplicación —comment:alice y comment:bob— de modo que dos comentarios solapados son dos claves distintas y el problema desaparece sin tocar el algoritmo.

💡
Cuándo no usar el contenedor de texto

La propia documentación advierte de que no todo texto quiere fusionarse carácter a carácter. Una dirección web o una cadena de resumen criptográfico fusionadas letra a letra producen una cadena inválida que no era la de nadie. Para esos valores lo correcto es guardarlos como cadena dentro de un mapa, donde la regla es que gana la última escritura y el resultado siempre es uno de los dos originales. La elección entre contenedor de texto y valor de mapa es una decisión de semántica, no de rendimiento.

Dos estructuras y una separación deliberada

La implementación revela una decisión de ingeniería que conviene mirar de cerca porque contradice el instinto de mantenerlo todo junto. Loro no guarda el texto y sus estilos en la misma estructura: mantiene un árbol para el contenido, que incluye los caracteres y las anclas y sabe convertir entre las distintas métricas de longitud, y otro árbol separado para los rangos de estilo. Ambos son árboles B más.

El motivo es que las dos cosas tienen densidades opuestas. La información de estilo es escasa y muy continua: varios párrafos enteros comparten formato y caben en una sola hoja. El texto es denso y necesita hojas pequeñas, porque convertir entre longitudes en Unicode, en UTF-8 y en UTF-16 dentro de una hoja enorme sería lento. Meterlos en la misma estructura habría obligado a elegir un tamaño de hoja malo para los dos.

Hay además dos optimizaciones locales que la implementación aplica sobre las anclas y que conviene conocer porque explican por qué el documento no se degrada con el uso. La primera elimina los pares de anclas que no llegan a contener texto, situación frecuente cuando se marca y se borra en la misma zona. La segunda elimina los pares que se anulan entre sí, como una negrita cancelada exactamente por su correspondiente supresión de negrita. Sin ellas, cada sesión de edición dejaría sedimento invisible en la secuencia y el coste de recorrerla crecería sin que el texto creciera.

🦀

Árbol de contenido

Guarda caracteres y anclas, y mantiene en cada nodo interno las cuatro medidas de longitud de su subárbol para localizar cualquier índice en tiempo logarítmico.

🎨

Mapa de rangos de estilo

Guarda qué claves y valores aplican a cada tramo, aprovechando que el formato tiene tramos largos y homogéneos.

🧩

Independiente de la secuencia

Toda la lógica de marcado vive fuera del algoritmo de lista, que se limita a integrar anclas como si fueran caracteres cualesquiera.

🧪

Validado contra los criterios

El algoritmo pasa los casos de prueba del artículo de Peritext, además de campañas prolongadas de pruebas aleatorias sobre consistencia e invariantes internos.

Reducir un problema nuevo a uno ya resuelto vale más que resolverlo bien

La contribución técnica que sostiene esta lección no es el árbol B ni la tabla de expansión: es un movimiento de reducción, y es el patrón más valioso que este nivel puede dejar. El formato llegaba como un problema de segunda clase —metadatos sobre rangos, con índices que caducan y sin ninguna teoría de fusión propia— y podría haberse atacado de frente, inventando un algoritmo de convergencia específico para rangos. Loro hace lo contrario: cambia la representación hasta que el problema nuevo se convierte en una instancia del problema viejo. Un rango deja de ser un par de índices y pasa a ser un par de elementos dentro de la misma secuencia que ya sabía converger; a partir de ahí no hay nada que demostrar de nuevo, porque la corrección la aporta la secuencia. El rédito de ese movimiento es enorme y conviene enumerarlo: la consistencia eventual fuerte se hereda en lugar de reprobarse, la lógica de estilos queda desacoplada del algoritmo de lista y podría montarse sobre otro, y las anclas viajan por la red con el mismo formato de actualización que el texto, sin protocolo adicional. Fíjate además en el precio que hubo que pagar para conseguirlo, porque es el que delata que la reducción es real: hubo que introducir una segunda métrica de longitud, la de entidad, porque los elementos nuevos son invisibles para el usuario pero no para el motor. Cuando una reducción es honesta siempre aparece una costura así, un lugar donde las dos categorías que has fusionado siguen sin ser la misma cosa y el código tiene que traducir. La heurística práctica es esta: ante un problema de fusión nuevo, antes de diseñar un algoritmo, pregúntate qué cambio de representación lo convertiría en un caso particular de algo que ya funciona, y comprueba después si la costura resultante es una traducción localizada o una infección que se extiende por toda la base de código. Si es localizada, la reducción es la respuesta correcta casi siempre.

⚔️ Reproduce los criterios y rompe las reglas de borde
  1. Monta dos documentos separados y reproduce el caso de formato e inserción concurrentes hasta obtener la fusión esperada.
  2. Reproduce el caso de negritas solapadas y comprueba que el resultado cubre la frase entera.
  3. Configura bold con expansión hacia adelante y link sin expansión, y escribe justo en el borde de cada uno.
  4. Aplica negrita y quítala en un documento mientras otro la aplica a un tramo distinto; razona el resultado con las marcas de Lamport.
  5. Usa dos comentarios solapados con claves prefijadas y observa la salida en formato delta.
  6. Guarda una dirección web como texto enriquecido, edítala en paralelo y comprueba por qué debía haber sido un valor de mapa.