mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
fix(slides): lint unsupported slide font families
This commit is contained in:
@@ -150,7 +150,7 @@
|
||||
<xs:simpleType name="FontFamilyType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体族名称, 支持任意字体。
|
||||
字体族名称, 支持下列字体。
|
||||
|
||||
常用中文字体:
|
||||
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、
|
||||
|
||||
@@ -74,7 +74,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `textAlign` | 文本对齐方式 |
|
||||
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontFamily` | 字体,必须来自 `slides_xml_schema_definition.xml` 的 `FontFamilyType` 清单 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ class XmlTextOverlapLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
FONT_FAMILY_PLACEHOLDER_VALUES = {"undefined"}
|
||||
_SUPPORTED_FONT_FAMILIES: set[str] | None = None
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
@@ -75,6 +79,81 @@ def xml_local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
|
||||
|
||||
|
||||
def schema_definition_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
|
||||
|
||||
|
||||
def extract_supported_font_families(schema_xml: str) -> set[str]:
|
||||
simple_type_match = re.search(
|
||||
r'<xs:simpleType\s+name="FontFamilyType">([\s\S]*?)</xs:simpleType>',
|
||||
schema_xml,
|
||||
)
|
||||
if simple_type_match is None:
|
||||
fail("FontFamilyType definition not found in slides XML schema")
|
||||
|
||||
documentation_match = re.search(
|
||||
r"<xs:documentation>([\s\S]*?)</xs:documentation>",
|
||||
simple_type_match.group(1),
|
||||
)
|
||||
if documentation_match is None:
|
||||
fail("FontFamilyType documentation not found in slides XML schema")
|
||||
|
||||
font_families: set[str] = set()
|
||||
for raw_line in documentation_match.group(1).splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("字体族名称") or line.endswith(":"):
|
||||
continue
|
||||
for font_family in re.split(r"[、,,]", line):
|
||||
font_family = font_family.strip()
|
||||
if font_family:
|
||||
font_families.add(font_family)
|
||||
|
||||
if not font_families:
|
||||
fail("FontFamilyType supported font list is empty")
|
||||
return font_families
|
||||
|
||||
|
||||
def supported_font_families() -> set[str]:
|
||||
global _SUPPORTED_FONT_FAMILIES
|
||||
if _SUPPORTED_FONT_FAMILIES is None:
|
||||
_SUPPORTED_FONT_FAMILIES = extract_supported_font_families(read_file(schema_definition_path()))
|
||||
return _SUPPORTED_FONT_FAMILIES
|
||||
|
||||
|
||||
def line_column_at_offset(source: str, offset: int) -> tuple[int, int]:
|
||||
line = source.count("\n", 0, offset) + 1
|
||||
line_start = source.rfind("\n", 0, offset)
|
||||
column = offset + 1 if line_start == -1 else offset - line_start
|
||||
return line, column
|
||||
|
||||
|
||||
def lint_font_families(xml: str) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
allowed_font_families = supported_font_families()
|
||||
for match in re.finditer(r"\bfontFamily\s*=\s*([\"'])(.*?)\1", xml):
|
||||
font_family = match.group(2).strip()
|
||||
if not font_family or font_family in FONT_FAMILY_PLACEHOLDER_VALUES:
|
||||
continue
|
||||
if font_family in allowed_font_families:
|
||||
continue
|
||||
line, column = line_column_at_offset(xml, match.start())
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "unsupported_font_family",
|
||||
"message": f'fontFamily "{font_family}" is not supported',
|
||||
"line": line,
|
||||
"column": column,
|
||||
"fontFamily": font_family,
|
||||
"hint": (
|
||||
"Use a FontFamilyType value from slides_xml_schema_definition.xml, "
|
||||
"or omit fontFamily to use the default font."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
|
||||
if line is None or column is None:
|
||||
return None
|
||||
@@ -326,18 +405,24 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
presentation = parse_presentation(xml)
|
||||
global_issues = lint_font_families(xml)
|
||||
slides = [
|
||||
lint_slide(slide_xml, index + 1)
|
||||
for index, slide_xml in enumerate(presentation["slides"])
|
||||
]
|
||||
error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
return {
|
||||
error_count = sum(1 for issue in global_issues if issue["level"] == "error")
|
||||
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in global_issues if issue["level"] == "warning")
|
||||
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
result = {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
|
||||
"slides": slides,
|
||||
}
|
||||
if global_issues:
|
||||
result["issues"] = global_issues
|
||||
return result
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
|
||||
@@ -110,6 +110,61 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_accepts_supported_font_family(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<theme>
|
||||
<textStyles>
|
||||
<body fontFamily="思源黑体"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="Inter"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_allows_legacy_undefined_font_family_placeholder(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="undefined"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_reports_unsupported_font_family(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="微软雅黑"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = result["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "unsupported_font_family")
|
||||
self.assertEqual(issue["fontFamily"], "微软雅黑")
|
||||
self.assertIn("FontFamilyType", issue["hint"])
|
||||
|
||||
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user