Metadata-Version: 2.4
Name: aioffice
Version: 0.2.0
Summary: An AI-native, declarative document engine for creating and validating office artifacts.
Keywords: ai,office,docx,document,agent
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: defusedxml<1,>=0.7.1
Requires-Dist: pydantic<3,>=2.7
Provides-Extra: render
Requires-Dist: Pillow<13,>=10; extra == "render"
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == "dev"
Requires-Dist: pyright<2,>=1.1.400; extra == "dev"
Requires-Dist: ruff<1,>=0.15; extra == "dev"
Requires-Dist: twine<7,>=5; extra == "dev"

# AiOffice

AiOffice is an AI-native, declarative document engine. It lets an agent describe the
document it wants, validates that intent as a strict spec, and compiles it into office
formats without exposing low-level Word object APIs.

The `0.1.0` release is an intentionally small, usable vertical slice of the larger
AiOffice architecture:

- strict AiOffice Document Spec 1.0 draft models;
- stable semantic node IDs;
- a Python API and convenience builder;
- JSON and Markdown input;
- JSON, Markdown, semantic HTML, and DOCX output;
- machine-readable validation diagnostics;
- atomic, revision-checked document patches;
- a CLI shared with the Python core.

The development branch is now `0.2.0.dev83`. It adds lossless DOCX opening, semantic
projection over a native package, persistent native identities, local revision
workspaces, copy-on-write native parts, exact text-range formatting, AI-addressable
named styles, document defaults, ordered page/section models, reusable header/footer
parts, structured dynamic fields, explicit table geometry, logical merged cells,
rich table-cell paragraphs, explicit table/cell border control, paragraph
background/border surfaces, conservative body and header/footer image projection,
verified asset extraction, selective native image metadata and geometry updates,
bounded rectangular source cropping, native picture rotation and
horizontal/vertical mirroring, direct-RGB picture outlines with preset dash styles,
fixed picture opacity with native thousandth-percentage precision,
direct-RGB picture outer shadows with explicit native geometry,
strict inline DrawingML/VML `mc:AlternateContent` compatibility evidence and
strict offset-floating compatibility projection plus dual-branch resizing,
conservative offset/alignment/percentage floating-image anchor projection,
optional Office 2010 relative width/height rules with absolute extent fallbacks,
square/no-wrap/top-and-bottom/tight/through text wrapping, selective floating-anchor layout
updates plus positioning and wrapping-mode switches,
occurrence-scoped copy-on-write image replacement, addressable native inline and
floating image insertion, direct image-paragraph layout formatting, semantic diffs,
isolated LibreOffice/Poppler native rendering, safe reusable native header/footer
creation, cloning, and binding, root append plus bidirectional stable-ID native
paragraph/heading/page-break/list/table insertion and block reordering, consistent
multi-page evidence, page occupancy diagnostics, visual-regression contracts,
fidelity reports, and an independent byte-level fidelity verifier that proves only
the intended native parts changed and that the result is still valid OPC.
Workbook, presentation, PDF editing, and MCP remain planned.

## Install

```bash
pip install aioffice
```

AiOffice requires Python 3.11 or newer.

## Python quick start

```python
from aioffice.documents import DocumentBuilder

doc = (
    DocumentBuilder(title="Project Report", theme="business-clean")
    .heading("Project Report", id="report_title")
    .paragraph("The first delivery milestone is complete.", id="status")
    .bullet_list(["Validated spec", "Generated DOCX", "Published HTML preview"])
    .build()
)

validation = doc.validate()
assert validation.valid

doc.export("report.json")
doc.export("report.md")
doc.export("report.html")
doc.export("report.docx")
```

Open an existing DOCX without rebuilding its unknown or unsupported parts:

```python
import aioffice

doc = aioffice.open("existing.docx", roundtrip="preserve_unknown")
assert doc.origin == "native"

result = doc.apply([
    {
        "op": "text.replace",
        "target": "#para_000001",
        "search": "Draft",
        "replacement": "Approved",
    }
], dry_run=True)

assert result.success
print(result.fidelity)
result.document.export("updated.docx")
```

Exporting an imported DOCX without changes returns the exact original package bytes.
When a supported edit is applied, AiOffice rewrites only the affected native part and
preserves untouched part payloads.

Every native patch also attaches an independent, machine-readable fidelity proof.
Instead of trusting the copy-on-write bookkeeping, AiOffice re-reads the source and
result bytes, hashes every part, and reports the byte-level truth so an agent can gate
its commit on `verification.verified`:

```python
verification = result.verification
assert verification.verified                  # only intended parts changed, OPC valid
assert "/word/document.xml" in verification.modified_parts
assert verification.undeclared_changes == []  # nothing changed unexpectedly
assert verification.opc_valid                 # content types + relationships resolve

# Prove the current native document re-exports exactly, or compare two packages:
snapshot = doc.verify_fidelity()
assert snapshot.byte_identical
```

Untouched parts are proven byte-identical (not merely counted), modified/added/removed
parts are classified explicitly, any change outside the declared affected set is flagged
in `undeclared_changes`, and the result package's content-type coverage and internal
relationship targets are validated. See
[native fidelity verification](docs/native-verification.md).

Image bytes deliberately stay out of the JSON Spec. A simple body, header, or footer
paragraph containing exactly one supported embedded DrawingML picture is projected
as an AI-addressable `image` block with inline or conservative floating placement,
physical extent, optional rectangular source crop, transform, direct-RGB outline,
opacity and outer shadow, alternative text, media type, filename, byte count, and
SHA-256 asset identity:

```python
image = next(
    node for node in doc.inspect()["nodes"]
    if node["type"] == "image"
)

verified = doc.read_image(image["id"])
assert verified.sha256 == image["asset"]["sha256"]
verified.write("extracted/" + verified.filename)
```

Body images appear in `inspect()["nodes"]`; reusable header/footer images appear in
`inspect()["header_footers"][...]["blocks"]`. Both use the same stable image ID and
verified read, update, replacement, and paragraph-layout APIs. Header/footer
insertion and deletion are not advertised: create or clone the complete reusable
part first, then update or replace its projected image in a subsequent Patch.

For a supported floating picture, `image["floating"]` preserves explicit horizontal
and vertical reference frames with offset, alignment, or percentage positioning, supported
square/no-wrap/top-and-bottom/tight/through wrapping, optional parent-anchor
distances, separate wrap-local distances, parent and wrap-child effect extents,
ordered native tight/through polygons, relative height, behind-text behavior,
anchor locking, cell layout, and overlap policy.
`image.anchor.update` can selectively change or switch those proven groups without
rebuilding the drawing or touching its image bytes, relationship, crop, extent,
accessibility metadata, or Office 2010 anchor identities. Native DOCX rendering
remains the visual authority.

```python
result = doc.apply([
    {
        "op": "image.anchor.update",
        "target": f"#{image['id']}",
        "set": {
            "horizontal": {
                "relative_to": "page",
                "offset": {"value": 1, "unit": "in"},
            },
            "vertical": {
                "relative_to": "paragraph",
                "offset": {"value": 12, "unit": "pt"},
            },
            "behind_text": False,
            "allow_overlap": True,
        },
    }
])
assert result.success
```

Horizontal, vertical, and wrap changes replace their complete grouped value;
scalar flags and relative height remain independently selectable. The operation
requires an attached native DOCX and an image already projected as a supported
floating anchor—it never converts an inline or opaque drawing.

Supported projected images can be resized, cropped, transformed, outlined, made
translucent, given an outer shadow, or given accessible metadata without rewriting
their binary part or relationship:

```python
result = doc.apply([
    {
        "op": "image.update",
        "target": f"#{image['id']}",
        "set": {
            "width": {"value": 3, "unit": "in"},
            "crop": {
                "left": 12.5,
                "top": 5,
                "right": 12.5,
                "bottom": 5,
            },
            "outline": {
                "width": {"value": 1.5, "unit": "pt"},
                "color": "#2457A7",
                "dash": "solid",
            },
            "opacity": 72.5,
            "shadow": {
                "color": "#000000",
                "opacity": 40,
                "blur_radius": {"value": 6, "unit": "pt"},
                "distance": {"value": 3, "unit": "pt"},
                "direction_degrees_clockwise": 45,
                "alignment": "center",
                "rotate_with_shape": False,
            },
            "alt_text": "Quarterly revenue by region",
            "title": "Revenue chart",
        },
    }
])
assert result.success
result.document.export("updated.docx")
```

Setting one dimension preserves the current aspect ratio; setting both applies the
exact requested extent. Crop edges are percentage points of the original source,
quantized to Word's `0.001`-percentage-point precision. The crop object is replaced
as a whole; omitted edges mean zero. Use `"clear": ["crop"]` to reveal the complete
source. Outline width is quantized to the nearest native EMU; color is one explicit
sRGB hex value and `dash` is one DrawingML preset exposed by the
`image-outline` schema. Both transform and outline are complete replacement groups.
Use `"clear": ["outline"]` to remove the direct line. Opacity is expressed in
percentage points from 0 inclusive to 100 exclusive, quantized to `0.001`; clear it
to restore fully opaque identity. Shadow is a complete replacement group backed by
one direct `a:effectLst/a:outerShdw`; clear it to remove the effect. It accepts one
direct sRGB color, 0.001-percentage-point opacity, explicit blur/distance lengths,
clockwise direction, nine-point alignment, rotate-with-shape behavior, and optional
four-edge inline effect-extent layout evidence. Floating shadows reuse
`floating.anchor_effect_extent`.
`alt_text` and `title` are also clearable. The
native patch updates the minimal DrawingML geometry while preserving image bytes and
package relationships. It requires the attached native DOCX, so a detached JSON
snapshot cannot perform this operation.

LibreOffice 26.8 renders the tested direct black outer shadow, retains its native
effect on save, and may quantize shadow lengths or add effect-extent evidence. It
still ignores the tested `a:alphaModFix` picture opacity. A LibreOffice save may
also wrap a drawing in `mc:AlternateContent`. Dev49 projects only the strictly
proven `Requires="wps"` DrawingML choice plus canonical VML picture fallback,
including a bounded floating form whose physical column/paragraph offsets, square
wrap, zero distances, anchor identity, and VML margins agree. The proof is exposed
as `alternate_content`, and only `width`/`height` are synchronized across both
branches. Replacement is advertised only when the VML fallback bytes and media type
equal the DrawingML choice. `image.anchor.update`, crop, effects, accessibility
fields, alignment/percentage-positioned wrappers, and unfamiliar fallback
structures remain fail-closed. A header/footer containing a strictly projected
wrapper can be cloned: AiOffice copies its complete local relationship graph,
shares both media targets, rebases DrawingML and VML occurrence identities, and
preserves the lexical `wps` namespace required for compatibility selection. AiOffice
preserves untouched native XML exactly;
Microsoft Word/Office remains the final authority for cross-producer visual
approval.

The projected image ID also addresses its native host paragraph. Reuse
`paragraph.format` to control layout around an existing picture without touching its
DrawingML or bytes:

```python
result = doc.apply([
    {
        "op": "paragraph.format",
        "target": f"#{image['id']}",
        "set": {
            "alignment": "center",
            "spacing_before": {"value": 10, "unit": "pt"},
            "spacing_after": {"value": 12, "unit": "pt"},
            "keep_together": True,
        },
    }
])
assert result.success
```

The same strict `ParagraphStyle` fields and `set`/`clear` semantics used by text
paragraphs apply to the image paragraph, including indentation, page-flow controls,
solid background, and supported physical borders. The operation appears on each
projected image's `supported_operations` list.

Image binaries also use an explicit out-of-band write path:

```python
result = doc.replace_image(
    image["id"],
    "assets/revenue-chart.png",
    media_type="image/png",
)
assert result.success
result.document.export("replaced.docx")
```

AiOffice signature-checks and bounds the raster input, creates a content-addressed
native image part and a new relationship for only that occurrence, and preserves its
stable image ID, displayed extent, source crop, transform, outline, opacity, shadow,
alternative text, and title. Other occurrences that shared the old image remain
unchanged. Raw JSON Patch cannot carry the binary.

New inline or conservative floating pictures use the same bounded asset channel and
require explicit layout:

```python
result = doc.insert_image_after(
    "#status",
    "assets/expert-workflow.png",
    width={"value": 3, "unit": "in"},
    height={"value": 1.5, "unit": "in"},
    alt_text="Expert workflow with three approval stages",
    image_id="expert_workflow",
    outline={
        "width": {"value": 1, "unit": "pt"},
        "color": "#2457A7",
        "dash": "solid",
    },
    opacity=85,
    shadow={
        "color": "#000000",
        "opacity": 40,
        "blur_radius": {"value": 6, "unit": "pt"},
        "distance": {"value": 3, "unit": "pt"},
    },
    paragraph_style={"alignment": "center"},
)
assert result.success
result.document.export("inserted.docx")
```

Inline remains the default. Pass the same strict layout object used by projected
floating pictures to create a canonical editable anchor:

```python
result = doc.insert_image_after(
    "#status",
    "assets/expert-workflow.png",
    width={"value": 3, "unit": "in"},
    height={"value": 1.5, "unit": "in"},
    alt_text="Expert workflow with three approval stages",
    image_id="floating_workflow",
    floating={
        "horizontal": {
            "relative_to": "column",
            "offset": {"value": 1, "unit": "in"},
        },
        "vertical": {
            "relative_to": "paragraph",
            "offset": {"value": 12, "unit": "pt"},
        },
        "anchor_distances": {
            "top": {"value": 4, "unit": "pt"},
            "right": {"value": 8, "unit": "pt"},
            "bottom": {"value": 4, "unit": "pt"},
            "left": {"value": 8, "unit": "pt"},
        },
        "wrap": {
            "mode": "square",
            "side": "both_sides",
        },
        "relative_height": 1024,
        "behind_text": False,
        "locked": False,
        "layout_in_cell": True,
        "allow_overlap": True,
    },
)
```

Each horizontal or vertical position uses exactly one mode: an explicit-unit
`offset`, a semantic `alignment`, or `percentage_offset` in percentage points of
the selected frame. Percentage values have `0.001`-percentage-point native
precision and may be negative. For example, a picture centered within the margins
and the physical page uses:

```python
floating = {
    "horizontal": {"relative_to": "margin", "alignment": "center"},
    "vertical": {"relative_to": "page", "alignment": "center"},
    # The same complete wrap and flag fields shown above are still required.
}
```

The Office 2010 percentage form is equally explicit:

```python
floating = {
    "horizontal": {"relative_to": "page", "percentage_offset": 50},
    "vertical": {"relative_to": "margin", "percentage_offset": 12.5},
    # The same complete wrap and flag fields shown above are still required.
}
```

Floating pictures may also carry independent Office 2010 relative-size rules:

```python
floating = {
    "horizontal": {"relative_to": "page", "alignment": "center"},
    "vertical": {"relative_to": "page", "alignment": "center"},
    "relative_size": {
        "width": {"relative_to": "margin", "percentage": 75},
        "height": {"relative_to": "page", "percentage": 40},
    },
    # The same complete wrap and flag fields shown above are still required.
}
```

`relative_size.width` and `.height` are independent; at least one is required.
Percentages use `0.001`-percentage-point native precision and cannot be negative.
The image node's ordinary `width` and `height` remain the exact `wp:extent`
fallback, so a relative rule never destroys the producer's absolute geometry.

`wrap.mode` accepts `square`, `none`, `top_and_bottom`, `tight`, or `through`.
Square, tight, and through require `side`; the other modes must omit it.
Tight/through additionally require an ordered polygon whose coordinates remain raw
OOXML signed integers rather than being mislabeled as physical lengths.
`anchor_distances` preserves the optional
distance attributes on `wp:anchor`. Square and top-and-bottom wrap may separately
carry wrap-element `distances` and `effect_extent`; `anchor_effect_extent` preserves
the parent value. AiOffice keeps these native sources distinct because the wrap
child extent overrides the parent for its wrapping boundary.
For `none`, `behind_text` chooses whether the picture is behind or in front of text.

The target must be a mapped top-level body node. AiOffice inserts after its last
native element, which keeps multi-paragraph lists addressable as one semantic node.
The new paragraph, DrawingML geometry, relationship, asset and identity manifest are
created atomically. Explicit width, height and nonblank alternative text avoid
model-side DPI guessing and inaccessible output.

The equivalent CLI is:

```bash
aioffice extract-image existing.docx IMAGE_ID -o extracted.png
aioffice replace-image existing.docx IMAGE_ID replacement.png -o replaced.docx
aioffice insert-image-after existing.docx TARGET replacement.png \
  --width 3 --width-unit in --height 1.5 --height-unit in \
  --alt-text "Expert workflow" --outline image-outline.json \
  --opacity 85 --shadow image-shadow.json \
  --floating-layout floating-layout.json \
  -o inserted.docx
```

Persistent workspaces expose the same operations through
`Workspace.replace_image(...)`, `Workspace.insert_image_after(...)`, and matching
CLI commands, recording verified asset and insertion metadata but never base64 in the
revision log.

The read path re-resolves the trusted native paragraph and its story-local OPC
relationship, then verifies the asset record, media type, size, and content hash
before returning bytes. Mixed text/picture paragraphs, active simple-position
anchors, malformed relative-size rules, unsupported wrap-specific effects, linked images,
multiple pictures, negative or overconstrained crop rectangles, malformed transforms,
unsupported outline fills, joins, compound lines, arrowheads or custom dashes,
malformed opacity, unsupported or malformed shadows, other picture effects, drawings
in tables, complex header/footer drawings, standalone or unrecognized VML, OLE, and
embedded objects remain
explicit opaque native
content. They are preserved losslessly and rendered through the native provider
rather than flattened into a misleading image model. See
[the native image and asset contract](docs/native-images.md).

AiOffice-generated DOCX files embed a versioned identity manifest. Artifact IDs,
semantic node IDs, native anchors, and revisions therefore survive export and reopen.
Third-party documents can keep the same guarantees through a local workspace:

```python
from aioffice import Workspace

workspace = Workspace.init("project")
doc = workspace.import_document("existing.docx")

result = workspace.apply(
    doc.id,
    [{
        "op": "text.replace",
        "target": f"#{doc.to_spec()['content'][0]['id']}",
        "search": "Draft",
        "replacement": "Approved",
    }],
    base_revision=doc.revision,
    idempotency_key="approve-first-paragraph",
)

assert result.success
revision_one = workspace.checkout(doc.id, revision=1)
revision_two = workspace.open_document(doc.id)
```

Use `workspace.reconcile_document(...)` to preview an externally edited DOCX. A
commit is refused when native identity is ambiguous. The detailed invariants are in
[the native round-trip architecture](docs/native-roundtrip.md).

Native DOCX lowering in this development version supports `text.replace`,
`paragraph.format`, `text.format`, `node.remove`, `style.define`, `style.apply`,
`style.format`, `section.format`, `field.update`, `image.insert_after`,
`image.replace`, `image.update`, `image.anchor.update`, `table.format`,
`table.column.format`, and `table.cell.format`. Ask the artifact before planning an
edit:

```python
capabilities = doc.capabilities()
assert "text.replace" in capabilities["operations"]
```

Formatting values always include units. This prevents an agent from confusing
points, pixels, inches, and native OOXML twips:

```python
result = doc.apply([
    {
        "op": "paragraph.format",
        "target": "#para_000001",
        "set": {
            "alignment": "justify",
            "spacing_after": {"value": 8, "unit": "pt"},
            "line_spacing": {"rule": "multiple", "value": 1.25},
        },
    },
    {
        "op": "text.format",
        "target": "#para_000001",
        "match": {
            "text": "重要结论",
            "occurrence": 1,
        },
        "set": {
            "font_size": {"value": 10.5, "unit": "pt"},
            "color": "#1F4E78",
        },
    },
])

assert result.success
print(result.diff.summary)
```

`text.format` can target the whole node, an exact text occurrence, or a half-open
Unicode code-point range such as
`{"range": {"start": 4, "end": 10, "unit": "unicode_codepoint"}}`. Imported
mixed Word runs and hyperlinks are projected as rich `TextSpan` content, so an
agent can inspect and edit local formatting without losing link targets.

Named styles are stable, AI-addressable layout rules rather than copied formatting.
The resolver applies document defaults, the complete `based_on` chain, node direct
formatting, and finally span formatting:

```python
doc = (
    DocumentBuilder()
    .define_style({
        "id": "Executive",
        "name": "Executive",
        "semantic_role": "custom",
        "based_on": "Normal",
        "paragraph_style": {
            "background_color": "#EAF2F8",
            "borders": {
                "left": {
                    "style": "single",
                    "width": {"value": 3, "unit": "pt"},
                    "color": "#1F4E78",
                    "space": {"value": 8, "unit": "pt"},
                },
            },
            "spacing_after": {"value": 14, "unit": "pt"},
            "keep_together": True,
        },
        "text_style": {
            "font_size": {"value": 13, "unit": "pt"},
            "color": "#7A1F5B",
            "bold": True,
        },
    })
    .paragraph("Board decision", id="decision", style_ref="Executive")
    .build()
)

result = doc.apply([
    {
        "op": "style.format",
        "target": "@Executive",
        "paragraph": {
            "set": {"spacing_after": {"value": 18, "unit": "pt"}}
        },
        "text": {
            "set": {"color": "#1F4E78"},
            "clear": ["bold"],
        },
    }
])
```

Imported `w:style` definitions, `w:docDefaults`, inheritance links, quick-style
metadata, and paragraph `w:pStyle` references are projected into the Spec. Native
style patches update only supported properties in `word/styles.xml`; unknown style
XML and every untouched package part remain byte-for-byte preserved.

`paragraph_style.background_color` creates a solid paragraph-wide surface.
`paragraph_style.borders` controls top/right/bottom/left edges with the same strict
border line model used by tables. Border edges inherit independently through the
named-style chain: a direct bottom edge can override a style while its other edges
continue to inherit. Clearing removes direct XML; `style: "none"` explicitly
suppresses an inherited edge. Pattern/theme shading and Word's `between`/`bar`
borders remain native-only and losslessly preserved. See
[the paragraph surface contract](docs/paragraph-surfaces.md).

Sections are ordered, AI-addressable page regions. The first section starts at the
document root; each later section is anchored at its first content node. Page size,
orientation, margins, gutter, header/footer distance, columns, vertical alignment,
first-page behavior, and Word section-start type all use strict values:

```python
doc = DocumentBuilder(
    sections=[
        {
            "id": "cover_section",
            "start_at": None,
            "layout": {
                "page_size": {"preset": "letter"},
                "margin_top": {"value": 1, "unit": "in"},
                "margin_right": {"value": 1, "unit": "in"},
                "margin_bottom": {"value": 1, "unit": "in"},
                "margin_left": {"value": 1, "unit": "in"},
            },
        },
        {
            "id": "analysis_section",
            "start_at": "analysis",
            "layout": {
                "start_type": "next_page",
                "page_size": {
                    "preset": "a4",
                    "orientation": "landscape",
                },
                "columns": {
                    "count": 2,
                    "spacing": {"value": 24, "unit": "pt"},
                    "separator": True,
                },
                "page_number_start": 1,
                "page_number_format": "lower_roman",
            },
        },
    ]
).paragraph("Cover", id="cover").paragraph("Analysis", id="analysis").build()

result = doc.apply([
    {
        "op": "section.format",
        "target": "#analysis_section",
        "set": {"margin_left": {"value": 18, "unit": "mm"}},
        "clear": ["footer_distance"],
    }
])
```

Split an existing section before a stable top-level content node when the following
content needs a different page design:

```python
result = doc.apply([
    {
        "op": "section.insert_before",
        "target": "#wide_appendix",
        "section": {
            "id": "wide_appendix_section",
            "layout": {
                "page_size": {
                    "preset": "a4",
                    "orientation": "landscape",
                },
                "margin_left": {"value": 18, "unit": "mm"},
                "margin_right": {"value": 18, "unit": "mm"},
            },
        },
    }
])
```

The new section inherits the containing section's remaining layout and
header/footer bindings, defaults to a `next_page` break, and can be formatted or
prepended to later in the same Patch. Imported DOCX lowering inserts one hidden
boundary paragraph before the target's complete native range, copies the old
section properties for the preceding content, and patches only the requested
properties on the existing boundary. See
[native section insertion](docs/native-section-insertion.md).

Generated multi-section DOCX uses Word's native section placement rules. Imported
paragraph-level and final body-level `w:sectPr` elements are projected separately,
and `section.format` changes only the selected native section properties. Unknown
section XML remains native and untouched. See
[the page and section contract](docs/section-layout.md).

Headers and footers use reusable parts rather than copied strings. Each section may
explicitly bind `default`, `first`, and `even` header/footer variants; a missing
binding means “inherit the same slot from the previous section”:

```python
doc = DocumentBuilder(
    settings={"even_and_odd_headers": True},
    header_footers=[
        {
            "id": "report_header",
            "kind": "header",
            "content": [
                {
                    "id": "report_header_text",
                    "type": "paragraph",
                    "text": "Confidential",
                }
            ],
        },
        {
            "id": "report_footer",
            "kind": "footer",
            "content": [
                {
                    "id": "report_footer_text",
                    "type": "paragraph",
                    "content": [
                        {"text": "Page "},
                        {
                            "id": "current_page",
                            "type": "field",
                            "kind": "page_number",
                            "cached_result": "1",
                        },
                        {"text": " of "},
                        {
                            "id": "total_pages",
                            "type": "field",
                            "kind": "page_count",
                            "cached_result": "1",
                        },
                    ],
                }
            ],
        },
    ],
    sections=[
        {
            "id": "main_section",
            "header_footer": {
                "header_default": "report_header",
                "footer_default": "report_footer",
            },
        }
    ],
).paragraph("Report body", id="body").build()
```

An imported DOCX can receive a new reusable region and bind it in one atomic Patch:

```python
result = doc.apply([
    {
        "op": "header_footer.create",
        "part": {
            "id": "appendix_header",
            "kind": "header",
            "content": [
                {
                    "id": "appendix_header_text",
                    "type": "paragraph",
                    "text": "Appendix",
                }
            ],
        },
    },
    {
        "op": "section.header_footer.bind",
        "target": "#appendix_section",
        "set": {"header_default": "appendix_header"},
    },
])
```

Native lowering allocates a collision-free part URI, document relationship,
content-type override, optional hyperlink relationships, and persistent identities.
It preserves every unrelated package part and refuses caller-supplied native
references. See
[native header/footer creation](docs/native-header-footer-creation.md).

When a section shares a native region that must diverge, clone the reusable part
instead of reconstructing it from JSON:

```python
result = doc.apply([
    {
        "op": "header_footer.clone",
        "target": "#report_header",
        "part": {
            "id": "appendix_header",
            "metadata": {"role": "appendix"},
        },
    },
    {
        "op": "section.header_footer.bind",
        "target": "#appendix_section",
        "set": {"header_default": "appendix_header"},
    },
])
```

Native cloning copies the complete supported `headerN.xml` or `footerN.xml` story
and its part-local relationships, shares relationship targets such as images, and
rebases paragraph and DrawingML identities that must be unique. The source remains
byte-for-byte unchanged. Edit the cloned part in a subsequent Patch. See
[native header/footer cloning](docs/native-header-footer-cloning.md).

An existing section can explicitly reuse another projected native region, or clear
one slot so Word inherits it from the previous section:

```python
result = doc.apply([
    {
        "op": "section.header_footer.bind",
        "target": "#appendix_section",
        "set": {
            "header_default": "appendix_header",
            "footer_default": "report_footer",
        },
        "clear": ["header_first"],
    }
])
```

For imported DOCX, the operation changes only the selected
`w:headerReference`/`w:footerReference` children. It never reconstructs the region,
copies content, or rewrites document relationships. The part must already be
projected in the document and have exactly one internal, type-compatible
relationship. See [native header/footer binding](docs/native-header-footer-binding.md).

The paragraph IDs inside a header/footer are regular edit selectors. `text.replace`,
`text.format`, and `paragraph.format` lower directly into the referenced
`headerN.xml` or `footerN.xml` part. A conservative one-picture inline paragraph is
instead exposed by its image ID for `image.update`, `paragraph.format`, verified
extraction, and occurrence-scoped `replace_image()`. PAGE, NUMPAGES, SECTION, and
SECTIONPAGES are structured fields with their own stable IDs. Their displayed result
is explicitly a non-authoritative cache:

```python
result = doc.apply([
    {
        "op": "field.update",
        "target": "#current_page",
        "set": {"number_format": "upper_roman"},
    }
])
```

Generated fields are marked dirty and `update_fields_on_open` is enabled unless
explicitly disabled. Unknown field instructions remain structured but read-only;
complex drawings, objects, tables, and malformed field structures remain opaque. See
[the dynamic field contract](docs/dynamic-fields.md) and
[the header/footer contract](docs/header-footer.md).

Document tables keep semantic column keys and stable column/row/cell IDs while
exposing layout geometry in explicit units. The `values` form remains a compact
input shorthand and is normalized to cells:

```python
table_doc = DocumentBuilder().table(
    id="metrics",
    columns=[
        {
            "id": "metric_column",
            "key": "metric",
            "title": "Metric",
            "width": {"value": 120, "unit": "pt"},
        },
        {
            "id": "value_column",
            "key": "value",
            "title": "Value",
            "data_type": "number",
            "width": {"value": 180, "unit": "pt"},
        },
    ],
    rows=[
        {
            "id": "revenue_row",
            "values": {"metric": "Revenue", "value": 42},
            "allow_break_across_pages": False,
        }
    ],
    layout={
        "preferred_width": {"mode": "percent", "value": 90},
        "alignment": "center",
        "algorithm": "fixed",
        "repeat_header": True,
        "cell_margin_left": {"value": 6, "unit": "pt"},
        "cell_margin_right": {"value": 6, "unit": "pt"},
        "borders": {
            "top": {
                "style": "single",
                "width": {"value": 1.5, "unit": "pt"},
                "color": "#1F4E78",
            },
            "right": {
                "style": "single",
                "width": {"value": 1.5, "unit": "pt"},
                "color": "#1F4E78",
            },
            "bottom": {
                "style": "single",
                "width": {"value": 1.5, "unit": "pt"},
                "color": "#1F4E78",
            },
            "left": {
                "style": "single",
                "width": {"value": 1.5, "unit": "pt"},
                "color": "#1F4E78",
            },
            "inside_horizontal": {
                "style": "single",
                "width": {"value": 0.5, "unit": "pt"},
                "color": "#D9E2F3",
            },
            "inside_vertical": {"style": "none"},
        },
    },
).build()

result = table_doc.apply(
    [
        {
            "op": "table.column.format",
            "target": "#metrics",
            "column": "#value_column",
            "set": {"width": {"value": 200, "unit": "pt"}},
        }
    ]
)
```

Logical cells can span rows or columns, contain multiple rich paragraphs, and carry
cell-local formatting:

```python
from aioffice import Document

rich_table = Document.from_spec({
    "content": [{
        "id": "summary_table",
        "type": "table",
        "columns": [
            {"key": "summary", "title": "Summary"},
            {"key": "detail", "title": "Detail"},
        ],
        "rows": [{
            "id": "summary_row",
            "cells": [{
                "id": "summary_cell",
                "column_key": "summary",
                "column_span": 2,
                "content": [{
                    "id": "summary_text",
                    "type": "paragraph",
                    "content": [
                        {"type": "text", "text": "Approved", "marks": ["strong"]},
                        {"type": "text", "text": " for release"},
                    ],
                }],
                "format": {
                    "vertical_alignment": "center",
                    "background_color": "#EAF2F8",
                    "margin_left": {"value": 8, "unit": "pt"},
                },
            }],
        }],
    }],
})

result = rich_table.apply([{
    "op": "table.cell.format",
    "target": "#summary_table",
    "cell": "#summary_cell",
    "set": {
        "background_color": "#FFF2CC",
        "borders": {
            "bottom": {
                "style": "double",
                "width": {"value": 2, "unit": "pt"},
                "color": "#C00000",
            },
        },
    },
}])
```

Imported DOCX grids are analyzed as logical cells before `gridSpan` and `vMerge`
are exposed. Regular grids support selective column widths; merged grids reject
column-width mutation but still support formatting a mapped anchor cell. Supported
cell paragraphs use the normal text and paragraph operations. Cells containing
drawings, nested tables, dynamic fields, or malformed content fall back to a
read-only text projection while their native XML remains intact. See
[the table layout contract](docs/table-layout.md) and
[the table cell contract](docs/table-cells.md).

Border edges use explicit styles, widths, colors, and optional spacing. Clearing the
`borders` property removes known direct border XML so table styles can apply again;
`{"style": "none"}` writes an explicit no-border edge. A direct cell edge wins over
the conflicting table perimeter or internal-grid edge.

`doc.render()` defaults to a semantic HTML preview whose contract explicitly reports
`fidelity="approximate"` and `verification_status="preview_only"`. A local
LibreOffice and Poppler installation enables native-compatible PDF and page PNG
evidence:

```python
pdf = doc.render(format="pdf", provider="libreoffice")
pdf.write("report-render.pdf")
assert pdf.metadata["page_count"] >= 1

page = doc.render(
    format="png",
    provider="libreoffice",
    options={"page_number": 1, "dpi": 144},
)
page.write("report-page-1.png")
```

For an entire document, render the PDF only once and derive a bounded, consistent
page set from it:

```python
evidence = doc.render_pages(
    options={"dpi": 144},
    analyze=True,
    max_pages=100,
)
paths = evidence.write("evidence", stem="report")
assert len(evidence.pages) == evidence.page_count
```

Page analysis reports the background, ink ratio, content bounding box, four-side
whitespace, apparent blank pages, and visible content near a page edge. It requires
`pip install "aioffice[render]"`; rendering pages without analysis does not require
Pillow.

Each job uses an isolated LibreOffice user profile and reports engine versions,
source/output hashes, page count, font-environment hash, page dimensions, and
diagnostics. Native evidence still reports `verification_status="unverified"`:
successful rendering proves that inspectable pages exist, not that an aesthetic
review has passed. See [the native rendering contract](docs/native-rendering.md)
and [style, diff, and rendering contracts](docs/style-rendering.md).

The equivalent CLI workflow is:

```bash
aioffice render report.docx --format pdf -o report-render.pdf
aioffice render report.docx --format png --page 1 --dpi 144 -o report-page-1.png
aioffice render-pages report.docx --analyze --output-directory evidence
```

You can also create a document directly from the strict spec:

```python
from aioffice.documents import Document

doc = Document.from_spec({
    "metadata": {"title": "Project Report"},
    "theme": {"ref": "business-clean"},
    "content": [
        {"type": "heading", "level": 1, "text": "Project Report"},
        {"type": "paragraph", "text": "The first milestone is complete."},
    ],
})
```

## Atomic patch

Patches never mutate the source `Document`. A successful result contains the next
logical revision:

```python
result = doc.apply(
    [
        {
            "op": "text.replace",
            "target": "#status",
            "search": "complete",
            "replacement": "approved",
        }
    ],
    base_revision=doc.revision,
    dry_run=True,
)

assert result.success
preview = result.document
```

Imported DOCX documents can receive a new paragraph, heading, explicit page break,
bullet or numbered list, or complete semantic table without rebuilding their
existing content:

```python
result = doc.apply([
    {
        "op": "node.insert_after",
        "target": "#executive_summary",
        "content": {
            "id": "recommendation",
            "type": "paragraph",
            "content": [
                {"type": "text", "text": "Recommendation: ", "marks": ["strong"]},
                {"type": "text", "text": "approve the proposed plan."},
            ],
            "paragraph_style": {
                "spacing_before": {"value": 8, "unit": "pt"},
                "spacing_after": {"value": 8, "unit": "pt"},
            },
        },
    }
])
assert result.success
```

Only the new native block is compiled. Existing XML, DrawingML, relationships, and
unsupported native features remain untouched. A caller-selected new ID can be
targeted again later in the same Patch; an omitted ID is returned in change
evidence. Rich text, direct formatting, internal/external hyperlinks, and normalized
document fields are supported in text blocks. Use `node.insert_before` for symmetric
placement, including insertion at the beginning of the document. Inserting before a
later section's first node safely rebinds that section's `start_at`. Use
`node.append` with target `$` when the AI should add content to the last section
without first discovering the final content ID; native lowering inserts it before
the terminal body `w:sectPr`. See
[native text insertion](docs/native-text-insertion.md).

Use the same operations with `{"type": "page_break"}` to insert one native
`w:p/w:r/w:br` pagination control. The break has its own stable ID, can be targeted
later in the Patch, and is verified by native rendering rather than approximated by
the JSON projection.

Bullet and numbered lists are also ordinary stable-ID blocks:

```python
result = doc.apply([
    {
        "op": "node.insert_after",
        "target": "#recommendation",
        "content": {
            "id": "release_steps",
            "type": "ordered_list",
            "items": [
                "Validate the evidence",
                "Approve the release",
                "Notify stakeholders",
            ],
        },
    }
])
assert result.success
```

AiOffice creates a fresh single-level `abstractNum` and `num` for each inserted
list, so numbering restarts deterministically and cannot accidentally continue an
adjacent native list. The list's contiguous `w:p` range has one stable root ID and
can immediately anchor another insertion, move as a group, or be removed. Existing
numbering definitions remain unchanged. See
[native list insertion](docs/native-list-insertion.md).

Tables use the same stable-ID placement contract:

```python
result = doc.apply([
    {
        "op": "node.insert_after",
        "target": "#recommendation",
        "content": {
            "id": "decision_table",
            "type": "table",
            "columns": [
                {"id": "metric_column", "key": "metric", "title": "Metric"},
                {"id": "value_column", "key": "value", "title": "Value"},
            ],
            "rows": [
                {
                    "id": "growth_row",
                    "cells": [
                        {
                            "id": "growth_label",
                            "column_key": "metric",
                            "value": "Growth",
                        },
                        {
                            "id": "growth_value",
                            "column_key": "value",
                            "value": "18%",
                        },
                    ],
                }
            ],
            "layout": {
                "style_ref": "TableGrid",
                "algorithm": "fixed",
                "repeat_header": True,
            },
        },
    },
    {
        "op": "table.cell.format",
        "target": "#decision_table",
        "cell": "#growth_value",
        "set": {"background_color": "#E2F0D9"},
    },
])
assert result.success
```

AiOffice compiles only the new `w:tbl`, assigns native references to its columns,
rows, cells, and rich cell paragraphs, and leaves every existing body element
untouched. Regular and merged cells, explicit geometry, direct table/cell formatting,
and internal/external links in rich cell paragraphs are supported. See
[native table insertion](docs/native-table-insertion.md).

Existing top-level content can be reordered without reconstructing it or addressing
an array index:

```python
result = doc.apply([
    {
        "op": "node.move_before",
        "target": "#risk_table",
        "before": "#executive_summary",
    }
])
assert result.success
```

For imported DOCX, AiOffice moves the target's complete mapped XML range. A
multi-paragraph list remains one contiguous unit, DrawingML and unknown XML stay in
their original elements, and every native reference is reindexed. `node.move_after`
and `node.move_before` cover every relative position without array indexes. The
conservative dev31 boundary permits moves only within one semantic section, refuses
moving a section start anchor, and rebinds `section.start_at` when prepending within
a later section. Native elements carrying `w:sectPr` remain immovable. See
[the structural editing contract](docs/structural-editing.md).

`node.remove` uses the same native-authority boundary. It removes the complete mapped
XML range, refuses native section carriers, attaches an identity manifest on the
first structural edit to a third-party DOCX, and preserves now-unreferenced
relationships or parts rather than guessing that they are safe to delete.

Semantic documents support `text.replace`, `paragraph.format`, `text.format`,
`node.append`, `node.insert_after`, `node.insert_before`, `node.move_after`,
`node.move_before`, `node.remove`, `node.update`, `style.define`, `style.apply`, `style.format`,
`header_footer.create`, `section.header_footer.bind`, `section.insert_before`,
`section.format`, `field.update`,
`table.format`, `table.column.format`, and `table.cell.format`. Imported DOCX
documents support incremental before/after insertion for paragraphs, headings, page
breaks, bullet/ordered lists, and tables, plus root append and safe native image
operations reported by `capabilities()`.
Selectors use stable content, section, header/footer block, field, image, table,
column, row, cell, or rich cell-paragraph identities in this release.

## CLI

```bash
aioffice inspect examples/report.json
aioffice capabilities existing.docx
aioffice verify original.docx modified.docx --declared-affected /word/document.xml
aioffice validate examples/report.json
aioffice build examples/report.json --output report.docx
aioffice export examples/report.json --to report.html
aioffice schema --output document.schema.json
aioffice schema --kind named-style --output named-style.schema.json
aioffice schema --kind paragraph-style --output paragraph-style.schema.json
aioffice schema --kind paragraph-borders --output paragraph-borders.schema.json
aioffice schema --kind document-defaults --output document-defaults.schema.json
aioffice schema --kind page-size --output page-size.schema.json
aioffice schema --kind section-layout --output section-layout.schema.json
aioffice schema --kind document-section --output document-section.schema.json
aioffice schema --kind document-settings --output document-settings.schema.json
aioffice schema --kind document-field --output document-field.schema.json
aioffice schema --kind table-width --output table-width.schema.json
aioffice schema --kind table-layout --output table-layout.schema.json
aioffice schema --kind table-column --output table-column.schema.json
aioffice schema --kind table-cell --output table-cell.schema.json
aioffice schema --kind table-cell-format --output table-cell-format.schema.json
aioffice schema --kind border-line --output border-line.schema.json
aioffice schema --kind table-borders --output table-borders.schema.json
aioffice schema --kind table-cell-borders --output table-cell-borders.schema.json
aioffice schema --kind header-footer-bindings --output header-footer-bindings.schema.json
aioffice schema --kind header-footer-part --output header-footer-part.schema.json
aioffice schema --kind text-range --output text-range.schema.json

aioffice workspace init project
aioffice workspace import existing.docx --root project
aioffice workspace list --root project
aioffice workspace capabilities ARTIFACT_ID --root project
aioffice workspace inspect ARTIFACT_ID --root project
aioffice workspace apply ARTIFACT_ID patch.json --root project
aioffice workspace reconcile ARTIFACT_ID edited.docx --root project
aioffice workspace reconcile ARTIFACT_ID edited.docx --root project --commit
aioffice workspace export ARTIFACT_ID updated.docx --root project
```

Patch files may be an operation array or an envelope:

```json
{
  "base_revision": 1,
  "idempotency_key": "agent-task-001",
  "operations": [
    {
      "op": "text.replace",
      "target": "#status",
      "search": "第一阶段",
      "replacement": "第二阶段"
    }
  ]
}
```

Preview or commit it without overwriting the input:

```bash
aioffice apply examples/report.json patch.json --dry-run
aioffice apply examples/report.json patch.json --output updated.json
```

## Capability matrix and documentation drift

AiOffice's real capabilities live in a machine-readable registry, not in prose.
The registry is the single source of truth; the documentation is generated from
it and is checked for drift in CI.

```bash
# Print the current capability matrix (also written to docs/capabilities.md)
aioffice capability-doc

# Fail (exit 1) if any doc claims a supported capability is still "future"/"planned"
aioffice capability-drift
```

Status vocabulary: **supported** (safe for AI edits), **partial** (caveats apply),
**opaque** (preserved losslessly through the native layer, not directly editable by AI),
**planned** (known gap from the expert audit). When you ship a feature, update the
registry in `src/aioffice/capabilities/registry.py` and regenerate the doc — never
edit `docs/capabilities.md` by hand.

## Development and release

```bash
python -m pip install -e ".[dev,render]"
python -m unittest discover -s tests -v
ruff check src tests
pyright src
python -m build
python -m twine check dist/*
```

Production releases use PyPI Trusted Publishing. The tag must match the package
version in `src/aioffice/_version.py`; pushing it starts
`.github/workflows/publish.yml`:

```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```

No long-lived PyPI API token is stored in GitHub.

The current spec is a draft. Compatibility will be maintained within the `0.1.x`
series where practical, but the public model can still evolve before 1.0.
