fix(slides): lint unsupported slide font families

This commit is contained in:
zhanghuanxu
2026-06-29 13:28:58 +08:00
parent 9b05a71de3
commit d7be4205d0
4 changed files with 145 additions and 5 deletions

View File

@@ -150,7 +150,7 @@
<xs:simpleType name="FontFamilyType">
<xs:annotation>
<xs:documentation>
字体族名称, 支持任意字体。
字体族名称, 支持下列字体。
常用中文字体:
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、

View File

@@ -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` | 文本样式 |

View File

@@ -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:

View File

@@ -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(
"""