Skip to main content

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

pip install aioffice

AiOffice requires Python 3.11 or newer.

Python quick start

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:

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:

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.

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:

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.

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.

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:

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.

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:

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:

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:

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.

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:

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:

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.

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.

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”:

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:

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.

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

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.

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

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.

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:

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 and the header/footer contract.

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:

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:

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 and the table cell contract.

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:

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:

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 and style, diff, and rendering contracts.

The equivalent CLI workflow is:

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:

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:

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:

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.

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:

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.

Tables use the same stable-ID placement contract:

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.

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

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.

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

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:

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

Preview or commit it without overwriting the input:

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.

# 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

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:

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

aioffice-0.2.0.tar.gz (642.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

aioffice-0.2.0-py3-none-any.whl (384.5 kB view details)

Uploaded Python 3

File details

Details for the file aioffice-0.2.0.tar.gz.

File metadata

  • Download URL: aioffice-0.2.0.tar.gz
  • Upload date:
  • Size: 642.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for aioffice-0.2.0.tar.gz
Algorithm Hash digest
SHA256 5204b30acc84ee09c68686cd5fc262f8fe1cd842b5ce87e6063ec2b138043943
MD5 75d00bcc7429425a251521fc3b74bd1b
BLAKE2b-256 1f9e62a4e7aebd5b1ef2d2520b8c7c068415f77f7020bda1122971b849299909

See more details on using hashes here.

Provenance

The following attestation bundles were made for aioffice-0.2.0.tar.gz:

Publisher: publish.yml on HuiTurn/aioffice

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file aioffice-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: aioffice-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 384.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for aioffice-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 19d1115d7fbb57b05b696f76719a36f9bd13214551ef6fb2464fec6524206b01
MD5 0b655889f6281ff9b6afbe04b74e9ebf
BLAKE2b-256 8f758e37cfc8d1805ffcacffc7d8aae82d1de25fe5ee65c9757a76e67211efff

See more details on using hashes here.

Provenance

The following attestation bundles were made for aioffice-0.2.0-py3-none-any.whl:

Publisher: publish.yml on HuiTurn/aioffice

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.4.0

2 files

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page