@amaster.ai/pi-lark 0.1.2-beta.47 → 0.1.2-beta.49
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/README.md +5 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/lark-apps/SKILL.md +1 -0
- package/skills/lark-apps/references/lark-apps-cache.md +61 -0
- package/skills/lark-base/SKILL.md +16 -7
- package/skills/lark-base/references/lark-base-data-query.md +11 -4
- package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
- package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
- package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
- package/skills/lark-base/references/lark-base-role-guide.md +11 -0
- package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
- package/skills/lark-base/references/role-config.md +31 -5
- package/skills/lark-calendar/SKILL.md +14 -8
- package/skills/lark-calendar/references/lark-calendar-create.md +6 -6
- package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
- package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
- package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
- package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
- package/skills/lark-calendar/references/lark-calendar-update.md +7 -4
- package/skills/lark-contact/SKILL.md +19 -3
- package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
- package/skills/lark-drive/SKILL.md +21 -44
- package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
- package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
- package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
- package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
- package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
- package/skills/lark-drive/references/lark-drive-comment-location.md +7 -13
- package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
- package/skills/lark-drive/references/lark-drive-download.md +5 -1
- package/skills/lark-drive/references/lark-drive-list-comments.md +25 -68
- package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
- package/skills/lark-drive/references/lark-drive-member-add.md +2 -2
- package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
- package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
- package/skills/lark-drive/references/lark-drive-preview.md +11 -1
- package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
- package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
- package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
- package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
- package/skills/lark-drive/references/lark-drive-search.md +6 -1
- package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
- package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
- package/skills/lark-slides/SKILL.md +16 -26
- package/skills/lark-slides/references/lark-slides-create.md +14 -5
- package/skills/lark-slides/references/lark-slides-media-upload.md +1 -1
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +491 -31
- package/skills/lark-slides/references/xml-schema-quick-ref.md +39 -0
- package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +633 -124
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +2038 -219
- package/skills/lark-task/references/lark-task-create.md +9 -0
- package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -80
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import copy
|
|
8
9
|
import json
|
|
9
10
|
import math
|
|
10
11
|
import re
|
|
@@ -16,6 +17,8 @@ from difflib import SequenceMatcher, get_close_matches
|
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
20
|
+
import sxsd_validator
|
|
21
|
+
|
|
19
22
|
|
|
20
23
|
XS_NS = "{http://www.w3.org/2001/XMLSchema}"
|
|
21
24
|
XML_NS = "{http://www.w3.org/XML/1998/namespace}"
|
|
@@ -44,11 +47,28 @@ ROUNDTRIP_SXSD_ATTRS = {
|
|
|
44
47
|
("chartData", "isStaticData"),
|
|
45
48
|
}
|
|
46
49
|
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
|
|
47
|
-
# it
|
|
48
|
-
|
|
49
|
-
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
|
|
50
|
+
# it is server-emitted and absent from the write schema, so it must not block page linting.
|
|
51
|
+
ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")}
|
|
50
52
|
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
|
51
53
|
DEFAULT_TABLE_ROW_HEIGHT = 37
|
|
54
|
+
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
|
|
55
|
+
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
|
|
56
|
+
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
|
|
57
|
+
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
|
|
58
|
+
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
|
|
59
|
+
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
|
|
60
|
+
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
|
|
61
|
+
GHOST_TEXT_MIN_FONT_SIZE = 96
|
|
62
|
+
GHOST_TEXT_MAX_ALPHA = 0.5
|
|
63
|
+
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
|
|
64
|
+
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
|
|
65
|
+
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
|
|
66
|
+
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
|
|
67
|
+
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
|
|
68
|
+
LINE_TEXT_GRAZE_MIN_PX = 2.0
|
|
69
|
+
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
|
|
70
|
+
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
|
|
71
|
+
LINE_MIN_VISIBLE_ALPHA = 0.08
|
|
52
72
|
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
|
|
53
73
|
# visible defect; keep this well under 1px so real overflow is still always caught.
|
|
54
74
|
CANVAS_OVERFLOW_TOLERANCE = 0.5
|
|
@@ -106,6 +126,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
|
|
106
126
|
return int(value) if value.is_integer() else value
|
|
107
127
|
|
|
108
128
|
|
|
129
|
+
def extract_bool_attribute(tag_source: str, name: str) -> bool:
|
|
130
|
+
value = extract_attribute(tag_source, name)
|
|
131
|
+
return value in {"true", "1", "yes"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_color_alpha(color: str | None) -> int | float | None:
|
|
135
|
+
if color is None:
|
|
136
|
+
return None
|
|
137
|
+
normalized = re.sub(r"\s+", "", color).lower()
|
|
138
|
+
if normalized == "transparent":
|
|
139
|
+
return 0
|
|
140
|
+
rgba_match = re.fullmatch(
|
|
141
|
+
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
|
|
142
|
+
normalized,
|
|
143
|
+
)
|
|
144
|
+
if rgba_match is None:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
alpha = float(rgba_match.group(1))
|
|
148
|
+
except ValueError:
|
|
149
|
+
return None
|
|
150
|
+
return int(alpha) if alpha.is_integer() else alpha
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
|
|
154
|
+
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
|
|
155
|
+
color_alpha = extract_color_alpha(text_color)
|
|
156
|
+
if not isinstance(color_alpha, (int, float)):
|
|
157
|
+
return base_alpha
|
|
158
|
+
return base_alpha * color_alpha
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
|
|
162
|
+
for tag_name in style_tags:
|
|
163
|
+
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
|
|
169
|
+
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
|
|
170
|
+
if extract_bool_attribute(attrs, attr_name):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
109
175
|
def sum_sizes(sizes: list[int | float]) -> int | float:
|
|
110
176
|
return sum(sizes)
|
|
111
177
|
|
|
@@ -218,6 +284,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
|
|
|
218
284
|
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
|
|
219
285
|
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
|
|
220
286
|
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
|
|
287
|
+
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
|
|
221
288
|
}
|
|
222
289
|
)
|
|
223
290
|
return paragraphs
|
|
@@ -245,77 +312,13 @@ def xml_namespace(tag: str) -> str | None:
|
|
|
245
312
|
return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None
|
|
246
313
|
|
|
247
314
|
|
|
248
|
-
def strip_xsd_prefix(value: str | None) -> str | None:
|
|
249
|
-
if value is None:
|
|
250
|
-
return None
|
|
251
|
-
return value.rsplit(":", 1)[-1]
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]:
|
|
255
|
-
return [child for child in element if child.tag == f"{XS_NS}{local_name}"]
|
|
256
|
-
|
|
257
|
-
|
|
258
315
|
def load_sxsd_tag_attributes() -> dict[str, set[str]]:
|
|
259
316
|
global _SXSD_TAG_ATTRIBUTES_CACHE
|
|
260
317
|
if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
|
|
261
318
|
return _SXSD_TAG_ATTRIBUTES_CACHE
|
|
262
319
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
complex_type.attrib["name"]: complex_type
|
|
266
|
-
for complex_type in schema_root.findall(f"{XS_NS}complexType")
|
|
267
|
-
if complex_type.attrib.get("name")
|
|
268
|
-
}
|
|
269
|
-
resolving: set[str] = set()
|
|
270
|
-
|
|
271
|
-
def attributes_for_complex_type(complex_type: ET.Element) -> set[str]:
|
|
272
|
-
attrs: set[str] = {
|
|
273
|
-
attribute.attrib["name"]
|
|
274
|
-
for attribute in iter_direct_xsd_children(complex_type, "attribute")
|
|
275
|
-
if attribute.attrib.get("name")
|
|
276
|
-
}
|
|
277
|
-
for content_name in ("simpleContent", "complexContent"):
|
|
278
|
-
for complex_content in iter_direct_xsd_children(complex_type, content_name):
|
|
279
|
-
for extension in iter_direct_xsd_children(complex_content, "extension"):
|
|
280
|
-
base_type = strip_xsd_prefix(extension.attrib.get("base"))
|
|
281
|
-
if base_type:
|
|
282
|
-
attrs.update(attributes_for_type(base_type))
|
|
283
|
-
attrs.update(
|
|
284
|
-
attribute.attrib["name"]
|
|
285
|
-
for attribute in iter_direct_xsd_children(extension, "attribute")
|
|
286
|
-
if attribute.attrib.get("name")
|
|
287
|
-
)
|
|
288
|
-
return attrs
|
|
289
|
-
|
|
290
|
-
def attributes_for_type(type_name: str) -> set[str]:
|
|
291
|
-
if type_name in resolving:
|
|
292
|
-
return set()
|
|
293
|
-
complex_type = named_complex_types.get(type_name)
|
|
294
|
-
if complex_type is None:
|
|
295
|
-
return set()
|
|
296
|
-
resolving.add(type_name)
|
|
297
|
-
try:
|
|
298
|
-
return attributes_for_complex_type(complex_type)
|
|
299
|
-
finally:
|
|
300
|
-
resolving.remove(type_name)
|
|
301
|
-
|
|
302
|
-
tag_attributes: dict[str, set[str]] = {}
|
|
303
|
-
for element in schema_root.iter(f"{XS_NS}element"):
|
|
304
|
-
tag_name = element.attrib.get("name")
|
|
305
|
-
if not tag_name:
|
|
306
|
-
continue
|
|
307
|
-
|
|
308
|
-
attrs: set[str] = set()
|
|
309
|
-
type_name = strip_xsd_prefix(element.attrib.get("type"))
|
|
310
|
-
if type_name:
|
|
311
|
-
attrs.update(attributes_for_type(type_name))
|
|
312
|
-
for complex_type in iter_direct_xsd_children(element, "complexType"):
|
|
313
|
-
attrs.update(attributes_for_complex_type(complex_type))
|
|
314
|
-
|
|
315
|
-
tag_attributes.setdefault(tag_name, set()).update(attrs)
|
|
316
|
-
|
|
317
|
-
_SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes
|
|
318
|
-
return tag_attributes
|
|
320
|
+
_SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH)
|
|
321
|
+
return _SXSD_TAG_ATTRIBUTES_CACHE
|
|
319
322
|
|
|
320
323
|
|
|
321
324
|
def load_iconpark_icon_types() -> set[str]:
|
|
@@ -352,13 +355,19 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
|
|
|
352
355
|
return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."
|
|
353
356
|
|
|
354
357
|
|
|
355
|
-
def
|
|
358
|
+
def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]:
|
|
356
359
|
alias = SXSD_ATTR_ALIASES.get(attr_name)
|
|
357
360
|
if alias and alias in allowed_attrs:
|
|
358
|
-
return
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
361
|
+
return [alias]
|
|
362
|
+
return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
|
|
366
|
+
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
|
|
367
|
+
if suggestions:
|
|
368
|
+
if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]:
|
|
369
|
+
return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".'
|
|
370
|
+
return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?"
|
|
362
371
|
allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
|
|
363
372
|
if len(allowed_attrs) > 8:
|
|
364
373
|
allowed_summary += ", ..."
|
|
@@ -373,10 +382,33 @@ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
|
|
|
373
382
|
return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
|
|
374
383
|
|
|
375
384
|
|
|
376
|
-
def
|
|
385
|
+
def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool:
|
|
386
|
+
return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element:
|
|
390
|
+
sanitized_root = copy.deepcopy(root)
|
|
391
|
+
|
|
392
|
+
def sanitize(element: ET.Element) -> None:
|
|
393
|
+
tag_name = xml_local_name(element.tag)
|
|
394
|
+
for raw_attr_name in list(element.attrib):
|
|
395
|
+
if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)):
|
|
396
|
+
del element.attrib[raw_attr_name]
|
|
397
|
+
for child in list(element):
|
|
398
|
+
if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)):
|
|
399
|
+
element.remove(child)
|
|
400
|
+
continue
|
|
401
|
+
sanitize(child)
|
|
402
|
+
|
|
403
|
+
sanitize(sanitized_root)
|
|
404
|
+
return sanitized_root
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]:
|
|
377
408
|
tag_attributes = load_sxsd_tag_attributes()
|
|
378
409
|
supported_tags = set(tag_attributes)
|
|
379
410
|
issues: list[dict[str, Any]] = []
|
|
411
|
+
suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {}
|
|
380
412
|
|
|
381
413
|
def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
|
|
382
414
|
if should_skip_sxsd_subtree(element, ancestors):
|
|
@@ -384,7 +416,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
|
384
416
|
|
|
385
417
|
tag_name = xml_local_name(element.tag)
|
|
386
418
|
current_path = f"{path}/{tag_name}" if path else tag_name
|
|
387
|
-
if
|
|
419
|
+
parent_name = ancestors[-1] if ancestors else None
|
|
420
|
+
if should_skip_sxsd_tag(parent_name, tag_name):
|
|
388
421
|
return
|
|
389
422
|
if tag_name not in supported_tags:
|
|
390
423
|
issues.append(
|
|
@@ -408,6 +441,11 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
|
408
441
|
continue
|
|
409
442
|
if attr_name in allowed_attrs:
|
|
410
443
|
continue
|
|
444
|
+
suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
|
|
445
|
+
if suggestions:
|
|
446
|
+
suggested_attr_candidates.setdefault((current_path, tag_name), []).append(
|
|
447
|
+
set(suggestions)
|
|
448
|
+
)
|
|
411
449
|
issues.append(
|
|
412
450
|
{
|
|
413
451
|
"level": "error",
|
|
@@ -424,6 +462,76 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
|
|
424
462
|
visit(child, [*ancestors, tag_name], current_path)
|
|
425
463
|
|
|
426
464
|
visit(root, [], "")
|
|
465
|
+
existing = {
|
|
466
|
+
(issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr"))
|
|
467
|
+
for issue in issues
|
|
468
|
+
}
|
|
469
|
+
unsupported_tag_locations = {
|
|
470
|
+
(issue.get("path"), issue.get("tag"))
|
|
471
|
+
for issue in issues
|
|
472
|
+
if issue.get("code") == "sxsd_unsupported_tag"
|
|
473
|
+
}
|
|
474
|
+
schema_issues = _validate_sxsd_schema_constraints(xml, root)
|
|
475
|
+
missing_attrs_by_location: dict[tuple[str, str], set[str]] = {}
|
|
476
|
+
for schema_issue in schema_issues:
|
|
477
|
+
if schema_issue.get("code") != "sxsd_missing_required_attr":
|
|
478
|
+
continue
|
|
479
|
+
location = (schema_issue.get("path"), schema_issue.get("tag"))
|
|
480
|
+
missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr"))
|
|
481
|
+
|
|
482
|
+
suggested_attrs: set[tuple[str, str, str]] = set()
|
|
483
|
+
for location, candidate_groups in suggested_attr_candidates.items():
|
|
484
|
+
missing_attrs = missing_attrs_by_location.get(location, set())
|
|
485
|
+
for candidates in candidate_groups:
|
|
486
|
+
matching_missing_attrs = candidates & missing_attrs
|
|
487
|
+
if len(matching_missing_attrs) == 1:
|
|
488
|
+
suggested_attrs.add((*location, next(iter(matching_missing_attrs))))
|
|
489
|
+
|
|
490
|
+
for schema_issue in schema_issues:
|
|
491
|
+
if schema_issue.get("code") == "sxsd_unexpected_child" and (
|
|
492
|
+
schema_issue.get("path"),
|
|
493
|
+
schema_issue.get("tag"),
|
|
494
|
+
) in unsupported_tag_locations:
|
|
495
|
+
continue
|
|
496
|
+
if schema_issue.get("code") == "sxsd_missing_required_attr" and (
|
|
497
|
+
schema_issue.get("path"),
|
|
498
|
+
schema_issue.get("tag"),
|
|
499
|
+
schema_issue.get("attr"),
|
|
500
|
+
) in suggested_attrs:
|
|
501
|
+
continue
|
|
502
|
+
key = (
|
|
503
|
+
schema_issue.get("code"),
|
|
504
|
+
schema_issue.get("path"),
|
|
505
|
+
schema_issue.get("tag"),
|
|
506
|
+
schema_issue.get("attr"),
|
|
507
|
+
)
|
|
508
|
+
if key not in existing:
|
|
509
|
+
issues.append(schema_issue)
|
|
510
|
+
return issues
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]:
|
|
514
|
+
issues: list[dict[str, Any]] = []
|
|
515
|
+
if re.match(r"^\s*<\?xml\b", xml):
|
|
516
|
+
issues.append(
|
|
517
|
+
{
|
|
518
|
+
"level": "error",
|
|
519
|
+
"code": "sxsd_unsupported_declaration",
|
|
520
|
+
"path": xml_local_name(root.tag),
|
|
521
|
+
"tag": xml_local_name(root.tag),
|
|
522
|
+
"expected": "SXSD document without an XML declaration",
|
|
523
|
+
"actual": "<?xml ...?>",
|
|
524
|
+
"message": "XML declarations are not supported by the Slides SXSD write format",
|
|
525
|
+
"hint": "Remove the <?xml ...?> declaration and keep the SXSD root element.",
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
issues.extend(
|
|
530
|
+
sxsd_validator.validate_sxsd(
|
|
531
|
+
without_server_filled_sxsd_fields(root),
|
|
532
|
+
SXSD_SCHEMA_PATH,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
427
535
|
return issues
|
|
428
536
|
|
|
429
537
|
|
|
@@ -627,18 +735,39 @@ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
|
|
|
627
735
|
return xml_error
|
|
628
736
|
|
|
629
737
|
|
|
630
|
-
def
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
738
|
+
def serialize_slide_for_layout(slide_root: ET.Element) -> str:
|
|
739
|
+
slide_copy = copy.deepcopy(slide_root)
|
|
740
|
+
for element in slide_copy.iter():
|
|
741
|
+
if not isinstance(element.tag, str):
|
|
742
|
+
continue
|
|
743
|
+
element.tag = xml_local_name(element.tag)
|
|
744
|
+
attributes = {
|
|
745
|
+
xml_local_name(attribute_name): value
|
|
746
|
+
for attribute_name, value in element.attrib.items()
|
|
637
747
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
748
|
+
element.attrib.clear()
|
|
749
|
+
element.attrib.update(attributes)
|
|
750
|
+
return ET.tostring(slide_copy, encoding="unicode")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def parse_presentation(root: ET.Element) -> dict[str, Any]:
|
|
754
|
+
root_name = xml_local_name(root.tag)
|
|
755
|
+
if root_name == "slide":
|
|
756
|
+
slide_roots = [root]
|
|
757
|
+
width = 960
|
|
758
|
+
height = 540
|
|
759
|
+
elif root_name == "presentation":
|
|
760
|
+
slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"]
|
|
761
|
+
width = int(float(root.attrib.get("width", 960)))
|
|
762
|
+
height = int(float(root.attrib.get("height", 540)))
|
|
763
|
+
else:
|
|
764
|
+
fail("input must contain a <presentation> or <slide> root")
|
|
765
|
+
return {
|
|
766
|
+
"width": width,
|
|
767
|
+
"height": height,
|
|
768
|
+
"slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots],
|
|
769
|
+
"slide_roots": slide_roots,
|
|
770
|
+
}
|
|
642
771
|
|
|
643
772
|
|
|
644
773
|
def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
@@ -694,6 +823,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
694
823
|
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
|
695
824
|
if font_size is None:
|
|
696
825
|
font_size = extract_numeric_attribute(attrs, "fontSize")
|
|
826
|
+
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
|
|
827
|
+
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
|
|
828
|
+
bold = (
|
|
829
|
+
extract_bool_attribute(content_attrs, "bold")
|
|
830
|
+
or extract_bool_attribute(attrs, "bold")
|
|
831
|
+
or detect_inline_style_presence(content, {"strong", "b"})
|
|
832
|
+
or detect_any_span_bool_attribute(content, "bold")
|
|
833
|
+
)
|
|
834
|
+
italic = (
|
|
835
|
+
extract_bool_attribute(content_attrs, "italic")
|
|
836
|
+
or extract_bool_attribute(attrs, "italic")
|
|
837
|
+
or detect_inline_style_presence(content, {"i", "em"})
|
|
838
|
+
or detect_any_span_bool_attribute(content, "italic")
|
|
839
|
+
)
|
|
697
840
|
element.update(
|
|
698
841
|
{
|
|
699
842
|
"textType": extract_attribute(content_attrs, "textType"),
|
|
@@ -705,11 +848,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
705
848
|
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
|
|
706
849
|
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
|
|
707
850
|
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
|
|
851
|
+
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
|
|
708
852
|
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
|
|
709
853
|
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
|
|
710
854
|
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
|
|
711
855
|
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
|
|
712
856
|
"fontSize": font_size if font_size is not None else 16,
|
|
857
|
+
"fontFamily": font_family or "",
|
|
858
|
+
"color": text_color,
|
|
859
|
+
"textAlpha": effective_text_alpha(alpha, text_color),
|
|
860
|
+
"bold": bold,
|
|
861
|
+
"italic": italic,
|
|
713
862
|
"text": strip_xml_paragraphs(content),
|
|
714
863
|
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
|
|
715
864
|
}
|
|
@@ -745,7 +894,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
|
|
|
745
894
|
|
|
746
895
|
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
747
896
|
issues: list[dict[str, Any]] = []
|
|
748
|
-
text_elements = [
|
|
897
|
+
text_elements = [
|
|
898
|
+
element
|
|
899
|
+
for element in elements
|
|
900
|
+
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
|
|
901
|
+
]
|
|
749
902
|
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
|
|
750
903
|
for text_element in text_elements:
|
|
751
904
|
for image_element in image_elements:
|
|
@@ -782,22 +935,135 @@ def normalize_text_for_overlap(text: str) -> str:
|
|
|
782
935
|
return re.sub(r"\s+", "", text)
|
|
783
936
|
|
|
784
937
|
|
|
785
|
-
|
|
938
|
+
SERIF_FONT_PATTERNS = {
|
|
939
|
+
"song", "songti", "simsun", "ming", "mincho",
|
|
940
|
+
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
|
|
941
|
+
"source han serif", "思源宋体", "宋体", "明体",
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
|
|
945
|
+
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def classify_font_family(font_family: str | None) -> str:
|
|
949
|
+
if not font_family:
|
|
950
|
+
return "sans"
|
|
951
|
+
family_lower = font_family.lower()
|
|
952
|
+
for marker in SANS_EXPLICIT_MARKERS:
|
|
953
|
+
if marker in family_lower:
|
|
954
|
+
return "sans"
|
|
955
|
+
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
|
|
956
|
+
for pattern in serif_keywords:
|
|
957
|
+
if pattern in family_lower:
|
|
958
|
+
return "serif"
|
|
959
|
+
return "sans"
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
|
|
963
|
+
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
|
|
964
|
+
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def estimate_character_width(
|
|
969
|
+
character: str,
|
|
970
|
+
font_size: int | float,
|
|
971
|
+
bold: bool = False,
|
|
972
|
+
font_family: str | None = None,
|
|
973
|
+
) -> int | float:
|
|
974
|
+
bold_multiplier = 1.05 if bold else 1.0
|
|
786
975
|
if character.isspace():
|
|
787
|
-
return font_size * 0.33
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
976
|
+
return font_size * 0.33 * bold_multiplier
|
|
977
|
+
ea_width = unicodedata.east_asian_width(character)
|
|
978
|
+
if ea_width in {"F", "W"}:
|
|
979
|
+
return font_size * bold_multiplier
|
|
980
|
+
category = classify_font_family(font_family)
|
|
981
|
+
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
|
|
982
|
+
if character.isupper():
|
|
983
|
+
return font_size * coeffs["upper"] * bold_multiplier
|
|
984
|
+
if character.islower():
|
|
985
|
+
return font_size * coeffs["lower"] * bold_multiplier
|
|
986
|
+
if character.isdigit():
|
|
987
|
+
return font_size * coeffs["digit"] * bold_multiplier
|
|
988
|
+
return font_size * coeffs["punct"] * bold_multiplier
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def estimate_text_width(
|
|
992
|
+
text: str,
|
|
993
|
+
font_size: int | float,
|
|
994
|
+
letter_spacing: int | float = 0,
|
|
995
|
+
bold: bool = False,
|
|
996
|
+
font_family: str | None = None,
|
|
997
|
+
) -> int | float:
|
|
998
|
+
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
|
|
999
|
+
return base + max(len(text) - 1, 0) * letter_spacing
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
|
|
1003
|
+
if paragraph is not None:
|
|
1004
|
+
value = paragraph.get("letterSpacing")
|
|
1005
|
+
if isinstance(value, (int, float)):
|
|
1006
|
+
return value
|
|
1007
|
+
value = element.get("letterSpacing")
|
|
1008
|
+
return value if isinstance(value, (int, float)) else 0
|
|
1009
|
+
|
|
791
1010
|
|
|
1011
|
+
def text_wrap_width_tolerance() -> int | float:
|
|
1012
|
+
return TEXT_WRAP_WIDTH_TOLERANCE_PX
|
|
792
1013
|
|
|
793
|
-
|
|
794
|
-
|
|
1014
|
+
|
|
1015
|
+
def text_height_overflow_tolerance() -> int | float:
|
|
1016
|
+
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
|
|
1020
|
+
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def is_short_metric_text(text: str) -> bool:
|
|
1024
|
+
compact = re.sub(r"\s+", "", text)
|
|
1025
|
+
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
|
|
1026
|
+
return False
|
|
1027
|
+
if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
|
|
1028
|
+
return True
|
|
1029
|
+
if re.search(r"[,.,+\-–—/%%]", compact) is None:
|
|
1030
|
+
return False
|
|
1031
|
+
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def is_single_line_visual_candidate(
|
|
1035
|
+
element: dict[str, Any],
|
|
1036
|
+
paragraph: dict[str, Any] | None,
|
|
1037
|
+
text: str,
|
|
1038
|
+
logical_width: int | float,
|
|
1039
|
+
effective_width: int | float,
|
|
1040
|
+
) -> bool:
|
|
1041
|
+
if "\n" in text or logical_width <= effective_width:
|
|
1042
|
+
return False
|
|
1043
|
+
if is_short_metric_text(text):
|
|
1044
|
+
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
|
|
1045
|
+
|
|
1046
|
+
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
|
|
1047
|
+
compact_len = len(re.sub(r"\s+", "", text))
|
|
1048
|
+
if text_align == "center" and compact_len <= 32:
|
|
1049
|
+
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
|
|
1050
|
+
|
|
1051
|
+
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
1052
|
+
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
|
|
1053
|
+
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
|
|
1054
|
+
return False
|
|
795
1055
|
|
|
796
1056
|
|
|
797
1057
|
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
|
|
798
1058
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
1059
|
+
bold = element.get("bold", False)
|
|
1060
|
+
font_family = element.get("fontFamily", "")
|
|
1061
|
+
letter_spacing = resolve_letter_spacing(element)
|
|
799
1062
|
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
|
|
800
|
-
return max(
|
|
1063
|
+
return max(
|
|
1064
|
+
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
|
|
1065
|
+
or [1]
|
|
1066
|
+
)
|
|
801
1067
|
|
|
802
1068
|
|
|
803
1069
|
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
@@ -810,8 +1076,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
|
|
|
810
1076
|
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
|
|
811
1077
|
|
|
812
1078
|
|
|
813
|
-
def estimate_text_line_count_for_text(
|
|
1079
|
+
def estimate_text_line_count_for_text(
|
|
1080
|
+
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
|
|
1081
|
+
) -> int:
|
|
814
1082
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
1083
|
+
bold = element.get("bold", False)
|
|
1084
|
+
font_family = element.get("fontFamily", "")
|
|
1085
|
+
letter_spacing = resolve_letter_spacing(element, paragraph)
|
|
1086
|
+
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
|
|
815
1087
|
hard_lines = text.split("\n")
|
|
816
1088
|
if not text:
|
|
817
1089
|
return 0
|
|
@@ -820,8 +1092,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
|
|
|
820
1092
|
if element.get("wrap") in {"false", "0"}:
|
|
821
1093
|
line_count += 1
|
|
822
1094
|
continue
|
|
823
|
-
logical_width = max(estimate_text_width(hard_line, font_size), 1)
|
|
824
|
-
|
|
1095
|
+
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
|
|
1096
|
+
effective_width = available_width + text_wrap_width_tolerance()
|
|
1097
|
+
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
|
|
1098
|
+
line_count += 1
|
|
1099
|
+
continue
|
|
1100
|
+
line_count += max(1, math.ceil(logical_width / effective_width))
|
|
825
1101
|
return line_count
|
|
826
1102
|
|
|
827
1103
|
|
|
@@ -831,7 +1107,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
|
|
|
831
1107
|
|
|
832
1108
|
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
|
|
833
1109
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
834
|
-
|
|
1110
|
+
if line_spacing is None:
|
|
1111
|
+
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
|
|
835
1112
|
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
|
836
1113
|
if match is None:
|
|
837
1114
|
return None
|
|
@@ -839,12 +1116,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
|
|
|
839
1116
|
return font_size * float(value) if spacing_type == "multiple" else float(value)
|
|
840
1117
|
|
|
841
1118
|
|
|
1119
|
+
def adjust_dense_body_line_height(
|
|
1120
|
+
element: dict[str, Any],
|
|
1121
|
+
line_spacing: str | None,
|
|
1122
|
+
line_height: int | float,
|
|
1123
|
+
paragraph_count: int,
|
|
1124
|
+
) -> int | float:
|
|
1125
|
+
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
1126
|
+
if paragraph_count < 4 or font_size > 14 or not line_spacing:
|
|
1127
|
+
return line_height
|
|
1128
|
+
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
|
1129
|
+
if match is None:
|
|
1130
|
+
return line_height
|
|
1131
|
+
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
|
|
1132
|
+
|
|
1133
|
+
|
|
842
1134
|
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
843
1135
|
issues: list[dict[str, Any]] = []
|
|
844
1136
|
for element in elements:
|
|
845
1137
|
if not is_text_element(element) or not has_text_content(element):
|
|
846
1138
|
continue
|
|
847
|
-
if element
|
|
1139
|
+
if has_explicit_height_auto_fit(element):
|
|
848
1140
|
continue
|
|
849
1141
|
|
|
850
1142
|
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
@@ -860,10 +1152,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
|
|
860
1152
|
estimated_height = 0.0
|
|
861
1153
|
line_heights: list[int | float] = []
|
|
862
1154
|
for paragraph in paragraphs:
|
|
863
|
-
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
|
|
1155
|
+
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
|
|
864
1156
|
if paragraph_line_count == 0:
|
|
865
1157
|
continue
|
|
866
|
-
|
|
1158
|
+
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
|
|
1159
|
+
line_height = estimate_text_line_height(element, resolved_line_spacing)
|
|
867
1160
|
before_spacing = estimate_text_line_height(
|
|
868
1161
|
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
|
|
869
1162
|
)
|
|
@@ -873,6 +1166,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
|
|
873
1166
|
if line_height is None or before_spacing is None or after_spacing is None:
|
|
874
1167
|
line_count = 0
|
|
875
1168
|
break
|
|
1169
|
+
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
|
|
876
1170
|
first_line_height = font_size if line_count == 0 else line_height
|
|
877
1171
|
line_count += paragraph_line_count
|
|
878
1172
|
line_heights.append(line_height)
|
|
@@ -883,12 +1177,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
|
|
883
1177
|
continue
|
|
884
1178
|
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
|
|
885
1179
|
overflow = estimated_height - available_height
|
|
886
|
-
if overflow <=
|
|
1180
|
+
if overflow <= text_height_overflow_tolerance():
|
|
887
1181
|
continue
|
|
888
1182
|
|
|
1183
|
+
is_background = is_background_decorative_text(element, elements)
|
|
1184
|
+
if is_background:
|
|
1185
|
+
level = "info"
|
|
1186
|
+
else:
|
|
1187
|
+
level = "error" if overflow > 10 else "warning"
|
|
1188
|
+
message = (
|
|
1189
|
+
f'text shape {element["id"]} may overflow its own content box '
|
|
1190
|
+
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
|
1191
|
+
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
|
1192
|
+
)
|
|
1193
|
+
if is_background:
|
|
1194
|
+
message += " (likely background decoration: large font, low alpha, underneath other text)"
|
|
889
1195
|
issues.append(
|
|
890
1196
|
{
|
|
891
|
-
"level":
|
|
1197
|
+
"level": level,
|
|
892
1198
|
"code": "text_may_overflow_shape",
|
|
893
1199
|
"elements": [element["id"]],
|
|
894
1200
|
"line_count": line_count,
|
|
@@ -896,11 +1202,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
|
|
896
1202
|
"estimated_height": estimated_height,
|
|
897
1203
|
"available_height": available_height,
|
|
898
1204
|
"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
|
-
),
|
|
1205
|
+
"message": message,
|
|
904
1206
|
"hint": (
|
|
905
1207
|
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
|
|
906
1208
|
"autoFit=\"normal-auto-fit\". "
|
|
@@ -911,6 +1213,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
|
|
911
1213
|
return issues
|
|
912
1214
|
|
|
913
1215
|
|
|
1216
|
+
def is_background_decorative_text(
|
|
1217
|
+
element: dict[str, Any], elements: list[dict[str, Any]]
|
|
1218
|
+
) -> bool:
|
|
1219
|
+
if not is_ghost_text(element):
|
|
1220
|
+
return False
|
|
1221
|
+
for other in elements:
|
|
1222
|
+
if other is element:
|
|
1223
|
+
continue
|
|
1224
|
+
if not is_text_element(other) or not has_text_content(other):
|
|
1225
|
+
continue
|
|
1226
|
+
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
|
|
1227
|
+
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
|
|
1228
|
+
continue
|
|
1229
|
+
if other["order"] <= element["order"]:
|
|
1230
|
+
continue
|
|
1231
|
+
if intersects(element, other):
|
|
1232
|
+
return True
|
|
1233
|
+
return False
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def is_ghost_text(element: dict[str, Any]) -> bool:
|
|
1237
|
+
if not is_text_element(element) or not has_text_content(element):
|
|
1238
|
+
return False
|
|
1239
|
+
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
|
1240
|
+
text_alpha = element.get("textAlpha", element.get("alpha", 1))
|
|
1241
|
+
if not isinstance(text_alpha, (int, float)):
|
|
1242
|
+
return False
|
|
1243
|
+
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
|
|
1244
|
+
return True
|
|
1245
|
+
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
|
|
1246
|
+
|
|
1247
|
+
|
|
914
1248
|
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
|
|
915
1249
|
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
|
|
916
1250
|
return None
|
|
@@ -1022,6 +1356,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
|
|
1022
1356
|
return False
|
|
1023
1357
|
if not (has_text_content(left) and has_text_content(right)):
|
|
1024
1358
|
return False
|
|
1359
|
+
if is_ghost_text(left) or is_ghost_text(right):
|
|
1360
|
+
return False
|
|
1025
1361
|
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
|
|
1026
1362
|
return False
|
|
1027
1363
|
|
|
@@ -1038,13 +1374,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
|
|
1038
1374
|
return False
|
|
1039
1375
|
|
|
1040
1376
|
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
|
|
1377
|
+
padding_left = source.get("paddingLeft", 0)
|
|
1378
|
+
padding_right = source.get("paddingRight", 0)
|
|
1379
|
+
available_width = max(source["width"] - padding_left - padding_right, 1)
|
|
1041
1380
|
visual_width = estimate_text_max_line_width(source)
|
|
1042
|
-
overflow_width = visual_width -
|
|
1043
|
-
min_overflow = max(font_size * 1.5,
|
|
1381
|
+
overflow_width = visual_width - available_width
|
|
1382
|
+
min_overflow = max(font_size * 1.5, available_width * 0.08)
|
|
1044
1383
|
if overflow_width < min_overflow:
|
|
1045
1384
|
return False
|
|
1046
1385
|
|
|
1047
|
-
intrusion_width = source["x"] + visual_width - target["x"]
|
|
1386
|
+
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
|
|
1048
1387
|
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
|
|
1049
1388
|
if intrusion_width < min_intrusion:
|
|
1050
1389
|
return False
|
|
@@ -1056,8 +1395,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
|
|
1056
1395
|
|
|
1057
1396
|
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
|
|
1058
1397
|
source, target = sorted([left, right], key=lambda element: element["x"])
|
|
1398
|
+
padding_left = source.get("paddingLeft", 0)
|
|
1059
1399
|
visual_width = estimate_text_max_line_width(source)
|
|
1060
|
-
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
|
1400
|
+
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
|
|
1061
1401
|
width = intersection_width(source_visual_bbox, target)
|
|
1062
1402
|
height = intersection_height(source_visual_bbox, target)
|
|
1063
1403
|
return {
|
|
@@ -1072,6 +1412,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
|
|
1072
1412
|
return False
|
|
1073
1413
|
if is_text_element(right) and not has_text_content(right):
|
|
1074
1414
|
return False
|
|
1415
|
+
if is_ghost_text(left) or is_ghost_text(right):
|
|
1416
|
+
return False
|
|
1075
1417
|
if is_template_text_stack(left, right):
|
|
1076
1418
|
return False
|
|
1077
1419
|
if is_text_element(left) and is_text_element(right):
|
|
@@ -1118,6 +1460,8 @@ def should_report_whiteboard_overlap(
|
|
|
1118
1460
|
) -> dict[str, Any] | None:
|
|
1119
1461
|
if other is whiteboard or not intersects(whiteboard, other):
|
|
1120
1462
|
return None
|
|
1463
|
+
if is_ghost_text(other):
|
|
1464
|
+
return None
|
|
1121
1465
|
if contains(whiteboard, other):
|
|
1122
1466
|
return None
|
|
1123
1467
|
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
|
|
@@ -1194,6 +1538,8 @@ def detect_whiteboard_external_overlaps(
|
|
|
1194
1538
|
|
|
1195
1539
|
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
|
1196
1540
|
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
|
1541
|
+
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
|
1542
|
+
return bbox
|
|
1197
1543
|
rotation = element["rotation"]
|
|
1198
1544
|
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
|
1199
1545
|
rotation = 0
|
|
@@ -1219,7 +1565,12 @@ def detect_elements_out_of_canvas(
|
|
|
1219
1565
|
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
|
1220
1566
|
) -> list[dict[str, Any]]:
|
|
1221
1567
|
issues: list[dict[str, Any]] = []
|
|
1222
|
-
for element in
|
|
1568
|
+
for element in (
|
|
1569
|
+
element
|
|
1570
|
+
for element in elements
|
|
1571
|
+
if element["kind"] in {"table", "chart"}
|
|
1572
|
+
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
|
|
1573
|
+
):
|
|
1223
1574
|
bbox = element_canvas_bbox(element)
|
|
1224
1575
|
overflow = {
|
|
1225
1576
|
"left": max(-bbox["x"], 0),
|
|
@@ -1329,6 +1680,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
|
|
|
1329
1680
|
return issues
|
|
1330
1681
|
|
|
1331
1682
|
|
|
1683
|
+
def segment_intersects_rect(
|
|
1684
|
+
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
|
|
1685
|
+
) -> bool:
|
|
1686
|
+
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
|
|
1687
|
+
left = rect["x"]
|
|
1688
|
+
top = rect["y"]
|
|
1689
|
+
right = rect["x"] + rect["width"]
|
|
1690
|
+
bottom = rect["y"] + rect["height"]
|
|
1691
|
+
if right <= left or bottom <= top:
|
|
1692
|
+
return False
|
|
1693
|
+
dx = x2 - x1
|
|
1694
|
+
dy = y2 - y1
|
|
1695
|
+
if dx == 0 and dy == 0:
|
|
1696
|
+
return left <= x1 <= right and top <= y1 <= bottom
|
|
1697
|
+
t_enter, t_exit = 0.0, 1.0
|
|
1698
|
+
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
|
|
1699
|
+
if delta == 0:
|
|
1700
|
+
if distance < 0:
|
|
1701
|
+
return False
|
|
1702
|
+
continue
|
|
1703
|
+
t = distance / delta
|
|
1704
|
+
if delta < 0:
|
|
1705
|
+
t_enter = max(t_enter, t)
|
|
1706
|
+
else:
|
|
1707
|
+
t_exit = min(t_exit, t)
|
|
1708
|
+
if t_enter > t_exit:
|
|
1709
|
+
return False
|
|
1710
|
+
return True
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
|
|
1714
|
+
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
|
|
1715
|
+
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
|
|
1719
|
+
width = rect["width"] - 2 * margin
|
|
1720
|
+
height = rect["height"] - 2 * margin
|
|
1721
|
+
if width <= 0 or height <= 0:
|
|
1722
|
+
return None
|
|
1723
|
+
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
|
|
1727
|
+
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
|
|
1728
|
+
return False
|
|
1729
|
+
if not is_text_element(text_element) or not has_text_content(text_element):
|
|
1730
|
+
return False
|
|
1731
|
+
if is_ghost_text(text_element) or is_decorative_text(text_element):
|
|
1732
|
+
return False
|
|
1733
|
+
glyph_bbox = estimate_text_visual_bbox(text_element)
|
|
1734
|
+
if glyph_bbox is None:
|
|
1735
|
+
return False
|
|
1736
|
+
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
|
|
1737
|
+
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
|
|
1738
|
+
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
|
|
1739
|
+
if target is None:
|
|
1740
|
+
return False
|
|
1741
|
+
return segment_intersects_rect(
|
|
1742
|
+
line["startX"], line["startY"], line["endX"], line["endY"], target
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def detect_line_text_crossings(
|
|
1747
|
+
slide_xml: str, elements: list[dict[str, Any]]
|
|
1748
|
+
) -> list[dict[str, Any]]:
|
|
1749
|
+
lines = extract_line_elements(slide_xml)
|
|
1750
|
+
if not lines:
|
|
1751
|
+
return []
|
|
1752
|
+
text_elements = [element for element in elements if is_text_element(element)]
|
|
1753
|
+
issues: list[dict[str, Any]] = []
|
|
1754
|
+
for line in lines:
|
|
1755
|
+
for text_element in text_elements:
|
|
1756
|
+
if not line_crosses_text(line, text_element):
|
|
1757
|
+
continue
|
|
1758
|
+
issues.append(
|
|
1759
|
+
{
|
|
1760
|
+
"level": "error",
|
|
1761
|
+
"code": "bbox_overlap",
|
|
1762
|
+
"elements": [line["id"], text_element["id"]],
|
|
1763
|
+
"message": f'line {line["id"]} crosses text {text_element["id"]}',
|
|
1764
|
+
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
|
|
1765
|
+
}
|
|
1766
|
+
)
|
|
1767
|
+
return issues
|
|
1768
|
+
|
|
1769
|
+
|
|
1332
1770
|
def lint_slide(
|
|
1333
1771
|
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
|
1334
1772
|
) -> dict[str, Any]:
|
|
@@ -1339,6 +1777,7 @@ def lint_slide(
|
|
|
1339
1777
|
*detect_table_layout_size_mismatches(elements),
|
|
1340
1778
|
*detect_text_may_overflow_shapes(elements),
|
|
1341
1779
|
*detect_image_text_occlusions(elements),
|
|
1780
|
+
*detect_line_text_crossings(slide_xml, elements),
|
|
1342
1781
|
]
|
|
1343
1782
|
|
|
1344
1783
|
for index, left in enumerate(elements):
|
|
@@ -1944,7 +2383,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
|
|
1944
2383
|
|
|
1945
2384
|
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
1946
2385
|
elements: list[dict[str, Any]] = []
|
|
1947
|
-
for match in re.finditer(r"<line\b([^>]
|
|
2386
|
+
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
|
|
1948
2387
|
attrs = match.group(1)
|
|
1949
2388
|
start_x = extract_numeric_attribute(attrs, "startX")
|
|
1950
2389
|
start_y = extract_numeric_attribute(attrs, "startY")
|
|
@@ -1953,6 +2392,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
1953
2392
|
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
|
1954
2393
|
continue
|
|
1955
2394
|
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
|
2395
|
+
base_alpha = line_alpha if line_alpha is not None else 1
|
|
2396
|
+
border_alpha = 1
|
|
2397
|
+
if match.group(2) != "/":
|
|
2398
|
+
close_index = slide_xml.find("</line>", match.end())
|
|
2399
|
+
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
|
|
2400
|
+
border_attrs = extract_tag_attributes(body, "border")
|
|
2401
|
+
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
|
|
2402
|
+
if isinstance(color_alpha, (int, float)):
|
|
2403
|
+
border_alpha = color_alpha
|
|
1956
2404
|
elements.append(
|
|
1957
2405
|
{
|
|
1958
2406
|
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
|
@@ -1962,8 +2410,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
|
|
1962
2410
|
"y": min(start_y, end_y),
|
|
1963
2411
|
"width": abs(end_x - start_x),
|
|
1964
2412
|
"height": abs(end_y - start_y),
|
|
2413
|
+
"startX": start_x,
|
|
2414
|
+
"startY": start_y,
|
|
2415
|
+
"endX": end_x,
|
|
2416
|
+
"endY": end_y,
|
|
1965
2417
|
"rotation": 0,
|
|
1966
|
-
"alpha":
|
|
2418
|
+
"alpha": base_alpha * border_alpha,
|
|
1967
2419
|
"order": len(elements),
|
|
1968
2420
|
}
|
|
1969
2421
|
)
|
|
@@ -1976,8 +2428,6 @@ def normalize_issue(
|
|
|
1976
2428
|
elements_by_id: dict[str, dict[str, Any]],
|
|
1977
2429
|
) -> dict[str, Any]:
|
|
1978
2430
|
normalized = dict(issue)
|
|
1979
|
-
if normalized.get("level") == "info":
|
|
1980
|
-
normalized["level"] = "warning"
|
|
1981
2431
|
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
|
|
1982
2432
|
normalized["schema_version"] = "2.0"
|
|
1983
2433
|
normalized["element_ids"] = element_ids
|
|
@@ -2031,6 +2481,21 @@ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -
|
|
|
2031
2481
|
return "passed"
|
|
2032
2482
|
|
|
2033
2483
|
|
|
2484
|
+
def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool:
|
|
2485
|
+
if issue.get("code") == "sxsd_unsupported_declaration":
|
|
2486
|
+
return False
|
|
2487
|
+
if root_name == "slide":
|
|
2488
|
+
return True
|
|
2489
|
+
path = issue.get("path")
|
|
2490
|
+
if not isinstance(path, str):
|
|
2491
|
+
return False
|
|
2492
|
+
if path.startswith("presentation/slide/"):
|
|
2493
|
+
return True
|
|
2494
|
+
return path == "presentation/slide" and (
|
|
2495
|
+
issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace"
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
|
|
2034
2499
|
def build_result(
|
|
2035
2500
|
source_path: str | None,
|
|
2036
2501
|
slide_size: dict[str, int | float],
|
|
@@ -2039,8 +2504,10 @@ def build_result(
|
|
|
2039
2504
|
) -> dict[str, Any]:
|
|
2040
2505
|
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
|
|
2041
2506
|
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
|
|
2507
|
+
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
|
|
2042
2508
|
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
|
|
2043
2509
|
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
|
|
2510
|
+
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
|
|
2044
2511
|
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
|
|
2045
2512
|
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
|
|
2046
2513
|
status = slide_status(all_errors, all_warnings)
|
|
@@ -2053,6 +2520,7 @@ def build_result(
|
|
|
2053
2520
|
"slide_count": len(slides),
|
|
2054
2521
|
"error_count": error_count,
|
|
2055
2522
|
"warning_count": warning_count,
|
|
2523
|
+
"info_count": info_count,
|
|
2056
2524
|
"status": status,
|
|
2057
2525
|
"release_ready": error_count == 0,
|
|
2058
2526
|
"screenshot_review_required": warning_count > 0,
|
|
@@ -2060,6 +2528,7 @@ def build_result(
|
|
|
2060
2528
|
"document": {
|
|
2061
2529
|
"errors": document_errors,
|
|
2062
2530
|
"warnings": document_warnings,
|
|
2531
|
+
"infos": document_infos,
|
|
2063
2532
|
},
|
|
2064
2533
|
"slides": slides,
|
|
2065
2534
|
}
|
|
@@ -2082,11 +2551,20 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
2082
2551
|
raise AssertionError("parse_xml_root must return a root or error")
|
|
2083
2552
|
|
|
2084
2553
|
namespace_issues = validate_sml_tag_prefixes(xml)
|
|
2085
|
-
|
|
2554
|
+
root_name = xml_local_name(root.tag)
|
|
2555
|
+
sxsd_issues = validate_sxsd_document(xml, root)
|
|
2086
2556
|
iconpark_issues = validate_iconpark_icon_types(root)
|
|
2087
2557
|
top_level_issues = [
|
|
2088
2558
|
normalize_issue(issue, None, {})
|
|
2089
|
-
for issue in [
|
|
2559
|
+
for issue in [
|
|
2560
|
+
*namespace_issues,
|
|
2561
|
+
*[
|
|
2562
|
+
issue
|
|
2563
|
+
for issue in sxsd_issues
|
|
2564
|
+
if not is_slide_scoped_sxsd_issue(issue, root_name)
|
|
2565
|
+
],
|
|
2566
|
+
*iconpark_issues,
|
|
2567
|
+
]
|
|
2090
2568
|
]
|
|
2091
2569
|
if any(issue["level"] == "error" for issue in top_level_issues):
|
|
2092
2570
|
return build_result(
|
|
@@ -2096,10 +2574,36 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
2096
2574
|
[],
|
|
2097
2575
|
)
|
|
2098
2576
|
|
|
2099
|
-
presentation = parse_presentation(
|
|
2577
|
+
presentation = parse_presentation(root)
|
|
2578
|
+
slide_roots = presentation["slide_roots"]
|
|
2100
2579
|
slides: list[dict[str, Any]] = []
|
|
2101
2580
|
for index, slide_xml in enumerate(presentation["slides"]):
|
|
2102
2581
|
slide_number = index + 1
|
|
2582
|
+
slide_root = slide_roots[index]
|
|
2583
|
+
slide_sxsd_issues = [
|
|
2584
|
+
normalize_issue(issue, slide_number, {})
|
|
2585
|
+
for issue in validate_sxsd_document(slide_xml, slide_root)
|
|
2586
|
+
]
|
|
2587
|
+
slide_sxsd_errors = [
|
|
2588
|
+
issue for issue in slide_sxsd_issues if issue["level"] == "error"
|
|
2589
|
+
]
|
|
2590
|
+
if slide_sxsd_errors:
|
|
2591
|
+
slide_sxsd_warnings = [
|
|
2592
|
+
issue for issue in slide_sxsd_issues if issue["level"] == "warning"
|
|
2593
|
+
]
|
|
2594
|
+
slides.append(
|
|
2595
|
+
{
|
|
2596
|
+
"slide_number": slide_number,
|
|
2597
|
+
"status": slide_status(slide_sxsd_errors, slide_sxsd_warnings),
|
|
2598
|
+
"element_count": 0,
|
|
2599
|
+
"errors": slide_sxsd_errors,
|
|
2600
|
+
"warnings": slide_sxsd_warnings,
|
|
2601
|
+
"infos": [],
|
|
2602
|
+
"issues": slide_sxsd_issues,
|
|
2603
|
+
}
|
|
2604
|
+
)
|
|
2605
|
+
continue
|
|
2606
|
+
|
|
2103
2607
|
geometry = lint_slide(
|
|
2104
2608
|
slide_xml,
|
|
2105
2609
|
slide_number,
|
|
@@ -2145,11 +2649,15 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
2145
2649
|
),
|
|
2146
2650
|
]
|
|
2147
2651
|
issues = [
|
|
2148
|
-
|
|
2149
|
-
|
|
2652
|
+
*slide_sxsd_issues,
|
|
2653
|
+
*[
|
|
2654
|
+
normalize_issue(issue, slide_number, elements_by_id)
|
|
2655
|
+
for issue in raw_issues
|
|
2656
|
+
],
|
|
2150
2657
|
]
|
|
2151
2658
|
errors = [issue for issue in issues if issue["level"] == "error"]
|
|
2152
2659
|
warnings = [issue for issue in issues if issue["level"] == "warning"]
|
|
2660
|
+
infos = [issue for issue in issues if issue["level"] == "info"]
|
|
2153
2661
|
slides.append(
|
|
2154
2662
|
{
|
|
2155
2663
|
"slide_number": slide_number,
|
|
@@ -2157,6 +2665,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
|
|
2157
2665
|
"element_count": len(elements_by_id),
|
|
2158
2666
|
"errors": errors,
|
|
2159
2667
|
"warnings": warnings,
|
|
2668
|
+
"infos": infos,
|
|
2160
2669
|
"issues": issues,
|
|
2161
2670
|
}
|
|
2162
2671
|
)
|