Skip to content

fix(layout): space words by glyph position - #4

Closed
yoiberdev wants to merge 6 commits into
mainfrom
fix/text-word-spacing
Closed

yoiberdev wants to merge 6 commits into
mainfrom
fix/text-word-spacing

Conversation

@yoiberdev

Copy link
Copy Markdown
Owner

Depende de #1 (chore/clippy-lockfile): esta rama sale de ella. Hasta que se mezcle #1, el diff contra main incluye también sus commits; los de esta PR son los dos últimos (fix(layout) y test(layout)).

Problema

En PDFs que colocan las palabras por posición en lugar de con un glifo de espacio, el texto extraído sale con palabras pegadas y con espacios a mitad de palabra. Pasa con el libro de patrones de diseño (Prince 12.5): frente a pdftotext solo se recuperaba un 39 % de las palabras (mediana por página) y 396 de 436 páginas quedaban por debajo de 0,8. El libro de Git (XeTeX) salía bien.

Causa: las páginas de una sola columna no pasaban por layout.rs, sino por lopdf::Document::extract_text (parser.rs), que:

  • solo convierte en espacio los desplazamientos TJ enteros menores que -100. Prince los escribe como reales (-266.2423), así que se perdían;
  • añade un espacio al final de cada array TJ, de ahí los cortes a mitad de palabra cuando una palabra se reparte en varios TJ;
  • ignora los movimientos Td/Tm entre fragmentos, que es como Prince separa muchas palabras (a menudo con un BT/ET por palabra).

Git salía bien porque XeTeX usa desplazamientos enteros (-250) dentro de un único TJ por línea.

Cambios

src/document/layout.rs

  • Lee las anchuras reales de los glifos: /Widths, /FirstChar y MissingWidth en fuentes simples, /W y /DW en fuentes CID (Type0) y FontMatrix en Type3. Aplica el estado de texto (Tc, Tw, Tz, cuerpo) y la matriz de texto × CTM, así que cada fragmento tiene su posición y anchura reales en espacio de usuario. Si la fuente no trae anchuras, se mantiene la estimación anterior (0,52 em).
  • TJ: los desplazamientos, enteros y reales, cuentan para el avance. Por debajo de -100 milésimas de em se leen como espacio entre palabras (el mismo umbral de antes, ahora también con reales).
  • Nueva join_fragments_in_stream_order: une los fragmentos en el orden del flujo. Pone salto de línea cuando cambia la línea base (más de medio cuerpo) y espacio cuando el hueco supera 0,15 em o hay un salto hacia atrás mayor que el cuerpo. group_fragments_into_lines (multicolumna) usa el mismo criterio en vez de 2 pt fijos.
  • Nueva extract_page_text: orden de lectura multicolumna si detecta un canal y orden del flujo si no. extract_positioned_fragments y extract_page_text_spatial conservan su firma.
  • Un Tj que solo contiene espacios ahora avanza la matriz de texto (antes se saltaba sin moverla), y " aplica Tw/Tc.

src/document/parser.rs

  • Todas las páginas usan extract_page_text. lopdf::extract_text queda solo de respaldo cuando el flujo no se puede decodificar o no hay texto.
  • La detección de texto oculto (scan_page_security) no cambia.

tests/text_word_spacing_test.rs (nuevo), con un PDF generado con una fuente de anchuras conocidas:

  1. TJ con desplazamientos reales y enteros, kerning dentro de una palabra y una palabra partida en dos TJ.
  2. Td entre palabras, un BT/ET por palabra con la puntuación aparte y una matriz Tm escalada.
  3. Unión por posición con fragmentos sintéticos (hueco pequeño, línea base algo elevada, cambio de línea y salto hacia atrás).

Cómo se verificó

En local, con nice -n 19 ionice -c3 y -j 2 (rustc 1.98):

  • cargo fmt --all -- --check: sin diferencias (exit 0).
  • cargo clippy --locked --all-targets -- -D warnings: exit 0, sin avisos.
  • cargo test --locked: exit 0, 75 pasados, 0 fallos y 1 ignorado (el test del PDF real, condicionado por variable de entorno desde chore: fix clippy and commit lockfile #1). Los 3 tests nuevos pasan; los de multicolumna, tablas y texto oculto siguen verdes.
  • Los tests nuevos fallan contra el código base (d2d6941) y muestran justo el fallo:
    • ["Singlifo deespacio ", "Patrones crea cionales "] en lugar de ["Sin glifo de espacio", "Patrones", "creacionales"]
    • ["Holamundosinespacios", "Otra", "linea", ".", "Textoescalado"] en lugar de ["Hola mundo sin espacios", "Otra linea.", "Texto escalado"]

Con los dos libros reales (fuera del repo, caché en una carpeta temporal):

  • Binarios debug compilados desde d2d6941 (antes) y desde esta rama (después); docugraph index de los dos PDFs con DOCUGRAPH_CACHE_DIR distinto para cada uno.
  • Referencia: pdftotext -raw, página a página.
  • Método de la auditoría previa: para cada página con 5 o más palabras de referencia (\w+, 3 letras o más, en minúsculas), la fracción de esas palabras que aparece en el texto de docugraph. Como medida de orden, además, la fracción de pares de palabras consecutivas de pdftotext que aparecen seguidos en docugraph.
Libro Métrica Antes Después
Patrones (436 págs. medidas) Palabras recuperadas, mediana 0,393 0,932
Media 0,442 0,916
Mínimo 0,077 0,077
Páginas < 0,8 396 11
Pares consecutivos, mediana 0,238 0,895
Git (219 págs. medidas) Palabras recuperadas, mediana 1,000 1,000
Media 0,997 0,999
Mínimo 0,919 0,980
Páginas < 0,8 0 0
Pares consecutivos, mediana 1,000 1,000
  • Página a página, ninguna empeora en ningún libro: en Patrones mejoran 426 y quedan igual 10; en Git mejoran 24 y quedan igual 195.
  • Sin cambios en páginas marcadas como texto no fiable (0 → 0 en los dos), páginas escaneadas (1 y 2) ni secciones.
  • La marca de agua del comprador sigue sin aparecer en el texto extraído (0 apariciones, antes y después).
  • Las 11 páginas de Patrones que siguen por debajo de 0,8 tienen muy poco texto (7 a 35 palabras: portadas e ilustraciones). La del mínimo (0,077) da lo mismo antes y después, así que no es cosa del espaciado.
  • Indexado con binario debug, en una sola medición: Git 5,2 s → 3,3 s y Patrones 11,6 s → 7,3 s. Antes cada página de una columna se decodificaba dos veces.

Nota: la cifra de «32 %» de la auditoría salió de otro script. Con este método, el mismo código base da 0,393 (coincide con la verificación independiente: 0,393, mínimo 0,077 y 396 páginas).

Riesgos y límites

  • En las páginas de una columna el texto sigue el orden del flujo, igual que antes. Cambia dónde se cortan las líneas: ahora por línea base, antes en cada ET/T*.
  • En fuentes Type0 se asumen códigos de 2 bytes (Identity-H). Con otros CMap la anchura puede no cuadrar y entonces decide el umbral de TJ.
  • Siguen sin leerse los Form XObjects y el texto rotado se mide sobre el eje X, como antes.

Clippy 0.1.98 flags two new lints that break the CI step
`cargo clippy --all-targets -- -D warnings` on stable:

- chunks_exact_to_as_chunks (attachments.rs): iterate the
  /Names leaf with `as_chunks::<2>()`. The trailing odd element
  is still skipped, as with `chunks_exact(2)`.
- collapsible_match (layout.rs): move the "no trailing space"
  check into the TJ match guards. A failing guard falls through
  to `_ => {}`, which is what the inner `if` did.

No behaviour change. `as_chunks` needs Rust 1.88 or newer.
docugraph ships a binary, so the lockfile belongs in the repo.
Without it every build resolves dependencies again and a fixed
commit does not pin rmcp, lopdf, tokio and the rest.

The lock is the one resolved for 2832efa (182 packages, rmcp
3.3.0, lopdf 0.45.0, tokio 1.53.1) and matches Cargo.toml under
`--locked`.
test_real_pdf_loading_and_extraction read a hardcoded Windows
path and returned early when it was missing, so it reported "ok"
without testing anything on every machine but one.

Read the path from DOCUGRAPH_TEST_PDF instead and mark the test
#[ignore] with a reason, so it shows up as ignored by default.
Run it with `DOCUGRAPH_TEST_PDF=... cargo test -- --ignored`;
a missing variable or file now fails loudly.
Now that Cargo.lock is tracked, fail CI when it is out of date
with Cargo.toml instead of silently rewriting it.

There is no separate build step: clippy is the first step that
resolves dependencies and `cargo test` builds the crate, so both
get --locked. Without it on clippy the lock would be rewritten
before the test step runs.
Single-column pages went through lopdf's extract_text, which ignores
real-valued TJ adjustments, appends a space after every TJ array and
drops Td/Tm moves between fragments. PDFs that place words by position
(Prince, fonts without a space glyph) came out with glued words and
spaces in the middle of words.

Place every fragment with the font's real glyph widths (/Widths, /W,
/DW, MissingWidth, Type3 FontMatrix) and the text state (Tc, Tw, Tz,
Tm, CTM). Read TJ adjustments below -100/1000 em as word gaps for both
integers and reals, and join fragments in stream order with a newline
when the baseline changes and a space when the gap exceeds 0.15 em.

All pages now use extract_page_text: multi-column reading order when a
gutter is detected, stream order otherwise. lopdf's extract_text stays
as a fallback when the content stream cannot be decoded.
Generate PDFs with a font of known widths: TJ arrays with real and
integer adjustments, kerning inside a word and a word split across two
TJ operators; Td moves between words, one text object per word with
separate punctuation, and a scaled text matrix. Also check the stream
order join on synthetic fragments.
@yoiberdev

Copy link
Copy Markdown
Owner Author

Se descarta: los arreglos de docugraph los está haciendo el autor en su copia local y estos cambios chocarían con ellos.

@yoiberdev yoiberdev closed this Sep 14, 2026
@yoiberdev
yoiberdev deleted the fix/text-word-spacing branch September 14, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant