3. `text::` namespace — text
25 functions to extract, normalize, search and validate the text of a document.
In functions marked with
[text], the argument is optional: without it, the function works on the whole document; with it, on the string you pass.
3.1 Extraction
Section titled “3.1 Extraction”text::extract_all()
Section titled “text::extract_all()”All text in the document (pages joined by newlines).
check "Document has content" { content = text::extract_all() assert content.trim() != "", "PDF has no extractable text" print("total characters:", content.length)}text::extract_from_page(page)
Section titled “text::extract_from_page(page)”Text from one page (1-based). Friendly error if the page does not exist.
check "Cover and back cover" { cover = text::extract_from_page(1) assert cover.contains("User Manual"), "cover lacks the expected title"
last = text::extract_from_page(doc.page_count) assert last.contains("ISBN"), "last page has no ISBN"}text::extract_from_region(page, region)
Section titled “text::extract_from_region(page, region)”Text inside a specific area. Returns an empty string when the region has no text (that is not an error).
check "Production footers must not survive" { // Production footers (InDesign file name, export date) sometimes // leak into the final file footer = region(0, 0, 467, 40, "footer")
doc.pages.each { |page| content = text::extract_from_region(page.number, footer) assert !content.contains(".indd"), "page #{page.number} has a production mark in the footer: #{content.trim()}" }}text::extract_with_normalization()
Section titled “text::extract_with_normalization()”The document text already normalized (lowercase, collapsed whitespace).
Shorthand for text::normalize(text::extract_all()).
check "Search without worrying about case" { content = text::extract_with_normalization() require content.contains("general conditions") // matches "GENERAL CONDITIONS"}3.2 Normalization and splitting
Section titled “3.2 Normalization and splitting”text::normalize([text])
Section titled “text::normalize([text])”Lowercase and collapsed whitespace (runs of spaces become one).
check "Normalization" { require text::normalize(" HELLO World ") == "hello world"
// With no argument it normalizes the whole document print("normalized document has", text::normalize().length, "characters")}text::split_words([text])
Section titled “text::split_words([text])”Splits into words, stripping punctuation from the edges.
check "Words" { words = text::split_words("Hello, world! (test)") require words.length == 3 require words.first() == "Hello" require words.contains("test")}text::split_sentences([text])
Section titled “text::split_sentences([text])”Splits into sentences (separated by ., ! or ? followed by a space).
check "Sentences that run too long" { // Package inserts and contracts have a practical readability limit text::split_sentences().each { |sentence| assert sentence.length < 400, "sentence with #{sentence.length} characters — hard to read" }}text::split_paragraphs([text])
Section titled “text::split_paragraphs([text])”Splits into paragraphs (separated by a blank line).
check "Document structure" { paragraphs = text::split_paragraphs() print("paragraphs:", paragraphs.length) require paragraphs.length >= 3}text::count_words([text]) and text::count_characters([text])
Section titled “text::count_words([text]) and text::count_characters([text])”check "Text volume" { require text::count_words() > 100 require text::count_characters() > 500
// They also work on any string summary = text::extract_from_page(1) assert text::count_words(summary) <= 250, "summary has #{text::count_words(summary)} words (max 250)"}text::detect_language([text])
Section titled “text::detect_language([text])”Returns "pt", "en", "es" or "unknown" (heuristic based on common words).
check "Document language" { language = text::detect_language() assert language == "en", "document should be in English, detected: #{language}"}3.3 Search and required content
Section titled “3.3 Search and required content”text::require_text(term) and text::forbid_text(term)
Section titled “text::require_text(term) and text::forbid_text(term)”Return true/false. Comparison ignores case and spacing.
profile "contract" { check "Mandatory clauses" { assert text::require_text("governing law"), "contract has no governing-law clause" assert text::require_text("term of agreement"), "contract has no term clause" }
check "Forbidden terms" { assert text::forbid_text("DRAFT"), "document still marked as draft" assert text::forbid_text("lorem ipsum"), "placeholder text was not replaced" }}text::require_match(regex) and text::forbid_match(regex)
Section titled “text::require_match(regex) and text::forbid_match(regex)”Same as above, but with a regular expression.
check "Patterns in the document" { // Must carry a contract number like 2026/0001 assert text::require_match("\d{4}/\d{4}"), "contract number not found"
// Must not carry US-style dates assert text::forbid_match("\d{2}-\d{2}-\d{4}"), "US-format date found"}text::fuzzy_match(a, b)
Section titled “text::fuzzy_match(a, b)”Similarity between two strings, from 0.0 (unrelated) to 1.0 (identical).
Useful when typos or OCR noise are expected.
check "Product name with tolerance" { expected = "Paracetamol 750mg" found = text::extract_from_region(1, region(50, 700, 300, 40))
similarity = text::fuzzy_match(expected, found) assert similarity > 0.9, "product name differs from expected (#{round(similarity * 100)}% similar)"}3.4 Personal data (privacy)
Section titled “3.4 Personal data (privacy)”text::detect_personal_data([text]) and text::detect_pii([text])
Section titled “text::detect_personal_data([text]) and text::detect_pii([text])”Synonyms. They return the list of personal data found: CPF, CNPJ (Brazilian tax IDs), e-mail and phone number.
CPF and CNPJ only make the list when the check digit is valid. A number that merely looks like a CPF (e.g.
111.111.111-12) raises no alarm.
check "Public document must carry no personal data" { found = text::detect_personal_data() assert found.length == 0, "personal data exposed: #{found.join("; ")}"}
check "Report what was found" { // Each entry looks like "CPF: 529.982.247-25" text::detect_pii().each { |item| print("found:", item) }}3.5 Brazilian validations
Section titled “3.5 Brazilian validations”text::validate_cpf(text) and text::validate_cnpj(text)
Section titled “text::validate_cpf(text) and text::validate_cnpj(text)”Validate the check digit (mod 11) of Brazilian tax IDs. They accept punctuated or
plain input and reject repeated sequences (111.111.111-11).
check "Account holder's CPF" { cpf = text::extract_from_region(1, region(100, 600, 200, 20)).trim() assert text::validate_cpf(cpf), "invalid CPF in the record: #{cpf}"}
check "Company CNPJ" { require text::validate_cnpj("11.222.333/0001-81") require !text::validate_cnpj("11.222.333/0001-82") // wrong check digit}text::validate_date_format(text [, format])
Section titled “text::validate_date_format(text [, format])”Checks whether the string is a valid calendar date (leap years and days per
month included). Accepted formats: "dd/mm/aaaa" and "aaaa-mm-dd"; without the
second argument, both are accepted.
check "Dates in the document" { require text::validate_date_format("29/02/2024") // 2024 is a leap year require !text::validate_date_format("29/02/2023") // 2023 is not require !text::validate_date_format("31/04/2026") // April has 30 days
// Requiring one specific format 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(text)
Section titled “text::validate_phone_format(text)”Brazilian phone numbers: (DD) 9XXXX-XXXX or (DD) XXXX-XXXX, punctuation
optional.
check "Contact phone" { require text::validate_phone_format("(11) 98765-4321") require text::validate_phone_format("1198765432") require !text::validate_phone_format("12345")}text::validate_format(text, regex)
Section titled “text::validate_format(text, regex)”True when the entire string matches the regular expression.
check "Batch code in the factory pattern" { batch = text::extract_from_region(1, region(400, 50, 150, 20)).trim() assert text::validate_format(batch, "L\d{4}-\d{2}"), "batch code does not follow the L0000-00 pattern: #{batch}"}3.6 Comparison and diagnostics
Section titled “3.6 Comparison and diagnostics”text::diff(a, b)
Section titled “text::diff(a, b)”Lists lines that changed between two strings: - for lines removed, + for
lines added.
check "Comparing two pages" { before = text::extract_from_page(1) after = text::extract_from_page(2)
changes = text::diff(before, after) print("changed lines:", changes.length) changes.each { |line| print(line) }}To compare two files, use the
pdfl comparecommand — it aligns pages automatically. See chapter 11.
text::detect_rasterized_text()
Section titled “text::detect_rasterized_text()”True when some page has no extractable text but does have an image covering half the area or more — a sign of text turned into an image.
check "Text must be text" { // A scanned or outlined page cannot be searched, made accessible // or spell-checked assert !text::detect_rasterized_text(), "there are pages with rasterized text (scanned or outlined)"}3.7 Complete example
Section titled “3.7 Complete example”// legal_document.pdfl — contract validationprofile "standard-contract" {
check "Required content" tags: ["legal"] { assert text::require_text("governing law"), "no governing-law clause" assert text::require_text("term of agreement"), "no term clause" assert text::require_match("\d{4}/\d{4}"), "no contract number" }
check "No drafts" tags: ["legal"] { assert text::forbid_text("DRAFT"), "marked as draft" assert text::forbid_text("lorem ipsum"), "placeholder text present" assert text::forbid_match("XXX+"), "unfilled fields (XXX)" }
check "Privacy" tags: ["compliance"] { found = text::detect_personal_data() assert found.length == 0, "personal data in a public document: #{found.join("; ")}" }
check "Text quality" tags: ["text"] { assert text::detect_language() == "en", "document is not in English" assert !text::detect_rasterized_text(), "rasterized text blocks search" require text::count_words() > 200 }}