@amaster.ai/pi-lark 0.1.2-beta.44 → 0.1.2-beta.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/lark-apps/SKILL.md +23 -12
- package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
- package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
- package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
- package/skills/lark-apps/creative-design/assets/index.html +27 -0
- package/skills/lark-apps/creative-design/creative-design.md +239 -0
- package/skills/lark-apps/creative-design/references/aily.md +39 -0
- package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
- package/skills/lark-apps/creative-design/references/charts.md +165 -0
- package/skills/lark-apps/creative-design/references/claude.md +36 -0
- package/skills/lark-apps/creative-design/references/codex.md +32 -0
- package/skills/lark-apps/creative-design/references/data-report.md +108 -0
- package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
- package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
- package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
- package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
- package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
- package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
- package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
- package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
- package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
- package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
- package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
- package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
- package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
- package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
- package/skills/lark-apps/references/lark-apps-automation.md +80 -2
- package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
- package/skills/lark-apps/references/lark-apps-create.md +1 -2
- package/skills/lark-apps/references/lark-apps-db.md +1 -1
- package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
- package/skills/lark-apps/references/lark-apps-file.md +2 -2
- package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
- package/skills/lark-apps/references/lark-apps-html-publish.md +4 -8
- package/skills/lark-apps/references/lark-apps-init.md +1 -1
- package/skills/lark-apps/references/lark-apps-list.md +1 -1
- package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
- package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
- package/skills/lark-apps/references/lark-apps-release-create.md +2 -2
- package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
- package/skills/lark-base/SKILL.md +4 -6
- package/skills/lark-base/references/lark-base-cell-value.md +3 -3
- package/skills/lark-base/references/lark-base-field-create.md +4 -0
- package/skills/lark-base/references/lark-base-field-json.md +4 -4
- package/skills/lark-base/references/lark-base-field-update.md +17 -1
- package/skills/lark-base/references/lark-base-form-submit.md +16 -7
- package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
- package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
- package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
- package/skills/lark-calendar/references/lark-calendar-create.md +1 -0
- package/skills/lark-calendar/references/lark-calendar-update.md +3 -0
- package/skills/lark-doc/references/lark-doc-fetch.md +10 -2
- package/skills/lark-doc/references/lark-doc-whiteboard.md +9 -8
- package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +41 -0
- package/skills/lark-doc/references/lark-doc-xml.md +4 -3
- package/skills/lark-drive/SKILL.md +4 -1
- package/skills/lark-drive/references/lark-drive-comment-location.md +2 -2
- package/skills/lark-drive/references/lark-drive-search.md +1 -0
- package/skills/lark-drive/references/lark-drive-upload.md +1 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
- package/skills/lark-drive/references/lark-drive-workflow.md +5 -4
- package/skills/lark-event/SKILL.md +1 -0
- package/skills/lark-event/references/lark-event-application.md +38 -0
- package/skills/lark-im/SKILL.md +1 -1
- package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
- package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
- package/skills/lark-im/references/card/resource/icons.md +14 -0
- package/skills/lark-im/references/lark-im-flag-list.md +8 -7
- package/skills/lark-okr/SKILL.md +71 -26
- package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
- package/skills/lark-okr/references/lark-okr-create.md +173 -0
- package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
- package/skills/lark-okr/references/lark-okr-entities.md +1 -0
- package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
- package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
- package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
- package/skills/lark-slides/SKILL.md +104 -47
- package/skills/lark-slides/references/asset-planning.md +6 -4
- package/skills/lark-slides/references/iconpark.md +2 -2
- package/skills/lark-slides/references/lark-slides-create.md +2 -3
- package/skills/lark-slides/references/lark-slides-history.md +132 -0
- package/skills/lark-slides/references/lark-slides-media-upload.md +1 -2
- package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +7 -11
- package/skills/lark-slides/references/lark-slides-replace-slide.md +0 -3
- package/skills/lark-slides/references/lark-slides-screenshot.md +4 -4
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +219 -0
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +6 -5
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +2 -2
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -3
- package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +65 -30
- package/skills/lark-slides/references/planning-layer.md +11 -10
- package/skills/lark-slides/references/slides_chart_demo.xml +1416 -1
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +1 -45
- package/skills/lark-slides/references/troubleshooting.md +25 -7
- package/skills/lark-slides/references/validation-checklist.md +53 -16
- package/skills/lark-slides/references/visual-planning.md +25 -22
- package/skills/lark-slides/references/xml-schema-quick-ref.md +243 -46
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +1054 -78
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +957 -150
- package/skills/lark-task/SKILL.md +7 -0
- package/skills/lark-task/references/lark-task-complete.md +6 -2
- package/skills/lark-task/references/lark-task-update.md +6 -2
- package/skills/lark-whiteboard/SKILL.md +13 -12
- package/skills/lark-whiteboard/elements/layout.md +1 -1
- package/skills/lark-whiteboard/elements/schema.md +2 -2
- package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
- package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
- package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +7 -17
- package/skills/lark-whiteboard/routes/dsl.md +3 -3
- package/skills/lark-whiteboard/routes/mermaid.md +2 -2
- package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
- package/skills/lark-whiteboard/routes/svg.md +11 -6
- package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
- package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
- package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
- package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
- package/skills/lark-whiteboard/scenes/treemap.md +1 -1
- package/skills/lark-wiki/SKILL.md +1 -0
- package/skills/lark-slides/references/examples.md +0 -91
- package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -331
- package/skills/lark-slides/references/lark-slides-xml-get.md +0 -100
- package/skills/lark-slides/references/slide-templates.md +0 -201
- package/skills/lark-slides/references/slides_demo.xml +0 -226
- package/skills/lark-slides/references/xml-format-guide.md +0 -433
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
3
3
|
# SPDX-License-Identifier: MIT
|
|
4
|
+
"""Validate Slides XML structure and page layout through one release gate."""
|
|
4
5
|
|
|
5
6
|
from __future__ import annotations
|
|
6
7
|
|
|
@@ -38,18 +39,29 @@ SXSD_ATTR_ALIASES = {
|
|
|
38
39
|
"fontColor": "color",
|
|
39
40
|
}
|
|
40
41
|
SERVER_FILLED_SXSD_ATTRS = {"id"}
|
|
42
|
+
ROUNDTRIP_SXSD_ATTRS = {
|
|
43
|
+
("chart", "updated"),
|
|
44
|
+
("chartData", "isStaticData"),
|
|
45
|
+
}
|
|
46
|
+
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
|
|
47
|
+
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
|
|
48
|
+
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
|
|
49
|
+
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
|
|
41
50
|
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
|
42
51
|
DEFAULT_TABLE_ROW_HEIGHT = 37
|
|
52
|
+
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
|
|
53
|
+
# visible defect; keep this well under 1px so real overflow is still always caught.
|
|
54
|
+
CANVAS_OVERFLOW_TOLERANCE = 0.5
|
|
43
55
|
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
|
|
44
56
|
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
|
|
45
57
|
|
|
46
58
|
|
|
47
|
-
class
|
|
59
|
+
class XmlLayoutLintError(Exception):
|
|
48
60
|
pass
|
|
49
61
|
|
|
50
62
|
|
|
51
63
|
def fail(message: str) -> None:
|
|
52
|
-
raise
|
|
64
|
+
raise XmlLayoutLintError(message)
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def read_file(file_path: str | Path) -> str:
|
|
@@ -62,7 +74,7 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
|
|
|
62
74
|
while index < len(argv):
|
|
63
75
|
token = argv[index]
|
|
64
76
|
if not token.startswith("--"):
|
|
65
|
-
fail(f"unexpected argument: {token}")
|
|
77
|
+
fail(f"unexpected argument: {token}, need --input")
|
|
66
78
|
key = token[2:]
|
|
67
79
|
next_token = argv[index + 1] if index + 1 < len(argv) else None
|
|
68
80
|
if next_token is None or next_token.startswith("--"):
|
|
@@ -75,8 +87,12 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
|
|
|
75
87
|
|
|
76
88
|
|
|
77
89
|
def extract_attribute(tag_source: str, name: str) -> str | None:
|
|
78
|
-
match = re.search(
|
|
79
|
-
|
|
90
|
+
match = re.search(
|
|
91
|
+
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
|
|
92
|
+
)
|
|
93
|
+
if not match:
|
|
94
|
+
return None
|
|
95
|
+
return match.group(1) if match.group(1) is not None else match.group(2)
|
|
80
96
|
|
|
81
97
|
|
|
82
98
|
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
|
@@ -168,8 +184,10 @@ def solve_weighted_min_layout(
|
|
|
168
184
|
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
|
|
169
185
|
|
|
170
186
|
|
|
171
|
-
def strip_xml(value: str) -> str:
|
|
187
|
+
def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
|
|
172
188
|
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
|
189
|
+
if preserve_line_breaks:
|
|
190
|
+
stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
|
|
173
191
|
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
|
174
192
|
stripped = stripped.replace(" ", " ")
|
|
175
193
|
stripped = stripped.replace("&", "&")
|
|
@@ -177,14 +195,46 @@ def strip_xml(value: str) -> str:
|
|
|
177
195
|
stripped = stripped.replace(">", ">")
|
|
178
196
|
stripped = stripped.replace(""", '"')
|
|
179
197
|
stripped = stripped.replace("'", "'")
|
|
198
|
+
if preserve_line_breaks:
|
|
199
|
+
return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
|
|
180
200
|
return re.sub(r"\s+", " ", stripped).strip()
|
|
181
201
|
|
|
182
202
|
|
|
183
203
|
def strip_xml_paragraphs(value: str) -> str:
|
|
184
204
|
paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
|
|
185
205
|
if paragraphs:
|
|
186
|
-
return "\n".join(strip_xml(paragraph) for paragraph in paragraphs)
|
|
187
|
-
return strip_xml(value)
|
|
206
|
+
return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
|
|
207
|
+
return strip_xml(value, preserve_line_breaks=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
|
|
211
|
+
paragraphs = []
|
|
212
|
+
for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
|
|
213
|
+
paragraphs.append(
|
|
214
|
+
{
|
|
215
|
+
"text": strip_xml(body, preserve_line_breaks=True),
|
|
216
|
+
"fontSize": extract_max_span_font_size(body, default_font_size),
|
|
217
|
+
"textAlign": extract_attribute(attrs, "textAlign"),
|
|
218
|
+
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
|
|
219
|
+
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
|
|
220
|
+
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
return paragraphs
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
|
|
227
|
+
font_sizes = [
|
|
228
|
+
font_size
|
|
229
|
+
for attrs in re.findall(r"<span\b([^>]*)>", value)
|
|
230
|
+
if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
|
|
231
|
+
]
|
|
232
|
+
return max([default_font_size, *font_sizes])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def extract_tag_attributes(value: str, tag: str) -> str:
|
|
236
|
+
match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
|
|
237
|
+
return match.group(1) if match else ""
|
|
188
238
|
|
|
189
239
|
|
|
190
240
|
def xml_local_name(tag: str) -> str:
|
|
@@ -319,8 +369,8 @@ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
|
|
|
319
369
|
return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
|
|
320
370
|
|
|
321
371
|
|
|
322
|
-
def should_skip_sxsd_attribute(attr_name: str) -> bool:
|
|
323
|
-
return attr_name in SERVER_FILLED_SXSD_ATTRS
|
|
372
|
+
def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
|
|
373
|
+
return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
|
|
324
374
|
|
|
325
375
|
|
|
326
376
|
def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
@@ -334,6 +384,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
|
334
384
|
|
|
335
385
|
tag_name = xml_local_name(element.tag)
|
|
336
386
|
current_path = f"{path}/{tag_name}" if path else tag_name
|
|
387
|
+
if tag_name in ROUNDTRIP_SXSD_TAGS:
|
|
388
|
+
return
|
|
337
389
|
if tag_name not in supported_tags:
|
|
338
390
|
issues.append(
|
|
339
391
|
{
|
|
@@ -352,7 +404,7 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
|
352
404
|
if raw_attr_name.startswith(XML_NS):
|
|
353
405
|
continue
|
|
354
406
|
attr_name = xml_local_name(raw_attr_name)
|
|
355
|
-
if should_skip_sxsd_attribute(attr_name):
|
|
407
|
+
if should_skip_sxsd_attribute(tag_name, attr_name):
|
|
356
408
|
continue
|
|
357
409
|
if attr_name in allowed_attrs:
|
|
358
410
|
continue
|
|
@@ -594,8 +646,9 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
594
646
|
|
|
595
647
|
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
|
|
596
648
|
kind, attrs = match.group(1), match.group(2)
|
|
649
|
+
is_self_closing = attrs.rstrip().endswith("/")
|
|
597
650
|
content = ""
|
|
598
|
-
if kind in {"shape", "table"}:
|
|
651
|
+
if kind in {"shape", "table"} and not is_self_closing:
|
|
599
652
|
close_index = slide_xml.find(f"</{kind}>", match.end())
|
|
600
653
|
if close_index != -1:
|
|
601
654
|
content = slide_xml[match.end() : close_index]
|
|
@@ -606,6 +659,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
606
659
|
width = extract_numeric_attribute(attrs, "width")
|
|
607
660
|
height = extract_numeric_attribute(attrs, "height")
|
|
608
661
|
rotation = extract_numeric_attribute(attrs, "rotation") or 0
|
|
662
|
+
alpha = extract_numeric_attribute(attrs, "alpha")
|
|
609
663
|
table_layouts: dict[str, dict[str, Any] | None] = {}
|
|
610
664
|
if kind == "table":
|
|
611
665
|
width, table_layouts["width"] = resolve_table_dimension(
|
|
@@ -624,6 +678,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
624
678
|
"width": width,
|
|
625
679
|
"height": height,
|
|
626
680
|
"rotation": rotation,
|
|
681
|
+
"alpha": alpha if alpha is not None else 1,
|
|
627
682
|
"order": len(elements),
|
|
628
683
|
}
|
|
629
684
|
if kind == "table":
|
|
@@ -635,15 +690,28 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
635
690
|
}
|
|
636
691
|
)
|
|
637
692
|
if kind == "shape":
|
|
693
|
+
content_attrs = extract_tag_attributes(content, "content")
|
|
694
|
+
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
|
695
|
+
if font_size is None:
|
|
696
|
+
font_size = extract_numeric_attribute(attrs, "fontSize")
|
|
638
697
|
element.update(
|
|
639
698
|
{
|
|
640
|
-
"textType": extract_attribute(
|
|
641
|
-
"textAlign": extract_attribute(
|
|
642
|
-
"
|
|
643
|
-
"
|
|
644
|
-
|
|
645
|
-
),
|
|
699
|
+
"textType": extract_attribute(content_attrs, "textType"),
|
|
700
|
+
"textAlign": extract_attribute(content_attrs, "textAlign"),
|
|
701
|
+
"verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
|
|
702
|
+
"vert": extract_attribute(attrs, "vert") or "horz",
|
|
703
|
+
"autoFit": extract_attribute(content_attrs, "autoFit"),
|
|
704
|
+
"wrap": extract_attribute(content_attrs, "wrap"),
|
|
705
|
+
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
|
|
706
|
+
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
|
|
707
|
+
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
|
|
708
|
+
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
|
|
709
|
+
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
|
|
710
|
+
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
|
|
711
|
+
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
|
|
712
|
+
"fontSize": font_size if font_size is not None else 16,
|
|
646
713
|
"text": strip_xml_paragraphs(content),
|
|
714
|
+
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
|
|
647
715
|
}
|
|
648
716
|
)
|
|
649
717
|
elements.append(element)
|
|
@@ -671,6 +739,40 @@ def has_text_content(element: dict[str, Any]) -> bool:
|
|
|
671
739
|
return bool(element.get("text"))
|
|
672
740
|
|
|
673
741
|
|
|
742
|
+
def is_vertical_text(element: dict[str, Any]) -> bool:
|
|
743
|
+
return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
747
|
+
issues: list[dict[str, Any]] = []
|
|
748
|
+
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
|
|
749
|
+
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
|
|
750
|
+
for text_element in text_elements:
|
|
751
|
+
for image_element in image_elements:
|
|
752
|
+
if image_element["order"] <= text_element["order"]:
|
|
753
|
+
continue
|
|
754
|
+
if is_vertical_text(text_element):
|
|
755
|
+
if intersects(image_element, text_element):
|
|
756
|
+
issues.append({
|
|
757
|
+
"level": "info",
|
|
758
|
+
"code": "image_may_cover_vertical_text",
|
|
759
|
+
"elements": [image_element["id"], text_element["id"]],
|
|
760
|
+
"message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
|
|
761
|
+
"hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
|
|
762
|
+
})
|
|
763
|
+
continue
|
|
764
|
+
text_visual_bbox = estimate_text_visual_bbox(text_element)
|
|
765
|
+
if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
|
|
766
|
+
issues.append({
|
|
767
|
+
"level": "error",
|
|
768
|
+
"code": "image_covers_text",
|
|
769
|
+
"elements": [image_element["id"], text_element["id"]],
|
|
770
|
+
"message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
|
|
771
|
+
"hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
|
|
772
|
+
})
|
|
773
|
+
return issues
|
|
774
|
+
|
|
775
|
+
|
|
674
776
|
def is_decorative_text(element: dict[str, Any]) -> bool:
|
|
675
777
|
text = element.get("text") or ""
|
|
676
778
|
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
|
|
@@ -708,27 +810,135 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
|
|
|
708
810
|
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
|
|
709
811
|
|
|
710
812
|
|
|
711
|
-
def
|
|
813
|
+
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
|
|
712
814
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
713
|
-
|
|
815
|
+
hard_lines = text.split("\n")
|
|
816
|
+
if not text:
|
|
817
|
+
return 0
|
|
714
818
|
line_count = 0
|
|
715
|
-
for
|
|
716
|
-
|
|
819
|
+
for hard_line in hard_lines:
|
|
820
|
+
if element.get("wrap") in {"false", "0"}:
|
|
821
|
+
line_count += 1
|
|
822
|
+
continue
|
|
823
|
+
logical_width = max(estimate_text_width(hard_line, font_size), 1)
|
|
717
824
|
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
|
|
718
|
-
return
|
|
825
|
+
return line_count
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def estimate_text_line_count(element: dict[str, Any]) -> int:
|
|
829
|
+
return max(estimate_text_line_count_for_text(element, element["text"]), 1)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
|
|
833
|
+
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
834
|
+
line_spacing = line_spacing or "multiple:1.5"
|
|
835
|
+
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
|
836
|
+
if match is None:
|
|
837
|
+
return None
|
|
838
|
+
spacing_type, value = match.groups()
|
|
839
|
+
return font_size * float(value) if spacing_type == "multiple" else float(value)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
843
|
+
issues: list[dict[str, Any]] = []
|
|
844
|
+
for element in elements:
|
|
845
|
+
if not is_text_element(element) or not has_text_content(element):
|
|
846
|
+
continue
|
|
847
|
+
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
851
|
+
paragraphs = element.get("paragraphs") or [
|
|
852
|
+
{
|
|
853
|
+
"text": element["text"],
|
|
854
|
+
"lineSpacing": None,
|
|
855
|
+
"beforeLineSpacing": None,
|
|
856
|
+
"afterLineSpacing": None,
|
|
857
|
+
}
|
|
858
|
+
]
|
|
859
|
+
line_count = 0
|
|
860
|
+
estimated_height = 0.0
|
|
861
|
+
line_heights: list[int | float] = []
|
|
862
|
+
for paragraph in paragraphs:
|
|
863
|
+
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
|
|
864
|
+
if paragraph_line_count == 0:
|
|
865
|
+
continue
|
|
866
|
+
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
|
|
867
|
+
before_spacing = estimate_text_line_height(
|
|
868
|
+
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
|
|
869
|
+
)
|
|
870
|
+
after_spacing = estimate_text_line_height(
|
|
871
|
+
element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
|
|
872
|
+
)
|
|
873
|
+
if line_height is None or before_spacing is None or after_spacing is None:
|
|
874
|
+
line_count = 0
|
|
875
|
+
break
|
|
876
|
+
first_line_height = font_size if line_count == 0 else line_height
|
|
877
|
+
line_count += paragraph_line_count
|
|
878
|
+
line_heights.append(line_height)
|
|
879
|
+
estimated_height += (
|
|
880
|
+
before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
|
|
881
|
+
)
|
|
882
|
+
if line_count == 0:
|
|
883
|
+
continue
|
|
884
|
+
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
|
|
885
|
+
overflow = estimated_height - available_height
|
|
886
|
+
if overflow <= 0:
|
|
887
|
+
continue
|
|
888
|
+
|
|
889
|
+
issues.append(
|
|
890
|
+
{
|
|
891
|
+
"level": "warning",
|
|
892
|
+
"code": "text_may_overflow_shape",
|
|
893
|
+
"elements": [element["id"]],
|
|
894
|
+
"line_count": line_count,
|
|
895
|
+
"line_height": max(line_heights),
|
|
896
|
+
"estimated_height": estimated_height,
|
|
897
|
+
"available_height": available_height,
|
|
898
|
+
"overflow": overflow,
|
|
899
|
+
"message": (
|
|
900
|
+
f'text shape {element["id"]} may overflow its own content box '
|
|
901
|
+
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
|
902
|
+
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
|
903
|
+
),
|
|
904
|
+
"hint": (
|
|
905
|
+
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
|
|
906
|
+
"autoFit=\"normal-auto-fit\". "
|
|
907
|
+
"This is an estimate based on font size, line spacing, and wrapped line count."
|
|
908
|
+
),
|
|
909
|
+
}
|
|
910
|
+
)
|
|
911
|
+
return issues
|
|
719
912
|
|
|
720
913
|
|
|
721
914
|
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
|
|
722
915
|
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
|
|
723
916
|
return None
|
|
724
917
|
|
|
918
|
+
padding_left = element.get("paddingLeft", 0)
|
|
919
|
+
padding_right = element.get("paddingRight", 0)
|
|
920
|
+
padding_top = element.get("paddingTop", 0)
|
|
921
|
+
padding_bottom = element.get("paddingBottom", 0)
|
|
922
|
+
content_width = max(element["width"] - padding_left - padding_right, 0)
|
|
923
|
+
content_height = max(element["height"] - padding_top - padding_bottom, 0)
|
|
725
924
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
726
925
|
line_count = estimate_text_line_count(element)
|
|
727
|
-
|
|
728
|
-
|
|
926
|
+
estimated_width = max(1, estimate_text_max_line_width(element))
|
|
927
|
+
visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
|
|
928
|
+
visual_height = min(content_height, max(1, line_count * font_size * 1.2))
|
|
929
|
+
x = element["x"] + padding_left
|
|
930
|
+
if element.get("textAlign") == "center":
|
|
931
|
+
x += (content_width - visual_width) / 2
|
|
932
|
+
elif element.get("textAlign") == "right":
|
|
933
|
+
x += content_width - visual_width
|
|
934
|
+
y = element["y"] + padding_top
|
|
935
|
+
if element.get("verticalAlign") == "middle":
|
|
936
|
+
y += (content_height - visual_height) / 2
|
|
937
|
+
elif element.get("verticalAlign") == "bottom":
|
|
938
|
+
y += content_height - visual_height
|
|
729
939
|
return {
|
|
730
|
-
"x":
|
|
731
|
-
"y":
|
|
940
|
+
"x": x,
|
|
941
|
+
"y": y,
|
|
732
942
|
"width": visual_width,
|
|
733
943
|
"height": visual_height,
|
|
734
944
|
}
|
|
@@ -818,6 +1028,10 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
|
|
818
1028
|
source, target = sorted([left, right], key=lambda element: element["x"])
|
|
819
1029
|
if source["x"] == target["x"]:
|
|
820
1030
|
return False
|
|
1031
|
+
wrap_enabled = source.get("wrap") not in {"false", "0"}
|
|
1032
|
+
has_horizontal_gap = source["x"] + source["width"] <= target["x"]
|
|
1033
|
+
if wrap_enabled and has_horizontal_gap:
|
|
1034
|
+
return False
|
|
821
1035
|
if source.get("autoFit") == "normal-auto-fit":
|
|
822
1036
|
return False
|
|
823
1037
|
if source.get("textAlign") in {"center", "right"}:
|
|
@@ -840,6 +1054,19 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
|
|
840
1054
|
return vertical_overlap >= min_vertical_overlap
|
|
841
1055
|
|
|
842
1056
|
|
|
1057
|
+
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
|
|
1058
|
+
source, target = sorted([left, right], key=lambda element: element["x"])
|
|
1059
|
+
visual_width = estimate_text_max_line_width(source)
|
|
1060
|
+
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
|
1061
|
+
width = intersection_width(source_visual_bbox, target)
|
|
1062
|
+
height = intersection_height(source_visual_bbox, target)
|
|
1063
|
+
return {
|
|
1064
|
+
"intersection_width": round(width, 3),
|
|
1065
|
+
"intersection_height": round(height, 3),
|
|
1066
|
+
"intersection_area": round(width * height, 3),
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
|
|
843
1070
|
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
844
1071
|
if is_text_element(left) and not has_text_content(left):
|
|
845
1072
|
return False
|
|
@@ -967,9 +1194,6 @@ def detect_whiteboard_external_overlaps(
|
|
|
967
1194
|
|
|
968
1195
|
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
|
969
1196
|
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
|
970
|
-
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
|
971
|
-
return bbox
|
|
972
|
-
|
|
973
1197
|
rotation = element["rotation"]
|
|
974
1198
|
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
|
975
1199
|
rotation = 0
|
|
@@ -995,12 +1219,7 @@ def detect_elements_out_of_canvas(
|
|
|
995
1219
|
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
|
996
1220
|
) -> list[dict[str, Any]]:
|
|
997
1221
|
issues: list[dict[str, Any]] = []
|
|
998
|
-
for element in
|
|
999
|
-
element
|
|
1000
|
-
for element in elements
|
|
1001
|
-
if element["kind"] in {"table", "chart"}
|
|
1002
|
-
or (element["kind"] == "shape" and element["type"] == "text")
|
|
1003
|
-
):
|
|
1222
|
+
for element in elements:
|
|
1004
1223
|
bbox = element_canvas_bbox(element)
|
|
1005
1224
|
overflow = {
|
|
1006
1225
|
"left": max(-bbox["x"], 0),
|
|
@@ -1009,7 +1228,9 @@ def detect_elements_out_of_canvas(
|
|
|
1009
1228
|
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
|
|
1010
1229
|
}
|
|
1011
1230
|
overflow_details = [
|
|
1012
|
-
f"{side} by {amount:g}px"
|
|
1231
|
+
f"{side} by {amount:g}px"
|
|
1232
|
+
for side, amount in overflow.items()
|
|
1233
|
+
if amount > CANVAS_OVERFLOW_TOLERANCE
|
|
1013
1234
|
]
|
|
1014
1235
|
if not overflow_details:
|
|
1015
1236
|
continue
|
|
@@ -1116,6 +1337,8 @@ def lint_slide(
|
|
|
1116
1337
|
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
|
|
1117
1338
|
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
|
|
1118
1339
|
*detect_table_layout_size_mismatches(elements),
|
|
1340
|
+
*detect_text_may_overflow_shapes(elements),
|
|
1341
|
+
*detect_image_text_occlusions(elements),
|
|
1119
1342
|
]
|
|
1120
1343
|
|
|
1121
1344
|
for index, left in enumerate(elements):
|
|
@@ -1129,62 +1352,714 @@ def lint_slide(
|
|
|
1129
1352
|
"code": "bbox_overlap",
|
|
1130
1353
|
"elements": [left["id"], right["id"]],
|
|
1131
1354
|
"message": f'{left["id"]} overlaps {right["id"]}',
|
|
1355
|
+
"hint": "Move or resize the elements so their visual bounds no longer intersect.",
|
|
1356
|
+
**(
|
|
1357
|
+
{"measurement": horizontal_text_overflow_measurement(left, right)}
|
|
1358
|
+
if horizontal_overflow
|
|
1359
|
+
else {}
|
|
1360
|
+
),
|
|
1132
1361
|
}
|
|
1133
1362
|
)
|
|
1134
1363
|
|
|
1135
|
-
return {
|
|
1364
|
+
return {
|
|
1365
|
+
"slide_number": slide_number,
|
|
1366
|
+
"element_count": len(elements),
|
|
1367
|
+
"elements": elements,
|
|
1368
|
+
"issues": issues,
|
|
1369
|
+
}
|
|
1136
1370
|
|
|
1137
1371
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1372
|
+
|
|
1373
|
+
MIN_CONTAINER_WIDTH = 140
|
|
1374
|
+
MIN_CONTAINER_HEIGHT = 160
|
|
1375
|
+
MIN_SHORT_CARD_HEIGHT = 80
|
|
1376
|
+
MIN_CONTAINER_AREA = 20_000
|
|
1377
|
+
MIN_CONTENT_COVERAGE_RATIO = 0.15
|
|
1378
|
+
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
|
|
1379
|
+
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
|
|
1380
|
+
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
|
|
1381
|
+
MIN_SIMILAR_SHORT_CARD_COUNT = 2
|
|
1382
|
+
LARGE_VISUAL_CHILD_RATIO = 0.35
|
|
1383
|
+
LAYOUT_PANEL_SPAN_RATIO = 0.90
|
|
1384
|
+
IMAGE_OVERLAY_MATCH_RATIO = 0.90
|
|
1385
|
+
DENSITY_CONTAINMENT_TOLERANCE = 8
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
|
1389
|
+
left = max(element["x"], container["x"])
|
|
1390
|
+
top = max(element["y"], container["y"])
|
|
1391
|
+
right = min(element["x"] + element["width"], container["x"] + container["width"])
|
|
1392
|
+
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
|
|
1393
|
+
if right <= left or bottom <= top:
|
|
1394
|
+
return None
|
|
1395
|
+
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
|
|
1399
|
+
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
|
|
1400
|
+
area = 0
|
|
1401
|
+
for left, right in zip(x_coordinates, x_coordinates[1:]):
|
|
1402
|
+
intervals = sorted(
|
|
1403
|
+
(rect["y"], rect["y"] + rect["height"])
|
|
1404
|
+
for rect in rectangles
|
|
1405
|
+
if rect["x"] < right and rect["x"] + rect["width"] > left
|
|
1406
|
+
)
|
|
1407
|
+
covered_height = 0
|
|
1408
|
+
interval_end: int | float | None = None
|
|
1409
|
+
for top, bottom in intervals:
|
|
1410
|
+
if interval_end is None:
|
|
1411
|
+
covered_height += bottom - top
|
|
1412
|
+
interval_end = bottom
|
|
1413
|
+
elif bottom > interval_end:
|
|
1414
|
+
covered_height += bottom - max(top, interval_end)
|
|
1415
|
+
interval_end = bottom
|
|
1416
|
+
area += (right - left) * covered_height
|
|
1417
|
+
return area
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
|
1421
|
+
return sum(
|
|
1422
|
+
other is not element
|
|
1423
|
+
and is_visually_rendered(other)
|
|
1424
|
+
and other["kind"] == "shape"
|
|
1425
|
+
and other["type"] == "rect"
|
|
1426
|
+
and other["width"] >= MIN_CONTAINER_WIDTH
|
|
1427
|
+
and other["height"] >= MIN_SHORT_CARD_HEIGHT
|
|
1428
|
+
and element_area(other) >= MIN_CONTAINER_AREA
|
|
1429
|
+
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
|
|
1430
|
+
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
|
1431
|
+
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
|
|
1432
|
+
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
|
1433
|
+
for other in elements
|
|
1434
|
+
) >= MIN_SIMILAR_SHORT_CARD_COUNT
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def is_layout_container(
|
|
1438
|
+
element: dict[str, Any],
|
|
1439
|
+
slide_width: int | float,
|
|
1440
|
+
slide_height: int | float,
|
|
1441
|
+
elements: list[dict[str, Any]] | None = None,
|
|
1442
|
+
) -> bool:
|
|
1443
|
+
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
|
|
1444
|
+
elements is not None
|
|
1445
|
+
and element["height"] >= MIN_SHORT_CARD_HEIGHT
|
|
1446
|
+
and has_similar_short_card_peer(element, elements)
|
|
1447
|
+
)
|
|
1448
|
+
return (
|
|
1449
|
+
element["kind"] == "shape"
|
|
1450
|
+
and element["type"] == "rect"
|
|
1451
|
+
and is_visually_rendered(element)
|
|
1452
|
+
and element["width"] >= MIN_CONTAINER_WIDTH
|
|
1453
|
+
and has_supported_height
|
|
1454
|
+
and element_area(element) >= MIN_CONTAINER_AREA
|
|
1455
|
+
and not (
|
|
1456
|
+
element["x"] <= 2
|
|
1457
|
+
and element["y"] <= 2
|
|
1458
|
+
and element["width"] >= slide_width - 4
|
|
1459
|
+
and element["height"] >= slide_height - 4
|
|
1460
|
+
)
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def is_edge_spanning_layout_panel(
|
|
1465
|
+
element: dict[str, Any], slide_width: int | float, slide_height: int | float
|
|
1466
|
+
) -> bool:
|
|
1467
|
+
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
|
|
1468
|
+
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
|
|
1469
|
+
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
|
|
1470
|
+
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
|
1475
|
+
container_area = element_area(container)
|
|
1476
|
+
return any(
|
|
1477
|
+
element["kind"] == "img"
|
|
1478
|
+
and is_visually_rendered(element)
|
|
1479
|
+
and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
|
|
1480
|
+
for element in elements
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def is_nested_in_layout_panel(
|
|
1485
|
+
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
|
1486
|
+
) -> bool:
|
|
1487
|
+
return any(
|
|
1488
|
+
element is not container
|
|
1489
|
+
and element["kind"] == "shape"
|
|
1490
|
+
and element["type"] == "rect"
|
|
1491
|
+
and is_visually_rendered(element)
|
|
1492
|
+
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
|
|
1493
|
+
and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
|
1494
|
+
for element in elements
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
1499
|
+
elements = extract_elements(slide_xml)
|
|
1500
|
+
elements_by_id = {element["id"]: element for element in elements}
|
|
1501
|
+
root = ET.fromstring(slide_xml)
|
|
1502
|
+
for node in root.iter():
|
|
1503
|
+
if xml_local_name(node.tag) != "shape":
|
|
1504
|
+
continue
|
|
1505
|
+
element = elements_by_id.get(node.attrib.get("id", ""))
|
|
1506
|
+
if element is None:
|
|
1507
|
+
continue
|
|
1508
|
+
content_node = next(
|
|
1509
|
+
(child for child in node if xml_local_name(child.tag) == "content"),
|
|
1510
|
+
None,
|
|
1511
|
+
)
|
|
1512
|
+
paragraphs = (
|
|
1513
|
+
[
|
|
1514
|
+
" ".join("".join(paragraph.itertext()).split())
|
|
1515
|
+
for paragraph in content_node.iter()
|
|
1516
|
+
if xml_local_name(paragraph.tag) == "p"
|
|
1517
|
+
]
|
|
1518
|
+
if content_node is not None
|
|
1519
|
+
else []
|
|
1520
|
+
)
|
|
1521
|
+
raw_font_size = (
|
|
1522
|
+
content_node.attrib.get("fontSize") if content_node is not None else None
|
|
1523
|
+
) or node.attrib.get("fontSize")
|
|
1524
|
+
try:
|
|
1525
|
+
base_font_size = float(raw_font_size or 16)
|
|
1526
|
+
except ValueError:
|
|
1527
|
+
base_font_size = 16.0
|
|
1528
|
+
element.update(
|
|
1529
|
+
{
|
|
1530
|
+
"textType": content_node.attrib.get("textType") if content_node is not None else None,
|
|
1531
|
+
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
|
|
1532
|
+
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
|
|
1533
|
+
"fontSize": base_font_size,
|
|
1534
|
+
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
|
|
1535
|
+
}
|
|
1536
|
+
)
|
|
1537
|
+
if not has_text_content(element):
|
|
1538
|
+
continue
|
|
1539
|
+
declared_font_sizes = []
|
|
1540
|
+
for descendant in node.iter():
|
|
1541
|
+
raw_declared_font_size = descendant.attrib.get("fontSize")
|
|
1542
|
+
if raw_declared_font_size is None:
|
|
1543
|
+
continue
|
|
1544
|
+
try:
|
|
1545
|
+
declared_font_sizes.append(float(raw_declared_font_size))
|
|
1546
|
+
except ValueError:
|
|
1547
|
+
continue
|
|
1548
|
+
if declared_font_sizes:
|
|
1549
|
+
element["fontSize"] = max(declared_font_sizes)
|
|
1550
|
+
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
|
|
1551
|
+
attrs = match.group(1)
|
|
1552
|
+
x = extract_numeric_attribute(attrs, "topLeftX")
|
|
1553
|
+
y = extract_numeric_attribute(attrs, "topLeftY")
|
|
1554
|
+
width = extract_numeric_attribute(attrs, "width")
|
|
1555
|
+
height = extract_numeric_attribute(attrs, "height")
|
|
1556
|
+
if any(value is None for value in (x, y, width, height)):
|
|
1557
|
+
continue
|
|
1558
|
+
icon_alpha = extract_numeric_attribute(attrs, "alpha")
|
|
1559
|
+
elements.append(
|
|
1560
|
+
{
|
|
1561
|
+
"id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
|
|
1562
|
+
"kind": "icon",
|
|
1563
|
+
"type": "icon",
|
|
1564
|
+
"x": x,
|
|
1565
|
+
"y": y,
|
|
1566
|
+
"width": width,
|
|
1567
|
+
"height": height,
|
|
1568
|
+
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
|
|
1569
|
+
"alpha": icon_alpha if icon_alpha is not None else 1,
|
|
1570
|
+
"order": len(elements),
|
|
1571
|
+
}
|
|
1572
|
+
)
|
|
1573
|
+
for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
|
|
1574
|
+
attrs = match.group(1)
|
|
1575
|
+
x = extract_numeric_attribute(attrs, "topLeftX")
|
|
1576
|
+
y = extract_numeric_attribute(attrs, "topLeftY")
|
|
1577
|
+
width = extract_numeric_attribute(attrs, "width")
|
|
1578
|
+
height = extract_numeric_attribute(attrs, "height")
|
|
1579
|
+
if any(value is None for value in (x, y, width, height)):
|
|
1580
|
+
continue
|
|
1581
|
+
polyline_alpha = extract_numeric_attribute(attrs, "alpha")
|
|
1582
|
+
elements.append(
|
|
1583
|
+
{
|
|
1584
|
+
"id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
|
|
1585
|
+
"kind": "polyline",
|
|
1586
|
+
"type": "polyline",
|
|
1587
|
+
"x": x,
|
|
1588
|
+
"y": y,
|
|
1589
|
+
"width": width,
|
|
1590
|
+
"height": height,
|
|
1591
|
+
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
|
|
1592
|
+
"alpha": polyline_alpha if polyline_alpha is not None else 1,
|
|
1593
|
+
"order": len(elements),
|
|
1594
|
+
}
|
|
1595
|
+
)
|
|
1596
|
+
for line_element in extract_line_elements(slide_xml):
|
|
1597
|
+
line_element["order"] = len(elements)
|
|
1598
|
+
elements.append(line_element)
|
|
1599
|
+
return elements
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def is_visually_rendered(element: dict[str, Any]) -> bool:
|
|
1603
|
+
return element.get("alpha", 1) > 0
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
|
1607
|
+
if not is_visually_rendered(element):
|
|
1608
|
+
return None
|
|
1609
|
+
if is_text_element(element):
|
|
1610
|
+
estimated = estimate_text_visual_bbox(element)
|
|
1611
|
+
return clipped_bbox(estimated, container) if estimated else None
|
|
1612
|
+
return clipped_bbox(element, container)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
|
|
1616
|
+
if container["kind"] != "shape" or not has_text_content(container):
|
|
1617
|
+
return None
|
|
1618
|
+
text_proxy = {**container, "type": "text"}
|
|
1619
|
+
estimated = estimate_text_visual_bbox(text_proxy)
|
|
1620
|
+
return clipped_bbox(estimated, container) if estimated else None
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def slide_content_visual_bbox(
|
|
1624
|
+
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
|
1625
|
+
) -> dict[str, int | float] | None:
|
|
1626
|
+
if not is_visually_rendered(element):
|
|
1627
|
+
return None
|
|
1628
|
+
if is_text_element(element):
|
|
1629
|
+
estimated = estimate_text_visual_bbox(element)
|
|
1630
|
+
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
|
1631
|
+
if element["kind"] == "shape" and has_text_content(element):
|
|
1632
|
+
estimated = own_text_visual_bbox(element)
|
|
1633
|
+
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
|
1634
|
+
if element["kind"] == "line":
|
|
1635
|
+
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
|
|
1636
|
+
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
|
|
1637
|
+
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
|
|
1638
|
+
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
|
|
1639
|
+
return clipped_bbox(element, slide_bbox)
|
|
1640
|
+
return None
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
|
|
1644
|
+
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def is_slide_content_present(
|
|
1648
|
+
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
|
1649
|
+
) -> bool:
|
|
1650
|
+
# Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
|
|
1651
|
+
# *anything* rendered here", not the richer "counts toward meaningful content density" bar
|
|
1652
|
+
# that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
|
|
1653
|
+
# decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
|
|
1654
|
+
# count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
|
|
1655
|
+
# instead of maintaining an allow-list that silently treats unlisted kinds as blank.
|
|
1656
|
+
if not is_visually_rendered(element):
|
|
1657
|
+
return False
|
|
1658
|
+
if (
|
|
1659
|
+
element["kind"] == "shape"
|
|
1660
|
+
and element["type"] == "rect"
|
|
1661
|
+
and not has_text_content(element)
|
|
1662
|
+
and element["x"] <= 2
|
|
1663
|
+
and element["y"] <= 2
|
|
1664
|
+
and element["width"] >= slide_bbox["width"] - 4
|
|
1665
|
+
and element["height"] >= slide_bbox["height"] - 4
|
|
1666
|
+
):
|
|
1667
|
+
# A full-canvas plain rect is a background panel, not content -- same reasoning as
|
|
1668
|
+
# is_layout_container's existing background exclusion. A slide with nothing else on it
|
|
1669
|
+
# is still effectively blank.
|
|
1670
|
+
return False
|
|
1671
|
+
bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
|
|
1672
|
+
return clipped_bbox(bbox, slide_bbox) is not None
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
|
|
1676
|
+
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
|
|
1677
|
+
return False
|
|
1678
|
+
if not is_visually_rendered(element):
|
|
1679
|
+
return False
|
|
1680
|
+
return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def detect_sparse_container_content(
|
|
1684
|
+
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
|
1685
|
+
) -> list[dict[str, Any]]:
|
|
1686
|
+
issues: list[dict[str, Any]] = []
|
|
1687
|
+
for container in (
|
|
1688
|
+
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
|
|
1689
|
+
):
|
|
1690
|
+
if (
|
|
1691
|
+
is_edge_spanning_layout_panel(container, slide_width, slide_height)
|
|
1692
|
+
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
|
|
1693
|
+
or has_matching_image_overlay(container, elements)
|
|
1694
|
+
):
|
|
1695
|
+
continue
|
|
1696
|
+
children = [
|
|
1697
|
+
element
|
|
1698
|
+
for element in elements
|
|
1699
|
+
if element is not container
|
|
1700
|
+
and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
|
1701
|
+
]
|
|
1702
|
+
if any(is_large_visual_child(child, container) for child in children):
|
|
1703
|
+
continue
|
|
1704
|
+
own_text_bbox = own_text_visual_bbox(container)
|
|
1705
|
+
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
|
|
1706
|
+
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
|
|
1707
|
+
]
|
|
1708
|
+
content_area = rectangle_union_area(rectangles) if rectangles else 0
|
|
1709
|
+
coverage_ratio = content_area / element_area(container)
|
|
1710
|
+
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
|
|
1711
|
+
continue
|
|
1712
|
+
issues.append(
|
|
1713
|
+
{
|
|
1714
|
+
"level": "warning",
|
|
1715
|
+
"code": "sparse_container_content",
|
|
1716
|
+
"target": {
|
|
1717
|
+
"slide_number": slide_number,
|
|
1718
|
+
"container_id": container["id"],
|
|
1719
|
+
"container_type": container["type"],
|
|
1720
|
+
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
|
|
1721
|
+
},
|
|
1722
|
+
"rule": {
|
|
1723
|
+
"name": "large_container_visible_content_coverage",
|
|
1724
|
+
"threshold": MIN_CONTENT_COVERAGE_RATIO,
|
|
1725
|
+
"comparison": "content_coverage_ratio < threshold",
|
|
1726
|
+
},
|
|
1727
|
+
"measurement": {
|
|
1728
|
+
"container_area": element_area(container),
|
|
1729
|
+
"visible_content_area": round(content_area, 3),
|
|
1730
|
+
"content_coverage_ratio": round(coverage_ratio, 3),
|
|
1731
|
+
"content_element_count": len(children) + (1 if own_text_bbox else 0),
|
|
1732
|
+
},
|
|
1733
|
+
"elements": [container["id"], *[child["id"] for child in children]],
|
|
1734
|
+
}
|
|
1735
|
+
)
|
|
1736
|
+
return issues
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def detect_sparse_slide_content(
|
|
1740
|
+
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
|
1741
|
+
) -> list[dict[str, Any]]:
|
|
1742
|
+
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
|
|
1743
|
+
content = [
|
|
1744
|
+
(element, bbox)
|
|
1745
|
+
for element in elements
|
|
1746
|
+
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
|
|
1747
|
+
]
|
|
1748
|
+
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
|
|
1749
|
+
return []
|
|
1750
|
+
content_area = rectangle_union_area([bbox for _, bbox in content])
|
|
1751
|
+
slide_area = slide_width * slide_height
|
|
1752
|
+
coverage_ratio = content_area / slide_area
|
|
1753
|
+
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
|
|
1754
|
+
return []
|
|
1755
|
+
return [
|
|
1756
|
+
{
|
|
1757
|
+
"level": "warning",
|
|
1758
|
+
"code": "sparse_slide_content",
|
|
1759
|
+
"target": {
|
|
1760
|
+
"slide_number": slide_number,
|
|
1761
|
+
"bbox": slide_bbox,
|
|
1762
|
+
},
|
|
1763
|
+
"rule": {
|
|
1764
|
+
"name": "slide_visible_content_coverage",
|
|
1765
|
+
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
|
|
1766
|
+
"comparison": "content_coverage_ratio < threshold",
|
|
1767
|
+
},
|
|
1768
|
+
"measurement": {
|
|
1769
|
+
"slide_area": slide_area,
|
|
1770
|
+
"visible_content_area": round(content_area, 3),
|
|
1771
|
+
"content_coverage_ratio": round(coverage_ratio, 3),
|
|
1772
|
+
"content_element_count": len(content),
|
|
1773
|
+
},
|
|
1774
|
+
"elements": [element["id"] for element, _ in content],
|
|
1775
|
+
}
|
|
1776
|
+
]
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def detect_blank_slide(
|
|
1780
|
+
elements: list[dict[str, Any]],
|
|
1781
|
+
slide_number: int,
|
|
1782
|
+
slide_width: int | float,
|
|
1783
|
+
slide_height: int | float,
|
|
1784
|
+
) -> list[dict[str, Any]]:
|
|
1785
|
+
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
|
|
1786
|
+
visible_elements = [
|
|
1787
|
+
element for element in elements if is_slide_content_present(element, slide_bbox)
|
|
1788
|
+
]
|
|
1789
|
+
if visible_elements:
|
|
1790
|
+
return []
|
|
1791
|
+
return [
|
|
1792
|
+
{
|
|
1793
|
+
"level": "error",
|
|
1794
|
+
"code": "blank_slide",
|
|
1795
|
+
"schema_version": "2.0",
|
|
1796
|
+
"target": {"slide_number": slide_number},
|
|
1797
|
+
"rule": {
|
|
1798
|
+
"name": "slide_has_visible_content",
|
|
1799
|
+
"comparison": "visible_element_count == 0",
|
|
1800
|
+
},
|
|
1801
|
+
"measurement": {
|
|
1802
|
+
"visible_element_count": 0,
|
|
1803
|
+
"declared_element_count": len(elements),
|
|
1804
|
+
},
|
|
1805
|
+
"elements": [element["id"] for element in elements],
|
|
1806
|
+
"message": "slide has no visible content beyond empty layout shapes",
|
|
1807
|
+
"hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
|
|
1808
|
+
}
|
|
1809
|
+
]
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
RULE_METADATA: dict[str, dict[str, Any]] = {
|
|
1814
|
+
"xml_not_well_formed": {
|
|
1815
|
+
"name": "xml_is_well_formed",
|
|
1816
|
+
"comparison": "xml_parse_error == false",
|
|
1817
|
+
},
|
|
1818
|
+
"sml_prefixed_tag": {
|
|
1819
|
+
"name": "sml_uses_default_namespace",
|
|
1820
|
+
"comparison": "prefixed_sml_tag_count == 0",
|
|
1821
|
+
},
|
|
1822
|
+
"sxsd_unsupported_tag": {
|
|
1823
|
+
"name": "tag_is_supported_by_slides_xml_schema",
|
|
1824
|
+
"comparison": "unsupported_tag_count == 0",
|
|
1825
|
+
},
|
|
1826
|
+
"sxsd_unsupported_attr": {
|
|
1827
|
+
"name": "attribute_is_supported_by_slides_xml_schema",
|
|
1828
|
+
"comparison": "unsupported_attribute_count == 0",
|
|
1829
|
+
},
|
|
1830
|
+
"icon_missing_fill_color": {
|
|
1831
|
+
"name": "icon_has_visible_fill_color",
|
|
1832
|
+
"comparison": "fill_color_present == true",
|
|
1833
|
+
},
|
|
1834
|
+
"icon_transparent_fill_color": {
|
|
1835
|
+
"name": "icon_has_visible_fill_color",
|
|
1836
|
+
"comparison": "fill_alpha > 0",
|
|
1837
|
+
},
|
|
1838
|
+
"iconpark_unsupported_icon_type": {
|
|
1839
|
+
"name": "iconpark_type_is_supported",
|
|
1840
|
+
"comparison": "icon_type in iconpark_index",
|
|
1841
|
+
},
|
|
1842
|
+
"bbox_overlap": {
|
|
1843
|
+
"name": "text_visual_bounds_do_not_overlap",
|
|
1844
|
+
"comparison": "intersection_area == 0",
|
|
1845
|
+
},
|
|
1846
|
+
"text_may_overflow_shape": {
|
|
1847
|
+
"name": "estimated_text_fits_declared_shape",
|
|
1848
|
+
"comparison": "estimated_height <= available_height",
|
|
1849
|
+
},
|
|
1850
|
+
"whiteboard_external_overlap": {
|
|
1851
|
+
"name": "whiteboard_does_not_cross_sibling_content",
|
|
1852
|
+
"comparison": "external_overlap_count == 0",
|
|
1853
|
+
},
|
|
1854
|
+
"image_covers_text": {
|
|
1855
|
+
"name": "image_does_not_cover_text",
|
|
1856
|
+
"comparison": "intersection_area == 0",
|
|
1857
|
+
},
|
|
1858
|
+
"image_may_cover_vertical_text": {
|
|
1859
|
+
"name": "image_vertical_text_occlusion_requires_review",
|
|
1860
|
+
"comparison": "intersection_area == 0",
|
|
1861
|
+
},
|
|
1862
|
+
"table_resolved_size_mismatch": {
|
|
1863
|
+
"name": "table_declared_size_matches_resolved_grid",
|
|
1864
|
+
"comparison": "declared_size == resolved_size",
|
|
1865
|
+
},
|
|
1866
|
+
"blank_slide": {
|
|
1867
|
+
"name": "slide_has_visible_content",
|
|
1868
|
+
"comparison": "visible_element_count > 0",
|
|
1869
|
+
},
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
|
|
1874
|
+
if issue.get("rule"):
|
|
1875
|
+
return {**issue["rule"], "id": issue["code"]}
|
|
1876
|
+
if issue["code"].endswith("_out_of_canvas"):
|
|
1141
1877
|
return {
|
|
1142
|
-
"
|
|
1143
|
-
"
|
|
1144
|
-
"
|
|
1145
|
-
"issues": [xml_error],
|
|
1146
|
-
"slides": [],
|
|
1878
|
+
"id": issue["code"],
|
|
1879
|
+
"name": "element_stays_within_slide_canvas",
|
|
1880
|
+
"comparison": "max(left, top, right, bottom overflow) == 0",
|
|
1147
1881
|
}
|
|
1882
|
+
return {
|
|
1883
|
+
"id": issue["code"],
|
|
1884
|
+
**RULE_METADATA.get(
|
|
1885
|
+
issue["code"],
|
|
1886
|
+
{"name": issue["code"], "comparison": "violation_count == 0"},
|
|
1887
|
+
),
|
|
1888
|
+
}
|
|
1148
1889
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
if
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1890
|
+
|
|
1891
|
+
def issue_measurement(
|
|
1892
|
+
issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
|
|
1893
|
+
) -> dict[str, Any]:
|
|
1894
|
+
if issue.get("measurement") is not None:
|
|
1895
|
+
return issue["measurement"]
|
|
1896
|
+
if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
|
|
1897
|
+
left = elements_by_id.get(issue["elements"][0])
|
|
1898
|
+
right = elements_by_id.get(issue["elements"][1])
|
|
1899
|
+
if left and right:
|
|
1900
|
+
left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
|
|
1901
|
+
right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
|
|
1902
|
+
width = intersection_width(left_box, right_box)
|
|
1903
|
+
height = intersection_height(left_box, right_box)
|
|
1904
|
+
return {
|
|
1905
|
+
"intersection_width": round(width, 3),
|
|
1906
|
+
"intersection_height": round(height, 3),
|
|
1907
|
+
"intersection_area": round(width * height, 3),
|
|
1908
|
+
}
|
|
1909
|
+
if issue["code"].endswith("_out_of_canvas"):
|
|
1157
1910
|
return {
|
|
1158
|
-
"
|
|
1159
|
-
"
|
|
1160
|
-
"
|
|
1161
|
-
"slide_count": 0,
|
|
1162
|
-
"error_count": error_count,
|
|
1163
|
-
"warning_count": warning_count,
|
|
1164
|
-
"info_count": info_count,
|
|
1165
|
-
},
|
|
1166
|
-
"issues": top_level_issues,
|
|
1167
|
-
"slides": [],
|
|
1911
|
+
"canvas": issue.get("canvas"),
|
|
1912
|
+
"bbox": issue.get("bbox"),
|
|
1913
|
+
"overflow": issue.get("overflow"),
|
|
1168
1914
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1915
|
+
measurement_keys = (
|
|
1916
|
+
"line",
|
|
1917
|
+
"column",
|
|
1918
|
+
"tag",
|
|
1919
|
+
"attr",
|
|
1920
|
+
"iconType",
|
|
1921
|
+
"line_count",
|
|
1922
|
+
"line_height",
|
|
1923
|
+
"estimated_height",
|
|
1924
|
+
"available_height",
|
|
1925
|
+
"overflow",
|
|
1926
|
+
"dimension",
|
|
1927
|
+
"declared_size",
|
|
1928
|
+
"resolved_size",
|
|
1929
|
+
"resolved_sizes",
|
|
1930
|
+
"overlaps",
|
|
1931
|
+
)
|
|
1932
|
+
measured = {key: issue[key] for key in measurement_keys if key in issue}
|
|
1933
|
+
return measured or {"violation_count": 1}
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
|
1937
|
+
return {
|
|
1938
|
+
"element_id": element["id"],
|
|
1939
|
+
"kind": element["kind"],
|
|
1940
|
+
"type": element["type"],
|
|
1941
|
+
"bbox": {key: element[key] for key in ("x", "y", "width", "height")},
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
1946
|
+
elements: list[dict[str, Any]] = []
|
|
1947
|
+
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
|
1948
|
+
attrs = match.group(1)
|
|
1949
|
+
start_x = extract_numeric_attribute(attrs, "startX")
|
|
1950
|
+
start_y = extract_numeric_attribute(attrs, "startY")
|
|
1951
|
+
end_x = extract_numeric_attribute(attrs, "endX")
|
|
1952
|
+
end_y = extract_numeric_attribute(attrs, "endY")
|
|
1953
|
+
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
|
1954
|
+
continue
|
|
1955
|
+
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
|
1956
|
+
elements.append(
|
|
1957
|
+
{
|
|
1958
|
+
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
|
1959
|
+
"kind": "line",
|
|
1960
|
+
"type": "line",
|
|
1961
|
+
"x": min(start_x, end_x),
|
|
1962
|
+
"y": min(start_y, end_y),
|
|
1963
|
+
"width": abs(end_x - start_x),
|
|
1964
|
+
"height": abs(end_y - start_y),
|
|
1965
|
+
"rotation": 0,
|
|
1966
|
+
"alpha": line_alpha if line_alpha is not None else 1,
|
|
1967
|
+
"order": len(elements),
|
|
1968
|
+
}
|
|
1969
|
+
)
|
|
1970
|
+
return elements
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def normalize_issue(
|
|
1974
|
+
issue: dict[str, Any],
|
|
1975
|
+
slide_number: int | None,
|
|
1976
|
+
elements_by_id: dict[str, dict[str, Any]],
|
|
1977
|
+
) -> dict[str, Any]:
|
|
1978
|
+
normalized = dict(issue)
|
|
1979
|
+
if normalized.get("level") == "info":
|
|
1980
|
+
normalized["level"] = "warning"
|
|
1981
|
+
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
|
|
1982
|
+
normalized["schema_version"] = "2.0"
|
|
1983
|
+
normalized["element_ids"] = element_ids
|
|
1984
|
+
normalized["target"] = {
|
|
1985
|
+
**({"slide_number": slide_number} if slide_number is not None else {}),
|
|
1986
|
+
**normalized.get("target", {}),
|
|
1987
|
+
}
|
|
1988
|
+
normalized["rule"] = issue_rule(normalized)
|
|
1989
|
+
normalized["measurement"] = issue_measurement(normalized, elements_by_id)
|
|
1990
|
+
normalized["related_objects"] = [
|
|
1991
|
+
related_object(elements_by_id[element_id])
|
|
1992
|
+
for element_id in element_ids
|
|
1993
|
+
if element_id in elements_by_id
|
|
1173
1994
|
]
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1995
|
+
if normalized["code"] == "sparse_container_content":
|
|
1996
|
+
ratio = normalized["measurement"]["content_coverage_ratio"]
|
|
1997
|
+
threshold = normalized["rule"]["threshold"]
|
|
1998
|
+
container_id = normalized["target"].get("container_id", "unknown")
|
|
1999
|
+
normalized.setdefault(
|
|
2000
|
+
"message",
|
|
2001
|
+
f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
|
|
2002
|
+
)
|
|
2003
|
+
normalized.setdefault(
|
|
2004
|
+
"hint",
|
|
2005
|
+
"Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
|
|
2006
|
+
)
|
|
2007
|
+
elif normalized["code"] == "sparse_slide_content":
|
|
2008
|
+
ratio = normalized["measurement"]["content_coverage_ratio"]
|
|
2009
|
+
threshold = normalized["rule"]["threshold"]
|
|
2010
|
+
normalized.setdefault(
|
|
2011
|
+
"message",
|
|
2012
|
+
f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
|
|
2013
|
+
)
|
|
2014
|
+
normalized.setdefault(
|
|
2015
|
+
"hint",
|
|
2016
|
+
"Review the rendered screenshot to decide whether the page is intentionally sparse.",
|
|
2017
|
+
)
|
|
2018
|
+
else:
|
|
2019
|
+
normalized.setdefault("message", normalized["code"].replace("_", " "))
|
|
2020
|
+
normalized.setdefault(
|
|
2021
|
+
"hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
|
|
2022
|
+
)
|
|
2023
|
+
return normalized
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
|
|
2027
|
+
if errors:
|
|
2028
|
+
return "blocked"
|
|
2029
|
+
if warnings:
|
|
2030
|
+
return "needs_screenshot_review"
|
|
2031
|
+
return "passed"
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def build_result(
|
|
2035
|
+
source_path: str | None,
|
|
2036
|
+
slide_size: dict[str, int | float],
|
|
2037
|
+
top_level_issues: list[dict[str, Any]],
|
|
2038
|
+
slides: list[dict[str, Any]],
|
|
2039
|
+
) -> dict[str, Any]:
|
|
2040
|
+
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
|
|
2041
|
+
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
|
|
2042
|
+
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
|
|
2043
|
+
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
|
|
2044
|
+
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
|
|
2045
|
+
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
|
|
2046
|
+
status = slide_status(all_errors, all_warnings)
|
|
2047
|
+
result: dict[str, Any] = {
|
|
2048
|
+
"schema_version": "2.0",
|
|
2049
|
+
"tool": "xml_text_overlap_lint",
|
|
1181
2050
|
"file": source_path,
|
|
1182
|
-
"slide_size":
|
|
2051
|
+
"slide_size": slide_size,
|
|
1183
2052
|
"summary": {
|
|
1184
2053
|
"slide_count": len(slides),
|
|
1185
2054
|
"error_count": error_count,
|
|
1186
2055
|
"warning_count": warning_count,
|
|
1187
|
-
"
|
|
2056
|
+
"status": status,
|
|
2057
|
+
"release_ready": error_count == 0,
|
|
2058
|
+
"screenshot_review_required": warning_count > 0,
|
|
2059
|
+
},
|
|
2060
|
+
"document": {
|
|
2061
|
+
"errors": document_errors,
|
|
2062
|
+
"warnings": document_warnings,
|
|
1188
2063
|
},
|
|
1189
2064
|
"slides": slides,
|
|
1190
2065
|
}
|
|
@@ -1193,6 +2068,107 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
1193
2068
|
return result
|
|
1194
2069
|
|
|
1195
2070
|
|
|
2071
|
+
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
2072
|
+
root, xml_error = parse_xml_root(xml)
|
|
2073
|
+
if xml_error:
|
|
2074
|
+
issue = normalize_issue(xml_error, None, {})
|
|
2075
|
+
return build_result(
|
|
2076
|
+
source_path,
|
|
2077
|
+
{"width": 960, "height": 540},
|
|
2078
|
+
[issue],
|
|
2079
|
+
[],
|
|
2080
|
+
)
|
|
2081
|
+
if root is None:
|
|
2082
|
+
raise AssertionError("parse_xml_root must return a root or error")
|
|
2083
|
+
|
|
2084
|
+
namespace_issues = validate_sml_tag_prefixes(xml)
|
|
2085
|
+
sxsd_issues = validate_sxsd_tag_attributes(root)
|
|
2086
|
+
iconpark_issues = validate_iconpark_icon_types(root)
|
|
2087
|
+
top_level_issues = [
|
|
2088
|
+
normalize_issue(issue, None, {})
|
|
2089
|
+
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
|
|
2090
|
+
]
|
|
2091
|
+
if any(issue["level"] == "error" for issue in top_level_issues):
|
|
2092
|
+
return build_result(
|
|
2093
|
+
source_path,
|
|
2094
|
+
{"width": 960, "height": 540},
|
|
2095
|
+
top_level_issues,
|
|
2096
|
+
[],
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
presentation = parse_presentation(xml)
|
|
2100
|
+
slides: list[dict[str, Any]] = []
|
|
2101
|
+
for index, slide_xml in enumerate(presentation["slides"]):
|
|
2102
|
+
slide_number = index + 1
|
|
2103
|
+
geometry = lint_slide(
|
|
2104
|
+
slide_xml,
|
|
2105
|
+
slide_number,
|
|
2106
|
+
presentation["width"],
|
|
2107
|
+
presentation["height"],
|
|
2108
|
+
)
|
|
2109
|
+
density_elements = extract_density_elements(slide_xml)
|
|
2110
|
+
extra_elements = [
|
|
2111
|
+
element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
|
|
2112
|
+
]
|
|
2113
|
+
elements_by_id = {
|
|
2114
|
+
element["id"]: element for element in [*density_elements, *extra_elements]
|
|
2115
|
+
}
|
|
2116
|
+
# geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
|
|
2117
|
+
# decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
|
|
2118
|
+
# with whatever actually triggered the issue, instead of density_elements' separate re-parse.
|
|
2119
|
+
elements_by_id.update({element["id"]: element for element in geometry["elements"]})
|
|
2120
|
+
extra_overflow_issues = detect_elements_out_of_canvas(
|
|
2121
|
+
extra_elements,
|
|
2122
|
+
presentation["width"],
|
|
2123
|
+
presentation["height"],
|
|
2124
|
+
)
|
|
2125
|
+
raw_issues = [
|
|
2126
|
+
*geometry["issues"],
|
|
2127
|
+
*extra_overflow_issues,
|
|
2128
|
+
*detect_blank_slide(
|
|
2129
|
+
density_elements,
|
|
2130
|
+
slide_number,
|
|
2131
|
+
presentation["width"],
|
|
2132
|
+
presentation["height"],
|
|
2133
|
+
),
|
|
2134
|
+
*detect_sparse_container_content(
|
|
2135
|
+
density_elements,
|
|
2136
|
+
slide_number,
|
|
2137
|
+
presentation["width"],
|
|
2138
|
+
presentation["height"],
|
|
2139
|
+
),
|
|
2140
|
+
*detect_sparse_slide_content(
|
|
2141
|
+
density_elements,
|
|
2142
|
+
slide_number,
|
|
2143
|
+
presentation["width"],
|
|
2144
|
+
presentation["height"],
|
|
2145
|
+
),
|
|
2146
|
+
]
|
|
2147
|
+
issues = [
|
|
2148
|
+
normalize_issue(issue, slide_number, elements_by_id)
|
|
2149
|
+
for issue in raw_issues
|
|
2150
|
+
]
|
|
2151
|
+
errors = [issue for issue in issues if issue["level"] == "error"]
|
|
2152
|
+
warnings = [issue for issue in issues if issue["level"] == "warning"]
|
|
2153
|
+
slides.append(
|
|
2154
|
+
{
|
|
2155
|
+
"slide_number": slide_number,
|
|
2156
|
+
"status": slide_status(errors, warnings),
|
|
2157
|
+
"element_count": len(elements_by_id),
|
|
2158
|
+
"errors": errors,
|
|
2159
|
+
"warnings": warnings,
|
|
2160
|
+
"issues": issues,
|
|
2161
|
+
}
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
return build_result(
|
|
2165
|
+
source_path,
|
|
2166
|
+
{"width": presentation["width"], "height": presentation["height"]},
|
|
2167
|
+
top_level_issues,
|
|
2168
|
+
slides,
|
|
2169
|
+
)
|
|
2170
|
+
|
|
2171
|
+
|
|
1196
2172
|
def print_usage() -> None:
|
|
1197
2173
|
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
|
|
1198
2174
|
|
|
@@ -1215,6 +2191,6 @@ def run_cli(argv: list[str] | None = None) -> None:
|
|
|
1215
2191
|
if __name__ == "__main__":
|
|
1216
2192
|
try:
|
|
1217
2193
|
run_cli()
|
|
1218
|
-
except
|
|
2194
|
+
except XmlLayoutLintError as error:
|
|
1219
2195
|
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
|
|
1220
2196
|
raise SystemExit(1) from error
|