Files
Starlight_Lancher/.agents/skills/lark-slides/scripts/xml_text_overlap_lint.py

2483 lines
98 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Validate Slides XML structure and page layout through one release gate."""
from __future__ import annotations
import json
import math
import re
import sys
import unicodedata
import xml.parsers.expat as expat
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher, get_close_matches
from pathlib import Path
from typing import Any
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
XML_NS = "{http://www.w3.org/XML/1998/namespace}"
SVG_NS = "{http://www.w3.org/2000/svg}"
SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
SXSD_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
ICONPARK_INDEX_PATH = Path(__file__).resolve().parents[1] / "references" / "iconpark-index.json"
SXSD_TAG_ALIASES = {
"textbox": "<shape type=\"text\">",
"textBox": "<shape type=\"text\">",
"image": "<img>",
"picture": "<img>",
}
SXSD_ATTR_ALIASES = {
"x": "topLeftX",
"left": "topLeftX",
"y": "topLeftY",
"top": "topLeftY",
"w": "width",
"h": "height",
"fontColor": "color",
}
SERVER_FILLED_SXSD_ATTRS = {"id"}
ROUNDTRIP_SXSD_ATTRS = {
("chart", "updated"),
("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> 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"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
GHOST_TEXT_MIN_FONT_SIZE = 96
GHOST_TEXT_MAX_ALPHA = 0.5
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
class XmlLayoutLintError(Exception):
pass
def fail(message: str) -> None:
raise XmlLayoutLintError(message)
def read_file(file_path: str | Path) -> str:
return Path(file_path).read_text(encoding="utf-8")
def parse_args(argv: list[str]) -> dict[str, Any]:
options: dict[str, Any] = {}
index = 0
while index < len(argv):
token = argv[index]
if not token.startswith("--"):
fail(f"unexpected argument: {token}, need --input")
key = token[2:]
next_token = argv[index + 1] if index + 1 < len(argv) else None
if next_token is None or next_token.startswith("--"):
options[key] = True
index += 1
continue
options[key] = next_token
index += 2
return options
def extract_attribute(tag_source: str, name: str) -> str | None:
match = re.search(
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
)
if not match:
return None
return match.group(1) if match.group(1) is not None else match.group(2)
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
raw = extract_attribute(tag_source, name)
if raw is None:
return None
try:
value = float(raw)
except ValueError:
return None
return int(value) if value.is_integer() else value
def extract_bool_attribute(tag_source: str, name: str) -> bool:
value = extract_attribute(tag_source, name)
return value in {"true", "1", "yes"}
def extract_color_alpha(color: str | None) -> int | float | None:
if color is None:
return None
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return 0
rgba_match = re.fullmatch(
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
normalized,
)
if rgba_match is None:
return None
try:
alpha = float(rgba_match.group(1))
except ValueError:
return None
return int(alpha) if alpha.is_integer() else alpha
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
color_alpha = extract_color_alpha(text_color)
if not isinstance(color_alpha, (int, float)):
return base_alpha
return base_alpha * color_alpha
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
for tag_name in style_tags:
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
return True
return False
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
if extract_bool_attribute(attrs, attr_name):
return True
return False
def sum_sizes(sizes: list[int | float]) -> int | float:
return sum(sizes)
def is_filled_size(size: int | float | None) -> bool:
return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
if not sizes:
return sizes
final_sizes = [
size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
for index, size in enumerate(sizes)
]
remaining_size = target_size - sum_sizes(final_sizes[:-1])
if remaining_size >= 1:
final_sizes[-1] = remaining_size
return final_sizes
size_to_redistribute = 1 - remaining_size
for index in range(len(final_sizes) - 2, -1, -1):
reduction = min(final_sizes[index] - 1, size_to_redistribute)
final_sizes[index] -= reduction
size_to_redistribute -= reduction
if size_to_redistribute == 0:
final_sizes[-1] = 1
return final_sizes
final_sizes[-1] = 1
return final_sizes
def solve_weighted_min_layout(
input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
) -> dict[str, Any]:
filled_indexes: list[int] = []
empty_indexes: list[int] = []
base_sizes: list[int | float] = []
for index, size in enumerate(input_sizes):
if is_filled_size(size):
filled_indexes.append(index)
base_sizes.append(size)
else:
empty_indexes.append(index)
base_sizes.append(0)
filled_sum = sum_sizes(base_sizes)
if target_min_size is None:
final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
if not filled_indexes:
average_size = target_min_size / len(input_sizes)
final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
if empty_indexes:
remaining_size = target_min_size - filled_sum
final_sizes = [*base_sizes]
if remaining_size > 0:
average_size = remaining_size / len(empty_indexes)
empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
for index, empty_size in zip(empty_indexes, empty_sizes):
final_sizes[index] = empty_size
else:
for index in empty_indexes:
final_sizes[index] = default_size
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
ratio = max(1, target_min_size / filled_sum)
actual_size = max(target_min_size, filled_sum)
if ratio == 1:
return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
if preserve_line_breaks:
stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
stripped = re.sub(r"<[^>]+>", " ", stripped)
stripped = stripped.replace("&nbsp;", " ")
stripped = stripped.replace("&amp;", "&")
stripped = stripped.replace("&lt;", "<")
stripped = stripped.replace("&gt;", ">")
stripped = stripped.replace("&quot;", '"')
stripped = stripped.replace("&#39;", "'")
if preserve_line_breaks:
return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
return re.sub(r"\s+", " ", stripped).strip()
def strip_xml_paragraphs(value: str) -> str:
paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
if paragraphs:
return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
return strip_xml(value, preserve_line_breaks=True)
def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
paragraphs = []
for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
paragraphs.append(
{
"text": strip_xml(body, preserve_line_breaks=True),
"fontSize": extract_max_span_font_size(body, default_font_size),
"textAlign": extract_attribute(attrs, "textAlign"),
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
}
)
return paragraphs
def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
font_sizes = [
font_size
for attrs in re.findall(r"<span\b([^>]*)>", value)
if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
]
return max([default_font_size, *font_sizes])
def extract_tag_attributes(value: str, tag: str) -> str:
match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
return match.group(1) if match else ""
def xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
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
def load_iconpark_icon_types() -> set[str]:
global _ICONPARK_ICON_TYPES_CACHE
if _ICONPARK_ICON_TYPES_CACHE is not None:
return _ICONPARK_ICON_TYPES_CACHE
try:
index_data = json.loads(ICONPARK_INDEX_PATH.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
fail(f"invalid iconpark index JSON: {error}")
icons = index_data.get("icons")
if not isinstance(icons, list):
fail("iconpark index must contain an icons array")
icon_types = {
icon["iconType"]
for icon in icons
if isinstance(icon, dict) and isinstance(icon.get("iconType"), str) and icon["iconType"]
}
_ICONPARK_ICON_TYPES_CACHE = icon_types
return icon_types
def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
alias = SXSD_TAG_ALIASES.get(tag_name)
if alias:
return f"Use {alias} instead of <{tag_name}>."
if tag_name == "svg":
return 'Inside <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
close_matches = get_close_matches(tag_name, sorted(supported_tags), n=3, cutoff=0.72)
if close_matches:
return "Unsupported SXSD tag. Did you mean " + ", ".join(f"<{match}>" for match in close_matches) + "?"
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:
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) + "?"
allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
if len(allowed_attrs) > 8:
allowed_summary += ", ..."
return f"Unsupported SXSD attribute for <{tag_name}>. Allowed attributes include: {allowed_summary}."
def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
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]]:
tag_attributes = load_sxsd_tag_attributes()
supported_tags = set(tag_attributes)
issues: list[dict[str, Any]] = []
def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
if should_skip_sxsd_subtree(element, ancestors):
return
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:
return
if tag_name not in supported_tags:
issues.append(
{
"level": "error",
"code": "sxsd_unsupported_tag",
"tag": tag_name,
"path": current_path,
"message": f"unsupported SXSD tag <{tag_name}> at {current_path}",
"hint": build_sxsd_tag_hint(tag_name, supported_tags),
}
)
return
else:
allowed_attrs = tag_attributes[tag_name]
for raw_attr_name in element.attrib:
if raw_attr_name.startswith(XML_NS):
continue
attr_name = xml_local_name(raw_attr_name)
if should_skip_sxsd_attribute(tag_name, attr_name):
continue
if attr_name in allowed_attrs:
continue
issues.append(
{
"level": "error",
"code": "sxsd_unsupported_attr",
"tag": tag_name,
"attr": attr_name,
"path": current_path,
"message": f'unsupported SXSD attribute "{attr_name}" on <{tag_name}> at {current_path}',
"hint": build_sxsd_attr_hint(tag_name, attr_name, allowed_attrs),
}
)
for child in element:
visit(child, [*ancestors, tag_name], current_path)
visit(root, [], "")
return issues
def build_iconpark_icon_type_hint(icon_type: str, supported_icon_types: set[str]) -> str:
close_matches = get_close_matches(icon_type, sorted(supported_icon_types), n=3, cutoff=0.58)
if close_matches:
return (
"iconType must exist in iconpark-index.json. Did you mean "
+ ", ".join(f'"{match}"' for match in close_matches)
+ "?"
)
return "iconType must exist in iconpark-index.json. Use scripts/iconpark_tool.py to search supported icons."
def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
supported_icon_types: set[str] | None = None
issues: list[dict[str, Any]] = []
def direct_child(element: ET.Element, local_name: str) -> ET.Element | None:
return next((child for child in element if xml_local_name(child.tag) == local_name), None)
def is_transparent_color(color: str) -> bool:
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return True
rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([0-9.]+)\)", normalized)
if not rgba_match:
return False
try:
return float(rgba_match.group(1)) <= 0
except ValueError:
return False
def append_missing_fill_color_issue(current_path: str) -> None:
issues.append(
{
"level": "error",
"code": "icon_missing_fill_color",
"tag": "icon",
"path": current_path,
"message": f"<icon> must set explicit non-transparent fillColor for visual visibility at {current_path}",
"hint": 'Add <fill><fillColor color="rgba(R, G, B, 1)"/></fill> inside <icon>. This is a visual lint rule, not an SXSD required field.',
}
)
def visit(element: ET.Element, path: str) -> None:
nonlocal supported_icon_types
tag_name = xml_local_name(element.tag)
current_path = f"{path}/{tag_name}" if path else tag_name
if tag_name == "icon":
icon_type = element.attrib.get("iconType")
if icon_type is not None:
if supported_icon_types is None:
supported_icon_types = load_iconpark_icon_types()
if icon_type not in supported_icon_types:
issues.append(
{
"level": "error",
"code": "iconpark_unsupported_icon_type",
"tag": "icon",
"attr": "iconType",
"iconType": icon_type,
"path": current_path,
"message": f'unsupported iconpark iconType "{icon_type}" at {current_path}',
"hint": build_iconpark_icon_type_hint(icon_type, supported_icon_types),
}
)
fill = direct_child(element, "fill")
fill_color = direct_child(fill, "fillColor") if fill is not None else None
color = fill_color.attrib.get("color") if fill_color is not None else None
if not color:
append_missing_fill_color_issue(current_path)
elif is_transparent_color(color):
issues.append(
{
"level": "error",
"code": "icon_transparent_fill_color",
"tag": "icon",
"attr": "fillColor",
"path": current_path,
"color": color,
"message": f'<icon> fillColor must not be transparent for visual visibility at {current_path}: "{color}"',
"hint": 'Use an opaque visible color, for example <fillColor color="rgba(37, 99, 235, 1)"/>.',
}
)
for child in element:
visit(child, current_path)
visit(root, "")
return issues
def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
if line is None or column is None:
return None
lines = xml.splitlines()
if line < 1 or line > len(lines):
return None
source_line = lines[line - 1]
start = max(column - radius, 0)
end = min(column + radius, len(source_line))
return source_line[start:end].strip()
def build_xml_error_issue(error: ET.ParseError, xml: str) -> dict[str, Any]:
line, column = getattr(error, "position", (None, None))
return {
"level": "error",
"code": "xml_not_well_formed",
"message": f"XML is not well-formed: {error}",
"line": line,
"column": column,
"context": extract_error_context(xml, line, column),
"hint": (
"Escape raw user text before placing it in XML. In text nodes and attribute values, bare & must be "
"written as &amp;. In text nodes, write < as &lt; and > as &gt;. For attribute URLs, use a=1&amp;b=2."
),
}
def validate_sml_tag_prefixes(xml: str) -> list[dict[str, Any]]:
namespace_map: dict[str, str] = {}
pending_declarations: list[tuple[str, str | None]] = []
declarations_by_element: list[list[tuple[str, str | None]]] = []
element_stack: list[str] = []
issues: list[dict[str, Any]] = []
parser = expat.ParserCreate(namespace_separator="|")
parser.namespace_prefixes = True
def handle_namespace_decl(prefix: str | None, namespace: str) -> None:
normalized_prefix = prefix or ""
previous_namespace = namespace_map.get(normalized_prefix)
namespace_map[normalized_prefix] = namespace
pending_declarations.append((normalized_prefix, previous_namespace))
def handle_start_element(name: str, _attrs: dict[str, str]) -> None:
declarations_by_element.append(pending_declarations.copy())
pending_declarations.clear()
name_parts = name.rsplit("|", 2)
if len(name_parts) == 3:
_namespace, local_name, prefix = name_parts
element_name = f"{prefix}:{local_name}"
else:
prefix = ""
local_name = name_parts[-1]
element_name = local_name
element_stack.append(element_name)
if not prefix:
return
if namespace_map.get(prefix) != SML_NAMESPACE:
return
path = "/".join(element_stack)
issues.append(
{
"level": "error",
"code": "sml_prefixed_tag",
"tag": element_name,
"namespace": SML_NAMESPACE,
"path": path,
"line": parser.CurrentLineNumber,
"column": parser.CurrentColumnNumber,
"message": f"SML tag <{element_name}> must not use a namespace prefix at {path}",
"hint": (
f'Use <{local_name}> under the default namespace '
f'<{local_name} xmlns="{SML_NAMESPACE}">, or use an unprefixed SML tag.'
),
}
)
def handle_end_element(_name: str) -> None:
for prefix, previous_namespace in reversed(declarations_by_element.pop()):
if previous_namespace is None:
namespace_map.pop(prefix, None)
else:
namespace_map[prefix] = previous_namespace
element_stack.pop()
parser.StartNamespaceDeclHandler = handle_namespace_decl
parser.StartElementHandler = handle_start_element
parser.EndElementHandler = handle_end_element
parser.Parse(xml, True)
return issues
def parse_xml_root(xml: str) -> tuple[ET.Element | None, dict[str, Any] | None]:
try:
root = ET.fromstring(xml)
except ET.ParseError as error:
return None, build_xml_error_issue(error, xml)
root_name = xml_local_name(root.tag)
if root_name not in {"presentation", "slide"}:
fail("input must contain a <presentation> or <slide> root")
return root, None
def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
_, xml_error = parse_xml_root(xml)
return xml_error
def parse_presentation(xml: str) -> dict[str, Any]:
presentation_match = re.search(r"<presentation\b([^>]*)>", 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"<slide\b[\s\S]*?</slide>", xml),
}
slide_match = re.findall(r"<slide\b[\s\S]*?</slide>", xml)
if slide_match:
return {"width": 960, "height": 540, "slides": slide_match}
fail("input must contain a <presentation> or <slide> root")
def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
kind, attrs = match.group(1), match.group(2)
is_self_closing = attrs.rstrip().endswith("/")
content = ""
if kind in {"shape", "table"} and not is_self_closing:
close_index = slide_xml.find(f"</{kind}>", match.end())
if close_index != -1:
content = slide_xml[match.end() : close_index]
element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
rotation = extract_numeric_attribute(attrs, "rotation") or 0
alpha = extract_numeric_attribute(attrs, "alpha")
table_layouts: dict[str, dict[str, Any] | None] = {}
if kind == "table":
width, table_layouts["width"] = resolve_table_dimension(
content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
)
height, table_layouts["height"] = resolve_table_dimension(
content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
)
if all(value is not None for value in [x, y, width, height]):
element = {
"id": element_id,
"kind": kind,
"type": extract_attribute(attrs, "type") or kind,
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": rotation,
"alpha": alpha if alpha is not None else 1,
"order": len(elements),
}
if kind == "table":
element.update(
{
"declared_width": extract_numeric_attribute(attrs, "width"),
"declared_height": extract_numeric_attribute(attrs, "height"),
"table_layouts": table_layouts,
}
)
if kind == "shape":
content_attrs = extract_tag_attributes(content, "content")
font_size = extract_numeric_attribute(content_attrs, "fontSize")
if font_size is None:
font_size = extract_numeric_attribute(attrs, "fontSize")
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
bold = (
extract_bool_attribute(content_attrs, "bold")
or extract_bool_attribute(attrs, "bold")
or detect_inline_style_presence(content, {"strong", "b"})
or detect_any_span_bool_attribute(content, "bold")
)
italic = (
extract_bool_attribute(content_attrs, "italic")
or extract_bool_attribute(attrs, "italic")
or detect_inline_style_presence(content, {"i", "em"})
or detect_any_span_bool_attribute(content, "italic")
)
element.update(
{
"textType": extract_attribute(content_attrs, "textType"),
"textAlign": extract_attribute(content_attrs, "textAlign"),
"verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
"vert": extract_attribute(attrs, "vert") or "horz",
"autoFit": extract_attribute(content_attrs, "autoFit"),
"wrap": extract_attribute(content_attrs, "wrap"),
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
"fontSize": font_size if font_size is not None else 16,
"fontFamily": font_family or "",
"color": text_color,
"textAlpha": effective_text_alpha(alpha, text_color),
"bold": bold,
"italic": italic,
"text": strip_xml_paragraphs(content),
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
}
)
elements.append(element)
return elements
def intersects(left: dict[str, Any], right: dict[str, Any]) -> bool:
return (
left["x"] < right["x"] + right["width"]
and left["x"] + left["width"] > right["x"]
and left["y"] < right["y"] + right["height"]
and left["y"] + left["height"] > right["y"]
)
def is_text_element(element: dict[str, Any]) -> bool:
return element["kind"] == "shape" and element["type"] == "text"
def is_whiteboard_element(element: dict[str, Any]) -> bool:
return element["kind"] == "whiteboard"
def has_text_content(element: dict[str, Any]) -> bool:
return bool(element.get("text"))
def is_vertical_text(element: dict[str, Any]) -> bool:
return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
text_elements = [
element
for element in elements
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
]
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
for text_element in text_elements:
for image_element in image_elements:
if image_element["order"] <= text_element["order"]:
continue
if is_vertical_text(text_element):
if intersects(image_element, text_element):
issues.append({
"level": "info",
"code": "image_may_cover_vertical_text",
"elements": [image_element["id"], text_element["id"]],
"message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
"hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
})
continue
text_visual_bbox = estimate_text_visual_bbox(text_element)
if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
issues.append({
"level": "error",
"code": "image_covers_text",
"elements": [image_element["id"], text_element["id"]],
"message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
"hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
})
return issues
def is_decorative_text(element: dict[str, Any]) -> bool:
text = element.get("text") or ""
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
def normalize_text_for_overlap(text: str) -> str:
return re.sub(r"\s+", "", text)
SERIF_FONT_PATTERNS = {
"song", "songti", "simsun", "ming", "mincho",
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
"source han serif", "思源宋体", "宋体", "明体",
}
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
def classify_font_family(font_family: str | None) -> str:
if not font_family:
return "sans"
family_lower = font_family.lower()
for marker in SANS_EXPLICIT_MARKERS:
if marker in family_lower:
return "sans"
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
for pattern in serif_keywords:
if pattern in family_lower:
return "serif"
return "sans"
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
}
def estimate_character_width(
character: str,
font_size: int | float,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
bold_multiplier = 1.05 if bold else 1.0
if character.isspace():
return font_size * 0.33 * bold_multiplier
ea_width = unicodedata.east_asian_width(character)
if ea_width in {"F", "W"}:
return font_size * bold_multiplier
category = classify_font_family(font_family)
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
if character.isupper():
return font_size * coeffs["upper"] * bold_multiplier
if character.islower():
return font_size * coeffs["lower"] * bold_multiplier
if character.isdigit():
return font_size * coeffs["digit"] * bold_multiplier
return font_size * coeffs["punct"] * bold_multiplier
def estimate_text_width(
text: str,
font_size: int | float,
letter_spacing: int | float = 0,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
return base + max(len(text) - 1, 0) * letter_spacing
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
if paragraph is not None:
value = paragraph.get("letterSpacing")
if isinstance(value, (int, float)):
return value
value = element.get("letterSpacing")
return value if isinstance(value, (int, float)) else 0
def text_wrap_width_tolerance() -> int | float:
return TEXT_WRAP_WIDTH_TOLERANCE_PX
def text_height_overflow_tolerance() -> int | float:
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
def is_short_metric_text(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
return False
if re.fullmatch(r"[+\-–—]?[0-9,.]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
return True
if re.search(r"[,.+\-–—/%]", compact) is None:
return False
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,./%\-–—\u4e00-\u9fff]+", compact) is not None
def is_single_line_visual_candidate(
element: dict[str, Any],
paragraph: dict[str, Any] | None,
text: str,
logical_width: int | float,
effective_width: int | float,
) -> bool:
if "\n" in text or logical_width <= effective_width:
return False
if is_short_metric_text(text):
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
compact_len = len(re.sub(r"\s+", "", text))
if text_align == "center" and compact_len <= 32:
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
return False
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element)
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
return max(
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
or [1]
)
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
left_text = normalize_text_for_overlap(left.get("text") or "")
right_text = normalize_text_for_overlap(right.get("text") or "")
if not left_text or not right_text:
return False
if left_text == right_text or left_text in right_text or right_text in left_text:
return True
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_line_count_for_text(
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element, paragraph)
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
hard_lines = text.split("\n")
if not text:
return 0
line_count = 0
for hard_line in hard_lines:
if element.get("wrap") in {"false", "0"}:
line_count += 1
continue
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
effective_width = available_width + text_wrap_width_tolerance()
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
line_count += 1
continue
line_count += max(1, math.ceil(logical_width / effective_width))
return line_count
def estimate_text_line_count(element: dict[str, Any]) -> int:
return max(estimate_text_line_count_for_text(element, element["text"]), 1)
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if line_spacing is None:
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return None
spacing_type, value = match.groups()
return font_size * float(value) if spacing_type == "multiple" else float(value)
def adjust_dense_body_line_height(
element: dict[str, Any],
line_spacing: str | None,
line_height: int | float,
paragraph_count: int,
) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if paragraph_count < 4 or font_size > 14 or not line_spacing:
return line_height
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return line_height
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if not is_text_element(element) or not has_text_content(element):
continue
if has_explicit_height_auto_fit(element):
continue
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
paragraphs = element.get("paragraphs") or [
{
"text": element["text"],
"lineSpacing": None,
"beforeLineSpacing": None,
"afterLineSpacing": None,
}
]
line_count = 0
estimated_height = 0.0
line_heights: list[int | float] = []
for paragraph in paragraphs:
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
if paragraph_line_count == 0:
continue
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
line_height = estimate_text_line_height(element, resolved_line_spacing)
before_spacing = estimate_text_line_height(
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
)
after_spacing = estimate_text_line_height(
element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
)
if line_height is None or before_spacing is None or after_spacing is None:
line_count = 0
break
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
first_line_height = font_size if line_count == 0 else line_height
line_count += paragraph_line_count
line_heights.append(line_height)
estimated_height += (
before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
)
if line_count == 0:
continue
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
overflow = estimated_height - available_height
if overflow <= text_height_overflow_tolerance():
continue
is_background = is_background_decorative_text(element, elements)
if is_background:
level = "info"
else:
level = "error" if overflow > 10 else "warning"
message = (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
)
if is_background:
message += " (likely background decoration: large font, low alpha, underneath other text)"
issues.append(
{
"level": level,
"code": "text_may_overflow_shape",
"elements": [element["id"]],
"line_count": line_count,
"line_height": max(line_heights),
"estimated_height": estimated_height,
"available_height": available_height,
"overflow": overflow,
"message": message,
"hint": (
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
"autoFit=\"normal-auto-fit\". "
"This is an estimate based on font size, line spacing, and wrapped line count."
),
}
)
return issues
def is_background_decorative_text(
element: dict[str, Any], elements: list[dict[str, Any]]
) -> bool:
if not is_ghost_text(element):
return False
for other in elements:
if other is element:
continue
if not is_text_element(other) or not has_text_content(other):
continue
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
continue
if other["order"] <= element["order"]:
continue
if intersects(element, other):
return True
return False
def is_ghost_text(element: dict[str, Any]) -> bool:
if not is_text_element(element) or not has_text_content(element):
return False
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
text_alpha = element.get("textAlpha", element.get("alpha", 1))
if not isinstance(text_alpha, (int, float)):
return False
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
return True
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
padding_left = element.get("paddingLeft", 0)
padding_right = element.get("paddingRight", 0)
padding_top = element.get("paddingTop", 0)
padding_bottom = element.get("paddingBottom", 0)
content_width = max(element["width"] - padding_left - padding_right, 0)
content_height = max(element["height"] - padding_top - padding_bottom, 0)
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
line_count = estimate_text_line_count(element)
estimated_width = max(1, estimate_text_max_line_width(element))
visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
visual_height = min(content_height, max(1, line_count * font_size * 1.2))
x = element["x"] + padding_left
if element.get("textAlign") == "center":
x += (content_width - visual_width) / 2
elif element.get("textAlign") == "right":
x += content_width - visual_width
y = element["y"] + padding_top
if element.get("verticalAlign") == "middle":
y += (content_height - visual_height) / 2
elif element.get("verticalAlign") == "bottom":
y += content_height - visual_height
return {
"x": x,
"y": y,
"width": visual_width,
"height": visual_height,
}
def intersection_area(left: dict[str, Any], right: dict[str, Any]) -> int | float:
width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
if width <= 0 or height <= 0:
return 0
return width * height
def intersection_height(left: dict[str, Any], right: dict[str, Any]) -> int | float:
height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
return max(height, 0)
def intersection_width(left: dict[str, Any], right: dict[str, Any]) -> int | float:
width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
return max(width, 0)
def element_area(element: dict[str, Any]) -> int | float:
return max(element["width"], 0) * max(element["height"], 0)
def contains(outer: dict[str, Any], inner: dict[str, Any], tolerance: int | float = 2) -> bool:
return (
inner["x"] >= outer["x"] - tolerance
and inner["y"] >= outer["y"] - tolerance
and inner["x"] + inner["width"] <= outer["x"] + outer["width"] + tolerance
and inner["y"] + inner["height"] <= outer["y"] + outer["height"] + tolerance
)
def is_bottom_layer_full_slide_whiteboard(
whiteboard: dict[str, Any], other: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
return (
whiteboard["order"] < other["order"]
and whiteboard["x"] <= 2
and whiteboard["y"] <= 2
and whiteboard["width"] >= slide_width - 4
and whiteboard["height"] >= slide_height - 4
)
def is_background_container_for_whiteboard(container: dict[str, Any], whiteboard: dict[str, Any]) -> bool:
if container["order"] > whiteboard["order"]:
return False
if is_text_element(container):
return False
return contains(container, whiteboard)
def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
if not (is_text_element(left) and is_text_element(right)):
return False
if not (has_text_content(left) and has_text_content(right)):
return True
top, bottom = sorted([left, right], key=lambda element: element["y"])
top_type = top.get("textType")
bottom_type = bottom.get("textType")
allowed_pairs = {
("title", "sub-headline"),
("title", None),
("headline", "headline"),
("headline", None),
}
if (top_type, bottom_type) not in allowed_pairs:
return False
same_column = abs(top["x"] - bottom["x"]) <= 4
vertical_offset = bottom["y"] - top["y"]
top_font_size = float(top.get("fontSize", 16))
return same_column and vertical_offset >= top_font_size * 0.75
def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str, Any]) -> bool:
if not (is_text_element(left) and is_text_element(right)):
return False
if not (has_text_content(left) and has_text_content(right)):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
return False
source, target = sorted([left, right], key=lambda element: element["x"])
if source["x"] == target["x"]:
return False
wrap_enabled = source.get("wrap") not in {"false", "0"}
has_horizontal_gap = source["x"] + source["width"] <= target["x"]
if wrap_enabled and has_horizontal_gap:
return False
if source.get("autoFit") == "normal-auto-fit":
return False
if source.get("textAlign") in {"center", "right"}:
return False
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
padding_left = source.get("paddingLeft", 0)
padding_right = source.get("paddingRight", 0)
available_width = max(source["width"] - padding_left - padding_right, 1)
visual_width = estimate_text_max_line_width(source)
overflow_width = visual_width - available_width
min_overflow = max(font_size * 1.5, available_width * 0.08)
if overflow_width < min_overflow:
return False
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
if intrusion_width < min_intrusion:
return False
vertical_overlap = intersection_height(source, target)
min_vertical_overlap = min(source["height"], target["height"]) * 0.40
return vertical_overlap >= min_vertical_overlap
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
source, target = sorted([left, right], key=lambda element: element["x"])
padding_left = source.get("paddingLeft", 0)
visual_width = estimate_text_max_line_width(source)
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
width = intersection_width(source_visual_bbox, target)
height = intersection_height(source_visual_bbox, target)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
if is_text_element(left) and not has_text_content(left):
return False
if is_text_element(right) and not has_text_content(right):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right):
return False
if is_text_element(left) and is_text_element(right):
if is_similar_text_overlay(left, right):
return False
left_visual = estimate_text_visual_bbox(left)
right_visual = estimate_text_visual_bbox(right)
if left_visual is None or right_visual is None:
return False
overlap_area = intersection_area(left_visual, right_visual)
if overlap_area <= 0:
return False
smaller_area = min(
left_visual["width"] * left_visual["height"],
right_visual["width"] * right_visual["height"],
)
return smaller_area > 0 and overlap_area / smaller_area >= 0.30
return False
def build_whiteboard_external_overlap_issue(
whiteboard: dict[str, Any], overlap_details: list[dict[str, Any]]
) -> dict[str, Any]:
element_ids = [detail["element"] for detail in overlap_details]
return {
"level": "warning",
"code": "whiteboard_external_overlap",
"elements": [whiteboard["id"], *element_ids],
"message": f'whiteboard {whiteboard["id"]} overlaps {len(element_ids)} sibling elements across its boundary',
"hint": (
"Treat this as a static whiteboard container-bbox risk, not final visual proof. "
"After moving or accepting the overlap, use screenshot QA or equivalent rendered visual inspection as "
"the final authority because XML readback does not include whiteboard SVG/Mermaid internals."
),
"overlaps": overlap_details,
}
def should_report_whiteboard_overlap(
whiteboard: dict[str, Any],
other: dict[str, Any],
slide_width: int | float,
slide_height: int | float,
) -> dict[str, Any] | None:
if other is whiteboard or not intersects(whiteboard, other):
return None
if is_ghost_text(other):
return None
if contains(whiteboard, other):
return None
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
return None
if is_background_container_for_whiteboard(other, whiteboard):
return None
overlap_width = intersection_width(whiteboard, other)
overlap_height = intersection_height(whiteboard, other)
if overlap_width < 8 or overlap_height < 8:
return None
other_area = element_area(other)
if other_area <= 0:
return None
overlap_area = overlap_width * overlap_height
overlap_ratio = overlap_area / other_area
if overlap_ratio < 0.15:
return None
return {
"element": other["id"],
"kind": other["kind"],
"type": other.get("type"),
"overlap_width": overlap_width,
"overlap_height": overlap_height,
"target_overlap_ratio": round(overlap_ratio, 3),
}
def prune_contained_text_overlap_details(
overlap_details: list[dict[str, Any]], elements_by_id: dict[str, dict[str, Any]]
) -> list[dict[str, Any]]:
pruned: list[dict[str, Any]] = []
for detail in overlap_details:
element = elements_by_id[detail["element"]]
if is_text_element(element):
has_reported_container = any(
detail["element"] != other_detail["element"]
and not is_text_element(elements_by_id[other_detail["element"]])
and contains(elements_by_id[other_detail["element"]], element)
for other_detail in overlap_details
)
if has_reported_container:
continue
pruned.append(detail)
return pruned
def detect_whiteboard_external_overlaps(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
elements_by_id = {element["id"]: element for element in elements}
for whiteboard in [element for element in elements if is_whiteboard_element(element)]:
overlap_details = [
detail
for element in elements
if (
detail := should_report_whiteboard_overlap(
whiteboard,
element,
slide_width,
slide_height,
)
)
is not None
]
overlap_details = prune_contained_text_overlap_details(overlap_details, elements_by_id)
if overlap_details:
issues.append(build_whiteboard_external_overlap_issue(whiteboard, overlap_details))
return issues
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
rotation %= 360
if math.isclose(rotation, 0, abs_tol=1e-9):
return bbox
radians = math.radians(rotation)
sine = abs(math.sin(radians))
cosine = abs(math.cos(radians))
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
rotated_width = element["width"] * cosine + element["height"] * sine
rotated_height = element["width"] * sine + element["height"] * cosine
return {
"x": element["x"] - (rotated_width - element["width"]) / 2,
"y": element["y"] - (rotated_height - element["height"]) / 2,
"width": rotated_width,
"height": rotated_height,
}
def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
):
if is_ghost_text(element):
continue
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
"top": max(-bbox["y"], 0),
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
}
overflow_details = [
f"{side} by {amount:g}px"
for side, amount in overflow.items()
if amount > CANVAS_OVERFLOW_TOLERANCE
]
if not overflow_details:
continue
issues.append(
{
"level": "error",
"code": f'{element["kind"]}_out_of_canvas',
"elements": [element["id"]],
"canvas": {"width": slide_width, "height": slide_height},
"bbox": bbox,
"overflow": overflow,
"message": (
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
f'({", ".join(overflow_details)})'
),
"hint": (
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
"slides."
if element["kind"] == "table"
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
),
}
)
return issues
def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
sizes: list[int | float | None] = []
for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
attrs = match.group(1)
span = extract_numeric_attribute(attrs, "span") or 1
span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
return sizes
def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
def resolve_table_dimension(
table_xml: str,
declared_size: int | float | None,
extract_sizes: Any,
default_size: int | float,
) -> tuple[int | float | None, dict[str, Any] | None]:
input_sizes = extract_sizes(table_xml)
if not input_sizes:
return declared_size, None
layout = solve_weighted_min_layout(
input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
)
return layout["actual_size"], layout
def format_size(size: int | float) -> str:
return f"{size:g}"
def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
dimensions = {
"width": ("col", "column widths"),
"height": ("tr", "row heights"),
}
for table in (element for element in elements if element["kind"] == "table"):
for dimension, (child_tag, child_description) in dimensions.items():
target_size = table[f"declared_{dimension}"]
if not is_filled_size(target_size):
continue
layout = table["table_layouts"][dimension]
if layout is None:
continue
actual_size = layout["actual_size"]
if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
continue
issues.append(
{
"level": "info",
"code": "table_resolved_size_mismatch",
"elements": [table["id"]],
"dimension": dimension,
"declared_size": target_size,
"resolved_size": actual_size,
"resolved_sizes": layout["final_sizes"],
"message": (
f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
f"{child_description} resolve to {format_size(actual_size)}px"
),
"hint": (
f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
f"so their resolved total matches {format_size(target_size)}px."
),
}
)
return issues
def lint_slide(
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
) -> dict[str, Any]:
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = [
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
*detect_table_layout_size_mismatches(elements),
*detect_text_may_overflow_shapes(elements),
*detect_image_text_occlusions(elements),
]
for index, left in enumerate(elements):
for right in elements[index + 1 :]:
horizontal_overflow = should_flag_horizontal_text_overflow(left, right)
if not horizontal_overflow and (not intersects(left, right) or not should_flag_overlap(left, right)):
continue
issues.append(
{
"level": "error",
"code": "bbox_overlap",
"elements": [left["id"], right["id"]],
"message": f'{left["id"]} overlaps {right["id"]}',
"hint": "Move or resize the elements so their visual bounds no longer intersect.",
**(
{"measurement": horizontal_text_overflow_measurement(left, right)}
if horizontal_overflow
else {}
),
}
)
return {
"slide_number": slide_number,
"element_count": len(elements),
"elements": elements,
"issues": issues,
}
MIN_CONTAINER_WIDTH = 140
MIN_CONTAINER_HEIGHT = 160
MIN_SHORT_CARD_HEIGHT = 80
MIN_CONTAINER_AREA = 20_000
MIN_CONTENT_COVERAGE_RATIO = 0.15
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
MIN_SIMILAR_SHORT_CARD_COUNT = 2
LARGE_VISUAL_CHILD_RATIO = 0.35
LAYOUT_PANEL_SPAN_RATIO = 0.90
IMAGE_OVERLAY_MATCH_RATIO = 0.90
DENSITY_CONTAINMENT_TOLERANCE = 8
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
left = max(element["x"], container["x"])
top = max(element["y"], container["y"])
right = min(element["x"] + element["width"], container["x"] + container["width"])
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
if right <= left or bottom <= top:
return None
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
area = 0
for left, right in zip(x_coordinates, x_coordinates[1:]):
intervals = sorted(
(rect["y"], rect["y"] + rect["height"])
for rect in rectangles
if rect["x"] < right and rect["x"] + rect["width"] > left
)
covered_height = 0
interval_end: int | float | None = None
for top, bottom in intervals:
if interval_end is None:
covered_height += bottom - top
interval_end = bottom
elif bottom > interval_end:
covered_height += bottom - max(top, interval_end)
interval_end = bottom
area += (right - left) * covered_height
return area
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
return sum(
other is not element
and is_visually_rendered(other)
and other["kind"] == "shape"
and other["type"] == "rect"
and other["width"] >= MIN_CONTAINER_WIDTH
and other["height"] >= MIN_SHORT_CARD_HEIGHT
and element_area(other) >= MIN_CONTAINER_AREA
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
for other in elements
) >= MIN_SIMILAR_SHORT_CARD_COUNT
def is_layout_container(
element: dict[str, Any],
slide_width: int | float,
slide_height: int | float,
elements: list[dict[str, Any]] | None = None,
) -> bool:
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
elements is not None
and element["height"] >= MIN_SHORT_CARD_HEIGHT
and has_similar_short_card_peer(element, elements)
)
return (
element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and element["width"] >= MIN_CONTAINER_WIDTH
and has_supported_height
and element_area(element) >= MIN_CONTAINER_AREA
and not (
element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_width - 4
and element["height"] >= slide_height - 4
)
)
def is_edge_spanning_layout_panel(
element: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
)
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
container_area = element_area(container)
return any(
element["kind"] == "img"
and is_visually_rendered(element)
and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
for element in elements
)
def is_nested_in_layout_panel(
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> bool:
return any(
element is not container
and element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
for element in elements
)
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
elements = extract_elements(slide_xml)
elements_by_id = {element["id"]: element for element in elements}
root = ET.fromstring(slide_xml)
for node in root.iter():
if xml_local_name(node.tag) != "shape":
continue
element = elements_by_id.get(node.attrib.get("id", ""))
if element is None:
continue
content_node = next(
(child for child in node if xml_local_name(child.tag) == "content"),
None,
)
paragraphs = (
[
" ".join("".join(paragraph.itertext()).split())
for paragraph in content_node.iter()
if xml_local_name(paragraph.tag) == "p"
]
if content_node is not None
else []
)
raw_font_size = (
content_node.attrib.get("fontSize") if content_node is not None else None
) or node.attrib.get("fontSize")
try:
base_font_size = float(raw_font_size or 16)
except ValueError:
base_font_size = 16.0
element.update(
{
"textType": content_node.attrib.get("textType") if content_node is not None else None,
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
"fontSize": base_font_size,
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
}
)
if not has_text_content(element):
continue
declared_font_sizes = []
for descendant in node.iter():
raw_declared_font_size = descendant.attrib.get("fontSize")
if raw_declared_font_size is None:
continue
try:
declared_font_sizes.append(float(raw_declared_font_size))
except ValueError:
continue
if declared_font_sizes:
element["fontSize"] = max(declared_font_sizes)
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
icon_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
"kind": "icon",
"type": "icon",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": icon_alpha if icon_alpha is not None else 1,
"order": len(elements),
}
)
for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
attrs = match.group(1)
x = extract_numeric_attribute(attrs, "topLeftX")
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
if any(value is None for value in (x, y, width, height)):
continue
polyline_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
"kind": "polyline",
"type": "polyline",
"x": x,
"y": y,
"width": width,
"height": height,
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
"alpha": polyline_alpha if polyline_alpha is not None else 1,
"order": len(elements),
}
)
for line_element in extract_line_elements(slide_xml):
line_element["order"] = len(elements)
elements.append(line_element)
return elements
def is_visually_rendered(element: dict[str, Any]) -> bool:
return element.get("alpha", 1) > 0
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, container) if estimated else None
return clipped_bbox(element, container)
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
if container["kind"] != "shape" or not has_text_content(container):
return None
text_proxy = {**container, "type": "text"}
estimated = estimate_text_visual_bbox(text_proxy)
return clipped_bbox(estimated, container) if estimated else None
def slide_content_visual_bbox(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
if is_text_element(element):
estimated = estimate_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "shape" and has_text_content(element):
estimated = own_text_visual_bbox(element)
return clipped_bbox(estimated, slide_bbox) if estimated else None
if element["kind"] == "line":
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
return clipped_bbox(element, slide_bbox)
return None
def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
def is_slide_content_present(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> bool:
# Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
# *anything* rendered here", not the richer "counts toward meaningful content density" bar
# that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
# decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
# count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
# instead of maintaining an allow-list that silently treats unlisted kinds as blank.
if not is_visually_rendered(element):
return False
if (
element["kind"] == "shape"
and element["type"] == "rect"
and not has_text_content(element)
and element["x"] <= 2
and element["y"] <= 2
and element["width"] >= slide_bbox["width"] - 4
and element["height"] >= slide_bbox["height"] - 4
):
# A full-canvas plain rect is a background panel, not content -- same reasoning as
# is_layout_container's existing background exclusion. A slide with nothing else on it
# is still effectively blank.
return False
bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
return clipped_bbox(bbox, slide_bbox) is not None
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
return False
if not is_visually_rendered(element):
return False
return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
def detect_sparse_container_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for container in (
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
):
if (
is_edge_spanning_layout_panel(container, slide_width, slide_height)
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
or has_matching_image_overlay(container, elements)
):
continue
children = [
element
for element in elements
if element is not container
and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
]
if any(is_large_visual_child(child, container) for child in children):
continue
own_text_bbox = own_text_visual_bbox(container)
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
]
content_area = rectangle_union_area(rectangles) if rectangles else 0
coverage_ratio = content_area / element_area(container)
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
continue
issues.append(
{
"level": "warning",
"code": "sparse_container_content",
"target": {
"slide_number": slide_number,
"container_id": container["id"],
"container_type": container["type"],
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
},
"rule": {
"name": "large_container_visible_content_coverage",
"threshold": MIN_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"container_area": element_area(container),
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(children) + (1 if own_text_bbox else 0),
},
"elements": [container["id"], *[child["id"] for child in children]],
}
)
return issues
def detect_sparse_slide_content(
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
content = [
(element, bbox)
for element in elements
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
]
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
return []
content_area = rectangle_union_area([bbox for _, bbox in content])
slide_area = slide_width * slide_height
coverage_ratio = content_area / slide_area
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
return []
return [
{
"level": "warning",
"code": "sparse_slide_content",
"target": {
"slide_number": slide_number,
"bbox": slide_bbox,
},
"rule": {
"name": "slide_visible_content_coverage",
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
"comparison": "content_coverage_ratio < threshold",
},
"measurement": {
"slide_area": slide_area,
"visible_content_area": round(content_area, 3),
"content_coverage_ratio": round(coverage_ratio, 3),
"content_element_count": len(content),
},
"elements": [element["id"] for element, _ in content],
}
]
def detect_blank_slide(
elements: list[dict[str, Any]],
slide_number: int,
slide_width: int | float,
slide_height: int | float,
) -> list[dict[str, Any]]:
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
visible_elements = [
element for element in elements if is_slide_content_present(element, slide_bbox)
]
if visible_elements:
return []
return [
{
"level": "error",
"code": "blank_slide",
"schema_version": "2.0",
"target": {"slide_number": slide_number},
"rule": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count == 0",
},
"measurement": {
"visible_element_count": 0,
"declared_element_count": len(elements),
},
"elements": [element["id"] for element in elements],
"message": "slide has no visible content beyond empty layout shapes",
"hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
}
]
RULE_METADATA: dict[str, dict[str, Any]] = {
"xml_not_well_formed": {
"name": "xml_is_well_formed",
"comparison": "xml_parse_error == false",
},
"sml_prefixed_tag": {
"name": "sml_uses_default_namespace",
"comparison": "prefixed_sml_tag_count == 0",
},
"sxsd_unsupported_tag": {
"name": "tag_is_supported_by_slides_xml_schema",
"comparison": "unsupported_tag_count == 0",
},
"sxsd_unsupported_attr": {
"name": "attribute_is_supported_by_slides_xml_schema",
"comparison": "unsupported_attribute_count == 0",
},
"icon_missing_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_color_present == true",
},
"icon_transparent_fill_color": {
"name": "icon_has_visible_fill_color",
"comparison": "fill_alpha > 0",
},
"iconpark_unsupported_icon_type": {
"name": "iconpark_type_is_supported",
"comparison": "icon_type in iconpark_index",
},
"bbox_overlap": {
"name": "text_visual_bounds_do_not_overlap",
"comparison": "intersection_area == 0",
},
"text_may_overflow_shape": {
"name": "estimated_text_fits_declared_shape",
"comparison": "estimated_height <= available_height",
},
"whiteboard_external_overlap": {
"name": "whiteboard_does_not_cross_sibling_content",
"comparison": "external_overlap_count == 0",
},
"image_covers_text": {
"name": "image_does_not_cover_text",
"comparison": "intersection_area == 0",
},
"image_may_cover_vertical_text": {
"name": "image_vertical_text_occlusion_requires_review",
"comparison": "intersection_area == 0",
},
"table_resolved_size_mismatch": {
"name": "table_declared_size_matches_resolved_grid",
"comparison": "declared_size == resolved_size",
},
"blank_slide": {
"name": "slide_has_visible_content",
"comparison": "visible_element_count > 0",
},
}
def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
if issue.get("rule"):
return {**issue["rule"], "id": issue["code"]}
if issue["code"].endswith("_out_of_canvas"):
return {
"id": issue["code"],
"name": "element_stays_within_slide_canvas",
"comparison": "max(left, top, right, bottom overflow) == 0",
}
return {
"id": issue["code"],
**RULE_METADATA.get(
issue["code"],
{"name": issue["code"], "comparison": "violation_count == 0"},
),
}
def issue_measurement(
issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
) -> dict[str, Any]:
if issue.get("measurement") is not None:
return issue["measurement"]
if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
left = elements_by_id.get(issue["elements"][0])
right = elements_by_id.get(issue["elements"][1])
if left and right:
left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
width = intersection_width(left_box, right_box)
height = intersection_height(left_box, right_box)
return {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
}
if issue["code"].endswith("_out_of_canvas"):
return {
"canvas": issue.get("canvas"),
"bbox": issue.get("bbox"),
"overflow": issue.get("overflow"),
}
measurement_keys = (
"line",
"column",
"tag",
"attr",
"iconType",
"line_count",
"line_height",
"estimated_height",
"available_height",
"overflow",
"dimension",
"declared_size",
"resolved_size",
"resolved_sizes",
"overlaps",
)
measured = {key: issue[key] for key in measurement_keys if key in issue}
return measured or {"violation_count": 1}
def related_object(element: dict[str, Any]) -> dict[str, Any]:
return {
"element_id": element["id"],
"kind": element["kind"],
"type": element["type"],
"bbox": {key: element[key] for key in ("x", "y", "width", "height")},
}
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
attrs = match.group(1)
start_x = extract_numeric_attribute(attrs, "startX")
start_y = extract_numeric_attribute(attrs, "startY")
end_x = extract_numeric_attribute(attrs, "endX")
end_y = extract_numeric_attribute(attrs, "endY")
if any(value is None for value in (start_x, start_y, end_x, end_y)):
continue
line_alpha = extract_numeric_attribute(attrs, "alpha")
elements.append(
{
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
"kind": "line",
"type": "line",
"x": min(start_x, end_x),
"y": min(start_y, end_y),
"width": abs(end_x - start_x),
"height": abs(end_y - start_y),
"rotation": 0,
"alpha": line_alpha if line_alpha is not None else 1,
"order": len(elements),
}
)
return elements
def normalize_issue(
issue: dict[str, Any],
slide_number: int | None,
elements_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
normalized = dict(issue)
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
normalized["schema_version"] = "2.0"
normalized["element_ids"] = element_ids
normalized["target"] = {
**({"slide_number": slide_number} if slide_number is not None else {}),
**normalized.get("target", {}),
}
normalized["rule"] = issue_rule(normalized)
normalized["measurement"] = issue_measurement(normalized, elements_by_id)
normalized["related_objects"] = [
related_object(elements_by_id[element_id])
for element_id in element_ids
if element_id in elements_by_id
]
if normalized["code"] == "sparse_container_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
container_id = normalized["target"].get("container_id", "unknown")
normalized.setdefault(
"message",
f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
)
elif normalized["code"] == "sparse_slide_content":
ratio = normalized["measurement"]["content_coverage_ratio"]
threshold = normalized["rule"]["threshold"]
normalized.setdefault(
"message",
f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
)
normalized.setdefault(
"hint",
"Review the rendered screenshot to decide whether the page is intentionally sparse.",
)
else:
normalized.setdefault("message", normalized["code"].replace("_", " "))
normalized.setdefault(
"hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
)
return normalized
def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
if errors:
return "blocked"
if warnings:
return "needs_screenshot_review"
return "passed"
def build_result(
source_path: str | None,
slide_size: dict[str, int | float],
top_level_issues: list[dict[str, Any]],
slides: list[dict[str, Any]],
) -> dict[str, Any]:
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
status = slide_status(all_errors, all_warnings)
result: dict[str, Any] = {
"schema_version": "2.0",
"tool": "xml_text_overlap_lint",
"file": source_path,
"slide_size": slide_size,
"summary": {
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
"status": status,
"release_ready": error_count == 0,
"screenshot_review_required": warning_count > 0,
},
"document": {
"errors": document_errors,
"warnings": document_warnings,
"infos": document_infos,
},
"slides": slides,
}
if top_level_issues:
result["issues"] = top_level_issues
return result
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
root, xml_error = parse_xml_root(xml)
if xml_error:
issue = normalize_issue(xml_error, None, {})
return build_result(
source_path,
{"width": 960, "height": 540},
[issue],
[],
)
if root is None:
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)
iconpark_issues = validate_iconpark_icon_types(root)
top_level_issues = [
normalize_issue(issue, None, {})
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
]
if any(issue["level"] == "error" for issue in top_level_issues):
return build_result(
source_path,
{"width": 960, "height": 540},
top_level_issues,
[],
)
presentation = parse_presentation(xml)
slides: list[dict[str, Any]] = []
for index, slide_xml in enumerate(presentation["slides"]):
slide_number = index + 1
geometry = lint_slide(
slide_xml,
slide_number,
presentation["width"],
presentation["height"],
)
density_elements = extract_density_elements(slide_xml)
extra_elements = [
element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
]
elements_by_id = {
element["id"]: element for element in [*density_elements, *extra_elements]
}
# geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
# decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
# with whatever actually triggered the issue, instead of density_elements' separate re-parse.
elements_by_id.update({element["id"]: element for element in geometry["elements"]})
extra_overflow_issues = detect_elements_out_of_canvas(
extra_elements,
presentation["width"],
presentation["height"],
)
raw_issues = [
*geometry["issues"],
*extra_overflow_issues,
*detect_blank_slide(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_container_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
*detect_sparse_slide_content(
density_elements,
slide_number,
presentation["width"],
presentation["height"],
),
]
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"]
infos = [issue for issue in issues if issue["level"] == "info"]
slides.append(
{
"slide_number": slide_number,
"status": slide_status(errors, warnings),
"element_count": len(elements_by_id),
"errors": errors,
"warnings": warnings,
"infos": infos,
"issues": issues,
}
)
return build_result(
source_path,
{"width": presentation["width"], "height": presentation["height"]},
top_level_issues,
slides,
)
def print_usage() -> None:
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
def run_cli(argv: list[str] | None = None) -> None:
options = parse_args(argv or sys.argv[1:])
if options.get("help") or options.get("--help"):
print_usage()
raise SystemExit(0)
if not options.get("input"):
print_usage()
fail("--input is required")
input_path = Path(options["input"]).resolve()
result = lint_xml(read_file(input_path), str(input_path))
print(json.dumps(result, ensure_ascii=False, indent=2))
if result["summary"]["error_count"] > 0:
raise SystemExit(1)
if __name__ == "__main__":
try:
run_cli()
except XmlLayoutLintError as error:
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
raise SystemExit(1) from error