Un PDF no tiene control de cambios. Cuando un contrato vuelve en PDF "con un par de cambios menores", lo único que te dice qué ha cambiado es la diferencia entre dos archivos. Hay tres formas evidentes de calcularla: comparar las páginas renderizadas píxel a píxel, comparar el texto extraído línea a línea o compararlo palabra a palabra. Con el mismo par de archivos dan resultados muy distintos, y solo una aguanta que el procesador de textos de la otra parte vuelva a maquetar el texto.
A continuación tienes mediciones de las tres sobre un par real de PDF y, después, una comparación a nivel de palabra en unas 120 líneas de Python (PyMuPDF más difflib de la biblioteca estándar) que obtiene las cajas de cada palabra modificada y vuelve a escribir los cambios en el PDF como comentarios. El editor de PDF de la app de escritorio de osFoundry funciona igual: su diálogo Comparar archivos indica que "el texto se compara palabra por palabra y las diferencias se muestran en el archivo nuevo", y con este par detecta los mismos cinco cambios que el script de abajo.
El par de prueba
Los archivos son un contrato de servicios de diseño de una página inventado (Example), generado con PyMuPDF para que la capa de texto sea real. La versión devuelta trae cinco cambios:
| Cláusula | Enviado | Devuelto |
|---|---|---|
| 3. Payment | within 30 days. Late payments add a 2% fee each month. | within 60 days. |
| 4. Revisions | two rounds of revisions | unlimited rounds of revisions |
| 5. Ownership | the final logo once the fee is paid in full. | the final logo and all drafts once work begins. |
Para simular que el software de la otra parte recompone el texto, un tercer archivo contiene exactamente las mismas palabras de la versión devuelta, compuestas de nuevo a 12 pt en una columna de 300 pt en lugar de 11.5 pt en 468 pt. El mismo texto, con otros saltos de línea.
Píxeles, líneas o palabras
El diff de píxeles renderiza la primera página de ambos archivos a 72 dpi en escala de grises y cuenta las filas en las que algún píxel difiere en más de 40 niveles; una "banda" es una racha seguida de esas filas. El diff de líneas pasa difflib.SequenceMatcher sobre page.get_text() dividido por saltos de línea. El diff de palabras es el script que viene más abajo.
| Archivo nuevo | Diff de píxeles | Diff de líneas | Diff de palabras |
|---|---|---|---|
| Tal como volvió | 30 de 792 filas, 3 bandas | 3 bloques, 3 líneas | 5 cambios |
| Mismas palabras, recompuesto | 284 de 792 filas, 20 bandas | 6 bloques, 9 líneas | 5 cambios |
Con el archivo tal como volvió, el diff de píxeles encuentra tres zonas y el de líneas, tres líneas. Ninguno se equivoca, pero ninguno dice qué ha cambiado: el diff de líneas marca la cláusula 3 como una línea sustituida y sigues teniendo que localizar tú mismo "30" frente a "60". Con la copia recompuesta, el diff de píxeles ilumina 20 bandas sueltas por casi todo el texto, y el de líneas sube al doble, 9 líneas afectadas, porque el ajuste de línea se ha movido. El diff de palabras da los mismos cinco cambios en los dos archivos, porque los espacios y los saltos de línea nunca le llegan.
Extraer los caracteres con sus cajas
Para encontrar cambios basta con la cadena de get_text(), pero para marcarlos en la página hacen falta coordenadas. get_text("rawdict") devuelve cada carácter con su caja delimitadora. La función siguiente lo aplana en una sola lista en orden de lectura:
import difflib, re, sys, unicodedata import pymupdf def char_stream(path): """All characters of a PDF in reading order, NFKC-normalised, each with its page and box.""" chars = [] for pno, page in enumerate(pymupdf.open(path)): for block in page.get_text('rawdict', sort=True)['blocks']: for line in block.get('lines', []): for span in line['spans']: for ch in span['chars']: for c in unicodedata.normalize('NFKC', ch['c']): chars.append((c, pno, pymupdf.Rect(ch['bbox']))) chars.append(('\n', pno, None)) # a line break is only whitespace return chars Hay dos detalles importantes. sort=True ordena los bloques de arriba abajo y de izquierda a derecha, que es justo lo que quieres en un contrato a una columna (las maquetas a dos columnas necesitan su propio orden). NFKC unifica los caracteres que se parecen, así que un "60" de ancho completo escrito en un lado y un "60" en el otro no aparecen como cambio. Cada carácter normalizado conserva la caja del carácter del que procede, de modo que la lista sigue alineada con la página.
Tokens: palabras, y un carácter cada vez donde no hay espacios
El diff trabaja sobre tokens, no sobre caracteres. Aquí un token es una secuencia de letras latinas y dígitos, o cualquier otro carácter visible suelto:
TOKEN = re.compile(r"[0-9A-Za-z][0-9A-Za-z.,%$'’-]*(?<![.,])|\S") def tokens(chars): text = ''.join(c for c, _, _ in chars) return text, [(m.start(), m.end(), m.group()) for m in TOKEN.finditer(text)] En inglés esto da palabras, con el punto final separado para que "days." y "days" sigan coincidiendo. Los números se mantienen enteros, así que un cambio aparece como "30" → "60" y no como "3" → "6".
Lo de "cualquier otro carácter suelto" es para los idiomas que no separan las palabras con espacios. Si divides por espacios una versión en japonés del mismo contrato, que es lo que hace get_text("words") de PyMuPDF, obtienes una "palabra" por frase, y el diff marca tres frases enteras como sustituidas. Con un token por carácter encuentra los cambios dentro de ellas, pero en 8 fragmentos, porque caracteres compartidos como の parten un mismo cambio en trozos ("2" eliminado, "まで" → "数", "制限なく" insertado). Si fusionas los cambios separados por como mucho un carácter sin cambiar, se quedan en 5, los mismos cinco cambios que en el archivo en inglés. En inglés la fusión nunca se activa, porque el hueco más corto entre dos cambios ("once") tiene cuatro caracteres.
El diff
PUNCT = set('.。.!?') def hunks(old_toks, new_toks, gap_chars=1): a = [t for _, _, t in old_toks] b = [t for _, _, t in new_toks] out = [] for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes(): if tag == 'equal': continue if tag in ('delete', 'insert'): seq, lo, hi = (a, i1, i2) if tag == 'delete' else (b, j1, j2) k = 0 while hi + k < len(seq) and seq[lo + k] == seq[hi + k]: k += 1 if seq[hi + k - 1] in PUNCT and seq[lo + k] not in PUNCT: i1, i2, j1, j2 = (i1 + k, i2 + k, j1 + k, j2 + k) break if out: pi1, pi2, pj1, pj2 = out[-1] if sum(len(x) for x in a[pi2:i1]) <= gap_chars and sum(len(x) for x in b[pj2:j1]) <= gap_chars: out[-1] = [pi1, i2, pj1, j2] continue out.append([i1, i2, j1, j2]) return out El bucle de desplazamiento resuelve una rareza que tienen todos los algoritmos de diff. La frase eliminada está entre "days" y "4. Revisions", y el texto que la rodea es ". Late payments add a 2% fee each month." en un lado y "." en el otro. Eliminar ". Late … month" y eliminar "Late … month." son soluciones igual de mínimas, y SequenceMatcher eligió la primera. Desplazar una eliminación o inserción pura a lo largo de los tokens repetidos hasta que termine en un signo de puntuación da la versión que marcaría una persona.
autojunk=False es intencionado; más abajo explico por qué. Ejecutado sobre los dos archivos, el script completo (contract_diff.py) imprime:
5 changes 1. replaced '30' -> '60' 2. deleted 'Late payments add a 2% fee each month.' -> '' 3. replaced 'two' -> 'unlimited' 4. inserted '' -> 'and all drafts' 5. replaced 'the fee is paid in full' -> 'work begins' y exactamente las mismas cinco líneas con la copia recompuesta.
Devolver los cambios al PDF como comentarios
Cada token lleva las cajas de sus caracteres, así que marcar un cambio en el archivo nuevo consiste en poner una anotación de resaltado sobre esas cajas, unidas por línea, con el texto antiguo y el nuevo como comentario. Una eliminación no deja nada que resaltar, así que recibe un icono de nota justo detrás del texto que la precedía:
def mark(new_pdf, changes, out_pdf): doc = pymupdf.open(new_pdf) for ch in changes: by_page = {} for pno, r in ch['boxes']: rects = by_page.setdefault(pno, []) if rects and abs(rects[-1].y0 - r.y0) < 2: # same line: one box, not one per character rects[-1] |= r else: rects.append(pymupdf.Rect(r)) note = {'replaced': f'Replaced: "{ch["old"]}" with "{ch["new"]}"', 'deleted': f'Deleted: "{ch["old"]}"', 'inserted': f'Inserted: "{ch["new"]}"'}[ch['kind']] for pno, rects in by_page.items(): page = doc[pno] if ch['kind'] == 'deleted': annot = page.add_text_annot(rects[-1].tr + (2, -4), note, icon='Comment') else: annot = page.add_highlight_annot(rects) annot.set_info(content=note) annot.set_info(title='compare') annot.update() doc.save(out_pdf) Son anotaciones PDF estándar, así que cualquier visor de PDF normal las muestra, y quien te envió los cambios ve exactamente sobre qué estás preguntando.
Contratos largos: autojunk y cláusula a cláusula
SequenceMatcher tiene una heurística llamada autojunk: cuando la segunda secuencia tiene 200 elementos o más, cualquier elemento que suponga más del 1 % de ella se descarta como ancla para emparejar. Está pensada para cosas como las líneas en blanco del código fuente. En el texto de un contrato, los tokens frecuentes son palabras como "the" o, en japonés, の y 甲 (el nombre que da el contrato a una de las partes). En el archivo japonés de una página (238 tokens en la versión nueva) dejó fuera 18 caracteres distintos, entre ellos 甲, 乙, の y 。, y el resultado pasó de 8 bloques a 6. En este caso no salió peor, pero sí distinto, y eso no es algo que quieras dejar en manos de una heurística en un documento que estás a punto de firmar.
Desactivarla tiene un coste. En el peor caso el diff es aproximadamente cuadrático, y un contrato de 40 páginas es mucho más largo que una página. Para medirlo, se repitió el contrato japonés 10 y 100 veces editando solo la última copia, en un Mac M1 con Python 3.12:
| Tokens | autojunk=False | autojunk=True | Cláusula a cláusula |
|---|---|---|---|
| 2,550 | 12 ms | 3 ms | 1 ms |
| 25,500 | 1,142–1,564 ms | 163–209 ms | 7–10 ms |
Las cifras de 25,500 tokens son el rango de tres ejecuciones; había otros procesos usando la CPU a la vez, y dos ejecuciones anteriores con más carga llegaron a 4.2 segundos sin autojunk.
La última columna es la solución. Los contratos ya vienen divididos en cláusulas numeradas, así que separa ambos textos por los encabezados de cláusula, empareja las cláusulas con un único SequenceMatcher sobre la lista de cláusulas (las idénticas coinciden como elementos completos, con muy poco coste) y ejecuta el diff de tokens solo dentro de los pares que difieren. Así mantienes autojunk=False y sigue siendo la opción más rápida de las tres. En measure.py la división es una expresión regular sobre 第N条 para el texto japonés; para inglés, un patrón sobre líneas como "3. Payment" haría lo mismo.
Dónde deja de funcionar
Todo lo anterior lee la capa de texto. Un contrato escaneado no la tiene y necesita OCR primero, y después los errores del OCR aparecen como cambios. Las tablas extraídas en un orden de lectura incorrecto generan diffs con mucho ruido. Y una cláusula que se ha movido en lugar de editarse sale como una eliminación y una inserción; para detectar movimientos hace falta una segunda pasada que empareje entre sí los bloques eliminados e insertados.
Los scripts (contract_diff.py, measure.py y los generadores de los archivos de prueba) se ejecutan con uvx --with pymupdf python. El editor de PDF con los mismos pasos de comparar, marcar como comentarios y generar informe forma parte de la app de escritorio gratuita que encontrarás en la página de descarga de osFoundry, y se ejecuta en tu propio equipo, como el resto de la plataforma local-first de osFoundry.



