Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-09-10 00:03:06 +08:00
1 parent 9b01a911ae
commit 074381889b
20 files changed
+464 -80

No files matched your search

+4 -4
View File
@@ -105,8 +105,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "a4f5487dc930ba22f7002d775f49c81f47210960",
"syncedAt": "2026-09-08T16:00:01Z"
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
"syncedAt": "2026-09-09T16:00:00Z"
},
{
"id": "grill-me",
@@ -141,8 +141,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "4cb547213b5303b7f01675a7502b84ce960fd19e",
"syncedAt": "2026-09-09T04:01:45Z"
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
"syncedAt": "2026-09-09T16:00:00Z"
},
{
"id": "shuorenhua",
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-09-09T04:04:38Z"
"syncedAt": "2026-09-09T16:03:05Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "4cb547213b5303b7f01675a7502b84ce960fd19e",
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-09-09T04:01:45Z"
"syncedAt": "2026-09-09T16:00:00Z"
}
@@ -373,6 +373,8 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to get involved.
See [third-party icon notices](./skills/ppt-master/templates/icons/THIRD_PARTY_NOTICES.md) for pinned versions, licenses, attribution, compatibility overlays, and trademark boundaries.
The optional PDF converter depends on [PyMuPDF](https://pymupdf.readthedocs.io/), which is licensed under AGPL-3.0 rather than MIT; it is only needed for PDF sources. See [conversion.md](./skills/ppt-master/scripts/docs/conversion.md) before redistributing a bundle that includes it.
## Related Tools
[cc-switch](https://github.com/farion1231/cc-switch) — one-click switching of API providers across Claude Code / Codex / Gemini CLI and more.
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "a4f5487dc930ba22f7002d775f49c81f47210960",
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-08T16:00:01Z"
"syncedAt": "2026-09-09T16:00:00Z"
}
@@ -2,7 +2,7 @@
name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata:
version: "6.3.0"
version: "6.3.1"
copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master"
@@ -34,6 +34,11 @@ edge-tts>=7.2.8
# ─────────────────────────────────────────────────────────────
# Converts PDF to Markdown with text, image, and table extraction.
# 用于将 PDF 文件转换为 Markdown 格式,支持文本、图片、表格提取
# License notice: PyMuPDF is AGPL-3.0 (commercial license available from Artifex).
# It is imported only by pdf_to_md.py; skip this line if you never convert PDF
# sources, and review the AGPL terms before redistributing a bundle that includes it.
# 许可证说明:PyMuPDF 采用 AGPL-3.0(Artifex 另提供商业许可)。仅 pdf_to_md.py 导入它;
# 不转换 PDF 源可跳过此行;若要连同依赖一起再分发,请先核对 AGPL 条款。
PyMuPDF>=1.23.0
# ─────────────────────────────────────────────────────────────
@@ -112,6 +112,8 @@ Dependency:
pip install PyMuPDF
```
PyMuPDF is licensed under AGPL-3.0, with a commercial license available from Artifex. It is the only AGPL dependency in this repository and is imported only by this converter, so it can be left uninstalled when no PDF sources are involved. Anyone redistributing PPT Master together with its installed dependencies should review the AGPL terms first.
## `source_to_md/doc_to_md.py`
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
@@ -1020,7 +1020,9 @@ class-average estimate, with the existing fixed advances for monospaced faces.
- `measure` prints one `width<TAB>text` line per input, or a JSON array with
`--json`.
- `wrap` prints greedy word- or CJK-cluster-wrapped SVG text content; `--y`
- `wrap` prints greedy word- or CJK-cluster-wrapped SVG text content; a CJK
line breaks after clause punctuation (`,。;:`) when that keeps at least
three quarters of the greedy line, otherwise at the greedy limit. `--y`
includes the outer `<text>` element, and `--json` prints line metrics.
- `box` prints a `data-pptx-bounds` attribute plus numeric `top` and `bottom`, or
a JSON bounds object with `--json`.
@@ -1029,7 +1031,8 @@ class-average estimate, with the existing fixed advances for monospaced faces.
`validation/text_calibration.json`, and prints a compact table or JSON. The
estimator is additive across scripts, so a line mixing CJK with Latin words
or digits is estimated as (CJK chars ÷ CJK rate + other chars ÷ Latin rate)
× 100; spaces and punctuation count as Latin, digits use the DIGITS rate.
× 100; spaces and ASCII punctuation count as Latin, fullwidth punctuation as
CJK, digits use the DIGITS rate.
The rates are sample averages taken with the checker's own estimator
(headroom included), while the checker measures each real line glyph by
glyph: capital-heavy words, digits, and wide letters run wider than the Latin
@@ -83,5 +83,5 @@ Important optional packages:
- `edge-tts` for `notes_to_audio.py` recorded narration audio
- `Pillow` for image utilities
- `numpy` for watermark removal
- `PyMuPDF` for PDF conversion
- `PyMuPDF` for PDF conversion (AGPL-3.0; only needed for PDF sources, see conversion.md)
- `google-genai` for Gemini image generation
@@ -1423,11 +1423,12 @@
},
{
"kind": "exact",
"fingerprint": "7e6ac64f0c0c",
"fingerprint": "efdf8b23a710",
"paths": [
"skills/ppt-master/templates/styles/academic-research/templates/design_spec.md",
"skills/ppt-master/templates/styles/consulting-decision/templates/design_spec.md",
"skills/ppt-master/templates/styles/creative-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/duty-manual/templates/design_spec.md",
"skills/ppt-master/templates/styles/incident-postmortem/templates/design_spec.md",
"skills/ppt-master/templates/styles/investor-pitch/templates/design_spec.md",
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md",
@@ -1467,6 +1468,69 @@
"skills/ppt-master/templates/styles/mbb-consulting/templates/design_spec.md"
],
"reason": "mbb-consulting inherits the consulting-decision method and image direction by design; only its visual system differs."
},
{
"kind": "exact",
"fingerprint": "da38ec6c641e",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "Sponsor badge markup is identical across the English and Chinese sponsor pages by design."
},
{
"kind": "exact",
"fingerprint": "7bc60afc63e2",
"paths": [
"skills/ppt-master/references/visual-styles/blueprint.md",
"skills/ppt-master/references/visual-styles/dark-tech.md"
],
"reason": "blueprint and dark-tech intentionally state the same sans + monospace font requirement."
},
{
"kind": "exact",
"fingerprint": "2b12c90b8142",
"paths": [
"skills/ppt-master/workflows/create-template/create-deck.md",
"skills/ppt-master/workflows/create-template/create-layout.md"
],
"reason": "create-deck and create-layout share the same child-workflow entry contract under Create Template by design."
},
{
"kind": "exact",
"fingerprint": "fcc902f6644a",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "Sponsor badge markup is identical across the English and Chinese sponsor pages by design."
},
{
"kind": "exact",
"fingerprint": "092a8a1f88e9",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "Sponsor badge markup is identical across the English and Chinese sponsor pages by design."
},
{
"kind": "exact",
"fingerprint": "71cbe13a4813",
"paths": [
"skills/ppt-master/SPONSORS.md",
"skills/ppt-master/SPONSORS_CN.md"
],
"reason": "Sponsor badge markup is identical across the English and Chinese sponsor pages by design."
},
{
"kind": "exact",
"fingerprint": "8fa66eadd0ba",
"paths": [
"skills/ppt-master/workflows/create-template/create-deck.md",
"skills/ppt-master/workflows/create-template/create-layout.md"
],
"reason": "create-deck and create-layout share the same child-workflow entry contract under Create Template by design."
}
]
},
@@ -732,10 +732,11 @@
waitForSlideRewrite(name);
return;
}
// Render SVG
// Validate before showing the canvas; parse errors use the catch below.
var sanitizedSvg = sanitizeSvg(data.content);
svgPlaceholder.style.display = "none";
svgContent.style.display = "block";
svgContent.innerHTML = sanitizeSvg(data.content);
svgContent.innerHTML = sanitizedSvg;
// Empty-canvas guard: surface a clear error if the SVG parsed
// to nothing renderable (issue #115's silent-blank scenario).
@@ -1849,13 +1850,30 @@
// ================================================================
function sanitizeSvg(svgString) {
var doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
doc.querySelectorAll("script,foreignObject").forEach(function (el) { el.remove(); });
if (doc.getElementsByTagName("parsererror").length ||
!doc.documentElement || doc.documentElement.localName.toLowerCase() !== "svg") {
throw new Error(t("err_empty_svg"));
}
doc.querySelectorAll("*").forEach(function (el) {
// XML preserves case; the innerHTML sink normalizes HTML/SVG names.
var tag = el.localName.toLowerCase();
if (tag === "script" || tag === "foreignobject" ||
(el.namespaceURI && el.namespaceURI !== "http://www.w3.org/2000/svg" &&
el.namespaceURI !== "http://www.w3.org/1999/xlink")) {
el.remove();
return;
}
Array.from(el.attributes).forEach(function (attr) {
if (attr.name.indexOf("on") === 0) el.removeAttribute(attr.name);
var name = attr.name.toLowerCase();
var local = attr.localName.toLowerCase();
if (local.indexOf("on") === 0 || name.indexOf("on") === 0) {
el.removeAttributeNode(attr);
return;
}
// Strip dangerous URI protocols from href/xlink:href
if (attr.localName === "href" &&
if (local === "href" &&
(/^\s*javascript\s*:/i.test(attr.value) ||
/^\s*vbscript\s*:/i.test(attr.value) ||
/^\s*data\s*:/i.test(attr.value))) {
el.removeAttributeNode(attr);
}
@@ -2,6 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="page_title">PPT Master - Live Preview</title>
<link rel="stylesheet" href="/static/style.css">
@@ -42,10 +42,11 @@ Options:
from __future__ import annotations
import argparse
import html
import os
import re
import sys
import argparse
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
@@ -72,10 +73,37 @@ ICON_BASE_SIZES = {
_ICON_IDENTIFIER_RE = re.compile(
r'(?P<library>[a-z0-9][a-z0-9-]*)/(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)'
)
_ICON_COLOR_RE = re.compile(
r'#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})'
r'|[a-z]+|(?:rgba?|hsla?)\([0-9\s.,%]+\)',
re.IGNORECASE | re.ASCII,
)
_NUMBER_PATTERN = r'[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?'
_TRANSFORM_FUNCTION_PATTERN = (
rf'(?:matrix|translate|scale|rotate|skewX|skewY)\(\s*{_NUMBER_PATTERN}'
rf'(?:(?:\s*,\s*|\s+){_NUMBER_PATTERN})*\s*\)'
)
_ICON_TRANSFORM_RE = re.compile(
rf'{_TRANSFORM_FUNCTION_PATTERN}(?:\s*,?\s*{_TRANSFORM_FUNCTION_PATTERN})*',
re.ASCII,
)
_ICON_STROKE_WIDTH_RE = re.compile(rf'{_NUMBER_PATTERN}(?:px|em|%)?')
DEFAULT_ICON_BASE_SIZE = 24
BaseGeometry = float | tuple[float, float, float, float]
def _xml_attr(value: object) -> str:
"""Escape a value for insertion into a quoted SVG attribute."""
return html.escape(str(value), quote=True)
def _validate_icon_attr(value: str, attr: str, pattern: re.Pattern[str]) -> str:
"""Reject unsafe icon attributes while preserving legal source spelling."""
if pattern.fullmatch(value.strip()) is None:
raise ValueError(f'Invalid icon {attr}: {value!r}; use a plain SVG {attr} value')
return value
def _get_viewbox_size(content: str) -> float:
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content)
@@ -305,7 +333,7 @@ def extract_paths_from_icon(
def _attr_value(tag_text: str, attr: str) -> str | None:
"""Return an attribute value from a raw tag, accepting either quote style."""
match = re.search(
rf'\b{re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
rf'(?<!\S){re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
tag_text,
re.DOTALL,
)
@@ -338,27 +366,27 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
# Extract fill color
fill_value = _attr_value(use_match, 'fill')
if fill_value is not None:
attrs['fill'] = fill_value
attrs['fill'] = _validate_icon_attr(fill_value, 'fill', _ICON_COLOR_RE)
# Stroke-style icons may be authored with natural SVG semantics:
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
# carrier, but preserve stroke so outline icons do not collapse to none.
stroke_value = _attr_value(use_match, 'stroke')
if stroke_value is not None:
attrs['stroke'] = stroke_value
attrs['stroke'] = _validate_icon_attr(stroke_value, 'stroke', _ICON_COLOR_RE)
# Live preview direct edits may write an absolute transform matrix back to
# the placeholder. Preserve it so the expanded icon matches the edited
# browser geometry instead of falling back to the original x/y placement.
transform_value = _attr_value(use_match, 'transform')
if transform_value is not None:
attrs['transform'] = transform_value
attrs['transform'] = _validate_icon_attr(transform_value, 'transform', _ICON_TRANSFORM_RE)
# Extract optional stroke-width override (stroke-style icons only).
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
stroke_width_value = _attr_value(use_match, 'stroke-width')
if stroke_width_value is not None:
attrs['stroke-width'] = stroke_width_value
attrs['stroke-width'] = _validate_icon_attr(stroke_width_value, 'stroke-width', _ICON_STROKE_WIDTH_RE)
return attrs
@@ -370,6 +398,10 @@ def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
fill = str(attrs.get('fill', '')).strip()
stroke = str(attrs.get('stroke', '')).strip()
if fill:
_validate_icon_attr(fill, 'fill', _ICON_COLOR_RE)
if stroke:
_validate_icon_attr(stroke, 'stroke', _ICON_COLOR_RE)
if style == 'stroke':
if fill and fill != 'none':
@@ -428,9 +460,9 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
if style == 'preserve':
if min_x or min_y:
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-min_y)})'
elements_str = f'<g transform="{inner_transform}">\n {elements_str}\n </g>'
return f'''<!-- icon: {icon_name} -->
<g transform="{transform}">
elements_str = f'<g transform="{_xml_attr(inner_transform)}">\n {elements_str}\n </g>'
return f'''<!-- icon: {_xml_attr(icon_name)} -->
<g transform="{_xml_attr(transform)}">
{elements_str}
</g>'''
@@ -439,12 +471,12 @@ def generate_icon_group(attrs: dict[str, str | float], elements: list[str], styl
# (and any other stroke library) so omitting the attribute reproduces
# pre-change visual output.
stroke_width = attrs.get('stroke-width', '2')
color_attrs = f'fill="none" stroke="{color}" stroke-width="{stroke_width}"'
color_attrs = f'fill="none" stroke="{_xml_attr(color)}" stroke-width="{_xml_attr(stroke_width)}"'
else:
color_attrs = f'fill="{color}"'
color_attrs = f'fill="{_xml_attr(color)}"'
return f'''<!-- icon: {icon_name} -->
<g transform="{transform}" {color_attrs}>
return f'''<!-- icon: {_xml_attr(icon_name)} -->
<g transform="{_xml_attr(transform)}" {color_attrs}>
{elements_str}
</g>'''
@@ -2029,14 +2029,17 @@ _TEXTBOX_PADDING_MIN_PX = 0.5
_TEXTBOX_PADDING_MAX_PX = 2.0
_TEXTBOX_PADDING_RATIO = 0.04
# Single-line auto-fit headroom interpolates between a low-caps base and an
# all-caps ceiling for each run. The crude per-char width estimate undercounts
# capitals most, so all-caps runs need the ceiling to keep wrap-ignoring
# renderers (LibreOffice) from folding. Applying headroom per run also prevents
# a short serif label from forcing a conservative serif multiplier onto an
# otherwise sans-serif line. Values are calibrated against LibreOffice renders
# of all-caps bold lines, with bases left above mixed-case and CJK render
# ratios; exact ratios shift with font substitution, so these carry deliberate
# margin rather than tracking one environment's numbers.
# all-caps ceiling for each script segment of a run. The crude per-char width
# estimate undercounts capitals most, so all-caps Latin segments need the
# ceiling to keep wrap-ignoring renderers (LibreOffice) from folding. The
# serif tier follows the typeface that draws the segment (``latin`` or ``ea``
# as resolved by parse_font_family), so a CJK segment drawn by Microsoft YaHei
# never takes the Times New Roman tier of the same stack, and a short serif
# label cannot force its multiplier onto an otherwise sans-serif line. Values
# are calibrated against LibreOffice renders of all-caps bold lines, with bases
# left above mixed-case and CJK render ratios; exact ratios shift with font
# substitution, so these carry deliberate margin rather than tracking one
# environment's numbers.
_TEXT_WIDTH_HEADROOM_BASE = 1.06
_TEXT_WIDTH_HEADROOM_CAPS = 1.12
_SERIF_TEXT_WIDTH_HEADROOM_BASE = 1.12
@@ -2130,17 +2133,76 @@ def _letter_spacing_to_drawingml_spc(letter_spacing_px: float) -> str:
return f' spc="{spacing}"'
def _is_serif_run(run: dict[str, Any]) -> bool:
"""Return whether a text run uses a serif-like family."""
for family in str(run.get('font_family', '')).split(','):
name = family.strip().strip("'\"").lower()
def _is_serif_face(typeface: str) -> bool:
"""Return whether one resolved DrawingML typeface is serif-like."""
name = typeface.strip().strip("'\"").lower()
if not name or name in {'sans-serif', 'sans serif'}:
continue
return False
if name in _SERIF_WIDTH_FAMILIES:
return True
if 'serif' in name and 'sans' not in name:
return True
return False
return 'serif' in name and 'sans' not in name
def _run_script_segments(run: dict[str, Any]) -> list[tuple[str, bool]]:
"""Split one run's text into ``(text, is_cjk)`` segments.
PowerPoint draws CJK clusters (fullwidth punctuation included) with the
run's ``ea`` typeface and every other cluster with ``latin``, the same
split :func:`parse_font_family` resolves. Width headroom is a property of
the face that actually draws a glyph, so it is applied per segment.
"""
segments: list[list[Any]] = []
for cluster in split_project_text_clusters(str(run.get('text', ''))):
cjk = any(is_cjk_char(ch) for ch in cluster)
if segments and segments[-1][1] == cjk:
segments[-1][0] += cluster
else:
segments.append([cluster, cjk])
return [(text, cjk) for text, cjk in segments]
def _estimate_run_width_with_headroom(run: dict[str, Any]) -> float:
"""Estimate one run with headroom chosen per script segment.
The serif/sans tier follows the typeface that draws each segment (``ea``
for CJK, ``latin`` otherwise), and the uppercase interpolation only ever
applies to the Latin segments: CJK advances are fixed-width, so a Chinese
sentence that mentions ``AI`` keeps its own base headroom instead of
inheriting the all-caps ceiling for the whole line.
"""
segments = _run_script_segments(run)
if not segments:
return 0.0
faces = parse_font_family(str(run.get('font_family', '')))
serif_by_script = {
False: _is_serif_face(faces['latin']),
True: _is_serif_face(faces['ea']),
}
letter_spacing_px = (
drawingml_letter_spacing(
float(run.get('letter_spacing', 0.0) or 0.0)
)
/ FONT_PX_TO_HUNDREDTHS_PT
)
width = 0.0
for text, cjk in segments:
segment = dict(run, text=text)
if serif_by_script[cjk]:
base = _SERIF_TEXT_WIDTH_HEADROOM_BASE
ceiling = _SERIF_TEXT_WIDTH_HEADROOM_CAPS
else:
base = _TEXT_WIDTH_HEADROOM_BASE
ceiling = _TEXT_WIDTH_HEADROOM_CAPS
caps = 0.0 if cjk else _uppercase_fraction([segment])
width += _estimate_run_text_width(segment) * (
base + (ceiling - base) * caps
)
# Splitting the run drops the tracking gap at each segment boundary; add
# it back unscaled so a negative gap is not amplified by the headroom of
# the segment that follows it. Headroom is a safety margin, so the result
# never falls below the run's raw advance either.
width += letter_spacing_px * (len(segments) - 1)
return max(width, _estimate_run_text_width(run))
# Faces whose glyphs run wider than the generic advance table at the same
@@ -2244,17 +2306,27 @@ def _estimate_run_text_width(run: dict[str, Any]) -> float:
font_family=run.get('font_family'),
font_style=str(run.get('font_style', 'normal')),
)
clusters = split_project_text_clusters(text)
cjk_flags = [any(is_cjk_char(ch) for ch in cluster) for cluster in clusters]
monospace_advance = _monospace_advance_em(run)
if monospace_advance is not None:
# Fixed-pitch faces ignore weight and glyph shape for Latin text.
cluster_widths = [
width
if any(is_cjk_char(ch) for ch in cluster)
else monospace_advance * font_size_px
for cluster, width in zip(
split_project_text_clusters(text),
cluster_widths,
)
width if cjk else monospace_advance * font_size_px
for cjk, width in zip(cjk_flags, cluster_widths)
]
# The wide-face correction belongs to the Latin typeface only: CJK
# clusters draw with the ``ea`` face, so they neither widen with the
# family nor count toward its uppercase fraction.
latin_factor = _family_width_factor(dict(
run,
text=''.join(
cluster for cluster, cjk in zip(clusters, cjk_flags) if not cjk
),
))
cluster_widths = [
width if cjk else width * latin_factor
for cjk, width in zip(cjk_flags, cluster_widths)
]
letter_spacing_px = (
drawingml_letter_spacing(
@@ -2262,10 +2334,7 @@ def _estimate_run_text_width(run: dict[str, Any]) -> float:
)
/ FONT_PX_TO_HUNDREDTHS_PT
)
return sum(cluster_widths) * _family_width_factor(run) + letter_spacing_px * max(
len(cluster_widths) - 1,
0,
)
return sum(cluster_widths) + letter_spacing_px * max(len(cluster_widths) - 1, 0)
def validate_text_run_advances(runs: list[dict[str, Any]]) -> None:
@@ -2313,11 +2382,12 @@ def _estimate_text_runs_width(
``include_headroom`` is useful for single-line auto-fit boxes where a
renderer that measures text slightly wider would otherwise wrap. The
headroom scales independently with each run's family and uppercase
fraction. This keeps mixed-font lines from inheriting the most conservative
run's multiplier. Paragraph boxes use this value as a wrapping constraint,
so adding headroom there stretches the merged text frame beyond the
author's source line width.
headroom scales independently with each script segment's typeface and,
for Latin segments, its uppercase fraction. This keeps mixed-font and
mixed-script lines from inheriting the most conservative segment's
multiplier. Paragraph boxes use this value as a wrapping constraint, so
adding headroom there stretches the merged text frame beyond the author's
source line width.
"""
if any('_inline_dx' in run for run in runs):
# A negative dx moves the cursor back; it must not subtract space
@@ -2333,19 +2403,7 @@ def _estimate_text_runs_width(
if not include_headroom:
return sum(_estimate_run_text_width(run) for run in runs)
width = 0.0
for run in runs:
if _is_serif_run(run):
base = _SERIF_TEXT_WIDTH_HEADROOM_BASE
ceiling = _SERIF_TEXT_WIDTH_HEADROOM_CAPS
else:
base = _TEXT_WIDTH_HEADROOM_BASE
ceiling = _TEXT_WIDTH_HEADROOM_CAPS
caps = _uppercase_fraction([run])
width += _estimate_run_text_width(run) * (
base + (ceiling - base) * caps
)
return width
return sum(_estimate_run_width_with_headroom(run) for run in runs)
def estimate_single_line_text_frame_width(
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Regression tests for icon attribute validation and SVG markup escaping."""
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
from xml.etree import ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from svg_finalize.embed_icons import ( # noqa: E402
generate_icon_group,
parse_use_element,
process_svg_file,
resolve_icon_color,
)
class IconAttributeSecurityTests(unittest.TestCase):
def test_rejects_single_quoted_transform_injection_before_write(self) -> None:
source = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<use data-icon="chunk-filled/rocket" '
'transform=\'translate(10, 10)" ONMOUSEOVER="alert(1)\'/></svg>'
)
with self.assertRaisesRegex(ValueError, 'Invalid icon transform'):
parse_use_element(source)
with patch.object(Path, 'exists', return_value=True), \
patch.object(Path, 'read_text', return_value=source), \
patch.object(Path, 'write_text') as write:
with self.assertRaisesRegex(ValueError, 'Invalid icon transform'):
process_svg_file(Path('/unused/svg_output/01.svg'), Path('/unused/icons'))
write.assert_not_called()
def test_direct_generation_escapes_attribute_boundaries(self) -> None:
payload = 'translate(10, 10)" ONMOUSEOVER="alert(1)'
for style, attribute in (
('fill', 'transform'), ('stroke', 'transform'),
('preserve', 'transform'), ('stroke', 'stroke-width'),
):
with self.subTest(style=style, attribute=attribute):
attrs = {'icon': 'chunk-filled/rocket', 'fill': '#000000', attribute: payload}
output = generate_icon_group(attrs, ['<path d="M0 0h1v1z"/>'], style, (1, 2, 16, 16))
group = ET.fromstring(output)
self.assertEqual(group.get(attribute), payload)
self.assertNotIn('" ONMOUSEOVER=', output)
self.assertFalse(any(
name.lower().startswith('on')
for element in group.iter() for name in element.attrib
))
def test_legal_transforms_remain_authoritative(self) -> None:
for transform in (
'translate(10, 10)',
'matrix(1 0 0 1 10 20)',
'matrix(1, 0, 0, 1, -1e2, .5)',
'translate(-10, +.5) scale(2) rotate(45, 10, 20) skewX(5) skewY(-5)',
):
with self.subTest(transform=transform):
attrs = parse_use_element(
'<use data-icon="chunk-filled/rocket" x="99" y="88" width="48" '
f"transform='{transform}'/>"
)
group = ET.fromstring(generate_icon_group(attrs, ['<path d="M0 0h1"/>'], 'fill', 16))
self.assertEqual(attrs['transform'], transform)
self.assertEqual(group.get('transform'), transform)
def test_legal_colors(self) -> None:
for color in (
'#abc', '#abcd', '#123ABC', '#123ABC80', 'none', 'currentColor',
'rebeccapurple', 'rgb(255, 0, 10)', 'rgba(100%, 0%, 20%, .5)',
'hsl(120, 50%, 40%)', 'hsla(120, 50%, 40%, .5)',
):
with self.subTest(color=color):
attrs = parse_use_element(f'<use data-icon="chunk-filled/rocket" fill="{color}"/>')
self.assertEqual(resolve_icon_color(attrs, 'fill'), color)
group = ET.fromstring(generate_icon_group(attrs, ['<path d="M0 0h1"/>'], 'fill', 16))
self.assertEqual(group.get('fill'), color)
def test_outline_colors_and_stroke_width_units(self) -> None:
for width in ('0', '2', '1.5', '.5px', '0.1em', '10%'):
with self.subTest(width=width):
attrs = parse_use_element(
'<use data-icon="tabler-outline/home" fill="none" '
f'stroke="currentColor" stroke-width="{width}"/>'
)
self.assertNotIn('width', attrs)
group = ET.fromstring(generate_icon_group(attrs, ['<path d="M0 0h1"/>'], 'stroke', 24))
self.assertEqual(group.get('fill'), 'none')
self.assertEqual(group.get('stroke'), 'currentColor')
self.assertEqual(group.get('stroke-width'), width)
def test_rejects_unsafe_attribute_grammars(self) -> None:
for attribute, value in (
('fill', '#12'), ('fill', '#12345'), ('fill', '#1234567'),
('fill', 'url(#gradient)'), ('stroke', 'url(https://example.invalid/x)'),
('fill', '#fff" ONMOUSEOVER="alert(1)'),
('stroke', '#fff" ONMOUSEOVER="alert(1)'),
('fill', 'rgb(1, 2, expression(alert(1)))'),
('transform', 'translate(10, 10);alert(1)'),
('transform', 'matrix(1 0 0 1 NaN 0)'), ('transform', 'rotate()'),
('transform', 'translate(1,,2)'), ('transform', 'translate(1.2.3)'),
('stroke-width', '2" ONMOUSEOVER="alert(1)'),
('stroke-width', 'calc(1px + 2px)'), ('stroke-width', '2pt'),
):
with self.subTest(attribute=attribute, value=value):
with self.assertRaisesRegex(ValueError, f'Invalid icon {attribute}'):
parse_use_element(f'<use data-icon="chunk-filled/rocket" {attribute}=\'{value}\'/>')
def test_direct_color_resolution_rejects_unsafe_values(self) -> None:
for style in ('fill', 'stroke'):
for attribute in ('fill', 'stroke'):
with self.subTest(style=style, attribute=attribute):
attrs = {attribute: '#fff" ONMOUSEOVER="alert(1)'}
with self.assertRaisesRegex(ValueError, f'Invalid icon {attribute}'):
resolve_icon_color(attrs, style)
with self.assertRaisesRegex(ValueError, f'Invalid icon {attribute}'):
generate_icon_group(attrs, ['<path d="M0 0h1"/>'], style, 16)
@@ -15,7 +15,8 @@ from xml.etree import ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
# Discovery may put svg_to_pptx/ ahead of the same-named animation_config CLI.
if not sys.path or sys.path[0] != str(SCRIPTS_DIR):
sys.path.insert(0, str(SCRIPTS_DIR))
from pptx_shapes.formula import OOXML_COORDINATE_MAX # noqa: E402
@@ -258,6 +258,71 @@ class TextMeasureTests(unittest.TestCase):
self.assertEqual(result.stdout, f'{token}\n')
self.assertIn('Warning: token exceeds max width', result.stderr)
def test_mixed_cjk_line_keeps_cjk_headroom_beside_an_acronym(self) -> None:
family = 'Microsoft YaHei, Times New Roman'
chinese = '本报告整理了当前生成式技术在企业中的应用现状与趋势'
with_acronym = '本报告整理了当前AI技术在企业中的应用现状与趋势'
# Two capitals replace three ideographs: the raw advance shrinks and
# the headroom estimate follows it instead of lifting the whole line
# to the all-caps serif tier.
self.assertLess(
measure_text(with_acronym, size=20, family=family),
measure_text(chinese, size=20, family=family),
)
# CJK glyphs draw with the resolved ``ea`` face (Microsoft YaHei), so
# the Times New Roman tier of the same stack never applies to them.
self.assertAlmostEqual(
measure_text(chinese, size=20, family=family),
measure_text(chinese, size=20, family='Microsoft YaHei'),
)
# The Latin segment alone still takes the serif all-caps ceiling.
raw = measure_text('AI', size=20, family=family, include_headroom=False)
self.assertAlmostEqual(measure_text('AI', size=20, family=family), raw * 1.36)
def test_headroom_never_drops_below_the_raw_advance(self) -> None:
# A negative tracking gap between the CJK and Latin segments must not
# be amplified by the Latin segment's larger headroom.
run = {
'text': '中I',
'font_size': 20.0,
'font_family': 'Microsoft YaHei, Times New Roman',
'font_weight': '400',
'letter_spacing': -25.0,
}
raw = estimate_single_line_text_frame_width([run], include_headroom=False)
self.assertGreater(raw, 0)
self.assertGreaterEqual(estimate_single_line_text_frame_width([run]), raw)
def test_wide_family_factor_applies_to_latin_clusters_in_both_paths(self) -> None:
text = '中' * 10 + 'AI'
raw = measure_text(text, size=20, family='Arial Black', include_headroom=False)
cjk_raw = measure_text('中' * 10, size=20, family='Arial Black', include_headroom=False)
latin_raw = measure_text('AI', size=20, family='Arial Black', include_headroom=False)
# CJK clusters draw with the ``ea`` face, so only ``AI`` widens.
self.assertAlmostEqual(raw, cjk_raw + latin_raw)
self.assertAlmostEqual(cjk_raw, measure_text('中' * 10, size=20, family='Arial',
include_headroom=False))
self.assertGreaterEqual(measure_text(text, size=20, family='Arial Black'), raw)
def test_cjk_wrap_skips_a_clause_break_that_leaves_the_line_short(self) -> None:
# 20px CJK clusters measure 21.2px with headroom, so 16 fit in 340px.
lines, widths, oversized = wrap_text('甲乙,' + '丙' * 30, size=20, max_width=340)
self.assertEqual(oversized, [])
self.assertEqual(lines[0], '甲乙,' + '丙' * 13)
self.assertGreaterEqual(widths[0], 340 * 0.75)
# A clause break that keeps most of the greedy line is still preferred,
# including one that lands exactly on the three-quarter mark.
for clause in ('丙' * 12 + ',', '甲' * 11 + ','):
lines, widths, oversized = wrap_text(clause + '丁' * 20, size=20, max_width=340)
self.assertEqual(oversized, [])
self.assertEqual(lines[0], clause)
def test_cjk_wrap_keeps_closing_punctuation_with_previous_cluster(self) -> None:
lines, widths, oversized = wrap_text('甲乙,丙丁', size=20, max_width=45)
@@ -39,6 +39,10 @@ from svg_to_pptx.drawingml.utils import split_project_text_clusters # noqa: E40
_CLOSING_PUNCTUATION = frozenset(',.;:!?)]}、,。;:!?)》」』】”’')
_OPENING_PUNCTUATION = frozenset('([{(《「『【“‘')
_PREFERRED_BREAK_PUNCTUATION = frozenset(',。;:')
# A CJK clause break is preferred over the greedy break only while it keeps
# this share of the greedy line; below it the punctuation break would leave a
# visibly short line, so the greedy fill wins.
_PREFERRED_BREAK_MIN_FILL = 0.75
_LATIN_TOKEN_CONNECTORS = frozenset("'’._:/+%@#-")
_WEIGHTS = ('normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900')
_CALIBRATION_CJK_SAMPLE = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往'
@@ -239,7 +243,13 @@ def wrap_text(
start += 1
continue
line_end = preferred_end or end
line_end = end
if (
preferred_end
and fit_widths[preferred_end]
>= fit_widths[end] * _PREFERRED_BREAK_MIN_FILL - 1e-6
):
line_end = preferred_end
lines.append(_joined_units(units, start, line_end))
widths.append(fit_widths[line_end])
start = line_end
@@ -310,7 +310,7 @@ python3 ${SKILL_DIR}/scripts/svg_quality_checker.py <project_path> \
**Logic Construction Phase (conditional)**: when the effective Speaker Notes outcome in §I is enabled, load [`executor-notes.md`](../references/executor-notes.md): validate a frozen `notes/total.md` against every information-bearing final SVG group (repair the page or the plan, never the script), or otherwise ground each page's narration in its final SVG and write `notes/total.md`. When disabled, load nothing and create no notes.
**✅ Internal checkpoint** — preview launched in time, P01 method gate, uninterrupted remaining pages, consolidated repair, exact §IX coverage, one-frame prose, final checker 0 errors, `notes/total.md` only when enabled. Do not print. Then run the applicable conditional gates and proceed to Step 7.
**✅ Internal checkpoint** — preview launched in time, early gate after P05 (skipped on a roster of six or fewer pages), uninterrupted remaining pages, consolidated repair, exact §IX coverage, one-frame prose, final checker 0 errors, `notes/total.md` only when enabled. Do not print. Then run the applicable conditional gates and proceed to Step 7.
> **Chart pages?** Run [`verify-charts`](stages/verify-charts.md) before Step 7 to calibrate coordinates; skip without chart pages.
>