diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d120cec7..1721edf5 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -30,6 +30,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | DOCX bounded paragraph alignment | `paragraph` and `rich_paragraph` optionally preserve exact `left`, `center`, `right`, or `justify`; omission preserves inherited/default Word alignment | Microsoft WordprocessingML paragraph documentation; python-docx paragraph API | protected-main #130 renderer/schema/tests, ADR 0024, Office guide and paragraph-alignment doctoring | Alignment is bounded to the protected paragraph contract; list/table/title/page-layout/style authority is not implied | | DOCX bounded heading alignment | `heading` optionally preserves the same exact `left`, `center`, `right`, or `justify` contract through the shared paragraph-alignment mapping; omission preserves inherited/default heading-style alignment | Microsoft WordprocessingML paragraph documentation; python-docx paragraph API | protected-main #134 renderer/schema/tests, ADR 0025, Office guide and heading-alignment doctoring | Heading alignment adds no arbitrary heading style, outline numbering, TOC, list/table/title/page-layout, source-format, network, model, credential or persistence authority | | DOCX bounded external hyperlinks | `rich_paragraph.runs[]` optionally preserves one exact bounded printable-ASCII absolute HTTP(S) target as a relationship-backed external `w:hyperlink` while retaining visible Unicode text and run emphasis | ECMA-376 Office Open XML package/WordprocessingML semantics; Microsoft Open XML hyperlink/relationship documentation; python-docx 1.2.0 hyperlink feature analysis | protected-main #137 renderer/schema/OOXML tests, ADR 0026 and Office guidance | Accepted syntax is not destination trust; Inkspan performs no fetch, DNS, redirect, local-file read, credential use, tenant policy, phishing policy, internationalized-URI conversion, persistence, or distribution | +| DOCX bounded single-section page layout | Proposed `page_layout` accepts only A4/Letter, portrait/landscape, and complete integer 0–100 mm edge margins; it applies only to the existing sole DOCX section and is canonicalized after the layout pass | ECMA-376 Office Open XML section/page semantics; python-docx 1.2.0 section API and section guidance | active PR #141 schema/renderer/page-layout tests, ADR 0027, `docs/docx-page-layout.md`, page-layout doctoring, all-four paper/orientation round trips, page-break/hyperlink/image fidelity and DOCX-only format-boundary tests | `implemented_on_active_pr`; not protected-main or released authority, and no arbitrary page geometry, multiple sections, headers/footers, printer/PDF service, persistence, auth, network, model, credential, or host policy authority is implied | | Release authority | Source movement invalidates exact-head evidence; stale assets/digest ambiguity fail closed; formal approval remains distinct from status/comments | GitHub protected-branch/review/release/attestation behavior and repository policy | release workflow tests, package checksums, browser evidence, SBOM/provenance, formal reviews | Local success or automated prose does not authorize protected merge/release | ## Current primary references @@ -66,6 +67,10 @@ python-docx. (n.d.-a). *Hyperlink — python-docx 1.2.0 documentation*. Retrieve python-docx. (n.d.-b). *Working with text*. Retrieved August 10, 2026, from https://python-docx.readthedocs.io/en/latest/user/text.html +python-docx contributors. (2025). *Working with sections (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/latest/user/sections.html + +python-docx contributors. (2025). *Section objects (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/stable/api/section.html + Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for Mitigating the Risk of Software Vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 @@ -106,7 +111,7 @@ Lower levels may explain intent or history but cannot override a contradictory h - **Planned** means an accepted future direction without a protected implementation. - **Superseded** means retained for history but replaced by a later explicit decision. -Envelope identity routing, SafeClipboard, W3C text-position selector evidence, cross-engine browser assurance, headless deterministic Markdown serialization, CSS paged-media output, accessible placeholder semantics, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and bounded external hyperlinks are implemented on protected `main`. The W3C selector remains revision-scoped and projection-version-scoped and must satisfy `0 <= start <= end <= projectedCodePointLength`; protected integration does not transfer annotation persistence, source identity, authorization, or re-anchoring authority from the host. The browser gate being protected does not let a future release reuse historical browser evidence: the exact release candidate must generate fresh evidence bound to its own source, committed synthetic corpus, package-lock SHA-256, run identity, browser revisions, and packed npm artifact SHA-256. +Envelope identity routing, SafeClipboard, W3C text-position selector evidence, cross-engine browser assurance, headless deterministic Markdown serialization, CSS paged-media output, accessible placeholder semantics, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and bounded external hyperlinks are implemented on protected `main`. The bounded single-section DOCX page-layout contract remains Proposed / `implemented_on_active_pr` in PR #141 and must not be promoted to protected behavior until integration. The W3C selector remains revision-scoped and projection-version-scoped and must satisfy `0 <= start <= end <= projectedCodePointLength`; protected integration does not transfer annotation persistence, source identity, authorization, or re-anchoring authority from the host. The browser gate being protected does not let a future release reuse historical browser evidence: the exact release candidate must generate fresh evidence bound to its own source, committed synthetic corpus, package-lock SHA-256, run identity, browser revisions, and packed npm artifact SHA-256. Documentation must not promote Proposed or Planned capabilities to Implemented merely because a PR, issue, or design document is detailed. diff --git a/docs/adr/0027-bounded-docx-page-layout.md b/docs/adr/0027-bounded-docx-page-layout.md new file mode 100644 index 00000000..fb044ec6 --- /dev/null +++ b/docs/adr/0027-bounded-docx-page-layout.md @@ -0,0 +1,80 @@ +# ADR 0027: Bounded single-section DOCX page layout + +Status: Proposed + +Implementation maturity: `implemented_on_active_pr` in PR #141; not protected-main behavior until that PR is integrated. + +## Context + +Inkspan Office already owns deterministic, network-free JSON-to-DOCX rendering for a deliberately bounded set of document constructs. Protected `main` does not expose page size, orientation, or page margins, so Word documents inherit the renderer template defaults. Enterprise reports and handoffs often need an explicit physical page contract, but exposing arbitrary WordprocessingML or unconstrained section authoring would substantially enlarge the fidelity, compatibility, and security surface. + +`python-docx` 1.2.0 models page dimensions, orientation, and margins on a Word `Section`. Its current section guidance explicitly describes a section as the page-layout authority and shows that landscape orientation requires the page width and height to be swapped together with the orientation property. ECMA-376 identifies Office Open XML as the normative vocabulary and document representation for DOCX packages. + +## Alternatives considered + +1. **Keep implicit template defaults.** Lowest implementation risk, but leaves buyer-visible physical layout nondeterministic from the request contract and cannot express common A4/Letter requirements. +2. **Expose arbitrary dimensions and raw section properties.** Maximizes flexibility but creates a broad OOXML/layout authority, increases invalid or nonsensical combinations, and makes deterministic support claims harder to prove. +3. **Add multi-section authoring now.** Supports mixed layouts but creates ordering, section-break, header/footer, and inheritance semantics that are not required for the immediate buyer gap. +4. **Selected: one optional complete bounded `page_layout` object for the existing single section.** Gives deterministic common page setup while keeping the contract small and fail-closed. + +## Decision + +For DOCX requests only, Inkspan may accept an optional top-level `page_layout` object with this exact complete shape: + +- `paper_size`: `a4` or `letter`; +- `orientation`: `portrait` or `landscape`; +- `margins_mm`: required integer `top`, `right`, `bottom`, and `left`, each from 0 through 100. + +A4 maps to 210 × 297 mm. Letter maps to 8.5 × 11 inches. Landscape sets the Word orientation and swaps the physical width and height. The layout applies only to the document's existing single section. Missing fields, unknown fields, aliases, case variants, booleans, floats, strings, nulls, out-of-range margins, and multiple sections fail closed. When `page_layout` is absent, the existing renderer behavior is preserved. + +The public safety facade strips `page_layout` before invoking the established content renderer, applies the validated section settings to the returned DOCX, and then performs the existing deterministic OOXML canonicalization. This preserves the internal content-renderer payload contract while keeping page-layout validation at the public product boundary. + +## Consequences and ownership trade-offs + +The selected contract covers the common A4/Letter portrait/landscape use case without granting arbitrary page geometry, multi-section layout, headers, footers, gutters, columns, mirrored margins, page numbering, printer selection, CSS-to-Word mapping, or PDF-service authority. Hosts still own source-format interpretation, export authorization, storage, distribution, and business-specific layout policy. + +Reopening and saving the generated package through `python-docx` is an intentional implementation step. Compatibility is therefore asserted only for the tested Inkspan-generated single-section document surface, not for arbitrary third-party DOCX round trips. + +## Failure and recovery semantics + +Invalid layout input raises the existing bounded `OfficeDocumentError` path and never publishes a partial output. `write_office_document()` renders completely before atomic publication, so layout rejection cannot leave a destination file containing a partially modified package. A multiple-section package is rejected instead of guessing which sections should inherit the request. + +Recovery is caller-owned: correct the request and rerun rendering. Inkspan does not mutate a durable source document, retry against a remote service, or retain a failed package. + +## Security and privacy impact + +The contract adds no network access, filesystem input authority, macros, raw OOXML, model call, credential, identity, tenant, persistence, audit, or print-service authority. Numeric and enum bounds prevent arbitrary section XML from entering the renderer. Existing XML/resource validation, deterministic packaging, redacted error, and atomic-publication boundaries remain in force. + +The request may still contain document content, so hosts remain responsible for authorization, privacy classification, retention, logging, and storage. Page-layout values themselves are ordinary document-format metadata and are not authorization or provenance evidence. + +## Compatibility and migration + +The field is optional. Existing DOCX requests without `page_layout` keep the prior contract and output behavior. XLSX and PPTX request schemas are unchanged. A future expansion to arbitrary paper dimensions or multiple sections requires a new ADR or an explicit superseding revision rather than silently widening this decision. + +Because the feature is still on an active Draft PR, downstream hosts must not depend on it as a released capability. Version/CHANGELOG promotion occurs only after the stable 0.6.0 release boundary is complete and this feature is accepted for the next release line. + +## Verification and acceptance + +Acceptance requires all of the following on one exact live-base-integrated head: + +- schema evidence for the exact object shape and bounds; +- real `python-docx` round trips proving A4 landscape and Letter portrait physical dimensions, orientation, and margins; +- fail-closed missing/type/enum/range/unknown-field tests; +- rejection of multiple sections; +- deterministic repeated-render evidence; +- no-partial-publication evidence; +- Python 3.11–3.14 Office verification with 100% owned production statement/branch and public-docstring coverage; +- repository CI, security scanning, package verification, and applicable review evidence; +- canonical buyer documentation, doctoring, traceability, and next-release metadata reconciled before Ready/merge evaluation. + +PR #141 currently supplies the implementation and exact-head machine evidence but remains Draft because issue #118 still owns the 0.6.0 registry operational boundary. This ADR therefore remains Proposed. + +## Rollback or supersession + +Before protected integration, rollback is branch closure. After a future accepted merge, rollback is a normal revert that removes the optional schema field, page-layout adapter, tests, and associated release documentation together. A revert must not leave documentation claiming support that protected code no longer provides. Any later widening to multiple sections or arbitrary dimensions should supersede this ADR explicitly. + +## Standards and implementation basis + +- Ecma International. (2021). *ECMA-376: Office Open XML file formats* (5th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-376/ +- python-docx contributors. (2025). *Working with sections (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/latest/user/sections.html +- python-docx contributors. (2025). *Section objects (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/stable/api/section.html diff --git a/docs/adr/README.md b/docs/adr/README.md index df8b7b80..6ba29df5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio | [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output | | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | +| [0027](0027-bounded-docx-page-layout.md) | Proposed | Bounded single-section page layout in deterministic DOCX output | ## Decision discipline diff --git a/docs/doctoring/docx-page-layout.md b/docs/doctoring/docx-page-layout.md new file mode 100644 index 00000000..67e0ecb9 --- /dev/null +++ b/docs/doctoring/docx-page-layout.md @@ -0,0 +1,41 @@ +# DOCX page-layout doctoring + +Status: `implemented_on_active_pr` (PR #141); not protected-main implementation authority. + +## Claim under review + +Inkspan's proposed DOCX `page_layout` contract makes common physical page setup deterministic without expanding the Office renderer into arbitrary WordprocessingML or a print/PDF service. The bounded contract is intentionally limited to A4 or Letter, portrait or landscape, and explicit integer edge margins from 0 through 100 mm on the renderer's existing single section. + +## Primary-source basis + +The current `python-docx` 1.2.0 section documentation states that Word sections own page-layout settings such as margins and orientation. Its documented landscape example changes the orientation and swaps `page_width` and `page_height`; the stable Section API exposes orientation, page width/height, and edge margins as read/write properties. Inkspan follows that public API rather than writing raw `w:sectPr` XML. + +ECMA-376 is the normative Office Open XML standard family for document representation and packaging. The current Ecma publication page identifies ECMA-376 as *Office Open XML file formats* and the 5th edition as the current listed edition. Inkspan does not claim to implement the entire WordprocessingML layout vocabulary; the standard is the interoperability basis for the generated package while `python-docx` is the concrete library boundary exercised in tests. + +## Why the contract is narrow + +A fully general Word page-layout surface would require policy for arbitrary dimensions, section breaks, headers/footers, gutters, columns, mirrored margins, inheritance, and mixed orientation. Those are independent fidelity commitments and would make invalid combinations much harder to reject deterministically. The selected contract instead covers two common paper sizes and one complete section-level layout object. Unknown or partial shapes fail closed rather than inheriting hidden defaults. + +## Fidelity and determinism evidence + +PR #141's permanent tests reopen generated DOCX bytes with `python-docx` and check the actual section properties for A4 landscape and Letter portrait. A4 uses 210 × 297 mm; Letter uses 8.5 × 11 inches. Landscape swaps width/height and sets `WD_ORIENT.LANDSCAPE`. All four margins are checked after round trip. + +The feature is applied before Inkspan's existing OOXML canonicalization step. Repeated rendering of the same request is required to remain byte-identical. A multiple-section package fails closed so the implementation never guesses which section should receive the caller's layout. + +## Safety, privacy, and operational boundary + +The field cannot introduce network fetches, file reads, macros, raw OOXML, model execution, credentials, tenant context, durable persistence, or printer/PDF-service authority. Existing XML/resource validation and atomic output publication remain authoritative. Invalid layout fails before the destination file is published. + +Hosts still decide whether an author may export, which page layout is appropriate for a business document, where the artifact may be stored, how it is distributed, and whether its content requires special privacy controls. Layout metadata is not authentication, authorization, provenance, or audit evidence. + +## Compatibility and rollback + +Omitting `page_layout` preserves the pre-existing DOCX request path. XLSX and PPTX schemas remain unchanged. Until PR #141 is merged, all documentation must classify the feature as active-PR behavior. Rollback before merge is branch closure; after a future merge, code, schema, tests, ADR status, buyer documentation, traceability, and release notes must be reverted together. + +## APA 7 references + +Ecma International. (2021). *ECMA-376: Office Open XML file formats* (5th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-376/ + +python-docx contributors. (2025). *Section objects (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/stable/api/section.html + +python-docx contributors. (2025). *Working with sections (python-docx 1.2.0 documentation).* https://python-docx.readthedocs.io/en/latest/user/sections.html diff --git a/docs/docx-page-layout.md b/docs/docx-page-layout.md new file mode 100644 index 00000000..79601dec --- /dev/null +++ b/docs/docx-page-layout.md @@ -0,0 +1,69 @@ +# Bounded DOCX page layout + +Status: `implemented_on_active_pr` — proposed by issue #140 and Draft PR #141; this is **not protected-main behavior** until that PR integrates. + +Inkspan Office's proposed DOCX `page_layout` contract makes basic paper geometry explicit without turning the renderer into a general Word layout engine. The contract is deterministic, network-free, complete when supplied, and limited to the document's existing single section. + +## Request contract + +`page_layout` is optional and valid only for DOCX requests. When omitted, Inkspan preserves the existing template/default page setup. When present, the object must contain exactly these fields: + +```json +{ + "page_layout": { + "paper_size": "a4", + "orientation": "landscape", + "margins_mm": { + "top": 10, + "right": 20, + "bottom": 30, + "left": 40 + } + } +} +``` + +- `paper_size` is exactly `a4` or `letter`. +- `orientation` is exactly `portrait` or `landscape`. +- `margins_mm` requires integer `top`, `right`, `bottom`, and `left` values from 0 through 100 inclusive. +- Booleans, floats, strings, nulls, aliases, case variants, missing fields, and unknown fields fail closed. +- A4 is exactly 210 × 297 mm in portrait orientation. US Letter is exactly 8.5 × 11 in. Landscape swaps the physical page dimensions and sets the Word orientation property explicitly. + +## Fidelity and safety boundary + +The implementation applies layout only after the normal bounded DOCX content renderer has produced the document, then canonicalizes the resulting OOXML package. It therefore keeps the existing renderer payload contract intact while adding one section-level transformation. + +The page-layout pass must preserve already-supported document semantics, including rich runs and relationship-backed external hyperlinks. Regression tests inspect the generated WordprocessingML relationship after page-layout application rather than treating successful file creation as fidelity proof. A second regression reopens the rendered package to prove inline PNG shape geometry and image alternative-description metadata survive the same page-layout pass. Repeated rendering of the same validated request remains byte-identical. + +Inkspan rejects a package with more than one DOCX section for this contract instead of guessing which sections should inherit layout. Invalid page-layout input cannot publish a partial file through `write_office_document`. + +## Deliberate non-goals + +This slice does not add arbitrary page dimensions, multiple sections, mixed portrait/landscape pages, headers, footers, gutters, mirrored margins, columns, binding, page numbers, printer selection, a PDF service, raw OOXML input, CSS-to-Word layout translation, network access, credentials, persistence, tenancy, or host authorization. + +Hosts remain responsible for source-format interpretation, document/export authorization, tenant isolation, destination/storage, retention, distribution, and any print/PDF service. + +## Verification + +Acceptance requires the exact active head to pass: + +- JSON Schema contract tests for the complete bounded object; +- A4-landscape and Letter-portrait round trips through `python-docx`; +- fail-closed malformed/type/range/unknown-field tests; +- multi-section rejection; +- no-partial-publication failure evidence; +- relationship-preservation regression evidence for existing DOCX hyperlinks; +- image-fidelity regression evidence for inline PNG shape geometry and image alternative-description metadata; +- byte-identical deterministic rendering; +- the full Office Python 3.11–3.14 matrix, 100% owned production statement/branch coverage, public-docstring coverage, wheel/package verification, repository CI, Security Scan, and SAST. + +Exact-head active-PR evidence does not transfer to protected `main` and does not satisfy stable registry acceptance under issue #118. + +## Decision and primary technical basis + +- ADR 0027: [`docs/adr/0027-bounded-docx-page-layout.md`](adr/0027-bounded-docx-page-layout.md). +- Doctoring and APA 7 references: [`docs/doctoring/docx-page-layout.md`](doctoring/docx-page-layout.md). +- Machine-readable schema: [`office/src/inkspan_office/schema.json`](../office/src/inkspan_office/schema.json). +- Runtime implementation: [`office/src/inkspan_office/page_layout.py`](../office/src/inkspan_office/page_layout.py). + +The implementation uses the public `python-docx` section API and the corresponding OOXML section properties rather than exposing arbitrary package XML. \ No newline at end of file diff --git a/office/src/inkspan_office/page_layout.py b/office/src/inkspan_office/page_layout.py new file mode 100644 index 00000000..efa3caab --- /dev/null +++ b/office/src/inkspan_office/page_layout.py @@ -0,0 +1,139 @@ +"""Apply the bounded deterministic page-layout contract to one DOCX package.""" + +from __future__ import annotations + +from collections.abc import Mapping +from io import BytesIO +from typing import Any + +from docx import Document +from docx.enum.section import WD_ORIENT +from docx.shared import Inches, Mm + +from .renderer import OfficeDocumentError + +_ALLOWED_PAPER_SIZES = {"a4", "letter"} +_ALLOWED_ORIENTATIONS = {"portrait", "landscape"} +_MARGIN_NAMES = ("top", "right", "bottom", "left") + + +def apply_docx_page_layout(data: bytes, value: Any) -> bytes: + """Return DOCX bytes with one validated page layout applied to its sole section.""" + + layout = _mapping(value, "page_layout") + _reject_unknown( + layout, + {"paper_size", "orientation", "margins_mm"}, + "page_layout", + ) + paper_size = _enum_string( + _require(layout, "paper_size", "page_layout"), + "page_layout.paper_size", + _ALLOWED_PAPER_SIZES, + ) + orientation = _enum_string( + _require(layout, "orientation", "page_layout"), + "page_layout.orientation", + _ALLOWED_ORIENTATIONS, + ) + margins = _mapping( + _require(layout, "margins_mm", "page_layout"), + "page_layout.margins_mm", + ) + _reject_unknown(margins, set(_MARGIN_NAMES), "page_layout.margins_mm") + normalized_margins = { + name: _bounded_integer( + _require(margins, name, "page_layout.margins_mm"), + f"page_layout.margins_mm.{name}", + minimum=0, + maximum=100, + ) + for name in _MARGIN_NAMES + } + + document = Document(BytesIO(data)) + if len(document.sections) != 1: + raise OfficeDocumentError("page_layout requires exactly one DOCX section") + section = document.sections[0] + portrait_width, portrait_height = _paper_dimensions(paper_size) + if orientation == "landscape": + section.orientation = WD_ORIENT.LANDSCAPE + section.page_width = portrait_height + section.page_height = portrait_width + else: + section.orientation = WD_ORIENT.PORTRAIT + section.page_width = portrait_width + section.page_height = portrait_height + + section.top_margin = Mm(normalized_margins["top"]) + section.right_margin = Mm(normalized_margins["right"]) + section.bottom_margin = Mm(normalized_margins["bottom"]) + section.left_margin = Mm(normalized_margins["left"]) + + output = BytesIO() + document.save(output) + return output.getvalue() + + +def _paper_dimensions(paper_size: str) -> tuple[int, int]: + """Return portrait page width and height for one supported paper size.""" + + if paper_size == "a4": + return Mm(210), Mm(297) + return Inches(8.5), Inches(11) + + +def _mapping(value: Any, path: str) -> Mapping[str, Any]: + """Validate a string-keyed mapping used by the page-layout contract.""" + + if not isinstance(value, Mapping): + raise OfficeDocumentError(f"{path} must be an object") + if any(not isinstance(key, str) for key in value): + raise OfficeDocumentError(f"{path} object keys must be strings") + return value + + +def _require(mapping: Mapping[str, Any], key: str, path: str) -> Any: + """Return one required field or raise a path-qualified contract error.""" + + if key not in mapping: + raise OfficeDocumentError(f"{path}.{key} is required") + return mapping[key] + + +def _reject_unknown(mapping: Mapping[str, Any], allowed: set[str], path: str) -> None: + """Reject undeclared page-layout fields so generated requests stay unambiguous.""" + + unexpected = sorted(set(mapping) - allowed) + if unexpected: + label = "field" if len(unexpected) == 1 else "fields" + raise OfficeDocumentError( + f"{path} has unexpected {label}: {', '.join(unexpected)}" + ) + + +def _enum_string(value: Any, path: str, allowed: set[str]) -> str: + """Validate one exact lower-case string enumeration value.""" + + if not isinstance(value, str): + raise OfficeDocumentError(f"{path} must be a string") + if value not in allowed: + options = ", ".join(sorted(allowed)) + raise OfficeDocumentError(f"{path} must be one of: {options}") + return value + + +def _bounded_integer( + value: Any, + path: str, + *, + minimum: int, + maximum: int, +) -> int: + """Validate one non-boolean integer inside an inclusive page-layout bound.""" + + if not isinstance(value, int) or isinstance(value, bool): + raise OfficeDocumentError(f"{path} must be an integer") + if value < minimum or value > maximum: + raise OfficeDocumentError(f"{path} must be between {minimum} and {maximum}") + return value diff --git a/office/src/inkspan_office/safe_renderer.py b/office/src/inkspan_office/safe_renderer.py index 7f8a3493..b080ece9 100644 --- a/office/src/inkspan_office/safe_renderer.py +++ b/office/src/inkspan_office/safe_renderer.py @@ -14,6 +14,7 @@ from openpyxl.utils import column_index_from_string from . import renderer as _renderer +from .page_layout import apply_docx_page_layout OfficeDocumentError = _renderer.OfficeDocumentError RenderedOfficeDocument = _renderer.RenderedOfficeDocument @@ -44,12 +45,28 @@ def render_office_document(payload: Mapping[str, Any]) -> RenderedOfficeDocument """Validate safety invariants and return canonical deterministic OOXML.""" _validate_request(payload) - rendered = _renderer.render_office_document(payload) + render_payload: Mapping[str, Any] = payload + page_layout: Any = None + has_page_layout = ( + isinstance(payload, Mapping) + and payload.get("format") == "docx" + and "page_layout" in payload + ) + if has_page_layout: + page_layout = payload["page_layout"] + stripped_payload = dict(payload) + del stripped_payload["page_layout"] + render_payload = stripped_payload + + rendered = _renderer.render_office_document(render_payload) + data = rendered.data + if has_page_layout: + data = apply_docx_page_layout(data, page_layout) return RenderedOfficeDocument( rendered.format, rendered.extension, rendered.content_type, - _canonicalize_ooxml(rendered.data), + _canonicalize_ooxml(data), ) diff --git a/office/src/inkspan_office/schema.json b/office/src/inkspan_office/schema.json index c89672bc..600a5582 100644 --- a/office/src/inkspan_office/schema.json +++ b/office/src/inkspan_office/schema.json @@ -13,6 +13,7 @@ "title": {"$ref": "#/$defs/nonEmptyString"}, "author": {"$ref": "#/$defs/nonEmptyString"}, "subject": {"$ref": "#/$defs/nonEmptyString"}, + "page_layout": {"$ref": "#/$defs/pageLayout"}, "blocks": { "type": "array", "items": {"$ref": "#/$defs/docxBlock"} @@ -84,6 +85,27 @@ } } }, + "pageMargins": { + "type": "object", + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false, + "properties": { + "top": {"type": "integer", "minimum": 0, "maximum": 100}, + "right": {"type": "integer", "minimum": 0, "maximum": 100}, + "bottom": {"type": "integer", "minimum": 0, "maximum": 100}, + "left": {"type": "integer", "minimum": 0, "maximum": 100} + } + }, + "pageLayout": { + "type": "object", + "required": ["paper_size", "orientation", "margins_mm"], + "additionalProperties": false, + "properties": { + "paper_size": {"type": "string", "enum": ["a4", "letter"]}, + "orientation": {"type": "string", "enum": ["portrait", "landscape"]}, + "margins_mm": {"$ref": "#/$defs/pageMargins"} + } + }, "docxBlock": { "oneOf": [ { diff --git a/office/tests/test_page_layout.py b/office/tests/test_page_layout.py new file mode 100644 index 00000000..16f91687 --- /dev/null +++ b/office/tests/test_page_layout.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from xml.etree import ElementTree +from zipfile import ZipFile + +import pytest +from docx import Document +from docx.enum.section import WD_ORIENT, WD_SECTION + +from inkspan_office import ( + OfficeDocumentError, + load_schema, + render_office_document, + write_office_document, +) +from inkspan_office.page_layout import apply_docx_page_layout + +_WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_HYPERLINK_REL = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" +) +_PNG_DATA_URI = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlXkWQAAAAASUVORK5CYII=" +) + + +def _layout_payload() -> dict[str, object]: + """Return one valid bounded landscape A4 request used by fidelity tests.""" + + return { + "format": "docx", + "page_layout": { + "paper_size": "a4", + "orientation": "landscape", + "margins_mm": {"top": 10, "right": 20, "bottom": 30, "left": 40}, + }, + "blocks": [{"type": "paragraph", "text": "Layout contract"}], + } + + +def _portrait_letter_layout() -> dict[str, object]: + """Return one valid Letter portrait layout used to cover alternate dimensions.""" + + return { + "paper_size": "letter", + "orientation": "portrait", + "margins_mm": {"top": 0, "right": 5, "bottom": 10, "left": 15}, + } + + +def test_page_layout_is_part_of_the_machine_readable_docx_contract() -> None: + schema = load_schema() + docx_schema = schema["oneOf"][0] + + assert docx_schema["properties"]["page_layout"] == { + "$ref": "#/$defs/pageLayout" + } + layout = schema["$defs"]["pageLayout"] + assert layout["required"] == ["paper_size", "orientation", "margins_mm"] + assert layout["additionalProperties"] is False + assert layout["properties"]["paper_size"]["enum"] == ["a4", "letter"] + assert layout["properties"]["orientation"]["enum"] == [ + "portrait", + "landscape", + ] + margins = schema["$defs"]["pageMargins"] + assert margins["required"] == ["top", "right", "bottom", "left"] + assert margins["additionalProperties"] is False + for name in margins["required"]: + assert margins["properties"][name] == { + "type": "integer", + "minimum": 0, + "maximum": 100, + } + + +def test_render_docx_applies_bounded_page_layout() -> None: + rendered = render_office_document(_layout_payload()) + + document = Document(BytesIO(rendered.data)) + assert len(document.sections) == 1 + section = document.sections[0] + assert section.orientation == WD_ORIENT.LANDSCAPE + assert section.page_width is not None + assert section.page_height is not None + assert section.top_margin is not None + assert section.right_margin is not None + assert section.bottom_margin is not None + assert section.left_margin is not None + assert section.page_width.mm == pytest.approx(297, abs=0.02) + assert section.page_height.mm == pytest.approx(210, abs=0.02) + assert section.top_margin.mm == pytest.approx(10, abs=0.02) + assert section.right_margin.mm == pytest.approx(20, abs=0.02) + assert section.bottom_margin.mm == pytest.approx(30, abs=0.02) + assert section.left_margin.mm == pytest.approx(40, abs=0.02) + + +def test_render_docx_applies_letter_portrait_page_layout() -> None: + rendered = render_office_document( + { + "format": "docx", + "page_layout": _portrait_letter_layout(), + "blocks": [], + } + ) + + section = Document(BytesIO(rendered.data)).sections[0] + assert section.orientation == WD_ORIENT.PORTRAIT + assert section.page_width is not None + assert section.page_height is not None + assert section.top_margin is not None + assert section.right_margin is not None + assert section.bottom_margin is not None + assert section.left_margin is not None + assert section.page_width.mm == pytest.approx(215.9, abs=0.02) + assert section.page_height.mm == pytest.approx(279.4, abs=0.02) + assert section.top_margin.mm == pytest.approx(0, abs=0.02) + assert section.right_margin.mm == pytest.approx(5, abs=0.02) + assert section.bottom_margin.mm == pytest.approx(10, abs=0.02) + assert section.left_margin.mm == pytest.approx(15, abs=0.02) + + +def test_render_docx_page_layout_preserves_external_hyperlink_relationship() -> None: + payload = _layout_payload() + payload["blocks"] = [ + { + "type": "rich_paragraph", + "runs": [ + { + "text": "Inkspan report", + "bold": True, + "href": "https://example.com/report", + } + ], + } + ] + + rendered = render_office_document(payload) + with ZipFile(BytesIO(rendered.data)) as package: + document_root = ElementTree.fromstring(package.read("word/document.xml")) + relationships_root = ElementTree.fromstring( + package.read("word/_rels/document.xml.rels") + ) + + hyperlink = document_root.find(f".//{{{_WORD_NS}}}hyperlink") + assert hyperlink is not None + relationship_id = hyperlink.attrib[f"{{{_DOC_REL_NS}}}id"] + assert "".join(hyperlink.itertext()) == "Inkspan report" + assert hyperlink.find(f".//{{{_WORD_NS}}}b") is not None + + relationship = next( + relation + for relation in relationships_root.findall(f"{{{_REL_NS}}}Relationship") + if relation.attrib.get("Id") == relationship_id + ) + assert relationship.attrib["Type"] == _HYPERLINK_REL + assert relationship.attrib["Target"] == "https://example.com/report" + assert relationship.attrib["TargetMode"] == "External" + + +def test_render_docx_page_layout_preserves_inline_image_and_alt_text() -> None: + payload = _layout_payload() + payload["blocks"] = [ + { + "type": "image", + "source": _PNG_DATA_URI, + "alt_text": "One-pixel layout fidelity fixture", + "width_px": 96, + } + ] + + rendered = render_office_document(payload) + document = Document(BytesIO(rendered.data)) + assert len(document.inline_shapes) == 1 + assert document.inline_shapes[0].width == 96 * 9_525 + assert document.inline_shapes[0].height == 96 * 9_525 + + with ZipFile(BytesIO(rendered.data)) as package: + image_parts = [name for name in package.namelist() if name.startswith("word/media/")] + assert len(image_parts) == 1 + document_xml = package.read("word/document.xml").decode("utf-8") + assert 'descr="One-pixel layout fidelity fixture"' in document_xml + + +def test_render_docx_page_layout_is_deterministic() -> None: + first = render_office_document(_layout_payload()) + second = render_office_document(_layout_payload()) + + assert first.data == second.data + + +def test_page_layout_rejects_multiple_docx_sections() -> None: + document = Document() + document.add_section(WD_SECTION.NEW_PAGE) + source = BytesIO() + document.save(source) + + with pytest.raises(OfficeDocumentError, match="exactly one DOCX section"): + apply_docx_page_layout(source.getvalue(), _portrait_letter_layout()) + + +def test_invalid_page_layout_never_publishes_partial_output(tmp_path: Path) -> None: + output = tmp_path / "invalid-layout.docx" + payload = { + "format": "docx", + "page_layout": { + "paper_size": "a4", + "orientation": "portrait", + "margins_mm": {"top": -1, "right": 10, "bottom": 10, "left": 10}, + }, + "blocks": [{"type": "paragraph", "text": "must not publish"}], + } + + with pytest.raises(OfficeDocumentError): + write_office_document(payload, output) + + assert not output.exists() + + +@pytest.mark.parametrize( + "page_layout", + [ + None, + {1: "not-a-string-key"}, + {"paper_size": "a4", "orientation": "portrait"}, + { + "paper_size": "a4", + "orientation": "portrait", + "margins_mm": [], + }, + { + "paper_size": 1, + "orientation": "portrait", + "margins_mm": {"top": 10, "right": 10, "bottom": 10, "left": 10}, + }, + { + "paper_size": "A4", + "orientation": "portrait", + "margins_mm": {"top": 10, "right": 10, "bottom": 10, "left": 10}, + }, + { + "paper_size": "letter", + "orientation": "PORTRAIT", + "margins_mm": {"top": 10, "right": 10, "bottom": 10, "left": 10}, + }, + { + "paper_size": "letter", + "orientation": "portrait", + "margins_mm": {"top": True, "right": 10, "bottom": 10, "left": 10}, + }, + { + "paper_size": "letter", + "orientation": "portrait", + "margins_mm": {"top": 101, "right": 10, "bottom": 10, "left": 10}, + }, + { + "paper_size": "letter", + "orientation": "portrait", + "margins_mm": { + "top": 10, + "right": 10, + "bottom": 10, + "left": 10, + "gutter": 1, + }, + }, + { + "paper_size": "letter", + "orientation": "portrait", + "margins_mm": {"top": 10, "right": 10, "bottom": 10, "left": 10}, + "columns": 2, + }, + ], +) +def test_render_docx_rejects_invalid_page_layout(page_layout: object) -> None: + with pytest.raises(OfficeDocumentError): + render_office_document( + { + "format": "docx", + "page_layout": page_layout, + "blocks": [], + } + ) diff --git a/office/tests/test_page_layout_content_fidelity.py b/office/tests/test_page_layout_content_fidelity.py new file mode 100644 index 00000000..184efe4c --- /dev/null +++ b/office/tests/test_page_layout_content_fidelity.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from io import BytesIO +from xml.etree import ElementTree +from zipfile import ZipFile + +from docx import Document + +from inkspan_office import render_office_document + +_WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def test_page_layout_preserves_explicit_docx_page_breaks() -> None: + """Prove the page-layout round trip keeps existing explicit page-break semantics.""" + + rendered = render_office_document( + { + "format": "docx", + "page_layout": { + "paper_size": "letter", + "orientation": "landscape", + "margins_mm": {"top": 12, "right": 12, "bottom": 12, "left": 12}, + }, + "blocks": [ + {"type": "paragraph", "text": "Before page break"}, + {"type": "page_break"}, + {"type": "paragraph", "text": "After page break"}, + ], + } + ) + + document = Document(BytesIO(rendered.data)) + paragraph_text = [paragraph.text for paragraph in document.paragraphs] + assert "Before page break" in paragraph_text + assert "After page break" in paragraph_text + + with ZipFile(BytesIO(rendered.data)) as package: + document_root = ElementTree.fromstring(package.read("word/document.xml")) + + page_breaks = [ + element + for element in document_root.findall(f".//{{{_WORD_NS}}}br") + if element.attrib.get(f"{{{_WORD_NS}}}type") == "page" + ] + assert len(page_breaks) == 1 diff --git a/office/tests/test_page_layout_format_boundary.py b/office/tests/test_page_layout_format_boundary.py new file mode 100644 index 00000000..8366b46d --- /dev/null +++ b/office/tests/test_page_layout_format_boundary.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from inkspan_office import OfficeDocumentError, render_office_document + +_PAGE_LAYOUT = { + "paper_size": "a4", + "orientation": "portrait", + "margins_mm": {"top": 10, "right": 10, "bottom": 10, "left": 10}, +} + + +@pytest.mark.parametrize( + "payload", + [ + {"format": "xlsx", "sheets": [{"name": "Sheet", "rows": []}]}, + {"format": "pptx", "slides": [{"title": "Title", "bullets": []}]}, + ], +) +def test_page_layout_is_rejected_outside_docx(payload: dict[str, object]) -> None: + """Keep page-layout authority scoped to DOCX rather than widening other formats.""" + + baseline = render_office_document(payload) + assert baseline.format == payload["format"] + + invalid = deepcopy(payload) + invalid["page_layout"] = _PAGE_LAYOUT + with pytest.raises(OfficeDocumentError): + render_office_document(invalid) diff --git a/office/tests/test_page_layout_matrix.py b/office/tests/test_page_layout_matrix.py new file mode 100644 index 00000000..06cb3dc2 --- /dev/null +++ b/office/tests/test_page_layout_matrix.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from io import BytesIO + +import pytest +from docx import Document +from docx.enum.section import WD_ORIENT + +from inkspan_office import render_office_document + + +@pytest.mark.parametrize( + ("paper_size", "orientation", "expected_orientation", "width_mm", "height_mm"), + [ + ("a4", "portrait", WD_ORIENT.PORTRAIT, 210.0, 297.0), + ("a4", "landscape", WD_ORIENT.LANDSCAPE, 297.0, 210.0), + ("letter", "portrait", WD_ORIENT.PORTRAIT, 215.9, 279.4), + ("letter", "landscape", WD_ORIENT.LANDSCAPE, 279.4, 215.9), + ], +) +def test_page_layout_round_trips_every_supported_paper_orientation_pair( + paper_size: str, + orientation: str, + expected_orientation: WD_ORIENT, + width_mm: float, + height_mm: float, +) -> None: + """Prove every declared paper/orientation pair survives the real DOCX round trip.""" + + rendered = render_office_document( + { + "format": "docx", + "page_layout": { + "paper_size": paper_size, + "orientation": orientation, + "margins_mm": {"top": 7, "right": 11, "bottom": 13, "left": 17}, + }, + "blocks": [{"type": "paragraph", "text": "Layout matrix"}], + } + ) + + section = Document(BytesIO(rendered.data)).sections[0] + assert section.orientation == expected_orientation + assert section.page_width is not None + assert section.page_height is not None + assert section.page_width.mm == pytest.approx(width_mm, abs=0.02) + assert section.page_height.mm == pytest.approx(height_mm, abs=0.02) diff --git a/office/tests/test_page_layout_omission.py b/office/tests/test_page_layout_omission.py new file mode 100644 index 00000000..9d1af4c7 --- /dev/null +++ b/office/tests/test_page_layout_omission.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from io import BytesIO + +from docx import Document + +from inkspan_office import render_office_document + + +def test_omitted_page_layout_preserves_python_docx_default_section() -> None: + """Prove omission keeps the pre-existing template/default page setup untouched.""" + + rendered = render_office_document( + { + "format": "docx", + "blocks": [{"type": "paragraph", "text": "Default layout"}], + } + ) + + actual = Document(BytesIO(rendered.data)).sections[0] + expected = Document().sections[0] + + assert actual.orientation == expected.orientation + assert actual.page_width == expected.page_width + assert actual.page_height == expected.page_height + assert actual.top_margin == expected.top_margin + assert actual.right_margin == expected.right_margin + assert actual.bottom_margin == expected.bottom_margin + assert actual.left_margin == expected.left_margin diff --git a/office/tests/test_page_layout_ooxml.py b/office/tests/test_page_layout_ooxml.py new file mode 100644 index 00000000..89fa206d --- /dev/null +++ b/office/tests/test_page_layout_ooxml.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from io import BytesIO +from xml.etree import ElementTree +from zipfile import ZipFile + +from docx.shared import Mm + +from inkspan_office import render_office_document + +_WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def _word_attr(name: str) -> str: + return f"{{{_WORD_NS}}}{name}" + + +def test_page_layout_emits_exact_wordprocessingml_section_properties() -> None: + """Bind the public layout contract to its exact WordprocessingML representation.""" + + rendered = render_office_document( + { + "format": "docx", + "page_layout": { + "paper_size": "a4", + "orientation": "landscape", + "margins_mm": {"top": 10, "right": 20, "bottom": 30, "left": 40}, + }, + "blocks": [{"type": "paragraph", "text": "OOXML layout"}], + } + ) + + with ZipFile(BytesIO(rendered.data)) as package: + root = ElementTree.fromstring(package.read("word/document.xml")) + + section = root.find(f".//{{{_WORD_NS}}}sectPr") + assert section is not None + page_size = section.find(f"{{{_WORD_NS}}}pgSz") + page_margins = section.find(f"{{{_WORD_NS}}}pgMar") + assert page_size is not None + assert page_margins is not None + + assert page_size.attrib[_word_attr("w")] == str(Mm(297).twips) + assert page_size.attrib[_word_attr("h")] == str(Mm(210).twips) + assert page_size.attrib[_word_attr("orient")] == "landscape" + assert page_margins.attrib[_word_attr("top")] == str(Mm(10).twips) + assert page_margins.attrib[_word_attr("right")] == str(Mm(20).twips) + assert page_margins.attrib[_word_attr("bottom")] == str(Mm(30).twips) + assert page_margins.attrib[_word_attr("left")] == str(Mm(40).twips) diff --git a/office/tests/test_page_layout_structural_fidelity.py b/office/tests/test_page_layout_structural_fidelity.py new file mode 100644 index 00000000..e40fb85c --- /dev/null +++ b/office/tests/test_page_layout_structural_fidelity.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from io import BytesIO + +from docx import Document + +from inkspan_office import render_office_document + + +def test_page_layout_preserves_metadata_lists_and_tables() -> None: + """Prove the layout round trip keeps representative pre-existing DOCX structure.""" + + rendered = render_office_document( + { + "format": "docx", + "title": "Layout fidelity report", + "author": "Inkspan", + "subject": "Structural round-trip evidence", + "page_layout": { + "paper_size": "a4", + "orientation": "portrait", + "margins_mm": {"top": 15, "right": 15, "bottom": 15, "left": 15}, + }, + "blocks": [ + { + "type": "bullet_list", + "ordered": False, + "items": ["First finding", "Second finding"], + }, + { + "type": "table", + "headers": ["Metric", "Value"], + "rows": [["Coverage", "100%"], ["Mode", "deterministic"]], + }, + ], + } + ) + + document = Document(BytesIO(rendered.data)) + assert document.core_properties.title == "Layout fidelity report" + assert document.core_properties.author == "Inkspan" + assert document.core_properties.subject == "Structural round-trip evidence" + + paragraphs = {paragraph.text: paragraph.style.name for paragraph in document.paragraphs} + assert paragraphs["First finding"] == "List Bullet" + assert paragraphs["Second finding"] == "List Bullet" + + assert len(document.tables) == 1 + assert [[cell.text for cell in row.cells] for row in document.tables[0].rows] == [ + ["Metric", "Value"], + ["Coverage", "100%"], + ["Mode", "deterministic"], + ] diff --git a/src/docxPageLayoutDocumentation.test.ts b/src/docxPageLayoutDocumentation.test.ts new file mode 100644 index 00000000..f6fa1830 --- /dev/null +++ b/src/docxPageLayoutDocumentation.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryText = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const adrIndex = repositoryText('docs/adr/README.md'); +const adr = repositoryText('docs/adr/0027-bounded-docx-page-layout.md'); +const doctoring = repositoryText('docs/doctoring/docx-page-layout.md'); +const buyerGuide = repositoryText('docs/docx-page-layout.md'); +const traceability = repositoryText('docs/TRACEABILITY.md'); + +describe('DOCX page-layout documentation contract', () => { + it('keeps the active decision discoverable without claiming protected-main maturity', () => { + expect(adrIndex).toContain('[0027](0027-bounded-docx-page-layout.md)'); + expect(adrIndex).toContain('| Proposed | Bounded single-section page layout'); + expect(adr).toContain('Status: Proposed'); + expect(adr).toContain('`implemented_on_active_pr`'); + expect(adr).toContain('not protected-main behavior'); + expect(buyerGuide).toContain('Status: `implemented_on_active_pr`'); + expect(buyerGuide).toContain('not protected-main behavior'); + expect(traceability).toContain('DOCX bounded single-section page layout'); + expect(traceability).toContain('`implemented_on_active_pr`'); + expect(traceability).toContain('not protected-main or released authority'); + }); + + it('records the bounded schema and excluded authorities', () => { + for (const term of [ + '`a4`', + '`letter`', + '`portrait`', + '`landscape`', + '`margins_mm`', + '0 through 100', + 'single section', + ]) { + expect(adr).toContain(term); + expect(buyerGuide).toContain(term); + } + expect(adr).toMatch(/no network access/iu); + expect(adr).toMatch(/print-service authority/iu); + expect(adr).toMatch(/atomic publication/iu); + expect(buyerGuide).toMatch(/Hosts remain responsible/iu); + expect(buyerGuide).toMatch(/relationship-backed external hyperlinks/iu); + expect(buyerGuide).toMatch(/image alternative-description metadata/iu); + }); + + it('anchors doctoring in primary library and OOXML sources with rollback guidance', () => { + expect(doctoring).toContain('python-docx 1.2.0'); + expect(doctoring).toContain('ECMA-376'); + expect(doctoring).toContain('https://python-docx.readthedocs.io/'); + expect(doctoring).toContain('https://ecma-international.org/'); + expect(doctoring).toMatch(/APA 7 references/iu); + expect(doctoring).toMatch(/rollback/iu); + }); +});