diff --git a/office/src/inkspan_office/renderer.py b/office/src/inkspan_office/renderer.py index ec6bf700..d519305c 100644 --- a/office/src/inkspan_office/renderer.py +++ b/office/src/inkspan_office/renderer.py @@ -56,6 +56,7 @@ class RenderedOfficeDocument: } _INVALID_SHEET_NAME = re.compile(r"[\\/*?:\[\]]") _FORMULA_PREFIXES = ("=", "+", "-", "@") +_MAX_DOCX_RICH_RUNS = 4096 _MISSING = object() @@ -127,6 +128,9 @@ def _render_docx(request: Mapping[str, Any]) -> bytes: allow_empty=True, ) ) + elif block_type == "rich_paragraph": + _reject_unknown(block, {"type", "runs"}, path) + _add_docx_rich_paragraph(document, block, path) elif block_type == "bullet_list": _reject_unknown(block, {"type", "items", "ordered"}, path) items = _array(_require(block, "items", path), f"{path}.items") @@ -157,6 +161,40 @@ def _render_docx(request: Mapping[str, Any]) -> bytes: return output.getvalue() +def _add_docx_rich_paragraph( + document: Document, block: Mapping[str, Any], path: str +) -> None: + """Append one bounded paragraph of explicitly formatted deterministic runs.""" + + runs = _array(_require(block, "runs", path), f"{path}.runs") + if not runs: + raise OfficeDocumentError(f"{path}.runs must contain at least one run") + if len(runs) > _MAX_DOCX_RICH_RUNS: + raise OfficeDocumentError( + f"{path}.runs must contain at most {_MAX_DOCX_RICH_RUNS} runs" + ) + + paragraph = document.add_paragraph() + for run_index, raw_run in enumerate(runs): + run_path = f"{path}.runs[{run_index}]" + run_spec = _mapping(raw_run, run_path) + _reject_unknown(run_spec, {"text", "bold", "italic", "underline"}, run_path) + text = _string( + _require(run_spec, "text", run_path), + f"{run_path}.text", + allow_empty=True, + ) + if text == "": + raise OfficeDocumentError(f"{run_path}.text must not be empty") + run = paragraph.add_run(text) + if "bold" in run_spec: + run.bold = _boolean(run_spec["bold"], f"{run_path}.bold") + if "italic" in run_spec: + run.italic = _boolean(run_spec["italic"], f"{run_path}.italic") + if "underline" in run_spec: + run.underline = _boolean(run_spec["underline"], f"{run_path}.underline") + + def _add_docx_table(document: Document, block: Mapping[str, Any], path: str) -> None: """Append a validated, rectangular table block to a Word document.""" diff --git a/office/src/inkspan_office/schema.json b/office/src/inkspan_office/schema.json index 62874bb8..0d0babce 100644 --- a/office/src/inkspan_office/schema.json +++ b/office/src/inkspan_office/schema.json @@ -6,81 +6,46 @@ "oneOf": [ { "type": "object", - "required": [ - "format", - "blocks" - ], + "required": ["format", "blocks"], "additionalProperties": false, "properties": { - "format": { - "const": "docx" - }, - "title": { - "$ref": "#/$defs/nonEmptyString" - }, - "author": { - "$ref": "#/$defs/nonEmptyString" - }, - "subject": { - "$ref": "#/$defs/nonEmptyString" - }, + "format": {"const": "docx"}, + "title": {"$ref": "#/$defs/nonEmptyString"}, + "author": {"$ref": "#/$defs/nonEmptyString"}, + "subject": {"$ref": "#/$defs/nonEmptyString"}, "blocks": { "type": "array", - "items": { - "$ref": "#/$defs/docxBlock" - } + "items": {"$ref": "#/$defs/docxBlock"} } } }, { "type": "object", - "required": [ - "format", - "sheets" - ], + "required": ["format", "sheets"], "additionalProperties": false, "properties": { - "format": { - "const": "xlsx" - }, - "title": { - "$ref": "#/$defs/nonEmptyString" - }, - "author": { - "$ref": "#/$defs/nonEmptyString" - }, + "format": {"const": "xlsx"}, + "title": {"$ref": "#/$defs/nonEmptyString"}, + "author": {"$ref": "#/$defs/nonEmptyString"}, "sheets": { "type": "array", "minItems": 1, - "items": { - "$ref": "#/$defs/sheet" - } + "items": {"$ref": "#/$defs/sheet"} } } }, { "type": "object", - "required": [ - "format", - "slides" - ], + "required": ["format", "slides"], "additionalProperties": false, "properties": { - "format": { - "const": "pptx" - }, - "title": { - "$ref": "#/$defs/nonEmptyString" - }, - "author": { - "$ref": "#/$defs/nonEmptyString" - }, + "format": {"const": "pptx"}, + "title": {"$ref": "#/$defs/nonEmptyString"}, + "author": {"$ref": "#/$defs/nonEmptyString"}, "slides": { "type": "array", "minItems": 1, - "items": { - "$ref": "#/$defs/slide" - } + "items": {"$ref": "#/$defs/slide"} } } } @@ -91,256 +56,157 @@ "pattern": "\\S" }, "scalar": { - "type": [ - "string", - "number", - "integer", - "boolean", - "null" - ] + "type": ["string", "number", "integer", "boolean", "null"] }, "excelScalar": { "description": "Excel strings are bounded to the OOXML cell limit. Runtime validation also rejects integers with more than 15 significant decimal digits.", "oneOf": [ - { - "type": "string", - "maxLength": 32767 - }, - { - "type": "number" - }, - { - "type": "boolean" - }, - { - "type": "null" - } + {"type": "string", "maxLength": 32767}, + {"type": "number"}, + {"type": "boolean"}, + {"type": "null"} ] }, + "richTextRun": { + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { + "text": {"type": "string", "minLength": 1}, + "bold": {"type": "boolean"}, + "italic": {"type": "boolean"}, + "underline": {"type": "boolean"} + } + }, "docxBlock": { "oneOf": [ { "type": "object", - "required": [ - "type", - "text", - "level" - ], + "required": ["type", "text", "level"], "additionalProperties": false, "properties": { - "type": { - "const": "heading" - }, - "text": { - "$ref": "#/$defs/nonEmptyString" - }, - "level": { - "type": "integer", - "minimum": 1, - "maximum": 9 - } + "type": {"const": "heading"}, + "text": {"$ref": "#/$defs/nonEmptyString"}, + "level": {"type": "integer", "minimum": 1, "maximum": 9} } }, { "type": "object", - "required": [ - "type", - "text" - ], + "required": ["type", "text"], "additionalProperties": false, "properties": { - "type": { - "const": "paragraph" - }, - "text": { - "type": "string" - } + "type": {"const": "paragraph"}, + "text": {"type": "string"} } }, { "type": "object", - "required": [ - "type", - "items" - ], + "required": ["type", "runs"], "additionalProperties": false, "properties": { - "type": { - "const": "bullet_list" - }, - "ordered": { - "type": "boolean", - "default": false - }, - "items": { + "type": {"const": "rich_paragraph"}, + "runs": { "type": "array", - "items": { - "type": "string" - } + "minItems": 1, + "maxItems": 4096, + "items": {"$ref": "#/$defs/richTextRun"} } } }, { "type": "object", - "required": [ - "type", - "rows" - ], + "required": ["type", "items"], "additionalProperties": false, "properties": { - "type": { - "const": "table" - }, - "headers": { - "type": "array", - "items": { - "$ref": "#/$defs/scalar" - } - }, + "type": {"const": "bullet_list"}, + "ordered": {"type": "boolean", "default": false}, + "items": {"type": "array", "items": {"type": "string"}} + } + }, + { + "type": "object", + "required": ["type", "rows"], + "additionalProperties": false, + "properties": { + "type": {"const": "table"}, + "headers": {"type": "array", "items": {"$ref": "#/$defs/scalar"}}, "rows": { "type": "array", - "items": { - "type": "array", - "items": { - "$ref": "#/$defs/scalar" - } - } + "items": {"type": "array", "items": {"$ref": "#/$defs/scalar"}} } } }, { "type": "object", - "required": [ - "type", - "source", - "alt_text", - "width_px" - ], + "required": ["type", "source", "alt_text", "width_px"], "additionalProperties": false, "properties": { - "type": { - "const": "image" - }, + "type": {"const": "image"}, "source": { "type": "string", "maxLength": 13981038, "pattern": "^data:image/png;base64,[A-Za-z0-9+/]+={0,2}$" }, - "alt_text": { - "type": "string", - "pattern": "\\S", - "maxLength": 1000 - }, - "width_px": { - "type": "integer", - "minimum": 1, - "maximum": 2400 - } + "alt_text": {"type": "string", "pattern": "\\S", "maxLength": 1000}, + "width_px": {"type": "integer", "minimum": 1, "maximum": 2400} } }, { "type": "object", - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false, - "properties": { - "type": { - "const": "page_break" - } - } + "properties": {"type": {"const": "page_break"}} } ] }, "sheet": { "type": "object", - "required": [ - "name", - "rows" - ], + "required": ["name", "rows"], "additionalProperties": false, "properties": { - "name": { - "$ref": "#/$defs/nonEmptyString", - "maxLength": 31 - }, + "name": {"$ref": "#/$defs/nonEmptyString", "maxLength": 31}, "rows": { "type": "array", "maxItems": 1048576, "items": { "type": "array", "maxItems": 16384, - "items": { - "$ref": "#/$defs/excelScalar" - } + "items": {"$ref": "#/$defs/excelScalar"} } }, - "header_row": { - "type": "boolean", - "default": false - }, + "header_row": {"type": "boolean", "default": false}, "freeze_panes": { "type": "string", "pattern": "^[A-Za-z]{1,3}[1-9][0-9]{0,6}$", "description": "A simple A1 coordinate; runtime validation further limits it to XFD1048576." }, - "auto_filter": { - "type": "boolean", - "default": false - } + "auto_filter": {"type": "boolean", "default": false} } }, "bullet": { "oneOf": [ - { - "type": "string" - }, + {"type": "string"}, { "type": "object", - "required": [ - "text" - ], + "required": ["text"], "additionalProperties": false, "properties": { - "text": { - "type": "string" - }, - "level": { - "type": "integer", - "minimum": 0, - "maximum": 8, - "default": 0 - } + "text": {"type": "string"}, + "level": {"type": "integer", "minimum": 0, "maximum": 8, "default": 0} } } ] }, "slide": { "type": "object", - "required": [ - "title" - ], + "required": ["title"], "additionalProperties": false, "properties": { - "title": { - "$ref": "#/$defs/nonEmptyString" - }, - "subtitle": { - "$ref": "#/$defs/nonEmptyString" - }, - "bullets": { - "type": "array", - "items": { - "$ref": "#/$defs/bullet" - } - } + "title": {"$ref": "#/$defs/nonEmptyString"}, + "subtitle": {"$ref": "#/$defs/nonEmptyString"}, + "bullets": {"type": "array", "items": {"$ref": "#/$defs/bullet"}} }, - "not": { - "required": [ - "subtitle", - "bullets" - ] - } + "not": {"required": ["subtitle", "bullets"]} } } } diff --git a/office/tests/test_docx_rich_paragraph.py b/office/tests/test_docx_rich_paragraph.py new file mode 100644 index 00000000..61f7aa39 --- /dev/null +++ b/office/tests/test_docx_rich_paragraph.py @@ -0,0 +1,181 @@ +"""Contract tests for deterministic rich-text runs in DOCX paragraphs.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import pytest +from docx import Document + +from inkspan_office import ( + OfficeDocumentError, + load_schema, + render_office_document, + write_office_document, +) + + +def _rich_payload(runs: list[object]) -> dict[str, object]: + """Build one minimal DOCX request containing a rich paragraph.""" + + return { + "format": "docx", + "blocks": [{"type": "rich_paragraph", "runs": runs}], + } + + +def test_docx_contract_preserves_explicit_rich_text_runs() -> None: + """The public schema and renderer must preserve run text and emphasis in order.""" + + schema = load_schema() + rich_branches = [ + branch + for branch in schema["$defs"]["docxBlock"]["oneOf"] + if branch.get("properties", {}).get("type", {}).get("const") + == "rich_paragraph" + ] + assert len(rich_branches) == 1 + + rendered = render_office_document( + _rich_payload( + [ + {"text": "Retention ", "bold": True}, + {"text": "improved", "italic": True}, + {"text": " year over year.", "underline": True}, + { + "text": " 검증", + "bold": True, + "italic": True, + "underline": True, + }, + ] + ) + ) + + document = Document(BytesIO(rendered.data)) + paragraph = document.paragraphs[0] + assert [run.text for run in paragraph.runs] == [ + "Retention ", + "improved", + " year over year.", + " 검증", + ] + assert [(run.bold, run.italic, run.underline) for run in paragraph.runs] == [ + (True, None, None), + (None, True, None), + (None, None, True), + (True, True, True), + ] + + +def test_docx_rich_paragraph_preserves_unicode_order_and_false_flags() -> None: + """Combining, CJK, and bidi text must retain logical order and explicit false flags.""" + + rendered = render_office_document( + _rich_payload( + [ + {"text": "e\u0301", "bold": False}, + {"text": "漢字", "italic": False}, + {"text": "مرحبا", "underline": False}, + ] + ) + ) + + paragraph = Document(BytesIO(rendered.data)).paragraphs[0] + assert [run.text for run in paragraph.runs] == ["e\u0301", "漢字", "مرحبا"] + assert [(run.bold, run.italic, run.underline) for run in paragraph.runs] == [ + (False, None, None), + (None, False, None), + (None, None, False), + ] + + +def test_docx_rich_paragraph_output_is_deterministic() -> None: + """The same rich-run request must produce byte-identical canonical OOXML.""" + + payload = _rich_payload( + [ + {"text": "Stable ", "bold": True}, + {"text": "evidence", "italic": True}, + ] + ) + assert render_office_document(payload).data == render_office_document(payload).data + + +def test_docx_rich_paragraph_rejects_empty_run_collection() -> None: + """Runtime validation must reject a rich paragraph that contains no runs.""" + + with pytest.raises( + OfficeDocumentError, + match=r"blocks\[0\]\.runs must contain at least one run", + ): + render_office_document(_rich_payload([])) + + +def test_docx_rich_paragraph_rejects_runtime_run_overflow() -> None: + """Runtime validation must retain a finite defense-in-depth run ceiling.""" + + with pytest.raises( + OfficeDocumentError, + match=r"blocks\[0\]\.runs must contain at most 4096 runs", + ): + render_office_document(_rich_payload([{"text": "x"}] * 4097)) + + +def test_docx_rich_paragraph_schema_and_runtime_bounds_match() -> None: + """Schema and runtime must share the same run ceiling and empty-text rule.""" + + schema = load_schema() + rich_branch = next( + branch + for branch in schema["$defs"]["docxBlock"]["oneOf"] + if branch.get("properties", {}).get("type", {}).get("const") + == "rich_paragraph" + ) + assert rich_branch["properties"]["runs"]["maxItems"] == 4096 + assert schema["$defs"]["richTextRun"]["properties"]["text"]["minLength"] == 1 + + with pytest.raises( + OfficeDocumentError, + match=r"blocks\[0\]\.runs\[0\]\.text must not be empty", + ): + render_office_document(_rich_payload([{"text": ""}])) + + rendered = render_office_document(_rich_payload([{"text": " "}])) + assert Document(BytesIO(rendered.data)).paragraphs[0].runs[0].text == " " + + +@pytest.mark.parametrize( + ("runs", "message"), + [ + (["not-an-object"], r"blocks\[0\]\.runs\[0\] must be an object"), + ( + [{"text": "x", "color": "red"}], + r"blocks\[0\]\.runs\[0\] has unexpected field: color", + ), + ( + [{"text": "x", "bold": 1}], + r"blocks\[0\]\.runs\[0\]\.bold must be a boolean", + ), + ], +) +def test_docx_rich_paragraph_rejects_invalid_run_shapes( + runs: list[object], message: str +) -> None: + """Rich runs must remain bounded to object, field, and strict-boolean contracts.""" + + with pytest.raises(OfficeDocumentError, match=message): + render_office_document(_rich_payload(runs)) + + +def test_invalid_rich_paragraph_never_partially_publishes(tmp_path: Path) -> None: + """Validation must fail before an invalid rich paragraph creates an output file.""" + + destination = tmp_path / "invalid-rich.docx" + with pytest.raises(OfficeDocumentError): + write_office_document( + _rich_payload([{"text": "x", "underline": "yes"}]), + destination, + ) + assert not destination.exists()