Linting & regularizing¶
Normalizing a Word document's formatting is one of the most tedious jobs in office work: hunting down the paragraph someone left double-spaced, the heading that got hand-set in Arial 16 instead of the style, the numeric table column that's left-aligned, the stray space before a period. Done by hand it's an afternoon of scrolling and clicking. wordlive turns it into two calls:
lintreads the document and reports what's off — a severity-ranked list of findings, each pointing at an anchor id you can act on.regularizeapplies the fixable findings in a single, atomic-undo edit.
It's built to be a power tool for both audiences: an LLM agent can call lint,
reason over the structured findings, and apply exactly the fixes it wants; a
human can run wordlive regularize and reclaim the afternoon. This page is the
guided tour — the Python API,
CLI, and MCP pages are the exhaustive
reference.
Politeness holds
lint is a pure read — layout rules repaginate content-neutrally, and your
selection, scroll position, and the document's Saved state are left
untouched. regularize wraps every fix in one doc.edit("Regularize
formatting"), so one Ctrl-Z reverts the whole pass and the cursor never
moves. See Core invariants.
The two verbs¶
lint answers "what's off about this document before I hand it over?" and
regularize is the write side that acts on the answer. Everything regularize
does, lint already told you it would — the fix is right there in the finding.
import wordlive as wl
with wl.attach() as word:
doc = word.documents.active
findings = doc.lint() # pure read — what's off?
for f in findings:
print(f["severity"], f["rule"], f["anchor_id"], f["fixable"])
report = doc.regularize() # apply the fixable ones, one undo
print(report["ops_run"], "fixes applied")
Anatomy of a finding¶
Every finding is a plain dict (exported as the frozen
Finding dataclass, with .to_dict()):
{
"rule": "space-before-punctuation",
"kind": "consistency",
"severity": "info",
"anchor_id": "para:4",
"message": "Whitespace before punctuation.",
"fixable": true,
"fix": {"op": "find_replace", "find": "[ \\t]+([,.;:\\)])", "text": "\\1",
"in": "para:4", "all": true, "mode": "regex", "required": false},
"adds_content": false,
"observed": "space before , . ; : )",
"expected": "no space before punctuation"
}
| field | meaning |
|---|---|
rule |
the rule id (stable — use it to select or suppress) |
kind |
consistency (drifted from the applied style), structural (an objective defect), or policy (deviates from a configured house-style target) |
severity |
error · warning · info — findings come back ranked worst-first |
anchor_id |
where it is, as an anchor id you can pass to any op |
fixable |
whether regularize can fix it automatically |
fix |
present iff fixable — an op-shaped dict (or list of them), literally the exec op regularize runs |
adds_content |
true when the fix inserts/deletes content (not just re-formats) — regularize withholds these unless allow_content |
observed / expected |
the drifted value and the target it's measured against |
A report-only finding (fixable: false) has no fix — it's flagging
something only you can resolve (an unresolved comment, a manual "heading" that
was never styled). regularize lists those under skipped.
A fixable finding whose fix adds or destroys content (inserting a caption or
notice, deleting a stray paragraph, stripping a watermark) carries
adds_content: true. regularize withholds those by default — a formatting
pass shouldn't silently change what the document says — and lists them under
deferred. Pass allow_content=True (CLI --allow-content) to apply them too.
A guided walkthrough¶
We'll audit and clean a deliberately-messy sample, then layer a house style on top. Everything below is real output from that document.
Get the sample¶
The guide drives messy-brief.docx — a short project brief that looks like
someone typed it in a hurry.
Grab
messy-brief.docx
from examples/sample/ and open it in Word.
Its blemishes, one per line: a Status heading hand-set in Arial 16 (its
Heading 1 style is Calibri 14); a body line with a space before its period
and trailing spaces; a line with a double space; a line with leading
whitespace; and a budget table whose Budget and Used columns are
numbers but left-aligned. Three sections: Status [heading:3], Budget
[heading:7], Next Steps [heading:25].
Step 1 — Audit¶
findings = doc.lint()
for f in findings:
print(f"[{f['severity']}] {f['rule']} ({f['anchor_id']}) {'✎' if f['fixable'] else '·'}")
[warning] trailing-whitespace (para:4) ✎
[warning] leading-whitespace (para:6) ✎
[info] heading-font-consistent (para:3) ✎ ← name 'Arial' ≠ style 'Calibri'
[info] heading-font-consistent (para:3) ✎ ← size 16.0 ≠ style 14.0
[info] double-space (para:5) ✎
[info] space-before-punctuation (para:4) ✎
Six findings, every one fixable. The default set is deliberately conservative — it runs the on-by-default consistency and structural rules and leaves the opinionated and policy rules off (more on selection below).
Step 2 — Preview¶
Want to see the plan before touching the document? dry_run runs the exact same
selection but writes nothing:
Where the plan lives on a dry run
On dry_run=True, applied stays [] (nothing was written) and the plan is
in findings — the fixable ones each carry their fix op. lint() itself
is also a fine preview: it already shows fixable and the fix.
Step 3 — Fix¶
report = doc.regularize()
print(sorted(f["rule"] for f in report["applied"]))
# ['double-space', 'heading-font-consistent', 'heading-font-consistent',
# 'leading-whitespace', 'space-before-punctuation', 'trailing-whitespace']
print(report["ops_run"]) # 6
All six fixes landed in one undo record — a single Ctrl-Z in Word reverts the whole pass, and your cursor and scroll are exactly where you left them. The fixes are targeted and idempotent: each writes the style's own value back as a direct property (or rewrites just the offending text span), so running it again is a clean no-op:
That idempotency is a tested invariant, and it's what makes regularize safe to
run in a loop or a pre-commit-style check.
Content-changing fixes are opt-in
By default regularize is a formatting/structure pass: fixes that change
what the document says — deleting a stray paragraph, inserting a caption or
notice, stripping a watermark — are flagged adds_content and held back in
deferred rather than applied. Pass allow_content=True (CLI
--allow-content) to apply them in the same atomic-undo pass. Some things stay
report-only regardless (an unresolved comment, accepting revisions — those are
yours to judge). And it's Track-Changes-aware: with Track Changes on, the fixes
are recorded as tracked revisions.
Step 4 — Apply a house style¶
The defaults catch objective slips. A house style goes further: it pins
policy — "our body text is justified and 1.5-spaced; numeric table columns are
right-aligned." Those are the policy rules, and they're off until a profile
opts them in and supplies their targets. Point lint/regularize at a profile:
profile = {
"rules": {
"body-justified": {"enabled": True},
"body-line-spacing": {"enabled": True, "target": "1.5"},
"table-numeric-right-align": {"enabled": True, "threshold": 0.8},
}
}
doc.lint(profile=profile) # or profile="wordlive.lint.json"
doc.regularize(profile=profile) # applies the policy fixes too
On the sample, the profile adds 20 policy findings to the six defaults — 7
left-aligned body paragraphs (body-justified), the same 7 at 1.15 line spacing
instead of 1.5 (body-line-spacing), and 6 left-aligned numeric cells across the
Budget and Used columns (table-numeric-right-align). regularize(profile=…)
applies all 26 in one undo record, and a second pass is still empty. See
house-style profiles for the full file format.
The rule catalog¶
Forty-five rules ship today. In the tables below, on (✅) marks the rules in the
default set, and fix marks whether regularize can repair it automatically:
✎ fixable, · report-only (yours to resolve by hand). A ✎⊕ fix adds or deletes
content, so it's applied only under allow_content (see the warning above). The
tags are what you pass to rules=[…] / --rule to select a whole cluster at once.
Consistency — drift from the applied style¶
A direct override that contradicts the paragraph's own style — the formatting someone hand-applied that the style would otherwise have supplied.
| rule | what it catches | on | fix | tags |
|---|---|---|---|---|
body-font-consistent |
A body paragraph whose font name, size, or bold is hand-set, overriding its style. Table cells are skipped — see table-style-consistent. A short, emphasized, non-sentence paragraph is exempt from the size/bold check — that's what manual-heading-formatting flags — so a fix here can't strip the emphasis that points at a missing heading style. |
✅ | ✎ | fonts |
heading-font-consistent |
A heading whose font name, size, or bold is hand-set, overriding its heading style. | ✅ | ✎ | headings, fonts |
heading-spacing-consistent |
A heading whose space-before / space-after is overridden away from its style. | ✅ | ✎ | headings, spacing |
mixed-run-format |
A heading whose font varies run-to-run — part of it was separately restyled. | ✅ | · | headings, fonts |
heading-numbering-manual |
A heading numbered by hand (3.1 Methods) instead of automatic numbering, so it won't renumber when sections move. |
— | · | headings, structure |
heading-trailing-period |
A heading whose text ends in a period — most house styles drop it. | — | ✎ | headings, structure |
double-space |
Two or more spaces between words. | ✅ | ✎ | typography |
space-before-punctuation |
Whitespace sitting before a , . ; : or ). |
✅ | ✎ | typography |
table-style-consistent |
A table that isn't on the document's dominant table style. | ✅ | ✎ | typography, tables |
hyphen-as-range |
A numeric range written with a hyphen (1990-1995, pp. 10-15) rather than an en-dash. |
— | ✎ | typography, academia |
tabs-for-layout |
Tabs used mid-paragraph to lay out text — the job of a table or real indents. | — | · | typography |
hyperlink-display-is-raw-url |
A hyperlink whose whole visible text is a bare URL, where a readable label was wanted. | — | · | hyperlinks, print |
header-footer-consistent |
A primary header/footer whose text disagrees across the document's own (non-linked) sections. | — | · | layout |
leftover-highlight |
Highlighter colour left on body text. | — | ✎ | finalization |
Structural — an objective defect¶
Wrong regardless of any style or house rule — a mechanical slip that will bite in layout, numbering, or hand-off.
| rule | what it catches | on | fix | tags |
|---|---|---|---|---|
heading-keep-with-next |
A heading with keep-with-next off, so it can be stranded alone at the foot of a page. | ✅ | ✎ | headings, pagination |
table-repeat-header |
A table that breaks across a page without repeating its header row. | ✅ | ✎ | tables, pagination |
list-numbering-continuity |
A numbered list Word split into independent runs, so the numbering restarts at 1. | ✅ | ✎ | lists |
trailing-whitespace |
A paragraph that ends in spaces or tabs. | ✅ | ✎ | typography |
leading-whitespace |
A paragraph that starts with literal spaces or tabs (use a paragraph indent). | ✅ | ✎ | typography |
stray-empty-paragraph |
An empty Normal paragraph between content blocks — a leftover blank line. |
— | ✎⊕ | typography, whitespace |
manual-heading-formatting |
A short, all-bold or enlarged body paragraph that reads like a heading but was never styled as one. Table cells are skipped — a bold header row is not a heading. | ✅ | · | typography, headings |
heading-level-skip |
An outline that jumps a level — an H1 followed by an H3 with no H2 between them. | ✅ | · | headings, structure |
empty-heading |
A heading paragraph with no text — a stray styled blank line that pollutes the outline. | ✅ | · | headings, structure |
adjacent-headings |
Two headings in a row with no body text between them (often a heading whose body was deleted). | — | · | headings, structure |
toc-present-and-current |
The document has top-level headings but no table-of-contents field. | — | · | headings, structure, layout |
broken-cross-reference |
A REF / PAGEREF field rendering Word's "Error! Reference source not found." |
✅ | · | crossref, academia |
caption-manual-numbering |
A Caption paragraph numbered with literal text instead of a SEQ field, so it won't renumber. |
✅ | · | captions, academia |
manual-line-break |
A Shift+Enter line break inside a paragraph, where a real paragraph break likely belongs. | — | · | typography |
xref-as-literal-text |
A body paragraph naming a figure/table by literal number ("see Figure 3") with no REF field to keep it in sync. |
— | · | crossref, academia |
hyperlink-broken-internal |
An internal jump (HYPERLINK \l) pointing at a bookmark that no longer exists — a dead link. |
✅ | · | hyperlinks |
draft-watermark-present |
A text watermark (a leftover DRAFT / CONFIDENTIAL stamp) still on the document. | — | ✎⊕ | layout, finalization |
comments-present |
Review comments still left in the document. | — | · | finalization |
unaccepted-revisions |
Tracked changes that were never accepted or rejected. | — | · | finalization |
track-changes-on |
Track Changes is still switched on (a document-global flag). | — | · | finalization |
hidden-text-present |
Runs formatted as hidden text — they print and export invisibly. | — | · | finalization |
stale-fields |
Updatable fields (TOC / SEQ / REF / PAGE) whose rendered result may have drifted — a refresh nudge. |
— | · | finalization |
Policy — deviates from a configured target¶
Off in the default set; enabled by naming them, by tag, or via a profile (which also supplies their targets). These encode a house style — legitimate choices that only become "wrong" once you've declared the target.
| rule | what it catches | fix | tags | config |
|---|---|---|---|---|
body-justified |
Body paragraphs that aren't justified. | ✎ | alignment, policy | — |
body-line-spacing |
Body paragraphs whose line spacing isn't the profile's target. | ✎ | spacing, policy | target ("single"/"1.5"/"double") — required |
table-numeric-right-align |
A table column that's mostly numbers but not right-aligned. | ✎ | tables, policy | threshold (default 0.8) |
em-dash-usage |
An em-dash is present — flags only; the -- swap is too opinion-laden to auto-apply. |
· | typography | — |
page-numbers-present |
No PAGE field in any header or footer. |
✎⊕ | layout | — |
hyperlink-bare-for-print |
An external link whose visible text doesn't contain its URL, so the destination is invisible on paper. | ✎⊕ | hyperlinks, print | — |
document-properties-filled |
A required built-in property (Title / Author) left empty. | · | layout | required (list of property names; default ["Title", "Author"]) |
confidentiality-notice |
A required confidentiality notice missing from every header/footer and the body. | ✎⊕ | layout, notices | text — required (the notice string to look for) |
copyright-notice |
A copyright notice missing from every header/footer and the body. | ✎⊕ | layout, notices | text (default "©") |
Selecting which rules run¶
With no selection, lint/regularize run the default set: every
on-by-default consistency and structural rule. Opinionated rules, the
finalization cluster, and policy rules stay off until you ask for them.
Naming a rule (by id or tag) overrides its default-off status — rules=["typography"]
lights up the entire typography cluster, including its off-by-default members
(hyphen-as-range, em-dash-usage, tabs-for-layout, manual-line-break,
stray-empty-paragraph).
within scopes the audit to a single anchor (heading:N, range:S-E,
table:N:R:C, or an Anchor).
The finalization pass
The finalization cluster is a ready-to-send checklist — unresolved comments,
unaccepted revisions, Track Changes still on, hidden text, stale fields. It's
all off by default; run it right before you send with rules=["finalization"]
(or --rule finalization).
House-style profiles¶
A profile is a small declarative config — an inline dict, or a
wordlive.lint.json file you commit next to the document. It does four things:
- opts policy rules in (a bare
{"enabled": true}), - supplies their targets (
target,threshold), - overrides a rule's severity, and
- disables a rule that's otherwise on by default.
{
"extends": "default",
"rules": {
"body-justified": { "enabled": true, "severity": "warning" },
"body-line-spacing": { "enabled": true, "target": "1.5" },
"table-numeric-right-align": { "enabled": true, "threshold": 0.8 },
"double-space": { "enabled": false }
}
}
Pass it as a path or a dict; both lint and regularize accept profile=, and
resolve it once:
| key | effect |
|---|---|
rules.<id>.enabled |
true opts a policy rule in (or a bare <id>: {} mention); false disables a rule that's otherwise on |
rules.<id>.target |
the target value a policy rule measures against (body-line-spacing needs it, or it no-ops) |
rules.<id>.threshold |
numeric cutoff (table-numeric-right-align: fraction of a column's cells that must parse numeric) |
rules.<id>.severity |
override the finding severity (error/warning/info) — retune what a lint failure means to you |
extends |
accepted and recorded; only "default" is meaningful today |
The three policy rules in this profile all fix idempotently through the same
format_paragraph vocabulary the rest of wordlive uses, so a profile-driven
regularize obeys the same one-undo, run-twice-is-a-no-op contract as the
defaults. (Other policy rules carry content-adding fixes — a page-number field,
a footer notice — flagged ✎⊕; those apply only under allow_content.)
Where next¶
- Python API → Linting & regularizing —
full
lint/regularize/Findingsignatures. - CLI → Linting & regularizing — every flag and exit code.
- MCP server —
word_read command=lintandword_write command=regularizefor agent tool use. - Tutorial — the broader guided editing session this sample's
sibling (
quarterly-report.docx) drives.