@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.
Files changed (63) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/skills/lark-apps/SKILL.md +1 -0
  11. package/skills/lark-apps/references/lark-apps-cache.md +61 -0
  12. package/skills/lark-base/SKILL.md +16 -7
  13. package/skills/lark-base/references/lark-base-data-query.md +11 -4
  14. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  15. package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
  16. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  17. package/skills/lark-base/references/lark-base-role-guide.md +11 -0
  18. package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
  19. package/skills/lark-base/references/role-config.md +31 -5
  20. package/skills/lark-calendar/SKILL.md +14 -8
  21. package/skills/lark-calendar/references/lark-calendar-create.md +6 -6
  22. package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
  23. package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
  24. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
  25. package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
  26. package/skills/lark-calendar/references/lark-calendar-update.md +7 -4
  27. package/skills/lark-contact/SKILL.md +19 -3
  28. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  29. package/skills/lark-drive/SKILL.md +21 -44
  30. package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
  31. package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
  32. package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
  33. package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
  34. package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
  35. package/skills/lark-drive/references/lark-drive-comment-location.md +7 -13
  36. package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
  37. package/skills/lark-drive/references/lark-drive-download.md +5 -1
  38. package/skills/lark-drive/references/lark-drive-list-comments.md +25 -68
  39. package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
  40. package/skills/lark-drive/references/lark-drive-member-add.md +2 -2
  41. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  42. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  43. package/skills/lark-drive/references/lark-drive-preview.md +11 -1
  44. package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
  45. package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
  46. package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
  47. package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
  48. package/skills/lark-drive/references/lark-drive-search.md +6 -1
  49. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  50. package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
  51. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  52. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  53. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  54. package/skills/lark-slides/SKILL.md +16 -26
  55. package/skills/lark-slides/references/lark-slides-create.md +14 -5
  56. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -1
  57. package/skills/lark-slides/references/slides_xml_schema_definition.xml +491 -31
  58. package/skills/lark-slides/references/xml-schema-quick-ref.md +39 -0
  59. package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
  60. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +633 -124
  61. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +2038 -219
  62. package/skills/lark-task/references/lark-task-create.md +9 -0
  63. package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -80
@@ -2,13 +2,17 @@
2
2
  # SPDX-License-Identifier: MIT
3
3
  from __future__ import annotations
4
4
 
5
+ import itertools
5
6
  import json
6
7
  import subprocess
7
8
  import sys
8
9
  import tempfile
9
10
  import unittest
11
+ import xml.etree.ElementTree as ET
10
12
  from pathlib import Path
13
+ from unittest import mock
11
14
 
15
+ import sxsd_validator
12
16
  import xml_text_overlap_lint
13
17
 
14
18
 
@@ -47,6 +51,37 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
47
51
  f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n",
48
52
  )
49
53
 
54
+ def test_cli_reports_structured_slide_sxsd_error_outside_skill_directory(self) -> None:
55
+ script_path = Path(xml_text_overlap_lint.__file__).resolve()
56
+ with tempfile.TemporaryDirectory() as temp_dir:
57
+ input_path = Path(temp_dir) / "invalid-slide.xml"
58
+ input_path.write_text(
59
+ """
60
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
61
+ <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
62
+ </slide>
63
+ """,
64
+ encoding="utf-8",
65
+ )
66
+
67
+ completed = subprocess.run(
68
+ [sys.executable, str(script_path), "--input", str(input_path)],
69
+ cwd=temp_dir,
70
+ capture_output=True,
71
+ check=False,
72
+ text=True,
73
+ )
74
+
75
+ result = json.loads(completed.stdout)
76
+ issue = result["slides"][0]["errors"][0]
77
+ self.assertEqual(completed.returncode, 1)
78
+ self.assertEqual(completed.stderr, "")
79
+ self.assertEqual(issue["code"], "sxsd_missing_required_attr")
80
+ self.assertEqual(issue["path"], "slide/data/shape")
81
+ self.assertEqual(issue["attr"], "height")
82
+ self.assertEqual(issue["target"]["slide_number"], 1)
83
+ self.assertTrue(issue["hint"])
84
+
50
85
  def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
51
86
  samples = {
52
87
  "image-led-cover": """
@@ -173,12 +208,12 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
173
208
  self.assertEqual(issues[0]["tag"], "ns0:slide")
174
209
  self.assertIn("default namespace", issues[0]["hint"])
175
210
 
176
- def test_lint_xml_allows_unprefixed_tags_without_namespace(self) -> None:
211
+ def test_lint_xml_accepts_server_readback_slide_without_namespace(self) -> None:
177
212
  result = xml_text_overlap_lint.lint_xml(
178
213
  """
179
- <slide>
214
+ <slide id="server-slide-id">
180
215
  <data>
181
- <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
216
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
182
217
  <content textType="body"><p>Unprefixed SML</p></content>
183
218
  </shape>
184
219
  </data>
@@ -187,6 +222,90 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
187
222
  )
188
223
  self.assertEqual(result["summary"]["error_count"], 0)
189
224
 
225
+ def test_lint_xml_accepts_server_readback_presentation_short_namespace(self) -> None:
226
+ result = xml_text_overlap_lint.lint_xml(
227
+ """
228
+ <presentation xmlns="/sml/2.0" width="960" height="540" id="server-presentation-id">
229
+ <slide id="server-slide-id">
230
+ <data>
231
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
232
+ <content textType="body"><p>Server readback presentation</p></content>
233
+ </shape>
234
+ </data>
235
+ </slide>
236
+ </presentation>
237
+ """
238
+ )
239
+
240
+ self.assertEqual(result["summary"]["slide_count"], 1)
241
+ self.assertEqual(result["summary"]["error_count"], 0)
242
+
243
+ def test_lint_xml_accepts_server_readback_presentation_https_namespace(self) -> None:
244
+ result = xml_text_overlap_lint.lint_xml(
245
+ """
246
+ <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540" id="server-presentation-id">
247
+ <slide id="server-slide-id">
248
+ <data>
249
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
250
+ <content textType="body"><p>Server readback presentation</p></content>
251
+ </shape>
252
+ </data>
253
+ </slide>
254
+ </presentation>
255
+ """
256
+ )
257
+
258
+ self.assertEqual(result["summary"]["slide_count"], 1)
259
+ self.assertEqual(result["summary"]["error_count"], 0)
260
+
261
+ def test_lint_xml_rejects_server_readback_presentation_wrong_namespace(self) -> None:
262
+ result = xml_text_overlap_lint.lint_xml(
263
+ """
264
+ <presentation xmlns="https://example.com/not-sml" width="960" height="540">
265
+ <slide/>
266
+ </presentation>
267
+ """
268
+ )
269
+
270
+ self.assertEqual(result["summary"]["error_count"], 1)
271
+ self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_namespace")
272
+
273
+ def test_lint_xml_rejects_xml_declaration(self) -> None:
274
+ result = xml_text_overlap_lint.lint_xml(
275
+ '<?xml version="1.0"?><slide xmlns="http://www.larkoffice.com/sml/2.0"><data/></slide>'
276
+ )
277
+
278
+ self.assertEqual(result["summary"]["error_count"], 1)
279
+ self.assertEqual(result["issues"][0]["code"], "sxsd_unsupported_declaration")
280
+
281
+ def test_lint_xml_reports_missing_required_sxsd_attribute(self) -> None:
282
+ result = xml_text_overlap_lint.lint_xml(
283
+ """
284
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
285
+ <data><shape type="text" topLeftX="80" topLeftY="80" width="300"/></data>
286
+ </slide>
287
+ """
288
+ )
289
+
290
+ self.assertEqual(result["summary"]["error_count"], 1)
291
+ self.assertNotIn("issues", result)
292
+ issue = result["slides"][0]["issues"][0]
293
+ self.assertEqual(issue["code"], "sxsd_missing_required_attr")
294
+ self.assertEqual(issue["attr"], "height")
295
+
296
+ def test_lint_xml_rejects_child_order_that_violates_xsd_sequence(self) -> None:
297
+ result = xml_text_overlap_lint.lint_xml(
298
+ """
299
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
300
+ <slide/>
301
+ <title>Late title</title>
302
+ </presentation>
303
+ """
304
+ )
305
+
306
+ self.assertEqual(result["summary"]["error_count"], 1)
307
+ self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_child_order")
308
+
190
309
  def test_lint_xml_accepts_escaped_entities_without_suspicious_entity_warning(self) -> None:
191
310
  result = xml_text_overlap_lint.lint_xml(
192
311
  """
@@ -250,8 +369,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
250
369
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
251
370
  <data>
252
371
  <img id="second" src="tok" topLeftX="100" topLeftY="120" width="240" height="160"/>
253
- <shape id="skipped" type="text" topLeftX="80" topLeftY="80" width="300">
254
- <content textType="body"><p>Missing height</p></content>
372
+ <shape id="second-shape" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
373
+ <content textType="body"><p>Second slide</p></content>
255
374
  </shape>
256
375
  </data>
257
376
  </slide>
@@ -261,10 +380,151 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
261
380
  self.assertEqual(result["slide_size"], {"width": 1280, "height": 720})
262
381
  self.assertEqual(result["summary"]["slide_count"], 2)
263
382
  self.assertEqual([slide["slide_number"] for slide in result["slides"]], [1, 2])
264
- self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 1])
383
+ self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 2])
265
384
  self.assertEqual(result["summary"]["error_count"], 0)
266
385
  self.assertEqual(result["summary"]["warning_count"], 0)
267
386
 
387
+ def test_lint_xml_handles_self_closing_slide_before_normal_slide(self) -> None:
388
+ result = xml_text_overlap_lint.lint_xml(
389
+ """
390
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
391
+ <slide/>
392
+ <slide>
393
+ <data>
394
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
395
+ <content textType="body"><p>Second slide</p></content>
396
+ </shape>
397
+ </data>
398
+ </slide>
399
+ </presentation>
400
+ """
401
+ )
402
+
403
+ self.assertEqual(result["summary"]["slide_count"], 2)
404
+ self.assertEqual(
405
+ [issue["code"] for issue in result["slides"][0]["errors"]],
406
+ ["blank_slide"],
407
+ )
408
+ self.assertEqual(result["slides"][1]["status"], "passed")
409
+
410
+ def test_lint_xml_keeps_trailing_self_closing_slide(self) -> None:
411
+ result = xml_text_overlap_lint.lint_xml(
412
+ """
413
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
414
+ <slide>
415
+ <data>
416
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
417
+ <content textType="body"><p>First slide</p></content>
418
+ </shape>
419
+ </data>
420
+ </slide>
421
+ <slide/>
422
+ </presentation>
423
+ """
424
+ )
425
+
426
+ self.assertEqual(result["summary"]["slide_count"], 2)
427
+ self.assertEqual(
428
+ [issue["code"] for issue in result["slides"][1]["errors"]],
429
+ ["blank_slide"],
430
+ )
431
+
432
+ def test_lint_xml_ignores_slide_markup_inside_xml_comments(self) -> None:
433
+ result = xml_text_overlap_lint.lint_xml(
434
+ """
435
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
436
+ <slide>
437
+ <data>
438
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
439
+ <content textType="body"><p>Real slide</p></content>
440
+ </shape>
441
+ </data>
442
+ </slide>
443
+ <!-- Old draft: <slide id="ghost"/> -->
444
+ </presentation>
445
+ """
446
+ )
447
+
448
+ self.assertEqual(result["summary"]["slide_count"], 1)
449
+ self.assertEqual(result["slides"][0]["status"], "passed")
450
+
451
+ def test_lint_xml_ignores_invalid_slide_markup_inside_xml_comments(self) -> None:
452
+ result = xml_text_overlap_lint.lint_xml(
453
+ """
454
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
455
+ <slide><data/></slide>
456
+ <!-- Old draft: <slide bogus="x"/> -->
457
+ </presentation>
458
+ """
459
+ )
460
+
461
+ self.assertEqual(result["summary"]["slide_count"], 1)
462
+ self.assertEqual(
463
+ [issue["code"] for issue in result["slides"][0]["errors"]],
464
+ ["blank_slide"],
465
+ )
466
+
467
+ def test_lint_xml_skips_only_invalid_slide_and_continues_geometry_checks(self) -> None:
468
+ result = xml_text_overlap_lint.lint_xml(
469
+ """
470
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
471
+ <slide>
472
+ <data>
473
+ <shape id="invalid" type="text" topLeftX="1000" topLeftY="80" width="300">
474
+ <content textType="body"><p>Missing height</p></content>
475
+ </shape>
476
+ </data>
477
+ </slide>
478
+ <slide>
479
+ <data>
480
+ <shape id="outside" type="text" topLeftX="1000" topLeftY="80" width="120" height="60">
481
+ <content textType="body"><p>Outside canvas</p></content>
482
+ </shape>
483
+ </data>
484
+ </slide>
485
+ </presentation>
486
+ """
487
+ )
488
+
489
+ self.assertEqual(result["summary"]["slide_count"], 2)
490
+ self.assertEqual(result["slides"][0]["element_count"], 0)
491
+ self.assertEqual(
492
+ [issue["code"] for issue in result["slides"][0]["errors"]],
493
+ ["sxsd_missing_required_attr"],
494
+ )
495
+ self.assertNotIn(
496
+ "shape_out_of_canvas",
497
+ [issue["code"] for issue in result["slides"][0]["issues"]],
498
+ )
499
+ self.assertIn(
500
+ "shape_out_of_canvas",
501
+ [issue["code"] for issue in result["slides"][1]["errors"]],
502
+ )
503
+
504
+ def test_lint_xml_scopes_invalid_slide_root_attribute_to_that_slide(self) -> None:
505
+ result = xml_text_overlap_lint.lint_xml(
506
+ """
507
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
508
+ <slide bogusAttr="oops">
509
+ <data><shape type="rect" topLeftX="80" topLeftY="80" width="100" height="100"/></data>
510
+ </slide>
511
+ <slide>
512
+ <data><shape type="rect" topLeftX="1000" topLeftY="80" width="100" height="100"/></data>
513
+ </slide>
514
+ </presentation>
515
+ """
516
+ )
517
+
518
+ self.assertEqual(result["summary"]["slide_count"], 2)
519
+ self.assertEqual(
520
+ [issue["code"] for issue in result["slides"][0]["errors"]],
521
+ ["sxsd_unsupported_attr"],
522
+ )
523
+ self.assertIn(
524
+ "shape_out_of_canvas",
525
+ [issue["code"] for issue in result["slides"][1]["errors"]],
526
+ )
527
+
268
528
  def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None:
269
529
  cases = [
270
530
  ("textbox", '<textbox topLeftX="80" topLeftY="80" width="300" height="60">Text</textbox>', '<shape type="text">'),
@@ -279,8 +539,15 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
279
539
  </slide>
280
540
  """
281
541
  )
282
- issue = result["issues"][0]
542
+ slide_issues = result["slides"][0]["issues"]
543
+ issue = next(
544
+ issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_tag"
545
+ )
283
546
  self.assertEqual(result["summary"]["error_count"], 1)
547
+ self.assertEqual(
548
+ [reported["code"] for reported in slide_issues],
549
+ ["sxsd_unsupported_tag"],
550
+ )
284
551
  self.assertEqual(issue["code"], "sxsd_unsupported_tag")
285
552
  self.assertEqual(issue["tag"], tag_name)
286
553
  self.assertIn(expected_hint, issue["hint"])
@@ -288,6 +555,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
288
555
  def test_lint_xml_reports_sxsd_unsupported_attr_with_alias_hint(self) -> None:
289
556
  cases = [
290
557
  ("shape", "x", "topLeftX", '<shape type="text" x="80" topLeftY="80" width="300" height="60"><content><p>Text</p></content></shape>'),
558
+ ("shape", "heigth", "height", '<shape type="text" topLeftX="80" topLeftY="80" width="300" heigth="60"><content><p>Text</p></content></shape>'),
291
559
  ("content", "fontColor", "color", '<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60"><content fontColor="rgba(0, 0, 0, 1)"><p>Text</p></content></shape>'),
292
560
  ]
293
561
  for tag_name, attr_name, expected_attr, element_xml in cases:
@@ -299,115 +567,256 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
299
567
  </slide>
300
568
  """
301
569
  )
302
- issue = result["issues"][0]
570
+ slide_issues = result["slides"][0]["issues"]
571
+ issue = next(
572
+ issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_attr"
573
+ )
303
574
  self.assertEqual(result["summary"]["error_count"], 1)
575
+ self.assertEqual(
576
+ [reported["code"] for reported in slide_issues],
577
+ ["sxsd_unsupported_attr"],
578
+ )
304
579
  self.assertEqual(issue["code"], "sxsd_unsupported_attr")
305
580
  self.assertEqual(issue["tag"], tag_name)
306
581
  self.assertEqual(issue["attr"], attr_name)
307
582
  self.assertIn(expected_attr, issue["hint"])
308
583
 
309
- def test_lint_xml_ignores_server_filled_id_attrs(self) -> None:
584
+ def test_lint_xml_keeps_unrelated_unsupported_and_missing_attrs(self) -> None:
310
585
  result = xml_text_overlap_lint.lint_xml(
311
586
  """
312
- <slide id="server-slide-id" xmlns="http://www.larkoffice.com/sml/2.0">
587
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
313
588
  <data>
314
- <shape id="server-shape-id" type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
315
- <fill id="server-fill-id" unexpected="value">
316
- <fillColor color="rgba(255, 255, 255, 1)"/>
317
- </fill>
318
- </shape>
589
+ <shape type="text" bogus="x" topLeftX="80" topLeftY="80" width="300"/>
319
590
  </data>
320
591
  </slide>
321
592
  """
322
593
  )
323
594
 
324
- self.assertEqual(result["summary"]["error_count"], 1)
325
- issue = result["issues"][0]
326
- self.assertEqual(issue["code"], "sxsd_unsupported_attr")
327
- self.assertEqual(issue["tag"], "fill")
328
- self.assertEqual(issue["attr"], "unexpected")
595
+ self.assertEqual(
596
+ [issue["code"] for issue in result["slides"][0]["issues"]],
597
+ ["sxsd_unsupported_attr", "sxsd_missing_required_attr"],
598
+ )
329
599
 
330
- def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None:
600
+ def test_lint_xml_keeps_missing_attrs_when_suggestion_is_ambiguous(self) -> None:
331
601
  result = xml_text_overlap_lint.lint_xml(
332
602
  """
333
603
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
334
604
  <data>
335
- <chart updated="true" topLeftX="80" topLeftY="80" width="300" height="160">
336
- <chartData isStaticData="true"/>
337
- </chart>
605
+ <shape type="text" topLeft="80" width="300" height="60"/>
338
606
  </data>
339
607
  </slide>
340
608
  """
341
609
  )
342
610
 
343
- self.assertEqual(result["summary"]["error_count"], 0)
344
- self.assertNotIn("issues", result)
611
+ self.assertEqual(
612
+ [issue["code"] for issue in result["slides"][0]["issues"]],
613
+ [
614
+ "sxsd_unsupported_attr",
615
+ "sxsd_missing_required_attr",
616
+ "sxsd_missing_required_attr",
617
+ ],
618
+ )
345
619
 
346
- def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
620
+ def test_lint_xml_suppresses_only_missing_attr_that_resolves_ambiguous_suggestion(self) -> None:
347
621
  result = xml_text_overlap_lint.lint_xml(
348
622
  """
349
623
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
350
624
  <data>
351
- <chart isStaticData="true" topLeftX="80" topLeftY="80" width="300" height="160">
352
- <chartData updated="true"/>
353
- </chart>
625
+ <shape type="text" topLeftXX="80" topLeftY="80" width="300" height="60"/>
354
626
  </data>
355
627
  </slide>
356
628
  """
357
629
  )
358
630
 
359
- self.assertEqual(result["summary"]["error_count"], 2)
360
631
  self.assertEqual(
361
- {(issue["tag"], issue["attr"]) for issue in result["issues"]},
362
- {("chart", "isStaticData"), ("chartData", "updated")},
632
+ [
633
+ (issue["code"], issue.get("attr"))
634
+ for issue in result["slides"][0]["issues"]
635
+ ],
636
+ [("sxsd_unsupported_attr", "topLeftXX")],
363
637
  )
364
- self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"]))
365
638
 
366
- def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None:
639
+ def test_lint_xml_ignores_server_filled_id_attrs(self) -> None:
367
640
  result = xml_text_overlap_lint.lint_xml(
368
641
  """
369
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
642
+ <slide id="server-slide-id" xmlns="http://www.larkoffice.com/sml/2.0">
370
643
  <data>
371
- <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
372
- <fill>
373
- <fillColor
374
- type="gradient"
375
- color1="rgba(255, 0, 0, 1)"
376
- color2="rgba(0, 0, 255, 1)"
377
- angle="45"
378
- stop1="0%"
379
- stop2="100%"/>
644
+ <shape id="server-shape-id" type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
645
+ <fill id="server-fill-id" unexpected="value">
646
+ <fillColor color="rgba(255, 255, 255, 1)"/>
380
647
  </fill>
381
648
  </shape>
382
649
  </data>
383
650
  </slide>
384
651
  """
385
652
  )
386
- unsupported_attrs = {issue["attr"] for issue in result["issues"]}
387
- self.assertEqual(result["summary"]["error_count"], 6)
653
+
654
+ self.assertEqual(result["summary"]["error_count"], 1)
388
655
  self.assertEqual(
389
- unsupported_attrs,
390
- {"type", "color1", "color2", "angle", "stop1", "stop2"},
656
+ [(issue["tag"], issue["attr"]) for issue in result["slides"][0]["issues"]],
657
+ [("fill", "unexpected")],
391
658
  )
392
- self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"]))
393
- self.assertTrue(all(issue["tag"] == "fillColor" for issue in result["issues"]))
394
659
 
395
- def test_lint_xml_accepts_chart_field_simple_content_attrs(self) -> None:
660
+ def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None:
396
661
  result = xml_text_overlap_lint.lint_xml(
397
662
  """
398
663
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
399
664
  <data>
400
- <chart topLeftX="80" topLeftY="80" width="520" height="320">
401
- <chartPlotArea>
402
- <chartPlot type="line"/>
403
- </chartPlotArea>
404
- <chartData>
665
+ <chart updated="true" topLeftX="80" topLeftY="80" width="300" height="160">
666
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
667
+ <chartData isStaticData="true">
668
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
669
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
670
+ </chartData>
671
+ </chart>
672
+ </data>
673
+ </slide>
674
+ """
675
+ )
676
+
677
+ self.assertEqual(result["summary"]["error_count"], 0)
678
+
679
+ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tags(self) -> None:
680
+ result = xml_text_overlap_lint.lint_xml(
681
+ """
682
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
683
+ <data>
684
+ <chart topLeftX="80" topLeftY="80" width="300" height="160">
685
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
686
+ <chartData isStaticData="true">
405
687
  <dim1>
406
- <chartField name="month" valueType="string">Jan, Feb</chartField>
688
+ <chartField name="category" valueType="string">
689
+ A<chartParsedValues>A</chartParsedValues>
690
+ </chartField>
407
691
  </dim1>
408
- <dim2>
409
- <chartField name="value" valueType="number">1, 2</chartField>
410
- </dim2>
692
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
693
+ </chartData>
694
+ </chart>
695
+ </data>
696
+ </slide>
697
+ """
698
+ )
699
+
700
+ self.assertEqual(result["summary"]["error_count"], 0)
701
+
702
+ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
703
+ result = xml_text_overlap_lint.lint_xml(
704
+ """
705
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
706
+ <data>
707
+ <chart topLeftX="80" topLeftY="80" width="300" height="160">
708
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
709
+ <chartData>
710
+ <dim1>
711
+ <chartField name="category" valueType="string">
712
+ Africa<chartParsedValues>Africa</chartParsedValues>
713
+ </chartField>
714
+ </dim1>
715
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
716
+ </chartData>
717
+ </chart>
718
+ </data>
719
+ </slide>
720
+ """
721
+ )
722
+
723
+ self.assertEqual(result["summary"]["error_count"], 0)
724
+ self.assertNotIn("issues", result)
725
+
726
+ def test_lint_xml_rejects_chart_parsed_values_outside_chart_field(self) -> None:
727
+ result = xml_text_overlap_lint.lint_xml(
728
+ """
729
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
730
+ <data>
731
+ <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
732
+ <chartParsedValues>unexpected</chartParsedValues>
733
+ </shape>
734
+ </data>
735
+ </slide>
736
+ """
737
+ )
738
+
739
+ self.assertEqual(result["summary"]["error_count"], 1)
740
+ self.assertEqual(
741
+ [issue["code"] for issue in result["slides"][0]["issues"]],
742
+ ["sxsd_unsupported_tag"],
743
+ )
744
+ self.assertEqual(
745
+ result["slides"][0]["issues"][0]["path"],
746
+ "slide/data/shape/chartParsedValues",
747
+ )
748
+
749
+ def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
750
+ result = xml_text_overlap_lint.lint_xml(
751
+ """
752
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
753
+ <data>
754
+ <chart isStaticData="true" topLeftX="80" topLeftY="80" width="300" height="160">
755
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
756
+ <chartData updated="true">
757
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
758
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
759
+ </chartData>
760
+ </chart>
761
+ </data>
762
+ </slide>
763
+ """
764
+ )
765
+
766
+ self.assertEqual(result["summary"]["error_count"], 2)
767
+ slide_issues = result["slides"][0]["issues"]
768
+ self.assertEqual(
769
+ {(issue["tag"], issue["attr"]) for issue in slide_issues},
770
+ {("chart", "isStaticData"), ("chartData", "updated")},
771
+ )
772
+ self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))
773
+
774
+ def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None:
775
+ result = xml_text_overlap_lint.lint_xml(
776
+ """
777
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
778
+ <data>
779
+ <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
780
+ <fill>
781
+ <fillColor
782
+ type="gradient"
783
+ color1="rgba(255, 0, 0, 1)"
784
+ color2="rgba(0, 0, 255, 1)"
785
+ angle="45"
786
+ stop1="0%"
787
+ stop2="100%"/>
788
+ </fill>
789
+ </shape>
790
+ </data>
791
+ </slide>
792
+ """
793
+ )
794
+ slide_issues = result["slides"][0]["issues"]
795
+ unsupported_attrs = {issue["attr"] for issue in slide_issues}
796
+ self.assertEqual(result["summary"]["error_count"], 6)
797
+ self.assertEqual(
798
+ unsupported_attrs,
799
+ {"type", "color1", "color2", "angle", "stop1", "stop2"},
800
+ )
801
+ self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))
802
+ self.assertTrue(all(issue["tag"] == "fillColor" for issue in slide_issues))
803
+
804
+ def test_lint_xml_accepts_chart_field_simple_content_attrs(self) -> None:
805
+ result = xml_text_overlap_lint.lint_xml(
806
+ """
807
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
808
+ <data>
809
+ <chart topLeftX="80" topLeftY="80" width="520" height="320">
810
+ <chartPlotArea>
811
+ <chartPlot type="line"/>
812
+ </chartPlotArea>
813
+ <chartData>
814
+ <dim1>
815
+ <chartField name="month" valueType="string">Jan, Feb</chartField>
816
+ </dim1>
817
+ <dim2>
818
+ <chartField name="value" valueType="number">1, 2</chartField>
819
+ </dim2>
411
820
  </chartData>
412
821
  </chart>
413
822
  </data>
@@ -637,9 +1046,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
637
1046
  </slide>
638
1047
  """
639
1048
  )
640
- self.assertEqual(result["summary"]["error_count"], 0)
641
- self.assertEqual(result["summary"]["warning_count"], 1)
1049
+ self.assertEqual(result["summary"]["error_count"], 1)
1050
+ self.assertEqual(result["summary"]["warning_count"], 0)
642
1051
  self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
1052
+ self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
643
1053
  self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
644
1054
 
645
1055
  def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
@@ -660,8 +1070,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
660
1070
  """
661
1071
  )
662
1072
  issue = result["slides"][0]["issues"][0]
663
- self.assertEqual(result["summary"]["error_count"], 1)
664
- self.assertEqual(result["summary"]["warning_count"], 1)
1073
+ self.assertEqual(result["summary"]["error_count"], 2)
1074
+ self.assertEqual(result["summary"]["warning_count"], 0)
665
1075
  self.assertEqual(issue["code"], "shape_out_of_canvas")
666
1076
  self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
667
1077
 
@@ -671,7 +1081,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
671
1081
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
672
1082
  <data>
673
1083
  <shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
674
- <content fontSize="20" lineSpacing="multiple:1.5">
1084
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
675
1085
  <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
676
1086
  </content>
677
1087
  </shape>
@@ -685,20 +1095,29 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
685
1095
  <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
686
1096
  </content>
687
1097
  </shape>
1098
+ <shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
1099
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
1100
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1101
+ </content>
1102
+ </shape>
688
1103
  </data>
689
1104
  </slide>
690
1105
  """
691
1106
  )
692
1107
  issues = result["slides"][0]["issues"]
693
- self.assertEqual(result["summary"]["error_count"], 0)
694
- self.assertEqual(result["summary"]["warning_count"], 1)
695
- self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
696
- self.assertEqual(issues[0]["elements"], ["overflowing"])
697
- self.assertEqual(issues[0]["line_count"], 4)
698
- self.assertEqual(issues[0]["estimated_height"], 110)
699
- self.assertEqual(issues[0]["available_height"], 80)
700
- self.assertEqual(issues[0]["overflow"], 30)
701
- self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
1108
+ overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
1109
+ self.assertEqual(result["summary"]["error_count"], 1)
1110
+ overflow_ids = {issue["elements"][0] for issue in overflow_issues}
1111
+ self.assertIn("overflowing", overflow_ids)
1112
+ self.assertNotIn("auto-fit", overflow_ids)
1113
+ self.assertNotIn("shape-auto-fit", overflow_ids)
1114
+ self.assertNotIn("fitting", overflow_ids)
1115
+ overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
1116
+ self.assertEqual(overflowing_issue["line_count"], 4)
1117
+ self.assertEqual(overflowing_issue["estimated_height"], 110)
1118
+ self.assertEqual(overflowing_issue["available_height"], 80)
1119
+ self.assertEqual(overflowing_issue["overflow"], 30)
1120
+ self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
702
1121
 
703
1122
  def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
704
1123
  result = xml_text_overlap_lint.lint_xml(
@@ -706,7 +1125,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
706
1125
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
707
1126
  <data>
708
1127
  <shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
709
- <content fontSize="20" lineSpacing="fixed:20">
1128
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
710
1129
  <p>第一段</p><p>第二段</p><p>第三段</p>
711
1130
  </content>
712
1131
  </shape>
@@ -716,190 +1135,903 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
716
1135
  )
717
1136
  issue = result["slides"][0]["issues"][0]
718
1137
  self.assertEqual(result["summary"]["warning_count"], 1)
1138
+ self.assertEqual(result["summary"]["error_count"], 0)
1139
+ self.assertEqual(issue["level"], "warning")
719
1140
  self.assertEqual(issue["line_height"], 20)
720
1141
  self.assertEqual(issue["estimated_height"], 60)
721
1142
  self.assertEqual(issue["overflow"], 10)
722
1143
 
723
- def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
1144
+ def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
724
1145
  result = xml_text_overlap_lint.lint_xml(
725
1146
  """
726
1147
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
727
1148
  <data>
728
- <shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="35">
729
- <content fontSize="20" lineSpacing="multiple:1.5">
730
- <p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
1149
+ <shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
1150
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1151
+ <p>第一段</p><p>第二段</p>
731
1152
  </content>
732
1153
  </shape>
733
- <shape id="paragraph-fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="40">
734
- <content fontSize="20" lineSpacing="multiple:1.5">
735
- <p lineSpacing="fixed:10">第一行<br/>第二行<br/>第三行</p>
736
- </content>
1154
+ </data>
1155
+ </slide>
1156
+ """
1157
+ )
1158
+ overflow_issues = [
1159
+ issue
1160
+ for issue in result["slides"][0]["issues"]
1161
+ if issue["code"] == "text_may_overflow_shape"
1162
+ ]
1163
+ self.assertEqual(overflow_issues, [])
1164
+
1165
+ def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
1166
+ result = xml_text_overlap_lint.lint_xml(
1167
+ """
1168
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1169
+ <data>
1170
+ <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
1171
+ <content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
737
1172
  </shape>
738
1173
  </data>
739
1174
  </slide>
740
1175
  """
741
1176
  )
742
- issues = result["slides"][0]["issues"]
743
- self.assertEqual(result["summary"]["warning_count"], 1)
744
- self.assertEqual(issues[0]["elements"], ["paragraph-overflow"])
745
- self.assertEqual(issues[0]["line_count"], 2)
746
- self.assertEqual(issues[0]["line_height"], 10)
747
- self.assertEqual(issues[0]["estimated_height"], 40)
748
- self.assertEqual(issues[0]["overflow"], 5)
1177
+ overflow_issues = [
1178
+ issue
1179
+ for issue in result["slides"][0]["issues"]
1180
+ if issue["code"] == "text_may_overflow_shape"
1181
+ ]
1182
+ self.assertEqual(overflow_issues, [])
749
1183
 
750
- def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
751
- self.assertEqual(
752
- xml_text_overlap_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
753
- "第一行\n第二行\n第三行",
1184
+ def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
1185
+ result = xml_text_overlap_lint.lint_xml(
1186
+ """
1187
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1188
+ <data>
1189
+ <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
1190
+ <content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
1191
+ </shape>
1192
+ <shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
1193
+ <content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
1194
+ </shape>
1195
+ </data>
1196
+ </slide>
1197
+ """
754
1198
  )
1199
+ overflow_issues = [
1200
+ issue
1201
+ for issue in result["slides"][0]["issues"]
1202
+ if issue["code"] == "text_may_overflow_shape"
1203
+ ]
1204
+ self.assertEqual(overflow_issues, [])
755
1205
 
756
- def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
1206
+ def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
757
1207
  result = xml_text_overlap_lint.lint_xml(
758
1208
  """
759
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
760
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
761
- <data>
762
- <img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
763
- <shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
764
- <content textType="title" fontSize="44"><p>Title</p></content>
765
- </shape>
766
- <shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
767
- <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
768
- </shape>
769
- </data>
770
- </slide>
771
- </presentation>
1209
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1210
+ <data>
1211
+ <shape id="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
1212
+ <content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</p></content>
1213
+ </shape>
1214
+ </data>
1215
+ </slide>
772
1216
  """
773
1217
  )
774
- self.assertEqual(result["summary"]["error_count"], 1)
775
- self.assertEqual(result["summary"]["warning_count"], 0)
776
- self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
1218
+ overflow_issues = [
1219
+ issue
1220
+ for issue in result["slides"][0]["issues"]
1221
+ if issue["code"] == "text_may_overflow_shape"
1222
+ ]
1223
+ self.assertEqual(len(overflow_issues), 1)
1224
+ self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
777
1225
 
778
- def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
779
- elements = xml_text_overlap_lint.extract_elements(
1226
+ def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
1227
+ result = xml_text_overlap_lint.lint_xml(
780
1228
  """
781
1229
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
782
1230
  <data>
783
- <img id="photo" src="tok" topLeftX="10" topLeftY="20" width="100" height="80"/>
784
- <shape id="headline" type="text" topLeftX="40" topLeftY="60" width="320" height="90">
785
- <content textType="headline" textAlign="center" autoFit="normal-auto-fit" fontSize="28">
786
- <p><![CDATA[Growth & scale]]></p>
787
- <p>Focused execution</p>
1231
+ <shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
1232
+ <content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
1233
+ <p>参数服务器 (Parameter Server)</p>
788
1234
  </content>
789
1235
  </shape>
790
- <table id="table" topLeftX="400" topLeftY="60" width="220" height="120"></table>
791
- <chart id="chart" topLeftX="640" topLeftY="60" width="220" height="120"/>
792
- <whiteboard id="wb" topLeftX="80" topLeftY="220" width="760" height="240"/>
793
- <shape id="missing-height" type="text" topLeftX="80" topLeftY="480" width="320">
794
- <content><p>Skipped</p></content>
795
- </shape>
796
1236
  </data>
797
1237
  </slide>
798
1238
  """
799
1239
  )
800
- self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb"])
801
- self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard"])
802
- self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4])
803
- self.assertEqual(elements[1]["type"], "text")
804
- self.assertEqual(elements[1]["textType"], "headline")
805
- self.assertEqual(elements[1]["textAlign"], "center")
806
- self.assertEqual(elements[1]["autoFit"], "normal-auto-fit")
807
- self.assertEqual(elements[1]["fontSize"], 28)
808
- self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
1240
+ overflow_issues = [
1241
+ issue
1242
+ for issue in result["slides"][0]["issues"]
1243
+ if issue["code"] == "text_may_overflow_shape"
1244
+ ]
1245
+ self.assertEqual(overflow_issues, [])
809
1246
 
810
- def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
1247
+ def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
811
1248
  result = xml_text_overlap_lint.lint_xml(
812
1249
  """
813
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
814
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
815
- <data>
816
- <img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
817
- </data>
818
- </slide>
819
- </presentation>
1250
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1251
+ <data>
1252
+ <shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
1253
+ <content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
1254
+ <p>全球半导体市场规模持续高速增长,AI驱动新一轮景气周期</p>
1255
+ </content>
1256
+ </shape>
1257
+ </data>
1258
+ </slide>
820
1259
  """
821
1260
  )
822
- self.assertEqual(result["summary"]["error_count"], 1)
823
- self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
1261
+ overflow_issues = [
1262
+ issue
1263
+ for issue in result["slides"][0]["issues"]
1264
+ if issue["code"] == "text_may_overflow_shape"
1265
+ ]
1266
+ self.assertEqual(overflow_issues, [])
824
1267
 
825
- def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
1268
+ def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
826
1269
  result = xml_text_overlap_lint.lint_xml(
827
1270
  """
828
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
829
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
830
- <data>
831
- <img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
832
- <img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
833
- </data>
834
- </slide>
835
- </presentation>
1271
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1272
+ <data>
1273
+ <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
1274
+ <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
1275
+ <p>总体目标:</p>
1276
+ <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
1277
+ <p>具体目标:</p>
1278
+ <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
1279
+ <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
1280
+ <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
1281
+ </content>
1282
+ </shape>
1283
+ </data>
1284
+ </slide>
836
1285
  """
837
1286
  )
838
- self.assertEqual(result["summary"]["error_count"], 2)
839
- self.assertEqual(
840
- [issue["code"] for issue in result["slides"][0]["errors"]],
841
- ["img_out_of_canvas", "img_out_of_canvas"],
842
- )
1287
+ overflow_issues = [
1288
+ issue
1289
+ for issue in result["slides"][0]["issues"]
1290
+ if issue["code"] == "text_may_overflow_shape"
1291
+ ]
1292
+ self.assertEqual(overflow_issues, [])
843
1293
 
844
- def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
1294
+ def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
845
1295
  result = xml_text_overlap_lint.lint_xml(
846
1296
  """
847
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
848
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
849
- <data>
850
- <img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
851
- </data>
852
- </slide>
853
- </presentation>
1297
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1298
+ <data>
1299
+ <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
1300
+ <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
1301
+ <p>总体目标:</p>
1302
+ <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
1303
+ <p>具体目标:</p>
1304
+ <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
1305
+ <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
1306
+ <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
1307
+ </content>
1308
+ </shape>
1309
+ </data>
1310
+ </slide>
854
1311
  """
855
1312
  )
856
- self.assertEqual(result["summary"]["error_count"], 1)
857
- self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
1313
+ overflow_issues = [
1314
+ issue
1315
+ for issue in result["slides"][0]["issues"]
1316
+ if issue["code"] == "text_may_overflow_shape"
1317
+ ]
1318
+ self.assertEqual(len(overflow_issues), 1)
1319
+ self.assertEqual(overflow_issues[0]["elements"], ["dense-body"])
858
1320
 
859
- def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
1321
+ def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
860
1322
  result = xml_text_overlap_lint.lint_xml(
861
1323
  """
862
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
863
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
864
- <data>
865
- <shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
1324
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1325
+ <data>
1326
+ <shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
1327
+ <content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
1328
+ <p>RISKS &amp; CHALLENGES</p>
1329
+ </content>
1330
+ </shape>
1331
+ </data>
1332
+ </slide>
1333
+ """
1334
+ )
1335
+ overflow_issues = [
1336
+ issue
1337
+ for issue in result["slides"][0]["issues"]
1338
+ if issue["code"] == "text_may_overflow_shape"
1339
+ ]
1340
+ self.assertEqual(len(overflow_issues), 1)
1341
+ self.assertEqual(overflow_issues[0]["elements"], ["caption"])
1342
+
1343
+ def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
1344
+ result = xml_text_overlap_lint.lint_xml(
1345
+ """
1346
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1347
+ <data>
1348
+ <shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
1349
+ <content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
1350
+ <p>MARKET INSIGHT · 市场洞察</p>
1351
+ </content>
1352
+ </shape>
1353
+ </data>
1354
+ </slide>
1355
+ """
1356
+ )
1357
+ overflow_issues = [
1358
+ issue
1359
+ for issue in result["slides"][0]["issues"]
1360
+ if issue["code"] == "text_may_overflow_shape"
1361
+ ]
1362
+ self.assertEqual(len(overflow_issues), 1)
1363
+ self.assertEqual(overflow_issues[0]["elements"], ["micro-caption"])
1364
+
1365
+ def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
1366
+ result = xml_text_overlap_lint.lint_xml(
1367
+ """
1368
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1369
+ <data>
1370
+ <shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
1371
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1372
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1373
+ </content>
1374
+ </shape>
1375
+ <shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
1376
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1377
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1378
+ </content>
1379
+ </shape>
1380
+ </data>
1381
+ </slide>
1382
+ """
1383
+ )
1384
+ issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
1385
+ self.assertEqual(issues["just-warning"]["level"], "warning")
1386
+ self.assertEqual(issues["just-warning"]["overflow"], 10)
1387
+ self.assertEqual(issues["error-overflow"]["level"], "error")
1388
+ self.assertEqual(issues["error-overflow"]["overflow"], 30)
1389
+ self.assertEqual(result["summary"]["error_count"], 1)
1390
+ self.assertEqual(result["summary"]["warning_count"], 1)
1391
+
1392
+ def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
1393
+ result = xml_text_overlap_lint.lint_xml(
1394
+ """
1395
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1396
+ <data>
1397
+ <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1398
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1399
+ </shape>
1400
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1401
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1402
+ </shape>
1403
+ </data>
1404
+ </slide>
1405
+ """
1406
+ )
1407
+ issues = {
1408
+ issue["elements"][0]: issue
1409
+ for issue in result["slides"][0]["issues"]
1410
+ if issue["code"] == "text_may_overflow_shape"
1411
+ }
1412
+ self.assertEqual(issues["bg-deco"]["level"], "info")
1413
+ self.assertEqual(result["summary"]["warning_count"], 0)
1414
+ self.assertEqual(result["summary"]["info_count"], 1)
1415
+ self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
1416
+ self.assertIn("background decoration", issues["bg-deco"]["message"])
1417
+
1418
+ def test_lint_xml_reports_shape_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1419
+ result = xml_text_overlap_lint.lint_xml(
1420
+ """
1421
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1422
+ <data>
1423
+ <shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
1424
+ <content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
1425
+ </shape>
1426
+ <shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
1427
+ <content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
1428
+ </shape>
1429
+ </data>
1430
+ </slide>
1431
+ """
1432
+ )
1433
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1434
+ self.assertEqual(result["summary"]["error_count"], 1)
1435
+ self.assertIn("shape_out_of_canvas", codes)
1436
+ self.assertNotIn("bbox_overlap", codes)
1437
+
1438
+ def test_lint_xml_reports_content_color_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1439
+ result = xml_text_overlap_lint.lint_xml(
1440
+ """
1441
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1442
+ <data>
1443
+ <shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
1444
+ <content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
1445
+ </shape>
1446
+ <shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
1447
+ <content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
1448
+ </shape>
1449
+ </data>
1450
+ </slide>
1451
+ """
1452
+ )
1453
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1454
+ self.assertEqual(result["summary"]["error_count"], 1)
1455
+ self.assertIn("shape_out_of_canvas", codes)
1456
+ self.assertNotIn("bbox_overlap", codes)
1457
+
1458
+ def test_lint_xml_reports_faint_medium_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1459
+ result = xml_text_overlap_lint.lint_xml(
1460
+ """
1461
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1462
+ <data>
1463
+ <shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
1464
+ <content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
1465
+ </shape>
1466
+ <shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
1467
+ <content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
1468
+ </shape>
1469
+ </data>
1470
+ </slide>
1471
+ """
1472
+ )
1473
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1474
+ self.assertEqual(result["summary"]["error_count"], 1)
1475
+ self.assertIn("shape_out_of_canvas", codes)
1476
+ self.assertNotIn("bbox_overlap", codes)
1477
+
1478
+ def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
1479
+ result = xml_text_overlap_lint.lint_xml(
1480
+ """
1481
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1482
+ <data>
1483
+ <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
1484
+ <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
1485
+ </shape>
1486
+ <img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
1487
+ <shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
1488
+ <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
1489
+ </shape>
1490
+ </data>
1491
+ </slide>
1492
+ """
1493
+ )
1494
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1495
+ self.assertNotIn("image_covers_text", codes)
1496
+ self.assertNotIn("bbox_overlap", codes)
1497
+
1498
+ def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
1499
+ result = xml_text_overlap_lint.lint_slide(
1500
+ """
1501
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1502
+ <data>
1503
+ <whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
1504
+ <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
1505
+ <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
1506
+ </shape>
1507
+ <shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
1508
+ <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
1509
+ </shape>
1510
+ </data>
1511
+ </slide>
1512
+ """,
1513
+ 1,
1514
+ )
1515
+ codes = [issue["code"] for issue in result["issues"]]
1516
+ self.assertNotIn("whiteboard_external_overlap", codes)
1517
+
1518
+ def test_lint_xml_reports_faint_ghost_text_out_of_canvas_without_area_threshold(self) -> None:
1519
+ result = xml_text_overlap_lint.lint_xml(
1520
+ """
1521
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1522
+ <data>
1523
+ <shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
1524
+ <content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
1525
+ </shape>
1526
+ </data>
1527
+ </slide>
1528
+ """
1529
+ )
1530
+ self.assertEqual(result["summary"]["error_count"], 1)
1531
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1532
+
1533
+ def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
1534
+ result = xml_text_overlap_lint.lint_xml(
1535
+ """
1536
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1537
+ <data>
1538
+ <shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
1539
+ <content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
1540
+ </shape>
1541
+ </data>
1542
+ </slide>
1543
+ """
1544
+ )
1545
+ self.assertEqual(result["summary"]["error_count"], 1)
1546
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1547
+ self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])
1548
+
1549
+ def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
1550
+ result = xml_text_overlap_lint.lint_xml(
1551
+ """
1552
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1553
+ <data>
1554
+ <shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
1555
+ <content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
1556
+ </shape>
1557
+ </data>
1558
+ </slide>
1559
+ """
1560
+ )
1561
+ self.assertEqual(result["summary"]["error_count"], 1)
1562
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1563
+ self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])
1564
+
1565
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
1566
+ result = xml_text_overlap_lint.lint_xml(
1567
+ """
1568
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1569
+ <data>
1570
+ <shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
1571
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1572
+ </shape>
1573
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1574
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1575
+ </shape>
1576
+ </data>
1577
+ </slide>
1578
+ """
1579
+ )
1580
+ issue = next(
1581
+ issue
1582
+ for issue in result["slides"][0]["issues"]
1583
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
1584
+ )
1585
+ self.assertEqual(issue["level"], "error")
1586
+
1587
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
1588
+ result = xml_text_overlap_lint.lint_xml(
1589
+ """
1590
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1591
+ <data>
1592
+ <shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1593
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1594
+ </shape>
1595
+ </data>
1596
+ </slide>
1597
+ """
1598
+ )
1599
+ issue = next(
1600
+ issue
1601
+ for issue in result["slides"][0]["issues"]
1602
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
1603
+ )
1604
+ self.assertEqual(issue["level"], "error")
1605
+
1606
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
1607
+ result = xml_text_overlap_lint.lint_xml(
1608
+ """
1609
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1610
+ <data>
1611
+ <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1612
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1613
+ </shape>
1614
+ <shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
1615
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1616
+ </shape>
1617
+ </data>
1618
+ </slide>
1619
+ """
1620
+ )
1621
+ issue = next(
1622
+ issue
1623
+ for issue in result["slides"][0]["issues"]
1624
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
1625
+ )
1626
+ self.assertEqual(issue["level"], "error")
1627
+
1628
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
1629
+ result = xml_text_overlap_lint.lint_xml(
1630
+ """
1631
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1632
+ <data>
1633
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1634
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1635
+ </shape>
1636
+ <shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1637
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1638
+ </shape>
1639
+ </data>
1640
+ </slide>
1641
+ """
1642
+ )
1643
+ issue = next(
1644
+ issue
1645
+ for issue in result["slides"][0]["issues"]
1646
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
1647
+ )
1648
+ self.assertEqual(issue["level"], "error")
1649
+
1650
+ def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
1651
+ result = xml_text_overlap_lint.lint_xml(
1652
+ """
1653
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1654
+ <data>
1655
+ <shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
1656
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
1657
+ <p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
1658
+ </content>
1659
+ </shape>
1660
+ <shape id="paragraph-fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="40">
1661
+ <content fontSize="20" lineSpacing="multiple:1.5">
1662
+ <p lineSpacing="fixed:10">第一行<br/>第二行<br/>第三行</p>
1663
+ </content>
1664
+ </shape>
1665
+ </data>
1666
+ </slide>
1667
+ """
1668
+ )
1669
+ issues = result["slides"][0]["issues"]
1670
+ self.assertEqual(result["summary"]["warning_count"], 1)
1671
+ self.assertEqual(issues[0]["elements"], ["paragraph-overflow"])
1672
+ self.assertEqual(issues[0]["line_count"], 2)
1673
+ self.assertEqual(issues[0]["line_height"], 10)
1674
+ self.assertEqual(issues[0]["estimated_height"], 40)
1675
+ self.assertEqual(issues[0]["overflow"], 10)
1676
+
1677
+ def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
1678
+ result = xml_text_overlap_lint.lint_xml(
1679
+ """
1680
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1681
+ <data>
1682
+ <shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
1683
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
1684
+ </shape>
1685
+ <shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
1686
+ <content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
1687
+ </shape>
1688
+ <shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
1689
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
1690
+ </shape>
1691
+ </data>
1692
+ </slide>
1693
+ """
1694
+ )
1695
+ issues = result["slides"][0]["issues"]
1696
+ overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
1697
+ self.assertNotIn("baseline", overflow_ids)
1698
+ self.assertIn("content-spaced", overflow_ids)
1699
+ self.assertIn("paragraph-spaced", overflow_ids)
1700
+ by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
1701
+ self.assertEqual(by_id["content-spaced"]["line_count"], 2)
1702
+ self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
1703
+ self.assertEqual(by_id["content-spaced"]["overflow"], 20)
1704
+ self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
1705
+
1706
+ def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
1707
+ self.assertEqual(
1708
+ xml_text_overlap_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
1709
+ "第一行\n第二行\n第三行",
1710
+ )
1711
+
1712
+ def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
1713
+ result = xml_text_overlap_lint.lint_xml(
1714
+ """
1715
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1716
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1717
+ <data>
1718
+ <img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
1719
+ <shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
1720
+ <content textType="title" fontSize="44"><p>Title</p></content>
1721
+ </shape>
1722
+ <shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
1723
+ <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
1724
+ </shape>
1725
+ </data>
1726
+ </slide>
1727
+ </presentation>
1728
+ """
1729
+ )
1730
+ self.assertEqual(result["summary"]["error_count"], 0)
1731
+ self.assertEqual(result["summary"]["warning_count"], 0)
1732
+
1733
+ def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
1734
+ elements = xml_text_overlap_lint.extract_elements(
1735
+ """
1736
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1737
+ <data>
1738
+ <img id="photo" src="tok" topLeftX="10" topLeftY="20" width="100" height="80"/>
1739
+ <shape id="headline" type="text" topLeftX="40" topLeftY="60" width="320" height="90">
1740
+ <content textType="headline" textAlign="center" autoFit="normal-auto-fit" fontSize="28">
1741
+ <p><![CDATA[Growth & scale]]></p>
1742
+ <p>Focused execution</p>
1743
+ </content>
1744
+ </shape>
1745
+ <table id="table" topLeftX="400" topLeftY="60" width="220" height="120"></table>
1746
+ <chart id="chart" topLeftX="640" topLeftY="60" width="220" height="120"/>
1747
+ <whiteboard id="wb" topLeftX="80" topLeftY="220" width="760" height="240"/>
1748
+ <shape id="missing-height" type="text" topLeftX="80" topLeftY="480" width="320">
1749
+ <content><p>Skipped</p></content>
1750
+ </shape>
1751
+ </data>
1752
+ </slide>
1753
+ """
1754
+ )
1755
+ self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb"])
1756
+ self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard"])
1757
+ self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4])
1758
+ self.assertEqual(elements[1]["type"], "text")
1759
+ self.assertEqual(elements[1]["textType"], "headline")
1760
+ self.assertEqual(elements[1]["textAlign"], "center")
1761
+ self.assertEqual(elements[1]["autoFit"], "normal-auto-fit")
1762
+ self.assertEqual(elements[1]["fontSize"], 28)
1763
+ self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
1764
+
1765
+ def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
1766
+ result = xml_text_overlap_lint.lint_xml(
1767
+ """
1768
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1769
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1770
+ <data>
1771
+ <img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
1772
+ </data>
1773
+ </slide>
1774
+ </presentation>
1775
+ """
1776
+ )
1777
+ self.assertEqual(result["summary"]["error_count"], 0)
1778
+
1779
+ def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
1780
+ result = xml_text_overlap_lint.lint_xml(
1781
+ """
1782
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1783
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1784
+ <data>
1785
+ <img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
1786
+ <img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
1787
+ </data>
1788
+ </slide>
1789
+ </presentation>
1790
+ """
1791
+ )
1792
+ self.assertEqual(result["summary"]["error_count"], 0)
1793
+
1794
+ def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
1795
+ result = xml_text_overlap_lint.lint_xml(
1796
+ """
1797
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1798
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1799
+ <data>
1800
+ <img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
1801
+ </data>
1802
+ </slide>
1803
+ </presentation>
1804
+ """
1805
+ )
1806
+ self.assertEqual(result["summary"]["error_count"], 0)
1807
+
1808
+ def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
1809
+ result = xml_text_overlap_lint.lint_xml(
1810
+ """
1811
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1812
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1813
+ <data>
1814
+ <shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
866
1815
  <img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
867
- <chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
1816
+ <chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100">
1817
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
1818
+ <chartData>
1819
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
1820
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
1821
+ </chartData>
1822
+ </chart>
868
1823
  </data>
869
1824
  </slide>
870
1825
  </presentation>
871
1826
  """
872
1827
  )
873
1828
  issues = result["slides"][0]["issues"]
874
- self.assertEqual(result["summary"]["error_count"], 3)
1829
+ self.assertEqual(result["summary"]["error_count"], 2)
875
1830
  self.assertEqual(
876
1831
  [(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
877
1832
  [
878
1833
  ("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
879
- ("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
880
1834
  ("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
881
1835
  ],
882
1836
  )
883
1837
 
884
- def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
1838
+ def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
1839
+ result = xml_text_overlap_lint.lint_xml(
1840
+ """
1841
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1842
+ <data>
1843
+ <shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
1844
+ <content fontSize="18"><p>Visible content</p></content>
1845
+ </shape>
1846
+ <line id="connector" startX="80" startY="120" endX="980" endY="120"><border/></line>
1847
+ </data>
1848
+ </slide>
1849
+ """
1850
+ )
1851
+
1852
+ self.assertEqual(result["summary"]["error_count"], 0)
1853
+ self.assertEqual(result["slides"][0]["issues"], [])
1854
+
1855
+ def test_lint_xml_reports_horizontal_line_crossing_headline_glyphs(self) -> None:
1856
+ result = xml_text_overlap_lint.lint_xml(
1857
+ """
1858
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1859
+ <data>
1860
+ <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
1861
+ <content fontSize="60"><p>测试文字 ABC</p></content>
1862
+ </shape>
1863
+ <line id="strike" startX="80" startY="245" endX="560" endY="245">
1864
+ <border color="rgb(255, 0, 0)" width="4"/>
1865
+ </line>
1866
+ </data>
1867
+ </slide>
1868
+ """
1869
+ )
1870
+ crossing = [
1871
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"strike", "title"}
1872
+ ]
1873
+ self.assertEqual(len(crossing), 1)
1874
+ self.assertEqual(crossing[0]["code"], "bbox_overlap")
1875
+
1876
+ def test_lint_xml_reports_vertical_line_crossing_multiline_text(self) -> None:
1877
+ result = xml_text_overlap_lint.lint_xml(
1878
+ """
1879
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1880
+ <data>
1881
+ <shape id="col" type="text" topLeftX="700" topLeftY="180" width="240" height="180">
1882
+ <content fontSize="20"><p>第一行文字内容</p><p>第二行文字内容</p><p>第三行文字内容</p></content>
1883
+ </shape>
1884
+ <line id="vbar" startX="740" startY="170" endX="740" endY="360">
1885
+ <border color="rgb(0, 0, 255)" width="3"/>
1886
+ </line>
1887
+ </data>
1888
+ </slide>
1889
+ """
1890
+ )
1891
+ crossing = [
1892
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"vbar", "col"}
1893
+ ]
1894
+ self.assertEqual(len(crossing), 1)
1895
+
1896
+ def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
1897
+ result = xml_text_overlap_lint.lint_xml(
1898
+ """
1899
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1900
+ <data>
1901
+ <shape id="para" type="text" topLeftX="80" topLeftY="400" width="420" height="140">
1902
+ <content fontSize="18"><p>这是一段测试文字用于验证线条穿过</p></content>
1903
+ </shape>
1904
+ <line id="diag" startX="80" startY="410" endX="500" endY="530">
1905
+ <border color="rgb(255, 0, 0)" width="3"/>
1906
+ </line>
1907
+ </data>
1908
+ </slide>
1909
+ """
1910
+ )
1911
+ crossing = [
1912
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"diag", "para"}
1913
+ ]
1914
+ self.assertEqual(len(crossing), 1)
1915
+
1916
+ def test_lint_xml_ignores_diagonal_line_whose_bbox_but_not_segment_crosses_text(self) -> None:
1917
+ # The diagonal's axis-aligned bounding box overlaps the text, but the segment itself passes
1918
+ # through empty space in the opposite corner -- a naive bbox test would false-positive here.
1919
+ result = xml_text_overlap_lint.lint_xml(
1920
+ """
1921
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1922
+ <data>
1923
+ <shape id="corner-text" type="text" topLeftX="80" topLeftY="80" width="120" height="40">
1924
+ <content fontSize="18"><p>corner</p></content>
1925
+ </shape>
1926
+ <line id="far-diag" startX="700" startY="80" endX="90" endY="500">
1927
+ <border color="rgb(255, 0, 0)" width="3"/>
1928
+ </line>
1929
+ </data>
1930
+ </slide>
1931
+ """
1932
+ )
1933
+ crossing = [
1934
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"far-diag", "corner-text"}
1935
+ ]
1936
+ self.assertEqual(crossing, [])
1937
+
1938
+ def test_lint_xml_ignores_line_touching_text_frame_but_not_glyphs(self) -> None:
1939
+ result = xml_text_overlap_lint.lint_xml(
1940
+ """
1941
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1942
+ <data>
1943
+ <shape id="lbl" type="text" topLeftX="80" topLeftY="80" width="300" height="200">
1944
+ <content fontSize="18" verticalAlign="top"><p>短标签</p></content>
1945
+ </shape>
1946
+ <line id="below" startX="80" startY="270" endX="380" endY="270">
1947
+ <border color="rgb(255, 0, 0)" width="2"/>
1948
+ </line>
1949
+ </data>
1950
+ </slide>
1951
+ """
1952
+ )
1953
+ self.assertEqual(result["summary"]["error_count"], 0)
1954
+
1955
+ def test_lint_xml_ignores_invisible_line_crossing_text(self) -> None:
1956
+ result = xml_text_overlap_lint.lint_xml(
1957
+ """
1958
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1959
+ <data>
1960
+ <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
1961
+ <content fontSize="60"><p>测试文字 ABC</p></content>
1962
+ </shape>
1963
+ <line id="ghost-line" startX="80" startY="245" endX="560" endY="245">
1964
+ <border color="rgba(255, 0, 0, 0.03)" width="4"/>
1965
+ </line>
1966
+ </data>
1967
+ </slide>
1968
+ """
1969
+ )
1970
+ self.assertEqual(result["summary"]["error_count"], 0)
1971
+
1972
+ def test_lint_xml_ignores_vertical_line_grazing_text_left_edge(self) -> None:
1973
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 4: a vertical line sitting on the text
1974
+ # frame's left edge renders before the first glyph, so it must not be flagged.
1975
+ result = xml_text_overlap_lint.lint_xml(
1976
+ """
1977
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1978
+ <data>
1979
+ <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmm">
1980
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
1981
+ <p>Vertical edge graze</p>
1982
+ </content>
1983
+ </shape>
1984
+ <line id="bmX" startX="120.00000000000001" startY="90" endX="120.00000000000001" endY="150.00833275470998">
1985
+ <border color="rgba(0, 0, 0, 1)"/>
1986
+ </line>
1987
+ </data>
1988
+ </slide>
1989
+ """
1990
+ )
1991
+ self.assertEqual(result["summary"]["error_count"], 0)
1992
+
1993
+ def test_lint_xml_ignores_polyline_crossing_text(self) -> None:
1994
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 6: the crossing check is scoped to
1995
+ # <line> only, so a <polyline> over text is not flagged.
1996
+ result = xml_text_overlap_lint.lint_xml(
1997
+ """
1998
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1999
+ <data>
2000
+ <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmr">
2001
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
2002
+ <p>Polyline target</p>
2003
+ </content>
2004
+ </shape>
2005
+ <polyline id="bmH" width="270" height="55" topLeftX="110" topLeftY="95">
2006
+ <border color="rgba(0, 0, 0, 1)"/>
2007
+ </polyline>
2008
+ </data>
2009
+ </slide>
2010
+ """
2011
+ )
2012
+ self.assertEqual(result["summary"]["error_count"], 0)
2013
+
2014
+ def test_lint_xml_ignores_line_below_visual_glyph_height(self) -> None:
2015
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 7: the shape frame is 80px tall but the
2016
+ # single 20px line of glyphs occupies only its top; a line at the frame's lower region grazes
2017
+ # under the visual glyph box (underline look) and must not be flagged.
885
2018
  result = xml_text_overlap_lint.lint_xml(
886
2019
  """
887
2020
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
888
2021
  <data>
889
- <shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
890
- <content fontSize="18"><p>Visible content</p></content>
2022
+ <shape width="240" height="80" topLeftX="120" topLeftY="100" type="text" id="bmB">
2023
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
2024
+ <p>Visual height target</p>
2025
+ </content>
891
2026
  </shape>
892
- <line id="connector" startX="80" startY="120" endX="980" endY="120"/>
2027
+ <line id="bmQ" startX="110" startY="150" endX="380.00185184550116" endY="150">
2028
+ <border color="rgba(0, 0, 0, 1)"/>
2029
+ </line>
893
2030
  </data>
894
2031
  </slide>
895
2032
  """
896
2033
  )
897
-
898
- issue = result["slides"][0]["errors"][0]
899
- self.assertEqual(issue["code"], "line_out_of_canvas")
900
- self.assertEqual(issue["element_ids"], ["connector"])
901
- self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
902
- self.assertEqual(issue["related_objects"][0]["kind"], "line")
2034
+ self.assertEqual(result["summary"]["error_count"], 0)
903
2035
 
904
2036
  def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
905
2037
  result = xml_text_overlap_lint.lint_xml(
@@ -908,7 +2040,13 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
908
2040
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
909
2041
  <data>
910
2042
  <shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
911
- <chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
2043
+ <chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45">
2044
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2045
+ <chartData>
2046
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2047
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2048
+ </chartData>
2049
+ </chart>
912
2050
  </data>
913
2051
  </slide>
914
2052
  </presentation>
@@ -922,28 +2060,70 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
922
2060
  self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
923
2061
  self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
924
2062
 
925
- def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
2063
+ def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
926
2064
  result = xml_text_overlap_lint.lint_xml(
927
2065
  """
928
2066
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
929
2067
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
930
2068
  <data>
931
- <shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
932
- <img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
2069
+ <shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
2070
+ <img id="rotated-image" src="token" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
933
2071
  </data>
934
2072
  </slide>
935
2073
  </presentation>
936
2074
  """
937
2075
  )
938
2076
  issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
939
- self.assertEqual(result["summary"]["error_count"], 2)
2077
+ self.assertEqual(result["summary"]["error_count"], 1)
940
2078
  self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
941
- self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
942
- self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
943
- self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
944
- self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
2079
+ self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
2080
+ self.assertNotIn("rotated-image", issues_by_element)
2081
+
2082
+ def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
2083
+ issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
2084
+ [
2085
+ {"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
2086
+ {"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
2087
+ {
2088
+ "id": "text",
2089
+ "kind": "shape",
2090
+ "type": "text",
2091
+ "x": 95,
2092
+ "y": 0,
2093
+ "width": 10,
2094
+ "height": 10,
2095
+ "rotation": 0,
2096
+ },
2097
+ {
2098
+ "id": "rect",
2099
+ "kind": "shape",
2100
+ "type": "rect",
2101
+ "x": 95,
2102
+ "y": 0,
2103
+ "width": 10,
2104
+ "height": 10,
2105
+ "rotation": 45,
2106
+ },
2107
+ {"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
2108
+ {
2109
+ "id": "ellipse",
2110
+ "kind": "shape",
2111
+ "type": "ellipse",
2112
+ "x": 95,
2113
+ "y": 0,
2114
+ "width": 10,
2115
+ "height": 10,
2116
+ "rotation": 0,
2117
+ },
2118
+ ],
2119
+ 100,
2120
+ 100,
2121
+ )
945
2122
 
946
- def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
2123
+ self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
2124
+ self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
2125
+
2126
+ def test_lint_xml_rejects_non_finite_rotation_values_from_xsd(self) -> None:
947
2127
  result = xml_text_overlap_lint.lint_xml(
948
2128
  """
949
2129
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -951,17 +2131,22 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
951
2131
  <data>
952
2132
  <shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
953
2133
  <shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
954
- <chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
2134
+ <chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan">
2135
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2136
+ <chartData>
2137
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2138
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2139
+ </chartData>
2140
+ </chart>
955
2141
  </data>
956
2142
  </slide>
957
2143
  </presentation>
958
2144
  """
959
2145
  )
960
- issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
961
2146
  self.assertEqual(result["summary"]["error_count"], 3)
962
- self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
963
- self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
964
- self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
2147
+ slide_issues = result["slides"][0]["issues"]
2148
+ self.assertTrue(all(issue["code"] == "sxsd_invalid_scalar" for issue in slide_issues))
2149
+ self.assertEqual({issue["actual"] for issue in slide_issues}, {"inf", "-inf", "nan"})
965
2150
 
966
2151
  def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
967
2152
  result = xml_text_overlap_lint.lint_xml(
@@ -1107,8 +2292,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
1107
2292
  )
1108
2293
  issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
1109
2294
  self.assertEqual(result["summary"]["error_count"], 0)
1110
- self.assertEqual(result["summary"]["warning_count"], 2)
1111
- self.assertEqual(issues_by_dimension["width"]["level"], "warning")
2295
+ self.assertEqual(result["summary"]["warning_count"], 0)
2296
+ self.assertEqual(result["summary"]["info_count"], 2)
2297
+ self.assertEqual(issues_by_dimension["width"]["level"], "info")
1112
2298
  self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
1113
2299
  self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
1114
2300
  self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
@@ -1201,7 +2387,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
1201
2387
  }
1202
2388
  script_path = Path(xml_text_overlap_lint.__file__).resolve()
1203
2389
  with tempfile.TemporaryDirectory() as temp_dir:
1204
- for name, (table_xml, expected_warning_count) in cases.items():
2390
+ for name, (table_xml, expected_info_count) in cases.items():
1205
2391
  with self.subTest(case=name):
1206
2392
  input_path = Path(temp_dir) / f"{name}.xml"
1207
2393
  input_path.write_text(
@@ -1221,9 +2407,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
1221
2407
  result = json.loads(completed.stdout)
1222
2408
  self.assertEqual(completed.returncode, 0, completed.stderr)
1223
2409
  self.assertEqual(result["summary"]["error_count"], 0)
1224
- self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
2410
+ self.assertEqual(result["summary"]["warning_count"], 0)
2411
+ self.assertEqual(result["summary"]["info_count"], expected_info_count)
1225
2412
  self.assertTrue(
1226
- all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
2413
+ all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
1227
2414
  result["slides"][0]["issues"],
1228
2415
  )
1229
2416
 
@@ -1268,8 +2455,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
1268
2455
  """
1269
2456
  )
1270
2457
  issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
1271
- self.assertEqual(issue["level"], "warning")
2458
+ self.assertEqual(issue["level"], "info")
1272
2459
  self.assertEqual(result["summary"]["error_count"], 0)
2460
+ self.assertEqual(result["summary"]["info_count"], 1)
1273
2461
 
1274
2462
 
1275
2463
  class XmlTextOverlapLintDensityTest(unittest.TestCase):
@@ -1313,7 +2501,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1313
2501
  """
1314
2502
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
1315
2503
  <data>
1316
- <img id="ghost" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
2504
+ <img id="ghost" src="token" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
1317
2505
  </data>
1318
2506
  </slide>
1319
2507
  """
@@ -1357,7 +2545,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1357
2545
  "id": "sparse_container_content",
1358
2546
  })
1359
2547
  self.assertEqual(issue["measurement"]["container_area"], 151700)
1360
- self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
2548
+ self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
1361
2549
  self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
1362
2550
  self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
1363
2551
  self.assertEqual(
@@ -1594,7 +2782,13 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1594
2782
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
1595
2783
  <data>
1596
2784
  <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
1597
- <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220"/>
2785
+ <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220">
2786
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2787
+ <chartData>
2788
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2789
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2790
+ </chartData>
2791
+ </chart>
1598
2792
  </data>
1599
2793
  </slide>
1600
2794
  """
@@ -1611,7 +2805,13 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1611
2805
  <content fontSize="20"><p>Section title</p></content>
1612
2806
  </shape>
1613
2807
  <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
1614
- <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0"/>
2808
+ <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0">
2809
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2810
+ <chartData>
2811
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2812
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2813
+ </chartData>
2814
+ </chart>
1615
2815
  </data>
1616
2816
  </slide>
1617
2817
  """
@@ -1688,7 +2888,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1688
2888
  <content fontSize="20"><p>Section title</p></content>
1689
2889
  </shape>
1690
2890
  <shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
1691
- <img id="ghost-overlay" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
2891
+ <img id="ghost-overlay" src="token" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
1692
2892
  </data>
1693
2893
  </slide>
1694
2894
  """
@@ -1794,7 +2994,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1794
2994
  <data>
1795
2995
  <shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
1796
2996
  <shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
1797
- <content fontSize="12.4"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
2997
+ <content fontSize="12"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
1798
2998
  </shape>
1799
2999
  </data>
1800
3000
  </slide>
@@ -1808,10 +3008,10 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1808
3008
  """
1809
3009
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
1810
3010
  <data>
1811
- <line id="l1" startX="100" startY="100" endX="800" endY="100"/>
1812
- <line id="l2" startX="100" startY="200" endX="800" endY="200"/>
1813
- <line id="l3" startX="100" startY="300" endX="800" endY="300"/>
1814
- <line id="l4" startX="100" startY="400" endX="800" endY="400"/>
3011
+ <line id="l1" startX="100" startY="100" endX="800" endY="100"><border/></line>
3012
+ <line id="l2" startX="100" startY="200" endX="800" endY="200"><border/></line>
3013
+ <line id="l3" startX="100" startY="300" endX="800" endY="300"><border/></line>
3014
+ <line id="l4" startX="100" startY="400" endX="800" endY="400"><border/></line>
1815
3015
  </data>
1816
3016
  </slide>
1817
3017
  """
@@ -1842,9 +3042,9 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1842
3042
  # Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
1843
3043
  # from extract_elements), not the fontSize=96 max-descendant value that
1844
3044
  # extract_density_elements computes for the same "left" element id.
1845
- self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
3045
+ self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
1846
3046
  self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
1847
- self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
3047
+ self.assertEqual(issue["measurement"]["intersection_area"], 742.56)
1848
3048
 
1849
3049
  def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
1850
3050
  card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
@@ -1973,5 +3173,624 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
1973
3173
  )
1974
3174
 
1975
3175
 
3176
+ SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
3177
+
3178
+
3179
+ class SxsdSyntaxTestCase(unittest.TestCase):
3180
+ def validate(self, xml: str) -> list[dict[str, object]]:
3181
+ result = xml_text_overlap_lint.lint_xml(xml)
3182
+ return [
3183
+ *result.get("issues", []),
3184
+ *(issue for slide in result["slides"] for issue in slide["issues"]),
3185
+ ]
3186
+
3187
+ def assert_issue(
3188
+ self,
3189
+ issues: list[dict[str, object]],
3190
+ code: str,
3191
+ *,
3192
+ path: str | None = None,
3193
+ attr: str | None = None,
3194
+ ) -> dict[str, object]:
3195
+ for issue in issues:
3196
+ if issue.get("code") != code:
3197
+ continue
3198
+ if path is not None and issue.get("path") != path:
3199
+ continue
3200
+ if attr is not None and issue.get("attr") != attr:
3201
+ continue
3202
+ return issue
3203
+ self.fail(f"missing issue code={code!r} path={path!r} attr={attr!r}: {issues!r}")
3204
+
3205
+ def assert_no_issue(self, issues: list[dict[str, object]], code: str) -> None:
3206
+ self.assertNotIn(code, [issue.get("code") for issue in issues])
3207
+
3208
+
3209
+ class SxsdSyntaxAttributeTest(SxsdSyntaxTestCase):
3210
+
3211
+ def test_xsd_pattern_translation_only_expands_whitespace_classes(self) -> None:
3212
+ self.assertEqual(
3213
+ sxsd_validator.python_pattern_for_xsd(r"\s+\S+\w+\d+"),
3214
+ "[ \\t\\n\\r]+[^ \\t\\n\\r]+\\w+\\d+",
3215
+ )
3216
+
3217
+ def test_href_domain_pattern_does_not_use_backtracking_regex(self) -> None:
3218
+ pattern = r"[\w.-]+[.:]\S*"
3219
+ adversarial_value = ("a." * 20_000) + " "
3220
+ original_fullmatch = sxsd_validator.re.fullmatch
3221
+ translated_pattern = sxsd_validator.python_pattern_for_xsd(pattern)
3222
+
3223
+ def reject_unsafe_pattern(candidate: str, value: str):
3224
+ if candidate == translated_pattern:
3225
+ raise AssertionError("href domain pattern must not use re.fullmatch")
3226
+ return original_fullmatch(candidate, value)
3227
+
3228
+ with mock.patch.object(sxsd_validator.re, "fullmatch", side_effect=reject_unsafe_pattern):
3229
+ self.assertFalse(sxsd_validator.xsd_pattern_matches(pattern, adversarial_value))
3230
+
3231
+ def test_href_domain_pattern_keeps_xsd_matching_behavior(self) -> None:
3232
+ pattern = r"[\w.-]+[.:]\S*"
3233
+ reference_pattern = sxsd_validator.re.compile(
3234
+ sxsd_validator.python_pattern_for_xsd(pattern)
3235
+ )
3236
+
3237
+ for length in range(5):
3238
+ for characters in itertools.product("a.:-/ ©", repeat=length):
3239
+ value = "".join(characters)
3240
+ with self.subTest(value=value):
3241
+ self.assertEqual(
3242
+ sxsd_validator.xsd_pattern_matches(pattern, value),
3243
+ reference_pattern.fullmatch(value) is not None,
3244
+ )
3245
+
3246
+ def test_accepts_valid_shape_attributes(self) -> None:
3247
+ issues = self.validate(
3248
+ f"""
3249
+ <slide xmlns="{SML_NAMESPACE}">
3250
+ <data>
3251
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3252
+ <content textType="body"><p>Valid</p></content>
3253
+ </shape>
3254
+ </data>
3255
+ </slide>
3256
+ """
3257
+ )
3258
+
3259
+ self.assertEqual(issues, [])
3260
+
3261
+ def test_reports_missing_required_shape_attribute(self) -> None:
3262
+ issues = self.validate(
3263
+ f"""
3264
+ <slide xmlns="{SML_NAMESPACE}">
3265
+ <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
3266
+ </slide>
3267
+ """
3268
+ )
3269
+
3270
+ issue = self.assert_issue(
3271
+ issues,
3272
+ "sxsd_missing_required_attr",
3273
+ path="slide/data/shape",
3274
+ attr="height",
3275
+ )
3276
+ self.assertEqual(issue["expected"], "required attribute of type PositiveSize")
3277
+ self.assertIsNone(issue["actual"])
3278
+
3279
+ def test_reports_invalid_scalar_value(self) -> None:
3280
+ issues = self.validate(
3281
+ f"""
3282
+ <slide xmlns="{SML_NAMESPACE}">
3283
+ <data>
3284
+ <shape type="text" topLeftX="NaN" topLeftY="20" width="300" height="80"/>
3285
+ </data>
3286
+ </slide>
3287
+ """
3288
+ )
3289
+
3290
+ issue = self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")
3291
+ self.assertEqual(issue["actual"], "NaN")
3292
+
3293
+ def test_rejects_python_only_numeric_separator(self) -> None:
3294
+ issues = self.validate(
3295
+ f"""
3296
+ <slide xmlns="{SML_NAMESPACE}">
3297
+ <data>
3298
+ <shape type="rect" topLeftX="1_0" topLeftY="20" width="300" height="80"/>
3299
+ </data>
3300
+ </slide>
3301
+ """
3302
+ )
3303
+
3304
+ self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")
3305
+
3306
+ def test_accepts_xsd_double_lexical_forms(self) -> None:
3307
+ for top_left_x in ("10", "-0.5", ".5", "1.", "1e2"):
3308
+ with self.subTest(top_left_x=top_left_x):
3309
+ issues = self.validate(
3310
+ f"""
3311
+ <slide xmlns="{SML_NAMESPACE}">
3312
+ <data>
3313
+ <shape type="rect" topLeftX="{top_left_x}" topLeftY="20" width="300" height="80"/>
3314
+ </data>
3315
+ </slide>
3316
+ """
3317
+ )
3318
+
3319
+ self.assertEqual(issues, [])
3320
+
3321
+ def test_accepts_bullet_char_length_boundaries(self) -> None:
3322
+ for bullet_char in ("A", "12345678"):
3323
+ with self.subTest(bullet_char=bullet_char):
3324
+ issues = self.validate(
3325
+ f"""
3326
+ <slide xmlns="{SML_NAMESPACE}">
3327
+ <data>
3328
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3329
+ <content bulletChar="{bullet_char}"><p>Text</p></content>
3330
+ </shape>
3331
+ </data>
3332
+ </slide>
3333
+ """
3334
+ )
3335
+
3336
+ self.assertEqual(issues, [])
3337
+
3338
+ def test_rejects_bullet_char_outside_length_boundaries(self) -> None:
3339
+ for bullet_char in ("", "123456789"):
3340
+ with self.subTest(bullet_char=bullet_char):
3341
+ issues = self.validate(
3342
+ f"""
3343
+ <slide xmlns="{SML_NAMESPACE}">
3344
+ <data>
3345
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3346
+ <content bulletChar="{bullet_char}"><p>Text</p></content>
3347
+ </shape>
3348
+ </data>
3349
+ </slide>
3350
+ """
3351
+ )
3352
+
3353
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="bulletChar")
3354
+
3355
+ def test_rejects_zero_size_that_violates_xsd(self) -> None:
3356
+ issues = self.validate(
3357
+ f"""
3358
+ <slide xmlns="{SML_NAMESPACE}">
3359
+ <data>
3360
+ <shape type="text" topLeftX="10" topLeftY="20" width="0" height="80"/>
3361
+ </data>
3362
+ </slide>
3363
+ """
3364
+ )
3365
+
3366
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")
3367
+
3368
+ def test_reports_negative_size_rejected_by_xsd(self) -> None:
3369
+ issues = self.validate(
3370
+ f"""
3371
+ <slide xmlns="{SML_NAMESPACE}">
3372
+ <data>
3373
+ <shape type="text" topLeftX="10" topLeftY="20" width="-1" height="80"/>
3374
+ </data>
3375
+ </slide>
3376
+ """
3377
+ )
3378
+
3379
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")
3380
+
3381
+ def test_rejects_shape_enum_that_violates_xsd(self) -> None:
3382
+ issues = self.validate(
3383
+ f"""
3384
+ <slide xmlns="{SML_NAMESPACE}">
3385
+ <data>
3386
+ <shape type="not-a-shape" topLeftX="10" topLeftY="20" width="300" height="80"/>
3387
+ </data>
3388
+ </slide>
3389
+ """
3390
+ )
3391
+
3392
+ issue = self.assert_issue(issues, "sxsd_invalid_enum", attr="type")
3393
+ self.assertLess(len(str(issue["message"])), 300)
3394
+ self.assertEqual(issue["actual"], "not-a-shape")
3395
+
3396
+ def test_rejects_rotation_upper_bound_that_violates_xsd(self) -> None:
3397
+ issues = self.validate(
3398
+ f"""
3399
+ <slide xmlns="{SML_NAMESPACE}">
3400
+ <data>
3401
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80" rotation="360"/>
3402
+ </data>
3403
+ </slide>
3404
+ """
3405
+ )
3406
+
3407
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="rotation")
3408
+
3409
+ def test_rejects_fill_color_that_violates_xsd(self) -> None:
3410
+ issues = self.validate(
3411
+ f"""
3412
+ <slide xmlns="{SML_NAMESPACE}">
3413
+ <style><fill><fillColor color="red"/></fill></style>
3414
+ <data/>
3415
+ </slide>
3416
+ """
3417
+ )
3418
+
3419
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")
3420
+
3421
+ def test_reports_missing_required_image_src(self) -> None:
3422
+ issues = self.validate(
3423
+ f"""
3424
+ <slide xmlns="{SML_NAMESPACE}">
3425
+ <data><img topLeftX="10" topLeftY="20" width="300" height="80"/></data>
3426
+ </slide>
3427
+ """
3428
+ )
3429
+
3430
+ self.assert_issue(issues, "sxsd_missing_required_attr", attr="src")
3431
+
3432
+ def test_accepts_inline_attribute_simple_type(self) -> None:
3433
+ issues = self.validate(
3434
+ f"""
3435
+ <slide xmlns="{SML_NAMESPACE}">
3436
+ <data>
3437
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3438
+ <content><p><a href="https://example.com">Link</a></p></content>
3439
+ </shape>
3440
+ </data>
3441
+ </slide>
3442
+ """
3443
+ )
3444
+
3445
+ self.assertEqual(issues, [])
3446
+
3447
+ def test_reports_inline_attribute_pattern_mismatch(self) -> None:
3448
+ issues = self.validate(
3449
+ f"""
3450
+ <slide xmlns="{SML_NAMESPACE}">
3451
+ <data>
3452
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3453
+ <content><p><a href="not a uri">Link</a></p></content>
3454
+ </shape>
3455
+ </data>
3456
+ </slide>
3457
+ """
3458
+ )
3459
+
3460
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")
3461
+
3462
+ def test_accepts_values_matching_inline_union_members(self) -> None:
3463
+ for bullet_size in ("25%", "100%", "400%", "6", "14", "400"):
3464
+ with self.subTest(bullet_size=bullet_size):
3465
+ issues = self.validate(
3466
+ f"""
3467
+ <slide xmlns="{SML_NAMESPACE}">
3468
+ <data>
3469
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3470
+ <content bulletSize="{bullet_size}"><p>Text</p></content>
3471
+ </shape>
3472
+ </data>
3473
+ </slide>
3474
+ """
3475
+ )
3476
+
3477
+ self.assertEqual(issues, [])
3478
+
3479
+ def test_rejects_values_outside_inline_union_members(self) -> None:
3480
+ for bullet_size in ("24%", "401%", "5", "401", "abc"):
3481
+ with self.subTest(bullet_size=bullet_size):
3482
+ issues = self.validate(
3483
+ f"""
3484
+ <slide xmlns="{SML_NAMESPACE}">
3485
+ <data>
3486
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3487
+ <content bulletSize="{bullet_size}"><p>Text</p></content>
3488
+ </shape>
3489
+ </data>
3490
+ </slide>
3491
+ """
3492
+ )
3493
+
3494
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="bulletSize")
3495
+
3496
+ def test_rejects_symbol_outside_python_word_semantics_in_href(self) -> None:
3497
+ issues = self.validate(
3498
+ f"""
3499
+ <slide xmlns="{SML_NAMESPACE}">
3500
+ <data>
3501
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3502
+ <content><p><a href="©:resource">Link</a></p></content>
3503
+ </shape>
3504
+ </data>
3505
+ </slide>
3506
+ """
3507
+ )
3508
+
3509
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")
3510
+
3511
+ def test_accepts_common_email_href_with_python_regex_semantics(self) -> None:
3512
+ issues = self.validate(
3513
+ f"""
3514
+ <slide xmlns="{SML_NAMESPACE}">
3515
+ <data>
3516
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3517
+ <content><p><a href="mailto:user@example.com">Email</a></p></content>
3518
+ </shape>
3519
+ </data>
3520
+ </slide>
3521
+ """
3522
+ )
3523
+
3524
+ self.assertEqual(issues, [])
3525
+
3526
+ def test_accepts_common_gradient_with_python_regex_semantics(self) -> None:
3527
+ issues = self.validate(
3528
+ f"""
3529
+ <slide xmlns="{SML_NAMESPACE}">
3530
+ <style>
3531
+ <fill>
3532
+ <fillColor color="linear-gradient(90deg, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)"/>
3533
+ </fill>
3534
+ </style>
3535
+ <data>
3536
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3537
+ <content><p>Gradient</p></content>
3538
+ </shape>
3539
+ </data>
3540
+ </slide>
3541
+ """
3542
+ )
3543
+
3544
+ self.assertEqual(issues, [])
3545
+
3546
+ def test_rejects_non_xsd_whitespace_in_color_pattern(self) -> None:
3547
+ issues = self.validate(
3548
+ f"""
3549
+ <slide xmlns="{SML_NAMESPACE}">
3550
+ <style><fill><fillColor color="rgb(1,\u00a02,3)"/></fill></style>
3551
+ <data/>
3552
+ </slide>
3553
+ """
3554
+ )
3555
+
3556
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")
3557
+
3558
+ class SxsdSyntaxStructureTest(SxsdSyntaxTestCase):
3559
+ def test_accepts_nested_content_in_referenced_rich_text_shadow(self) -> None:
3560
+ issues = self.validate(
3561
+ f"""
3562
+ <slide xmlns="{SML_NAMESPACE}">
3563
+ <data>
3564
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3565
+ <content>
3566
+ <p><span><shadow color="rgba(0, 0, 0, 1)"><strong>Text</strong></shadow></span></p>
3567
+ </content>
3568
+ </shape>
3569
+ </data>
3570
+ </slide>
3571
+ """
3572
+ )
3573
+
3574
+ self.assertEqual(issues, [])
3575
+
3576
+ def test_keeps_shape_effect_shadow_as_childless_local_type(self) -> None:
3577
+ issues = self.validate(
3578
+ f"""
3579
+ <slide xmlns="{SML_NAMESPACE}">
3580
+ <data>
3581
+ <shape type="rect" topLeftX="10" topLeftY="20" width="300" height="80">
3582
+ <shadow><strong>Not rich text</strong></shadow>
3583
+ </shape>
3584
+ </data>
3585
+ </slide>
3586
+ """
3587
+ )
3588
+
3589
+ self.assert_issue(
3590
+ issues,
3591
+ "sxsd_unexpected_child",
3592
+ path="slide/data/shape/shadow/strong",
3593
+ )
3594
+
3595
+ def test_accepts_standalone_slide_fragment_without_namespace(self) -> None:
3596
+ issues = self.validate(
3597
+ '<slide><data><shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">'
3598
+ '<content><p>Text</p></content></shape></data></slide>'
3599
+ )
3600
+
3601
+ self.assertEqual(issues, [])
3602
+
3603
+ def test_rejects_presentation_without_namespace(self) -> None:
3604
+ issues = self.validate(
3605
+ '<presentation width="960" height="540"><slide/></presentation>'
3606
+ )
3607
+
3608
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="presentation")
3609
+
3610
+ def test_rejects_wrong_namespace_that_violates_xsd(self) -> None:
3611
+ issues = self.validate('<slide xmlns="https://example.com/not-sml"><data/></slide>')
3612
+
3613
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="slide")
3614
+
3615
+ def test_rejects_descendant_outside_document_namespace(self) -> None:
3616
+ issues = self.validate(
3617
+ f"""
3618
+ <slide xmlns="{SML_NAMESPACE}">
3619
+ <data xmlns="">
3620
+ <shape xmlns="{SML_NAMESPACE}" type="rect" topLeftX="10" topLeftY="20" width="300" height="80"/>
3621
+ </data>
3622
+ </slide>
3623
+ """
3624
+ )
3625
+
3626
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="slide/data")
3627
+
3628
+ def test_rejects_unexpected_child_that_violates_xsd(self) -> None:
3629
+ issues = self.validate(
3630
+ f"""
3631
+ <slide xmlns="{SML_NAMESPACE}">
3632
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80"/>
3633
+ </slide>
3634
+ """
3635
+ )
3636
+
3637
+ self.assert_issue(issues, "sxsd_unexpected_child", path="slide/shape")
3638
+
3639
+ def test_rejects_child_order_that_violates_xsd(self) -> None:
3640
+ issues = self.validate(
3641
+ f"""
3642
+ <presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">
3643
+ <slide/>
3644
+ <title>Late title</title>
3645
+ </presentation>
3646
+ """
3647
+ )
3648
+
3649
+ self.assert_issue(issues, "sxsd_invalid_child_order", path="presentation/title")
3650
+
3651
+ def test_enforces_presentation_slide_minimum_from_xsd(self) -> None:
3652
+ issues = self.validate(
3653
+ f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080"/>'
3654
+ )
3655
+
3656
+ self.assert_issue(issues, "sxsd_missing_required_child", path="presentation")
3657
+
3658
+ def test_enforces_presentation_slide_maximum_from_xsd(self) -> None:
3659
+ slides = "".join("<slide/>" for _ in range(101))
3660
+ issues = self.validate(
3661
+ f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">{slides}</presentation>'
3662
+ )
3663
+
3664
+ self.assert_issue(issues, "sxsd_too_many_children", path="presentation/slide")
3665
+
3666
+ def test_rejects_multiple_choice_children_that_violate_xsd(self) -> None:
3667
+ issues = self.validate(
3668
+ f"""
3669
+ <slide xmlns="{SML_NAMESPACE}">
3670
+ <style>
3671
+ <fill><fillColor/><fillImg src="token"/></fill>
3672
+ </style>
3673
+ </slide>
3674
+ """
3675
+ )
3676
+
3677
+ self.assert_issue(issues, "sxsd_too_many_children", path="slide/style/fill")
3678
+
3679
+ def test_rejects_line_without_required_border_from_xsd(self) -> None:
3680
+ issues = self.validate(
3681
+ f"""
3682
+ <slide xmlns="{SML_NAMESPACE}">
3683
+ <data><line startX="0" startY="0" endX="100" endY="100"/></data>
3684
+ </slide>
3685
+ """
3686
+ )
3687
+
3688
+ self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/line")
3689
+
3690
+ def test_reports_missing_required_chart_structure(self) -> None:
3691
+ issues = self.validate(
3692
+ f"""
3693
+ <slide xmlns="{SML_NAMESPACE}">
3694
+ <data><chart topLeftX="0" topLeftY="0" width="300" height="200"/></data>
3695
+ </slide>
3696
+ """
3697
+ )
3698
+
3699
+ self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/chart")
3700
+
3701
+ def test_reports_missing_required_nested_sequence_child(self) -> None:
3702
+ issues = self.validate(
3703
+ f"""
3704
+ <slide xmlns="{SML_NAMESPACE}">
3705
+ <data><table topLeftX="0" topLeftY="0"><tr/></table></data>
3706
+ </slide>
3707
+ """
3708
+ )
3709
+
3710
+ issue = self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/table/tr")
3711
+ self.assertEqual(issue["expected"], "td (at least 1)")
3712
+
3713
+
3714
+ class SxsdSchemaModelTest(unittest.TestCase):
3715
+ def test_reports_unsupported_xsd_pattern_without_crashing(self) -> None:
3716
+ schema = rf"""
3717
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3718
+ xmlns:sml="{SML_NAMESPACE}"
3719
+ targetNamespace="{SML_NAMESPACE}"
3720
+ elementFormDefault="qualified">
3721
+ <xs:simpleType name="UnsupportedPatternType">
3722
+ <xs:union>
3723
+ <xs:simpleType>
3724
+ <xs:restriction base="xs:string"><xs:pattern value="[\S]"/></xs:restriction>
3725
+ </xs:simpleType>
3726
+ <xs:simpleType>
3727
+ <xs:restriction base="xs:string"><xs:pattern value="z+"/></xs:restriction>
3728
+ </xs:simpleType>
3729
+ </xs:union>
3730
+ </xs:simpleType>
3731
+ <xs:complexType name="SlideType">
3732
+ <xs:attribute name="value" type="sml:UnsupportedPatternType"/>
3733
+ </xs:complexType>
3734
+ </xs:schema>
3735
+ """
3736
+ with tempfile.TemporaryDirectory() as temp_dir:
3737
+ schema_path = Path(temp_dir) / "schema.xsd"
3738
+ schema_path.write_text(schema, encoding="utf-8")
3739
+ try:
3740
+ issues = sxsd_validator.validate_sxsd(
3741
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}" value="A"/>'),
3742
+ schema_path,
3743
+ )
3744
+ except (ValueError, sxsd_validator.re.error) as error:
3745
+ self.fail(f"SXSD pattern capability errors must be reported, not raised: {error}")
3746
+
3747
+ self.assertEqual([issue["code"] for issue in issues], ["sxsd_unsupported_pattern"])
3748
+ self.assertEqual(issues[0]["attr"], "value")
3749
+ self.assertIn("pattern interpreter", str(issues[0]["hint"]).lower())
3750
+
3751
+ def test_standalone_slide_uses_slide_type_without_global_element(self) -> None:
3752
+ schema = f"""
3753
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3754
+ xmlns:sml="{SML_NAMESPACE}"
3755
+ targetNamespace="{SML_NAMESPACE}"
3756
+ elementFormDefault="qualified">
3757
+ <xs:complexType name="SlideType"><xs:sequence/></xs:complexType>
3758
+ <xs:complexType name="PresentationType">
3759
+ <xs:sequence><xs:element name="slide" type="sml:SlideType"/></xs:sequence>
3760
+ </xs:complexType>
3761
+ <xs:element name="presentation" type="sml:PresentationType"/>
3762
+ </xs:schema>
3763
+ """
3764
+ with tempfile.TemporaryDirectory() as temp_dir:
3765
+ schema_path = Path(temp_dir) / "schema.xsd"
3766
+ schema_path.write_text(schema, encoding="utf-8")
3767
+ issues = sxsd_validator.validate_sxsd(
3768
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
3769
+ schema_path,
3770
+ )
3771
+
3772
+ self.assertEqual(issues, [])
3773
+
3774
+ def test_standalone_slide_requires_slide_type_in_xsd(self) -> None:
3775
+ schema = f"""
3776
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3777
+ xmlns:sml="{SML_NAMESPACE}"
3778
+ targetNamespace="{SML_NAMESPACE}"
3779
+ elementFormDefault="qualified">
3780
+ <xs:complexType name="PresentationType"><xs:sequence/></xs:complexType>
3781
+ <xs:element name="presentation" type="sml:PresentationType"/>
3782
+ </xs:schema>
3783
+ """
3784
+ with tempfile.TemporaryDirectory() as temp_dir:
3785
+ schema_path = Path(temp_dir) / "schema.xsd"
3786
+ schema_path.write_text(schema, encoding="utf-8")
3787
+ issues = sxsd_validator.validate_sxsd(
3788
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
3789
+ schema_path,
3790
+ )
3791
+
3792
+ self.assertEqual([issue["code"] for issue in issues], ["sxsd_unexpected_root"])
3793
+
3794
+
1976
3795
  if __name__ == "__main__":
1977
3796
  unittest.main()