3. Espacio de nombres text:: — texto

← Tipos · Índice · Siguiente: struct::

25 funciones para extraer, normalizar, buscar y validar el texto del documento.

En las funciones marcadas con [texto], el argumento es opcional: sin él, la función trabaja sobre el texto de todo el documento; con él, sobre la cadena que le pases.


3.1 Extracción

text::extract_all()

Todo el texto del documento (páginas unidas por saltos de línea).

check "El documento tiene contenido" {
  texto = text::extract_all()
  assert texto.trim() != "", "PDF sin texto extraíble"
  print("total de caracteres:", texto.length)
}

text::extract_from_page(pagina)

Texto de una página (1-based). Da un error claro si la página no existe.

check "Portada y contraportada" {
  portada = text::extract_from_page(1)
  assert portada.contains("Manual del Usuario"), "portada sin el título esperado"

  ultima = text::extract_from_page(doc.page_count)
  assert ultima.contains("ISBN"), "la última página no lleva ISBN"
}

text::extract_from_region(pagina, region)

Texto contenido en un área concreta. Devuelve una cadena vacía si la región no tiene texto (no es un error).

check "El pie técnico no puede quedarse" {
  // Los pies de producción (nombre del archivo .indd, fecha de exportación)
  // a veces se cuelan en el archivo final
  pie = region(0, 0, 467, 40, "pie")

  doc.pages.each { |page|
    contenido = text::extract_from_region(page.number, pie)
    assert !contenido.contains(".indd"),
      "la página #{page.number} lleva marca de producción en el pie: #{contenido.trim()}"
  }
}

text::extract_with_normalization()

El texto del documento ya normalizado (minúsculas, espacios colapsados). Atajo de text::normalize(text::extract_all()).

check "Buscar sin preocuparse por las mayúsculas" {
  texto = text::extract_with_normalization()
  require texto.contains("condiciones generales")   // encuentra "CONDICIONES  GENERALES"
}

3.2 Normalización y división

text::normalize([texto])

Minúsculas y espacios colapsados (varios espacios se convierten en uno).

check "Normalización" {
  require text::normalize("  HOLA   Mundo  ") == "hola mundo"

  // Sin argumento, normaliza todo el documento
  print("el documento normalizado tiene", text::normalize().length, "caracteres")
}

text::split_words([texto])

Divide en palabras, quitando la puntuación de los extremos.

check "Palabras" {
  palabras = text::split_words("Hola, mundo! (prueba)")
  require palabras.length == 3
  require palabras.first() == "Hola"
  require palabras.contains("prueba")
}

text::split_sentences([texto])

Divide en oraciones (separadas por ., ! o ? seguidos de espacio).

check "Oraciones demasiado largas" {
  // Los prospectos y los contratos tienen un límite práctico de legibilidad
  text::split_sentences().each { |frase|
    assert frase.length < 400,
      "oración de #{frase.length} caracteres: difícil de leer"
  }
}

text::split_paragraphs([texto])

Divide en párrafos (separados por una línea en blanco).

check "Estructura del documento" {
  parrafos = text::split_paragraphs()
  print("párrafos:", parrafos.length)
  require parrafos.length >= 3
}

text::count_words([texto]) y text::count_characters([texto])

check "Volumen de texto" {
  require text::count_words() > 100
  require text::count_characters() > 500

  // También funcionan sobre una cadena cualquiera
  resumen = text::extract_from_page(1)
  assert text::count_words(resumen) <= 250,
    "resumen de #{text::count_words(resumen)} palabras (máximo 250)"
}

text::detect_language([texto])

Devuelve "pt", "en", "es" o "unknown" (heurística por palabras frecuentes).

check "Idioma del documento" {
  idioma = text::detect_language()
  assert idioma == "es",
    "el documento debería estar en español; he detectado: #{idioma}"
}

3.3 Búsqueda y contenido obligatorio

text::require_text(termino) y text::forbid_text(termino)

Devuelven verdadero/falso. La comparación ignora mayúsculas y espaciado.

profile "contrato" {
  check "Cláusulas obligatorias" {
    assert text::require_text("fuero de la comarca"),
      "contrato sin cláusula de fuero"
    assert text::require_text("plazo de vigencia"),
      "contrato sin plazo de vigencia"
  }

  check "Términos prohibidos" {
    assert text::forbid_text("BORRADOR"),
      "el documento sigue marcado como borrador"
    assert text::forbid_text("lorem ipsum"),
      "texto de relleno sin sustituir"
  }
}

text::require_match(regex) y text::forbid_match(regex)

Como los anteriores, pero con expresión regular.

check "Patrones en el documento" {
  // Debe llevar un número de contrato con el formato 2026/0001
  assert text::require_match("\d{4}/\d{4}"),
    "no se encontró el número de contrato"

  // No puede llevar fechas en formato estadounidense
  assert text::forbid_match("\d{2}-\d{2}-\d{4}"),
    "se encontró una fecha en formato estadounidense"
}

text::fuzzy_match(a, b)

Similitud entre dos textos, de 0.0 (nada que ver) a 1.0 (idénticos). Útil cuando se esperan erratas o errores de OCR.

check "Nombre del producto con tolerancia" {
  esperado = "Paracetamol 750mg"
  encontrado = text::extract_from_region(1, region(50, 700, 300, 40))

  similitud = text::fuzzy_match(esperado, encontrado)
  assert similitud > 0.9,
    "el nombre del producto no coincide con el esperado (#{round(similitud * 100)}% de similitud)"
}

3.4 Datos personales

text::detect_personal_data() y text::detect_pii()

Son sinónimos. Devuelven la lista de datos personales encontrados: CPF, CNPJ, correo electrónico y teléfono.

El CPF y el CNPJ (documentos brasileños) solo entran en la lista si el dígito verificador es válido. Un número que solo se parece a un CPF (por ejemplo, 111.111.111-12) no dispara la alarma.

check "Un documento público no puede llevar datos personales" {
  hallazgos = text::detect_personal_data()
  assert hallazgos.length == 0,
    "datos personales expuestos: #{hallazgos.join("; ")}"
}

check "Informe de lo encontrado" {
  // Cada elemento viene con el formato "CPF: 529.982.247-25"
  text::detect_pii().each { |item|
    print("encontrado:", item)
  }
}

3.5 Validaciones brasileñas

text::validate_cpf(texto) y text::validate_cnpj(texto)

Validan el dígito verificador (mod 11). Aceptan el número con o sin puntuación y rechazan las secuencias repetidas (111.111.111-11).

check "CPF del titular" {
  cpf = text::extract_from_region(1, region(100, 600, 200, 20)).trim()
  assert text::validate_cpf(cpf),
    "CPF inválido en el registro: #{cpf}"
}

check "CNPJ de la empresa" {
  require text::validate_cnpj("11.222.333/0001-81")
  require !text::validate_cnpj("11.222.333/0001-82")   // dígito erróneo
}

text::validate_date_format(texto [, formato])

Comprueba que sea una fecha válida en el calendario (tiene en cuenta los años bisiestos y los días de cada mes). Formatos aceptados: "dd/mm/aaaa" y "aaaa-mm-dd"; sin el segundo argumento, acepta ambos.

check "Fechas del documento" {
  require text::validate_date_format("29/02/2024")     // 2024 es bisiesto
  require !text::validate_date_format("29/02/2023")    // 2023 no lo es
  require !text::validate_date_format("31/04/2026")    // abril tiene 30 días

  // Exigiendo un formato concreto
  require text::validate_date_format("02/08/2026", "dd/mm/aaaa")
  require !text::validate_date_format("2026-08-02", "dd/mm/aaaa")
}

text::validate_phone_format(texto)

Teléfono brasileño: (DD) 9XXXX-XXXX o (DD) XXXX-XXXX, con puntuación opcional.

check "Teléfono de contacto" {
  require text::validate_phone_format("(11) 98765-4321")
  require text::validate_phone_format("1198765432")
  require !text::validate_phone_format("12345")
}

text::validate_format(texto, regex)

Verdadero si la cadena entera casa con la expresión regular.

check "Código de lote con el patrón de fábrica" {
  lote = text::extract_from_region(1, region(400, 50, 150, 20)).trim()
  assert text::validate_format(lote, "L\d{4}-\d{2}"),
    "código de lote fuera del patrón L0000-00: #{lote}"
}

3.6 Comparación y diagnóstico

text::diff(a, b)

Lista las líneas que cambiaron entre dos textos: - para las que salieron, + para las que entraron.

check "Comparando dos páginas" {
  antes = text::extract_from_page(1)
  despues = text::extract_from_page(2)

  cambios = text::diff(antes, despues)
  print("líneas modificadas:", cambios.length)
  cambios.each { |linea| print(linea) }
}

Para comparar dos archivos, usa el comando pdfl compare: alinea las páginas automáticamente. Consulta el capítulo 11.

text::detect_rasterized_text()

Verdadero si alguna página no tiene texto extraíble pero sí una imagen que cubre la mitad del área o más, señal de texto convertido en imagen.

check "El texto tiene que ser texto" {
  // Una página escaneada o con el texto vectorizado no permite búsquedas,
  // accesibilidad ni corrección ortográfica
  assert !text::detect_rasterized_text(),
    "hay páginas con texto rasterizado (escaneado o convertido en imagen)"
}

3.7 Ejemplo completo

// documento_juridico.pdfl — validación de un contrato
profile "contrato-estandar" {

  check "Contenido obligatorio" tags: ["juridico"] {
    assert text::require_text("fuero de la comarca"), "sin cláusula de fuero"
    assert text::require_text("plazo de vigencia"), "sin plazo de vigencia"
    assert text::require_match("\d{4}/\d{4}"), "sin número de contrato"
  }

  check "Nada de borradores" tags: ["juridico"] {
    assert text::forbid_text("BORRADOR"), "marcado como borrador"
    assert text::forbid_text("lorem ipsum"), "hay texto de relleno"
    assert text::forbid_match("XXX+"), "campos sin rellenar (XXX)"
  }

  check "Datos personales" tags: ["compliance"] {
    hallazgos = text::detect_personal_data()
    assert hallazgos.length == 0,
      "datos personales en el documento público: #{hallazgos.join("; ")}"
  }

  check "Calidad del texto" tags: ["texto"] {
    assert text::detect_language() == "es", "el documento no está en español"
    assert !text::detect_rasterized_text(), "el texto rasterizado impide la búsqueda"
    require text::count_words() > 200
  }
}

← Tipos · Índice · Siguiente: struct::