diff --git a/skills/lark-slides/scripts/sxsd_validator.py b/skills/lark-slides/scripts/sxsd_validator.py new file mode 100644 index 0000000000..22b54e44a7 --- /dev/null +++ b/skills/lark-slides/scripts/sxsd_validator.py @@ -0,0 +1,908 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Internal XSD model and constraint validation for the Slides lint entrypoint.""" + +from __future__ import annotations + +import math +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from functools import lru_cache +from pathlib import Path +from typing import Any + + +XS_NS = "{http://www.w3.org/2001/XMLSchema}" +SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0" +SML_READBACK_NAMESPACE = "/sml/2.0" +SML_HTTPS_READBACK_NAMESPACE = "https://www.larkoffice.com/sml/2.0" +ACCEPTED_SML_NAMESPACES = frozenset( + (SML_NAMESPACE, SML_READBACK_NAMESPACE, SML_HTTPS_READBACK_NAMESPACE) +) + + +def local_name(value: str) -> str: + if value.startswith("{"): + return value.rsplit("}", 1)[-1] + return value.rsplit(":", 1)[-1] + + +def direct_children(element: ET.Element, name: str) -> list[ET.Element]: + return [child for child in element if child.tag == f"{XS_NS}{name}"] + + +def first_direct_child(element: ET.Element, *names: str) -> ET.Element | None: + wanted = {f"{XS_NS}{name}" for name in names} + return next((child for child in element if child.tag in wanted), None) + + +def occurs_value(raw: str | None, default: int) -> int | None: + if raw == "unbounded": + return None + return int(raw) if raw is not None else default + + +@dataclass(frozen=True) +class SimpleTypeRule: + name: str + base: str | None = None + enums: tuple[str, ...] = () + patterns: tuple[str, ...] = () + bounds: tuple[tuple[str, Decimal], ...] = () + length_bounds: tuple[tuple[str, int], ...] = () + union_members: tuple[str, ...] = () + + +@dataclass(frozen=True) +class AttributeRule: + name: str + type_name: str + required: bool + + +@dataclass(frozen=True) +class ElementRule: + name: str + type_name: str | None + inline_complex_type: ET.Element | None + ref_name: str | None + + +@dataclass(frozen=True) +class ChildRule: + element: ElementRule + min_occurs: int + max_occurs: int | None + order: int | None + + +@dataclass(frozen=True) +class ChoiceRequirement: + names: tuple[str, ...] + min_occurs: int + max_occurs: int | None + + +@dataclass(frozen=True) +class SchemaModel: + simple_types: dict[str, SimpleTypeRule] + complex_types: dict[str, ET.Element] + element_candidates: dict[str, tuple[ElementRule, ...]] + global_elements: dict[str, ElementRule] + + +def parse_simple_type( + element: ET.Element, + fallback_name: str, + simple_types: dict[str, SimpleTypeRule] | None = None, +) -> SimpleTypeRule: + name = element.attrib.get("name", fallback_name) + restriction = first_direct_child(element, "restriction") + union = first_direct_child(element, "union") + if union is not None: + union_members = [ + local_name(member) for member in union.attrib.get("memberTypes", "").split() + ] + for index, inline_simple in enumerate(direct_children(union, "simpleType"), start=1): + inline_name = f"__inline_union_member_{name}_{index}" + union_members.append(inline_name) + if simple_types is not None: + simple_types[inline_name] = parse_simple_type( + inline_simple, + inline_name, + simple_types, + ) + return SimpleTypeRule( + name=name, + union_members=tuple(union_members), + ) + if restriction is None: + return SimpleTypeRule(name=name) + + facet_names = { + "minInclusive", + "minExclusive", + "maxInclusive", + "maxExclusive", + } + bounds: list[tuple[str, Decimal]] = [] + length_bounds: list[tuple[str, int]] = [] + for child in restriction: + facet = local_name(child.tag) + if "value" not in child.attrib: + continue + if facet in facet_names: + bounds.append((facet, Decimal(child.attrib["value"]))) + elif facet in {"minLength", "maxLength"}: + length_bounds.append((facet, int(child.attrib["value"]))) + return SimpleTypeRule( + name=name, + base=local_name(restriction.attrib.get("base", "string")), + enums=tuple(child.attrib["value"] for child in direct_children(restriction, "enumeration")), + patterns=tuple(child.attrib["value"] for child in direct_children(restriction, "pattern")), + bounds=tuple(bounds), + length_bounds=tuple(length_bounds), + ) + + +def parse_element_rule(element: ET.Element) -> ElementRule | None: + raw_ref = element.attrib.get("ref") + name = element.attrib.get("name") + if name is None and raw_ref: + name = local_name(raw_ref) + if not name: + return None + return ElementRule( + name=name, + type_name=local_name(element.attrib["type"]) if element.attrib.get("type") else None, + inline_complex_type=first_direct_child(element, "complexType"), + ref_name=local_name(raw_ref) if raw_ref else None, + ) + + +@lru_cache(maxsize=4) +def load_schema_model(schema_path: str) -> SchemaModel: + root = ET.parse(schema_path).getroot() + simple_types: dict[str, SimpleTypeRule] = {} + for element in direct_children(root, "simpleType"): + name = element.attrib.get("name") + if not name: + continue + simple_types[name] = parse_simple_type(element, name, simple_types) + for attribute in root.iter(f"{XS_NS}attribute"): + inline_simple = first_direct_child(attribute, "simpleType") + if inline_simple is None: + continue + inline_name = f"__inline_attribute_{attribute.attrib.get('name', 'anonymous')}_{id(attribute)}" + simple_types[inline_name] = parse_simple_type(inline_simple, inline_name, simple_types) + complex_types = { + element.attrib["name"]: element + for element in direct_children(root, "complexType") + if element.attrib.get("name") + } + candidates: dict[str, list[ElementRule]] = {} + for element in root.iter(f"{XS_NS}element"): + rule = parse_element_rule(element) + if rule is not None: + candidates.setdefault(rule.name, []).append(rule) + global_elements = { + rule.name: rule + for element in direct_children(root, "element") + if (rule := parse_element_rule(element)) is not None + } + return SchemaModel( + simple_types=simple_types, + complex_types=complex_types, + element_candidates={name: tuple(rules) for name, rules in candidates.items()}, + global_elements=global_elements, + ) + + +def attributes_for_complex_type( + complex_type: ET.Element, + model: SchemaModel, + resolving: set[str] | None = None, +) -> dict[str, AttributeRule]: + resolving = resolving or set() + attributes: dict[str, AttributeRule] = {} + + for content_name in ("simpleContent", "complexContent"): + content = first_direct_child(complex_type, content_name) + if content is None: + continue + extension = first_direct_child(content, "extension") + if extension is None: + continue + base_name = local_name(extension.attrib.get("base", "")) + if base_name in model.complex_types and base_name not in resolving: + resolving.add(base_name) + attributes.update(attributes_for_complex_type(model.complex_types[base_name], model, resolving)) + resolving.remove(base_name) + attributes.update(direct_attribute_rules(extension)) + + attributes.update(direct_attribute_rules(complex_type)) + return attributes + + +def direct_attribute_rules(element: ET.Element) -> dict[str, AttributeRule]: + rules: dict[str, AttributeRule] = {} + for attribute in direct_children(element, "attribute"): + name = attribute.attrib.get("name") + if not name: + continue + type_name = local_name(attribute.attrib.get("type", "string")) + inline_simple = first_direct_child(attribute, "simpleType") + if inline_simple is not None: + type_name = f"__inline_attribute_{name}_{id(attribute)}" + rules[name] = AttributeRule( + name=name, + type_name=type_name, + required=attribute.attrib.get("use") == "required", + ) + return rules + + +def attributes_for_element(rule: ElementRule, model: SchemaModel) -> dict[str, AttributeRule]: + complex_type = rule.inline_complex_type + if complex_type is None and rule.type_name in model.complex_types: + complex_type = model.complex_types[rule.type_name] + if complex_type is None: + return {} + return attributes_for_complex_type(complex_type, model) + + +def best_element_rule(element_name: str, model: SchemaModel) -> ElementRule | None: + candidates = model.element_candidates.get(element_name, ()) + if not candidates: + return None + return max( + candidates, + key=lambda candidate: ( + candidate.type_name is not None or candidate.inline_complex_type is not None, + len(attributes_for_element(candidate, model)), + ), + ) + + +def concrete_element_rule(rule: ElementRule, model: SchemaModel) -> ElementRule: + if rule.ref_name is not None: + return model.global_elements.get(rule.ref_name, rule) + if rule.type_name is not None or rule.inline_complex_type is not None: + return rule + candidate = best_element_rule(rule.name, model) + return candidate or rule + + +def complex_type_for_element(rule: ElementRule, model: SchemaModel) -> ET.Element | None: + rule = concrete_element_rule(rule, model) + if rule.inline_complex_type is not None: + return rule.inline_complex_type + if rule.type_name is not None: + return model.complex_types.get(rule.type_name) + return None + + +def particle_for_complex_type(complex_type: ET.Element) -> ET.Element | None: + particle = first_direct_child(complex_type, "sequence", "all", "choice") + if particle is not None: + return particle + for content_name in ("simpleContent", "complexContent"): + content = first_direct_child(complex_type, content_name) + if content is None: + continue + extension = first_direct_child(content, "extension") + if extension is not None: + return first_direct_child(extension, "sequence", "all", "choice") + return None + + +def multiplied_max(left: int | None, right: int | None) -> int | None: + if left is None or right is None: + return None + return left * right + + +def child_rules_for_complex_type( + complex_type: ET.Element, +) -> tuple[list[ChildRule], list[ChoiceRequirement]]: + particle = particle_for_complex_type(complex_type) + if particle is None: + return [], [] + + rules: list[ChildRule] = [] + requirements: list[ChoiceRequirement] = [] + next_order = 0 + + def add_element( + element: ET.Element, + *, + order: int | None, + optional_by_choice: bool, + max_multiplier: int | None, + ) -> None: + element_rule = parse_element_rule(element) + if element_rule is None: + return + minimum = occurs_value(element.attrib.get("minOccurs"), 1) or 0 + maximum = occurs_value(element.attrib.get("maxOccurs"), 1) + rules.append( + ChildRule( + element=element_rule, + min_occurs=0 if optional_by_choice else minimum, + max_occurs=multiplied_max(maximum, max_multiplier), + order=order, + ) + ) + + def walk_group( + group: ET.Element, + *, + ordered: bool, + fixed_order: int | None = None, + optional_by_choice: bool = False, + max_multiplier: int | None = 1, + ) -> None: + nonlocal next_order + kind = local_name(group.tag) + group_min = occurs_value(group.attrib.get("minOccurs"), 1) or 0 + group_max = occurs_value(group.attrib.get("maxOccurs"), 1) + effective_max = multiplied_max(max_multiplier, group_max) + + if kind == "choice": + choice_order = fixed_order + if choice_order is None and ordered: + choice_order = next_order + next_order += 1 + names: list[str] = [] + for child in group: + child_kind = local_name(child.tag) + if child_kind == "element": + parsed = parse_element_rule(child) + if parsed is not None: + names.append(parsed.name) + add_element( + child, + order=choice_order, + optional_by_choice=True, + max_multiplier=effective_max, + ) + elif child_kind in {"sequence", "all", "choice"}: + walk_group( + child, + ordered=ordered, + fixed_order=choice_order, + optional_by_choice=True, + max_multiplier=effective_max, + ) + if names and (group_min > 0 or effective_max is not None): + requirements.append(ChoiceRequirement(tuple(names), group_min, effective_max)) + return + + group_ordered = kind == "sequence" + for child in group: + child_kind = local_name(child.tag) + if child_kind == "element": + child_order = fixed_order + if child_order is None and ordered and group_ordered: + child_order = next_order + next_order += 1 + add_element( + child, + order=child_order, + optional_by_choice=optional_by_choice or group_min == 0, + max_multiplier=effective_max, + ) + elif child_kind in {"sequence", "all", "choice"}: + walk_group( + child, + ordered=ordered and group_ordered, + fixed_order=fixed_order, + optional_by_choice=optional_by_choice or group_min == 0, + max_multiplier=effective_max, + ) + + walk_group(particle, ordered=local_name(particle.tag) == "sequence") + return rules, requirements + + +def issue( + code: str, + path: str, + tag: str, + *, + attr: str | None, + expected: str, + actual: Any, + message: str, + hint: str, +) -> dict[str, Any]: + result: dict[str, Any] = { + "level": "error", + "code": code, + "path": path, + "tag": tag, + "expected": expected, + "actual": actual, + "message": message, + "hint": hint, + } + if attr is not None: + result["attr"] = attr + return result + + +def builtin_scalar_value(type_name: str, value: str) -> Decimal | str | bool: + if type_name in {"string", "anyURI"}: + return value + if type_name == "boolean": + if value not in {"true", "false", "1", "0"}: + raise ValueError("expected boolean") + return value in {"true", "1"} + if type_name in {"integer", "positiveInteger", "nonNegativeInteger"}: + if re.fullmatch(r"[+-]?\d+", value) is None: + raise ValueError("expected integer") + number = Decimal(value) + if type_name == "positiveInteger" and number <= 0: + raise ArithmeticError("expected positive integer") + if type_name == "nonNegativeInteger" and number < 0: + raise ArithmeticError("expected non-negative integer") + return number + if type_name in {"double", "decimal"}: + lexical_value = value.strip(" \t\n\r") + decimal_pattern = r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)" + double_pattern = decimal_pattern + r"(?:[eE][+-]?[0-9]+)?" + expected_pattern = double_pattern if type_name == "double" else decimal_pattern + if re.fullmatch(expected_pattern, lexical_value) is None: + raise ValueError(f"expected {type_name}") + try: + number = Decimal(lexical_value) + except InvalidOperation as error: + raise ValueError(f"expected {type_name}") from error + if not math.isfinite(float(number)): + raise ValueError(f"expected finite {type_name}") + return number + return value + + +def scalar_value_for_type( + type_name: str, + value: str, + model: SchemaModel, + resolving: set[str] | None = None, +) -> Decimal | str | bool: + resolving = resolving or set() + if type_name in resolving: + return value + rule = model.simple_types.get(type_name) + if rule is None or rule.base is None: + return builtin_scalar_value(type_name, value) + resolving.add(type_name) + try: + return scalar_value_for_type(rule.base, value, model, resolving) + finally: + resolving.remove(type_name) + + +@lru_cache(maxsize=32) +def python_pattern_for_xsd(pattern: str) -> str: + translated: list[str] = [] + in_character_class = False + index = 0 + while index < len(pattern): + char = pattern[index] + if char == "\\" and index + 1 < len(pattern): + escaped = pattern[index + 1] + if escaped in {"s", "S"}: + body = r" \t\n\r" + if in_character_class: + if escaped == "S": + raise ValueError( + f"unsupported complemented XSD character class \\{escaped} inside []" + ) + translated.append(body) + else: + prefix = "^" if escaped == "S" else "" + translated.append(f"[{prefix}{body}]") + index += 2 + continue + translated.extend((char, escaped)) + index += 2 + continue + if char == "[": + in_character_class = True + elif char == "]": + in_character_class = False + elif char == "." and not in_character_class: + translated.append(r"[^\n\r]") + index += 1 + continue + elif char in "^$" and not in_character_class: + translated.append(f"\\{char}") + index += 1 + continue + translated.append(char) + index += 1 + return "".join(translated) + + +def xsd_pattern_matches(pattern: str, value: str) -> bool: + if pattern == r"[\w.-]+[.:]\S*": + if any(character in " \t\n\r" for character in value): + return False + for index, character in enumerate(value): + if index > 0 and character in ".:": + return True + if not (character == "_" or character.isalnum() or character in ".-"): + return False + return False + return re.fullmatch(python_pattern_for_xsd(pattern), value) is not None + + +def value_error_for_type( + type_name: str, + value: str, + model: SchemaModel, + resolving: set[str] | None = None, +) -> tuple[str, str] | None: + resolving = resolving or set() + if type_name in resolving: + return None + rule = model.simple_types.get(type_name) + if rule is None: + try: + builtin_scalar_value(type_name, value) + except ValueError: + return "sxsd_invalid_scalar", f"value valid for {type_name}" + except ArithmeticError: + return "sxsd_value_out_of_range", f"value in the range allowed by {type_name}" + return None + + resolving.add(type_name) + try: + if rule.union_members: + member_errors = [value_error_for_type(member, value, model, resolving) for member in rule.union_members] + if any(error is None for error in member_errors): + return None + unsupported_error = next( + (error for error in member_errors if error and error[0] == "sxsd_unsupported_pattern"), + None, + ) + if unsupported_error is not None: + return unsupported_error + if any(error and error[0] == "sxsd_pattern_mismatch" for error in member_errors): + return "sxsd_pattern_mismatch", f"value matching one member of {type_name}" + return member_errors[0] + + if rule.enums and value not in rule.enums: + return "sxsd_invalid_enum", "one of: " + ", ".join(rule.enums) + + if rule.patterns: + unsupported_patterns: list[str] = [] + for pattern in rule.patterns: + try: + if xsd_pattern_matches(pattern, value): + break + except (ValueError, re.error) as error: + unsupported_patterns.append(f"{pattern!r}: {error}") + else: + if unsupported_patterns: + return ( + "sxsd_unsupported_pattern", + "lint support for XSD pattern " + "; ".join(unsupported_patterns), + ) + return "sxsd_pattern_mismatch", "value matching pattern " + " or ".join(rule.patterns) + + base_name = rule.base or "string" + base_error = value_error_for_type(base_name, value, model, resolving) + if base_error is not None: + return base_error + for facet, bound in rule.length_bounds: + allowed = len(value) >= bound if facet == "minLength" else len(value) <= bound + if not allowed: + return "sxsd_value_out_of_range", f"{facet} {bound}" + scalar = scalar_value_for_type(base_name, value, model) + if isinstance(scalar, Decimal): + for facet, bound in rule.bounds: + allowed = { + "minInclusive": scalar >= bound, + "minExclusive": scalar > bound, + "maxInclusive": scalar <= bound, + "maxExclusive": scalar < bound, + }[facet] + if not allowed: + return "sxsd_value_out_of_range", f"{facet} {bound}" + return None + finally: + resolving.remove(type_name) + + +def validate_element_attributes( + element: ET.Element, + path: str, + model: SchemaModel, + element_rule: ElementRule | None = None, +) -> list[dict[str, Any]]: + tag = local_name(element.tag) + element_rule = element_rule or best_element_rule(tag, model) + if element_rule is None: + return [] + attribute_rules = attributes_for_element(element_rule, model) + issues: list[dict[str, Any]] = [] + for attr_rule in attribute_rules.values(): + if attr_rule.required and attr_rule.name not in element.attrib: + issues.append( + issue( + "sxsd_missing_required_attr", + path, + tag, + attr=attr_rule.name, + expected=f"required attribute of type {attr_rule.type_name}", + actual=None, + message=f'missing required SXSD attribute "{attr_rule.name}" on <{tag}> at {path}', + hint=f'Add attribute "{attr_rule.name}" with a value valid for {attr_rule.type_name}.', + ) + ) + for raw_name, value in element.attrib.items(): + attr_name = local_name(raw_name) + attr_rule = attribute_rules.get(attr_name) + if attr_rule is None: + continue + validation_error = value_error_for_type(attr_rule.type_name, value, model) + if validation_error is None: + continue + code, expected = validation_error + if code == "sxsd_unsupported_pattern": + message = ( + f'unsupported SXSD pattern for attribute "{attr_name}" on <{tag}> at {path}' + ) + hint = ( + f"Extend the SXSD pattern interpreter for {attr_rule.type_name}; " + "do not treat this attribute value as validated." + ) + else: + message = ( + f'invalid SXSD value {value!r} for attribute "{attr_name}" on <{tag}> at {path}' + ) + hint = f'Set attribute "{attr_name}" to a value valid for {attr_rule.type_name}.' + issues.append( + issue( + code, + path, + tag, + attr=attr_name, + expected=expected, + actual=value, + message=message, + hint=hint, + ) + ) + return issues + + +def element_namespace(tag: str) -> str | None: + if not tag.startswith("{"): + return None + return tag[1:].split("}", 1)[0] + + +def validate_element_children( + element: ET.Element, + path: str, + element_rule: ElementRule, + model: SchemaModel, +) -> tuple[list[dict[str, Any]], dict[int, ElementRule]]: + tag = local_name(element.tag) + complex_type = complex_type_for_element(element_rule, model) + child_rules, choice_requirements = ( + child_rules_for_complex_type(complex_type) if complex_type is not None else ([], []) + ) + rules_by_name: dict[str, list[ChildRule]] = {} + for child_rule in child_rules: + rules_by_name.setdefault(child_rule.element.name, []).append(child_rule) + + issues: list[dict[str, Any]] = [] + matched: dict[int, ElementRule] = {} + counts: dict[str, int] = {} + latest_order = -1 + for child in element: + child_name = local_name(child.tag) + child_path = f"{path}/{child_name}" + candidates = rules_by_name.get(child_name, []) + if not candidates: + issues.append( + issue( + "sxsd_unexpected_child", + child_path, + child_name, + attr=None, + expected="one of: " + ", ".join(sorted(rules_by_name)) if rules_by_name else "no child elements", + actual=child_name, + message=f"unexpected SXSD child <{child_name}> under <{tag}> at {child_path}", + hint=f"Move or remove <{child_name}> so <{tag}> follows the SXSD child structure.", + ) + ) + continue + + child_rule = candidates[0] + if child_rule.order is not None: + if child_rule.order < latest_order: + issues.append( + issue( + "sxsd_invalid_child_order", + child_path, + child_name, + attr=None, + expected="children in xs:sequence order", + actual=child_name, + message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}", + hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.", + ) + ) + latest_order = max(latest_order, child_rule.order) + + counts[child_name] = counts.get(child_name, 0) + 1 + if child_rule.max_occurs is not None and counts[child_name] > child_rule.max_occurs: + issues.append( + issue( + "sxsd_too_many_children", + child_path, + child_name, + attr=None, + expected=f"at most {child_rule.max_occurs}", + actual=counts[child_name], + message=f"too many SXSD <{child_name}> children under <{tag}> at {path}", + hint=f"Keep at most {child_rule.max_occurs} <{child_name}> children under <{tag}>.", + ) + ) + matched[id(child)] = concrete_element_rule(child_rule.element, model) + + for child_rule in child_rules: + child_name = child_rule.element.name + actual_count = counts.get(child_name, 0) + if child_rule.min_occurs <= actual_count: + continue + issues.append( + issue( + "sxsd_missing_required_child", + path, + tag, + attr=None, + expected=f"{child_name} (at least {child_rule.min_occurs})", + actual=actual_count, + message=f"missing required SXSD child <{child_name}> under <{tag}> at {path}", + hint=f"Add at least {child_rule.min_occurs} <{child_name}> child under <{tag}>.", + ) + ) + + for requirement in choice_requirements: + actual_count = sum(counts.get(name, 0) for name in requirement.names) + expected_names = ", ".join(requirement.names) + if actual_count < requirement.min_occurs: + issues.append( + issue( + "sxsd_missing_required_child", + path, + tag, + attr=None, + expected=f"one of: {expected_names} (at least {requirement.min_occurs})", + actual=actual_count, + message=f"missing required SXSD choice child under <{tag}> at {path}", + hint=f"Add at least {requirement.min_occurs} child from: {expected_names}.", + ) + ) + if requirement.max_occurs is not None and actual_count > requirement.max_occurs: + issues.append( + issue( + "sxsd_too_many_children", + path, + tag, + attr=None, + expected=f"at most {requirement.max_occurs} child from: {expected_names}", + actual=actual_count, + message=f"too many SXSD choice children under <{tag}> at {path}", + hint=f"Keep at most {requirement.max_occurs} child from: {expected_names}.", + ) + ) + return issues, matched + + +def validate_sxsd(root: ET.Element, schema_path: Path) -> list[dict[str, Any]]: + model = load_schema_model(str(schema_path.resolve())) + issues: list[dict[str, Any]] = [] + root_name = local_name(root.tag) + document_namespace = element_namespace(root.tag) + is_bare_slide_fragment = root_name == "slide" and document_namespace is None + has_valid_document_namespace = ( + document_namespace in ACCEPTED_SML_NAMESPACES or is_bare_slide_fragment + ) + + def visit(element: ET.Element, parent_path: str, element_rule: ElementRule) -> None: + tag = local_name(element.tag) + path = f"{parent_path}/{tag}" if parent_path else tag + namespace = element_namespace(element.tag) + invalid_root_namespace = ( + not parent_path + and namespace not in ACCEPTED_SML_NAMESPACES + and not is_bare_slide_fragment + ) + invalid_descendant_namespace = ( + bool(parent_path) + and has_valid_document_namespace + and namespace != document_namespace + ) + if invalid_root_namespace or invalid_descendant_namespace: + expected_namespace = document_namespace if parent_path else SML_NAMESPACE + namespace_hint = ( + "Keep SXSD descendants without xmlns in a bare readback fragment." + if expected_namespace is None + else f'Use xmlns="{expected_namespace}" for SXSD elements.' + ) + issues.append( + issue( + "sxsd_invalid_namespace", + path, + tag, + attr=None, + expected=expected_namespace, + actual=namespace, + message=f"invalid SXSD namespace on <{tag}> at {path}", + hint=namespace_hint, + ) + ) + issues.extend(validate_element_attributes(element, path, model, element_rule)) + child_issues, matched = validate_element_children(element, path, element_rule, model) + issues.extend(child_issues) + for child in element: + child_rule = matched.get(id(child)) + if child_rule is not None: + visit(child, path, child_rule) + + if root_name not in {"presentation", "slide"}: + issues.append( + issue( + "sxsd_unexpected_root", + root_name, + root_name, + attr=None, + expected="presentation or slide", + actual=root_name, + message=f"unsupported SXSD root <{root_name}>", + hint="Use a or root.", + ) + ) + return issues + if root_name == "presentation": + root_rule = model.global_elements.get("presentation") + elif "SlideType" in model.complex_types: + root_rule = ElementRule("slide", "SlideType", None, None) + else: + root_rule = None + if root_rule is None: + issues.append( + issue( + "sxsd_unexpected_root", + root_name, + root_name, + attr=None, + expected="presentation or slide", + actual=root_name, + message=f"unsupported SXSD root <{root_name}>", + hint="Use a or root.", + ) + ) + return issues + visit(root, "", root_rule) + return issues + + +def load_tag_attributes(schema_path: Path) -> dict[str, set[str]]: + model = load_schema_model(str(schema_path.resolve())) + tag_attributes: dict[str, set[str]] = {} + for tag_name, candidates in model.element_candidates.items(): + attrs = tag_attributes.setdefault(tag_name, set()) + for candidate in candidates: + attrs.update(attributes_for_element(concrete_element_rule(candidate, model), model)) + return tag_attributes diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index e37f26d862..b3534a14f8 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy import json import math import re @@ -16,6 +17,8 @@ from pathlib import Path from typing import Any +import sxsd_validator + XS_NS = "{http://www.w3.org/2001/XMLSchema}" XML_NS = "{http://www.w3.org/XML/1998/namespace}" @@ -44,9 +47,8 @@ ("chartData", "isStaticData"), } # Slides readback echoes each chartField's CSV text as per-value children; -# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing -# deck, so treating it as an unsupported tag would block per-slide linting document-wide. -ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"} +# it is server-emitted and absent from the write schema, so it must not block page linting. +ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")} DEFAULT_TABLE_COLUMN_WIDTH = 110 DEFAULT_TABLE_ROW_HEIGHT = 37 DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5 @@ -303,77 +305,13 @@ def xml_namespace(tag: str) -> str | None: return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None -def strip_xsd_prefix(value: str | None) -> str | None: - if value is None: - return None - return value.rsplit(":", 1)[-1] - - -def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]: - return [child for child in element if child.tag == f"{XS_NS}{local_name}"] - - def load_sxsd_tag_attributes() -> dict[str, set[str]]: global _SXSD_TAG_ATTRIBUTES_CACHE if _SXSD_TAG_ATTRIBUTES_CACHE is not None: return _SXSD_TAG_ATTRIBUTES_CACHE - schema_root = ET.parse(SXSD_SCHEMA_PATH).getroot() - named_complex_types = { - complex_type.attrib["name"]: complex_type - for complex_type in schema_root.findall(f"{XS_NS}complexType") - if complex_type.attrib.get("name") - } - resolving: set[str] = set() - - def attributes_for_complex_type(complex_type: ET.Element) -> set[str]: - attrs: set[str] = { - attribute.attrib["name"] - for attribute in iter_direct_xsd_children(complex_type, "attribute") - if attribute.attrib.get("name") - } - for content_name in ("simpleContent", "complexContent"): - for complex_content in iter_direct_xsd_children(complex_type, content_name): - for extension in iter_direct_xsd_children(complex_content, "extension"): - base_type = strip_xsd_prefix(extension.attrib.get("base")) - if base_type: - attrs.update(attributes_for_type(base_type)) - attrs.update( - attribute.attrib["name"] - for attribute in iter_direct_xsd_children(extension, "attribute") - if attribute.attrib.get("name") - ) - return attrs - - def attributes_for_type(type_name: str) -> set[str]: - if type_name in resolving: - return set() - complex_type = named_complex_types.get(type_name) - if complex_type is None: - return set() - resolving.add(type_name) - try: - return attributes_for_complex_type(complex_type) - finally: - resolving.remove(type_name) - - tag_attributes: dict[str, set[str]] = {} - for element in schema_root.iter(f"{XS_NS}element"): - tag_name = element.attrib.get("name") - if not tag_name: - continue - - attrs: set[str] = set() - type_name = strip_xsd_prefix(element.attrib.get("type")) - if type_name: - attrs.update(attributes_for_type(type_name)) - for complex_type in iter_direct_xsd_children(element, "complexType"): - attrs.update(attributes_for_complex_type(complex_type)) - - tag_attributes.setdefault(tag_name, set()).update(attrs) - - _SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes - return tag_attributes + _SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH) + return _SXSD_TAG_ATTRIBUTES_CACHE def load_iconpark_icon_types() -> set[str]: @@ -410,13 +348,19 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str: return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml." -def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str: +def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]: alias = SXSD_ATTR_ALIASES.get(attr_name) if alias and alias in allowed_attrs: - return f'Use "{alias}" on <{tag_name}> instead of "{attr_name}".' - close_matches = get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68) - if close_matches: - return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in close_matches) + "?" + return [alias] + return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68) + + +def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str: + suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs) + if suggestions: + if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]: + return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".' + return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?" allowed_summary = ", ".join(sorted(allowed_attrs)[:8]) if len(allowed_attrs) > 8: allowed_summary += ", ..." @@ -431,10 +375,33 @@ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool: return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS -def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]: +def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool: + return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS + + +def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element: + sanitized_root = copy.deepcopy(root) + + def sanitize(element: ET.Element) -> None: + tag_name = xml_local_name(element.tag) + for raw_attr_name in list(element.attrib): + if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)): + del element.attrib[raw_attr_name] + for child in list(element): + if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)): + element.remove(child) + continue + sanitize(child) + + sanitize(sanitized_root) + return sanitized_root + + +def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]: tag_attributes = load_sxsd_tag_attributes() supported_tags = set(tag_attributes) issues: list[dict[str, Any]] = [] + suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {} def visit(element: ET.Element, ancestors: list[str], path: str) -> None: if should_skip_sxsd_subtree(element, ancestors): @@ -442,7 +409,8 @@ def visit(element: ET.Element, ancestors: list[str], path: str) -> None: tag_name = xml_local_name(element.tag) current_path = f"{path}/{tag_name}" if path else tag_name - if tag_name in ROUNDTRIP_SXSD_TAGS: + parent_name = ancestors[-1] if ancestors else None + if should_skip_sxsd_tag(parent_name, tag_name): return if tag_name not in supported_tags: issues.append( @@ -466,6 +434,11 @@ def visit(element: ET.Element, ancestors: list[str], path: str) -> None: continue if attr_name in allowed_attrs: continue + suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs) + if suggestions: + suggested_attr_candidates.setdefault((current_path, tag_name), []).append( + set(suggestions) + ) issues.append( { "level": "error", @@ -482,6 +455,76 @@ def visit(element: ET.Element, ancestors: list[str], path: str) -> None: visit(child, [*ancestors, tag_name], current_path) visit(root, [], "") + existing = { + (issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr")) + for issue in issues + } + unsupported_tag_locations = { + (issue.get("path"), issue.get("tag")) + for issue in issues + if issue.get("code") == "sxsd_unsupported_tag" + } + schema_issues = _validate_sxsd_schema_constraints(xml, root) + missing_attrs_by_location: dict[tuple[str, str], set[str]] = {} + for schema_issue in schema_issues: + if schema_issue.get("code") != "sxsd_missing_required_attr": + continue + location = (schema_issue.get("path"), schema_issue.get("tag")) + missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr")) + + suggested_attrs: set[tuple[str, str, str]] = set() + for location, candidate_groups in suggested_attr_candidates.items(): + missing_attrs = missing_attrs_by_location.get(location, set()) + for candidates in candidate_groups: + matching_missing_attrs = candidates & missing_attrs + if len(matching_missing_attrs) == 1: + suggested_attrs.add((*location, next(iter(matching_missing_attrs)))) + + for schema_issue in schema_issues: + if schema_issue.get("code") == "sxsd_unexpected_child" and ( + schema_issue.get("path"), + schema_issue.get("tag"), + ) in unsupported_tag_locations: + continue + if schema_issue.get("code") == "sxsd_missing_required_attr" and ( + schema_issue.get("path"), + schema_issue.get("tag"), + schema_issue.get("attr"), + ) in suggested_attrs: + continue + key = ( + schema_issue.get("code"), + schema_issue.get("path"), + schema_issue.get("tag"), + schema_issue.get("attr"), + ) + if key not in existing: + issues.append(schema_issue) + return issues + + +def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + if re.match(r"^\s*<\?xml\b", xml): + issues.append( + { + "level": "error", + "code": "sxsd_unsupported_declaration", + "path": xml_local_name(root.tag), + "tag": xml_local_name(root.tag), + "expected": "SXSD document without an XML declaration", + "actual": "", + "message": "XML declarations are not supported by the Slides SXSD write format", + "hint": "Remove the declaration and keep the SXSD root element.", + } + ) + + issues.extend( + sxsd_validator.validate_sxsd( + without_server_filled_sxsd_fields(root), + SXSD_SCHEMA_PATH, + ) + ) return issues @@ -685,18 +728,39 @@ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None: return xml_error -def parse_presentation(xml: str) -> dict[str, Any]: - presentation_match = re.search(r"]*)>", xml) - if presentation_match: - return { - "width": int(float(extract_attribute(presentation_match.group(1), "width") or 960)), - "height": int(float(extract_attribute(presentation_match.group(1), "height") or 540)), - "slides": re.findall(r"", xml), +def serialize_slide_for_layout(slide_root: ET.Element) -> str: + slide_copy = copy.deepcopy(slide_root) + for element in slide_copy.iter(): + if not isinstance(element.tag, str): + continue + element.tag = xml_local_name(element.tag) + attributes = { + xml_local_name(attribute_name): value + for attribute_name, value in element.attrib.items() } - slide_match = re.findall(r"", xml) - if slide_match: - return {"width": 960, "height": 540, "slides": slide_match} - fail("input must contain a or root") + element.attrib.clear() + element.attrib.update(attributes) + return ET.tostring(slide_copy, encoding="unicode") + + +def parse_presentation(root: ET.Element) -> dict[str, Any]: + root_name = xml_local_name(root.tag) + if root_name == "slide": + slide_roots = [root] + width = 960 + height = 540 + elif root_name == "presentation": + slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"] + width = int(float(root.attrib.get("width", 960))) + height = int(float(root.attrib.get("height", 540))) + else: + fail("input must contain a or root") + return { + "width": width, + "height": height, + "slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots], + "slide_roots": slide_roots, + } def extract_elements(slide_xml: str) -> list[dict[str, Any]]: @@ -2311,6 +2375,21 @@ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) - return "passed" +def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool: + if issue.get("code") == "sxsd_unsupported_declaration": + return False + if root_name == "slide": + return True + path = issue.get("path") + if not isinstance(path, str): + return False + if path.startswith("presentation/slide/"): + return True + return path == "presentation/slide" and ( + issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace" + ) + + def build_result( source_path: str | None, slide_size: dict[str, int | float], @@ -2366,11 +2445,20 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: raise AssertionError("parse_xml_root must return a root or error") namespace_issues = validate_sml_tag_prefixes(xml) - sxsd_issues = validate_sxsd_tag_attributes(root) + root_name = xml_local_name(root.tag) + sxsd_issues = validate_sxsd_document(xml, root) iconpark_issues = validate_iconpark_icon_types(root) top_level_issues = [ normalize_issue(issue, None, {}) - for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues] + for issue in [ + *namespace_issues, + *[ + issue + for issue in sxsd_issues + if not is_slide_scoped_sxsd_issue(issue, root_name) + ], + *iconpark_issues, + ] ] if any(issue["level"] == "error" for issue in top_level_issues): return build_result( @@ -2380,10 +2468,36 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: [], ) - presentation = parse_presentation(xml) + presentation = parse_presentation(root) + slide_roots = presentation["slide_roots"] slides: list[dict[str, Any]] = [] for index, slide_xml in enumerate(presentation["slides"]): slide_number = index + 1 + slide_root = slide_roots[index] + slide_sxsd_issues = [ + normalize_issue(issue, slide_number, {}) + for issue in validate_sxsd_document(slide_xml, slide_root) + ] + slide_sxsd_errors = [ + issue for issue in slide_sxsd_issues if issue["level"] == "error" + ] + if slide_sxsd_errors: + slide_sxsd_warnings = [ + issue for issue in slide_sxsd_issues if issue["level"] == "warning" + ] + slides.append( + { + "slide_number": slide_number, + "status": slide_status(slide_sxsd_errors, slide_sxsd_warnings), + "element_count": 0, + "errors": slide_sxsd_errors, + "warnings": slide_sxsd_warnings, + "infos": [], + "issues": slide_sxsd_issues, + } + ) + continue + geometry = lint_slide( slide_xml, slide_number, @@ -2429,8 +2543,11 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: ), ] issues = [ - normalize_issue(issue, slide_number, elements_by_id) - for issue in raw_issues + *slide_sxsd_issues, + *[ + normalize_issue(issue, slide_number, elements_by_id) + for issue in raw_issues + ], ] errors = [issue for issue in issues if issue["level"] == "error"] warnings = [issue for issue in issues if issue["level"] == "warning"] diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py index 8e5cd03544..fc4f210b4b 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -2,13 +2,17 @@ # SPDX-License-Identifier: MIT from __future__ import annotations +import itertools import json import subprocess import sys import tempfile import unittest +import xml.etree.ElementTree as ET from pathlib import Path +from unittest import mock +import sxsd_validator import xml_text_overlap_lint @@ -47,6 +51,37 @@ def test_cli_suggests_input_flag_for_positional_argument(self) -> None: f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n", ) + def test_cli_reports_structured_slide_sxsd_error_outside_skill_directory(self) -> None: + script_path = Path(xml_text_overlap_lint.__file__).resolve() + with tempfile.TemporaryDirectory() as temp_dir: + input_path = Path(temp_dir) / "invalid-slide.xml" + input_path.write_text( + """ + + + + """, + encoding="utf-8", + ) + + completed = subprocess.run( + [sys.executable, str(script_path), "--input", str(input_path)], + cwd=temp_dir, + capture_output=True, + check=False, + text=True, + ) + + result = json.loads(completed.stdout) + issue = result["slides"][0]["errors"][0] + self.assertEqual(completed.returncode, 1) + self.assertEqual(completed.stderr, "") + self.assertEqual(issue["code"], "sxsd_missing_required_attr") + self.assertEqual(issue["path"], "slide/data/shape") + self.assertEqual(issue["attr"], "height") + self.assertEqual(issue["target"]["slide_number"], 1) + self.assertTrue(issue["hint"]) + def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None: samples = { "image-led-cover": """ @@ -173,12 +208,12 @@ def test_lint_xml_rejects_prefixed_sml_tags(self) -> None: self.assertEqual(issues[0]["tag"], "ns0:slide") self.assertIn("default namespace", issues[0]["hint"]) - def test_lint_xml_allows_unprefixed_tags_without_namespace(self) -> None: + def test_lint_xml_accepts_server_readback_slide_without_namespace(self) -> None: result = xml_text_overlap_lint.lint_xml( """ - + - +

Unprefixed SML

@@ -187,6 +222,90 @@ def test_lint_xml_allows_unprefixed_tags_without_namespace(self) -> None: ) self.assertEqual(result["summary"]["error_count"], 0) + def test_lint_xml_accepts_server_readback_presentation_short_namespace(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Server readback presentation

+
+
+
+
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 1) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_xml_accepts_server_readback_presentation_https_namespace(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Server readback presentation

+
+
+
+
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 1) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_xml_rejects_server_readback_presentation_wrong_namespace(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_namespace") + + def test_lint_xml_rejects_xml_declaration(self) -> None: + result = xml_text_overlap_lint.lint_xml( + '' + ) + + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["issues"][0]["code"], "sxsd_unsupported_declaration") + + def test_lint_xml_reports_missing_required_sxsd_attribute(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 1) + self.assertNotIn("issues", result) + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["code"], "sxsd_missing_required_attr") + self.assertEqual(issue["attr"], "height") + + def test_lint_xml_rejects_child_order_that_violates_xsd_sequence(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + Late title + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_child_order") + def test_lint_xml_accepts_escaped_entities_without_suspicious_entity_warning(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -250,8 +369,8 @@ def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None: - -

Missing height

+ +

Second slide

@@ -261,10 +380,151 @@ def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None: self.assertEqual(result["slide_size"], {"width": 1280, "height": 720}) self.assertEqual(result["summary"]["slide_count"], 2) self.assertEqual([slide["slide_number"] for slide in result["slides"]], [1, 2]) - self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 1]) + self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 2]) self.assertEqual(result["summary"]["error_count"], 0) self.assertEqual(result["summary"]["warning_count"], 0) + def test_lint_xml_handles_self_closing_slide_before_normal_slide(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + +

Second slide

+
+
+
+
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 2) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["blank_slide"], + ) + self.assertEqual(result["slides"][1]["status"], "passed") + + def test_lint_xml_keeps_trailing_self_closing_slide(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

First slide

+
+
+
+ +
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 2) + self.assertEqual( + [issue["code"] for issue in result["slides"][1]["errors"]], + ["blank_slide"], + ) + + def test_lint_xml_ignores_slide_markup_inside_xml_comments(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Real slide

+
+
+
+ +
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 1) + self.assertEqual(result["slides"][0]["status"], "passed") + + def test_lint_xml_ignores_invalid_slide_markup_inside_xml_comments(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + """ + ) + + self.assertEqual(result["summary"]["slide_count"], 1) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["blank_slide"], + ) + + def test_lint_xml_skips_only_invalid_slide_and_continues_geometry_checks(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Missing height

+
+
+
+ + + +

Outside canvas

+
+
+
+
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 2) + self.assertEqual(result["slides"][0]["element_count"], 0) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["sxsd_missing_required_attr"], + ) + self.assertNotIn( + "shape_out_of_canvas", + [issue["code"] for issue in result["slides"][0]["issues"]], + ) + self.assertIn( + "shape_out_of_canvas", + [issue["code"] for issue in result["slides"][1]["errors"]], + ) + + def test_lint_xml_scopes_invalid_slide_root_attribute_to_that_slide(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + + self.assertEqual(result["summary"]["slide_count"], 2) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["sxsd_unsupported_attr"], + ) + self.assertIn( + "shape_out_of_canvas", + [issue["code"] for issue in result["slides"][1]["errors"]], + ) + def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None: cases = [ ("textbox", 'Text', ''), @@ -279,8 +539,15 @@ def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None:
""" ) - issue = result["issues"][0] + slide_issues = result["slides"][0]["issues"] + issue = next( + issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_tag" + ) self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual( + [reported["code"] for reported in slide_issues], + ["sxsd_unsupported_tag"], + ) self.assertEqual(issue["code"], "sxsd_unsupported_tag") self.assertEqual(issue["tag"], tag_name) self.assertIn(expected_hint, issue["hint"]) @@ -288,6 +555,7 @@ def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None: def test_lint_xml_reports_sxsd_unsupported_attr_with_alias_hint(self) -> None: cases = [ ("shape", "x", "topLeftX", '

Text

'), + ("shape", "heigth", "height", '

Text

'), ("content", "fontColor", "color", '

Text

'), ] for tag_name, attr_name, expected_attr, element_xml in cases: @@ -299,13 +567,75 @@ def test_lint_xml_reports_sxsd_unsupported_attr_with_alias_hint(self) -> None:
""" ) - issue = result["issues"][0] + slide_issues = result["slides"][0]["issues"] + issue = next( + issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_attr" + ) self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual( + [reported["code"] for reported in slide_issues], + ["sxsd_unsupported_attr"], + ) self.assertEqual(issue["code"], "sxsd_unsupported_attr") self.assertEqual(issue["tag"], tag_name) self.assertEqual(issue["attr"], attr_name) self.assertIn(expected_attr, issue["hint"]) + def test_lint_xml_keeps_unrelated_unsupported_and_missing_attrs(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + """ + ) + + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["issues"]], + ["sxsd_unsupported_attr", "sxsd_missing_required_attr"], + ) + + def test_lint_xml_keeps_missing_attrs_when_suggestion_is_ambiguous(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + """ + ) + + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["issues"]], + [ + "sxsd_unsupported_attr", + "sxsd_missing_required_attr", + "sxsd_missing_required_attr", + ], + ) + + def test_lint_xml_suppresses_only_missing_attr_that_resolves_ambiguous_suggestion(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + """ + ) + + self.assertEqual( + [ + (issue["code"], issue.get("attr")) + for issue in result["slides"][0]["issues"] + ], + [("sxsd_unsupported_attr", "topLeftXX")], + ) + def test_lint_xml_ignores_server_filled_id_attrs(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -322,10 +652,10 @@ def test_lint_xml_ignores_server_filled_id_attrs(self) -> None: ) self.assertEqual(result["summary"]["error_count"], 1) - issue = result["issues"][0] - self.assertEqual(issue["code"], "sxsd_unsupported_attr") - self.assertEqual(issue["tag"], "fill") - self.assertEqual(issue["attr"], "unexpected") + self.assertEqual( + [(issue["tag"], issue["attr"]) for issue in result["slides"][0]["issues"]], + [("fill", "unexpected")], + ) def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -333,7 +663,34 @@ def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None: - + + + A + 1 + + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_xml_ignores_chart_parsed_values_roundtrip_tags(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + AA + + + 1 + @@ -341,7 +698,6 @@ def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None: ) self.assertEqual(result["summary"]["error_count"], 0) - self.assertNotIn("issues", result) def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -349,10 +705,14 @@ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None: + - - Africa - + + + AfricaAfrica + + + 1 @@ -363,13 +723,40 @@ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None: self.assertEqual(result["summary"]["error_count"], 0) self.assertNotIn("issues", result) + def test_lint_xml_rejects_chart_parsed_values_outside_chart_field(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + unexpected + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["issues"]], + ["sxsd_unsupported_tag"], + ) + self.assertEqual( + result["slides"][0]["issues"][0]["path"], + "slide/data/shape/chartParsedValues", + ) + def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None: result = xml_text_overlap_lint.lint_xml( """ - + + + A + 1 + @@ -377,11 +764,12 @@ def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None: ) self.assertEqual(result["summary"]["error_count"], 2) + slide_issues = result["slides"][0]["issues"] self.assertEqual( - {(issue["tag"], issue["attr"]) for issue in result["issues"]}, + {(issue["tag"], issue["attr"]) for issue in slide_issues}, {("chart", "isStaticData"), ("chartData", "updated")}, ) - self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"])) + self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues)) def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -403,14 +791,15 @@ def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None: """ ) - unsupported_attrs = {issue["attr"] for issue in result["issues"]} + slide_issues = result["slides"][0]["issues"] + unsupported_attrs = {issue["attr"] for issue in slide_issues} self.assertEqual(result["summary"]["error_count"], 6) self.assertEqual( unsupported_attrs, {"type", "color1", "color2", "angle", "stop1", "stop2"}, ) - self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"])) - self.assertTrue(all(issue["tag"] == "fillColor" for issue in result["issues"])) + self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues)) + self.assertTrue(all(issue["tag"] == "fillColor" for issue in slide_issues)) def test_lint_xml_accepts_chart_field_simple_content_attrs(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -1424,7 +1813,13 @@ def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> No - + + + + A + 1 + +
@@ -1448,7 +1843,7 @@ def test_lint_xml_ignores_line_out_of_canvas(self) -> None:

Visible content

- +
""" @@ -1464,7 +1859,13 @@ def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) - + + + + A + 1 + +
@@ -1485,7 +1886,7 @@ def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None - +
@@ -1541,7 +1942,7 @@ def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> No self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]]) self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10}) - def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None: + def test_lint_xml_rejects_non_finite_rotation_values_from_xsd(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1549,17 +1950,22 @@ def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None: - + + + + A + 1 + +
""" ) - issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]} self.assertEqual(result["summary"]["error_count"], 3) - self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0}) - self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0}) - self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0}) + slide_issues = result["slides"][0]["issues"] + self.assertTrue(all(issue["code"] == "sxsd_invalid_scalar" for issue in slide_issues)) + self.assertEqual({issue["actual"] for issue in slide_issues}, {"inf", "-inf", "nan"}) def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -1914,7 +2320,7 @@ def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None: """ - + """ @@ -2195,7 +2601,13 @@ def test_lint_xml_allows_container_with_large_visual_child(self) -> None: - + + + + A + 1 + + """ @@ -2212,7 +2624,13 @@ def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(

Section title

- + + + + A + 1 + +
""" @@ -2289,7 +2707,7 @@ def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning

Section title

- + """ @@ -2395,7 +2813,7 @@ def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None: -

400+ 项

+

400+ 项

@@ -2409,10 +2827,10 @@ def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> Non """ - - - - + + + + """ @@ -2574,5 +2992,624 @@ def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None: ) +SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0" + + +class SxsdSyntaxTestCase(unittest.TestCase): + def validate(self, xml: str) -> list[dict[str, object]]: + result = xml_text_overlap_lint.lint_xml(xml) + return [ + *result.get("issues", []), + *(issue for slide in result["slides"] for issue in slide["issues"]), + ] + + def assert_issue( + self, + issues: list[dict[str, object]], + code: str, + *, + path: str | None = None, + attr: str | None = None, + ) -> dict[str, object]: + for issue in issues: + if issue.get("code") != code: + continue + if path is not None and issue.get("path") != path: + continue + if attr is not None and issue.get("attr") != attr: + continue + return issue + self.fail(f"missing issue code={code!r} path={path!r} attr={attr!r}: {issues!r}") + + def assert_no_issue(self, issues: list[dict[str, object]], code: str) -> None: + self.assertNotIn(code, [issue.get("code") for issue in issues]) + + +class SxsdSyntaxAttributeTest(SxsdSyntaxTestCase): + + def test_xsd_pattern_translation_only_expands_whitespace_classes(self) -> None: + self.assertEqual( + sxsd_validator.python_pattern_for_xsd(r"\s+\S+\w+\d+"), + "[ \\t\\n\\r]+[^ \\t\\n\\r]+\\w+\\d+", + ) + + def test_href_domain_pattern_does_not_use_backtracking_regex(self) -> None: + pattern = r"[\w.-]+[.:]\S*" + adversarial_value = ("a." * 20_000) + " " + original_fullmatch = sxsd_validator.re.fullmatch + translated_pattern = sxsd_validator.python_pattern_for_xsd(pattern) + + def reject_unsafe_pattern(candidate: str, value: str): + if candidate == translated_pattern: + raise AssertionError("href domain pattern must not use re.fullmatch") + return original_fullmatch(candidate, value) + + with mock.patch.object(sxsd_validator.re, "fullmatch", side_effect=reject_unsafe_pattern): + self.assertFalse(sxsd_validator.xsd_pattern_matches(pattern, adversarial_value)) + + def test_href_domain_pattern_keeps_xsd_matching_behavior(self) -> None: + pattern = r"[\w.-]+[.:]\S*" + reference_pattern = sxsd_validator.re.compile( + sxsd_validator.python_pattern_for_xsd(pattern) + ) + + for length in range(5): + for characters in itertools.product("a.:-/ ©", repeat=length): + value = "".join(characters) + with self.subTest(value=value): + self.assertEqual( + sxsd_validator.xsd_pattern_matches(pattern, value), + reference_pattern.fullmatch(value) is not None, + ) + + def test_accepts_valid_shape_attributes(self) -> None: + issues = self.validate( + f""" + + + +

Valid

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_reports_missing_required_shape_attribute(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + issue = self.assert_issue( + issues, + "sxsd_missing_required_attr", + path="slide/data/shape", + attr="height", + ) + self.assertEqual(issue["expected"], "required attribute of type PositiveSize") + self.assertIsNone(issue["actual"]) + + def test_reports_invalid_scalar_value(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + issue = self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX") + self.assertEqual(issue["actual"], "NaN") + + def test_rejects_python_only_numeric_separator(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX") + + def test_accepts_xsd_double_lexical_forms(self) -> None: + for top_left_x in ("10", "-0.5", ".5", "1.", "1e2"): + with self.subTest(top_left_x=top_left_x): + issues = self.validate( + f""" + + + + + + """ + ) + + self.assertEqual(issues, []) + + def test_accepts_bullet_char_length_boundaries(self) -> None: + for bullet_char in ("A", "12345678"): + with self.subTest(bullet_char=bullet_char): + issues = self.validate( + f""" + + + +

Text

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_rejects_bullet_char_outside_length_boundaries(self) -> None: + for bullet_char in ("", "123456789"): + with self.subTest(bullet_char=bullet_char): + issues = self.validate( + f""" + + + +

Text

+
+
+
+ """ + ) + + self.assert_issue(issues, "sxsd_value_out_of_range", attr="bulletChar") + + def test_rejects_zero_size_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + self.assert_issue(issues, "sxsd_value_out_of_range", attr="width") + + def test_reports_negative_size_rejected_by_xsd(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + self.assert_issue(issues, "sxsd_value_out_of_range", attr="width") + + def test_rejects_shape_enum_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + issue = self.assert_issue(issues, "sxsd_invalid_enum", attr="type") + self.assertLess(len(str(issue["message"])), 300) + self.assertEqual(issue["actual"], "not-a-shape") + + def test_rejects_rotation_upper_bound_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + self.assert_issue(issues, "sxsd_value_out_of_range", attr="rotation") + + def test_rejects_fill_color_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + + + """ + ) + + self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color") + + def test_reports_missing_required_image_src(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + self.assert_issue(issues, "sxsd_missing_required_attr", attr="src") + + def test_accepts_inline_attribute_simple_type(self) -> None: + issues = self.validate( + f""" + + + +

Link

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_reports_inline_attribute_pattern_mismatch(self) -> None: + issues = self.validate( + f""" + + + +

Link

+
+
+
+ """ + ) + + self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href") + + def test_accepts_values_matching_inline_union_members(self) -> None: + for bullet_size in ("25%", "100%", "400%", "6", "14", "400"): + with self.subTest(bullet_size=bullet_size): + issues = self.validate( + f""" + + + +

Text

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_rejects_values_outside_inline_union_members(self) -> None: + for bullet_size in ("24%", "401%", "5", "401", "abc"): + with self.subTest(bullet_size=bullet_size): + issues = self.validate( + f""" + + + +

Text

+
+
+
+ """ + ) + + self.assert_issue(issues, "sxsd_pattern_mismatch", attr="bulletSize") + + def test_rejects_symbol_outside_python_word_semantics_in_href(self) -> None: + issues = self.validate( + f""" + + + +

Link

+
+
+
+ """ + ) + + self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href") + + def test_accepts_common_email_href_with_python_regex_semantics(self) -> None: + issues = self.validate( + f""" + + + +

Email

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_accepts_common_gradient_with_python_regex_semantics(self) -> None: + issues = self.validate( + f""" + + + + +

Gradient

+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_rejects_non_xsd_whitespace_in_color_pattern(self) -> None: + issues = self.validate( + f""" + + + + + """ + ) + + self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color") + +class SxsdSyntaxStructureTest(SxsdSyntaxTestCase): + def test_accepts_nested_content_in_referenced_rich_text_shadow(self) -> None: + issues = self.validate( + f""" + + + + +

Text

+
+
+
+
+ """ + ) + + self.assertEqual(issues, []) + + def test_keeps_shape_effect_shadow_as_childless_local_type(self) -> None: + issues = self.validate( + f""" + + + + Not rich text + + + + """ + ) + + self.assert_issue( + issues, + "sxsd_unexpected_child", + path="slide/data/shape/shadow/strong", + ) + + def test_accepts_standalone_slide_fragment_without_namespace(self) -> None: + issues = self.validate( + '' + '

Text

' + ) + + self.assertEqual(issues, []) + + def test_rejects_presentation_without_namespace(self) -> None: + issues = self.validate( + '' + ) + + self.assert_issue(issues, "sxsd_invalid_namespace", path="presentation") + + def test_rejects_wrong_namespace_that_violates_xsd(self) -> None: + issues = self.validate('') + + self.assert_issue(issues, "sxsd_invalid_namespace", path="slide") + + def test_rejects_descendant_outside_document_namespace(self) -> None: + issues = self.validate( + f""" + + + + + + """ + ) + + self.assert_issue(issues, "sxsd_invalid_namespace", path="slide/data") + + def test_rejects_unexpected_child_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + self.assert_issue(issues, "sxsd_unexpected_child", path="slide/shape") + + def test_rejects_child_order_that_violates_xsd(self) -> None: + issues = self.validate( + f""" + + + Late title + + """ + ) + + self.assert_issue(issues, "sxsd_invalid_child_order", path="presentation/title") + + def test_enforces_presentation_slide_minimum_from_xsd(self) -> None: + issues = self.validate( + f'' + ) + + self.assert_issue(issues, "sxsd_missing_required_child", path="presentation") + + def test_enforces_presentation_slide_maximum_from_xsd(self) -> None: + slides = "".join("" for _ in range(101)) + issues = self.validate( + f'{slides}' + ) + + self.assert_issue(issues, "sxsd_too_many_children", path="presentation/slide") + + def test_rejects_multiple_choice_children_that_violate_xsd(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + self.assert_issue(issues, "sxsd_too_many_children", path="slide/style/fill") + + def test_rejects_line_without_required_border_from_xsd(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/line") + + def test_reports_missing_required_chart_structure(self) -> None: + issues = self.validate( + f""" + + + + """ + ) + + self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/chart") + + def test_reports_missing_required_nested_sequence_child(self) -> None: + issues = self.validate( + f""" + +
+
+ """ + ) + + issue = self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/table/tr") + self.assertEqual(issue["expected"], "td (at least 1)") + + +class SxsdSchemaModelTest(unittest.TestCase): + def test_reports_unsupported_xsd_pattern_without_crashing(self) -> None: + schema = rf""" + + + + + + + + + + + + + + + + """ + with tempfile.TemporaryDirectory() as temp_dir: + schema_path = Path(temp_dir) / "schema.xsd" + schema_path.write_text(schema, encoding="utf-8") + try: + issues = sxsd_validator.validate_sxsd( + ET.fromstring(f''), + schema_path, + ) + except (ValueError, sxsd_validator.re.error) as error: + self.fail(f"SXSD pattern capability errors must be reported, not raised: {error}") + + self.assertEqual([issue["code"] for issue in issues], ["sxsd_unsupported_pattern"]) + self.assertEqual(issues[0]["attr"], "value") + self.assertIn("pattern interpreter", str(issues[0]["hint"]).lower()) + + def test_standalone_slide_uses_slide_type_without_global_element(self) -> None: + schema = f""" + + + + + + + + """ + with tempfile.TemporaryDirectory() as temp_dir: + schema_path = Path(temp_dir) / "schema.xsd" + schema_path.write_text(schema, encoding="utf-8") + issues = sxsd_validator.validate_sxsd( + ET.fromstring(f''), + schema_path, + ) + + self.assertEqual(issues, []) + + def test_standalone_slide_requires_slide_type_in_xsd(self) -> None: + schema = f""" + + + + + """ + with tempfile.TemporaryDirectory() as temp_dir: + schema_path = Path(temp_dir) / "schema.xsd" + schema_path.write_text(schema, encoding="utf-8") + issues = sxsd_validator.validate_sxsd( + ET.fromstring(f''), + schema_path, + ) + + self.assertEqual([issue["code"] for issue in issues], ["sxsd_unexpected_root"]) + + if __name__ == "__main__": unittest.main()