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:
1 parent
9b01a911ae
commit
074381889b
20 files changed
+464
-80
No files matched your search
@@ -105,8 +105,8 @@
|
|||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"commit": "a4f5487dc930ba22f7002d775f49c81f47210960",
|
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
|
||||||
"syncedAt": "2026-09-08T16:00:01Z"
|
"syncedAt": "2026-09-09T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "grill-me",
|
"id": "grill-me",
|
||||||
@@ -141,8 +141,8 @@
|
|||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"commit": "4cb547213b5303b7f01675a7502b84ce960fd19e",
|
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
|
||||||
"syncedAt": "2026-09-09T04:01:45Z"
|
"syncedAt": "2026-09-09T16:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "shuorenhua",
|
"id": "shuorenhua",
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"name": "playwright浏览器自动化操作",
|
"name": "playwright浏览器自动化操作",
|
||||||
"version": "20260605",
|
"version": "20260605",
|
||||||
"keySource": "none",
|
"keySource": "none",
|
||||||
"syncedAt": "2026-09-09T04:04:38Z"
|
"syncedAt": "2026-09-09T16:03:05Z"
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"sourceId": "next-skills",
|
"sourceId": "next-skills",
|
||||||
"repo": "https://github.com/vercel/next.js.git",
|
"repo": "https://github.com/vercel/next.js.git",
|
||||||
"ref": "canary",
|
"ref": "canary",
|
||||||
"commit": "4cb547213b5303b7f01675a7502b84ce960fd19e",
|
"commit": "e0966df11438b1f82e06d6f650a2a044b84457c3",
|
||||||
"adapter": "skill-collection",
|
"adapter": "skill-collection",
|
||||||
"sourcePath": "skills",
|
"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.
|
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
|
## Related Tools
|
||||||
|
|
||||||
[cc-switch](https://github.com/farion1231/cc-switch) — one-click switching of API providers across Claude Code / Codex / Gemini CLI and more.
|
[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",
|
"sourceId": "ppt-master",
|
||||||
"repo": "https://github.com/hugohe3/ppt-master.git",
|
"repo": "https://github.com/hugohe3/ppt-master.git",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"commit": "a4f5487dc930ba22f7002d775f49c81f47210960",
|
"commit": "64b65839c7f8096a534c872c03d688a2b2491c8f",
|
||||||
"adapter": "claude-skill",
|
"adapter": "claude-skill",
|
||||||
"sourcePath": "skills/ppt-master",
|
"sourcePath": "skills/ppt-master",
|
||||||
"syncedAt": "2026-09-08T16:00:01Z"
|
"syncedAt": "2026-09-09T16:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
name: ppt-master
|
name: ppt-master
|
||||||
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
|
||||||
metadata:
|
metadata:
|
||||||
version: "6.3.0"
|
version: "6.3.1"
|
||||||
copyright: "Copyright (c) 2025-2026 Hugo He"
|
copyright: "Copyright (c) 2025-2026 Hugo He"
|
||||||
license: "MIT"
|
license: "MIT"
|
||||||
official_repository: "https://github.com/hugohe3/ppt-master"
|
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.
|
# Converts PDF to Markdown with text, image, and table extraction.
|
||||||
# 用于将 PDF 文件转换为 Markdown 格式,支持文本、图片、表格提取
|
# 用于将 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
|
PyMuPDF>=1.23.0
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ Dependency:
|
|||||||
pip install PyMuPDF
|
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`
|
## `source_to_md/doc_to_md.py`
|
||||||
|
|
||||||
Hybrid converter: pure-Python for the common formats, pandoc fallback for the rest.
|
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
|
- `measure` prints one `width<TAB>text` line per input, or a JSON array with
|
||||||
`--json`.
|
`--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.
|
includes the outer `<text>` element, and `--json` prints line metrics.
|
||||||
- `box` prints a `data-pptx-bounds` attribute plus numeric `top` and `bottom`, or
|
- `box` prints a `data-pptx-bounds` attribute plus numeric `top` and `bottom`, or
|
||||||
a JSON bounds object with `--json`.
|
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
|
`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
|
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)
|
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
|
The rates are sample averages taken with the checker's own estimator
|
||||||
(headroom included), while the checker measures each real line glyph by
|
(headroom included), while the checker measures each real line glyph by
|
||||||
glyph: capital-heavy words, digits, and wide letters run wider than the Latin
|
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
|
- `edge-tts` for `notes_to_audio.py` recorded narration audio
|
||||||
- `Pillow` for image utilities
|
- `Pillow` for image utilities
|
||||||
- `numpy` for watermark removal
|
- `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
|
- `google-genai` for Gemini image generation
|
||||||
+65
-1
@@ -1423,11 +1423,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "exact",
|
"kind": "exact",
|
||||||
"fingerprint": "7e6ac64f0c0c",
|
"fingerprint": "efdf8b23a710",
|
||||||
"paths": [
|
"paths": [
|
||||||
"skills/ppt-master/templates/styles/academic-research/templates/design_spec.md",
|
"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/consulting-decision/templates/design_spec.md",
|
||||||
"skills/ppt-master/templates/styles/creative-pitch/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/incident-postmortem/templates/design_spec.md",
|
||||||
"skills/ppt-master/templates/styles/investor-pitch/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",
|
"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"
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+23
-5
@@ -732,10 +732,11 @@
|
|||||||
waitForSlideRewrite(name);
|
waitForSlideRewrite(name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Render SVG
|
// Validate before showing the canvas; parse errors use the catch below.
|
||||||
|
var sanitizedSvg = sanitizeSvg(data.content);
|
||||||
svgPlaceholder.style.display = "none";
|
svgPlaceholder.style.display = "none";
|
||||||
svgContent.style.display = "block";
|
svgContent.style.display = "block";
|
||||||
svgContent.innerHTML = sanitizeSvg(data.content);
|
svgContent.innerHTML = sanitizedSvg;
|
||||||
|
|
||||||
// Empty-canvas guard: surface a clear error if the SVG parsed
|
// Empty-canvas guard: surface a clear error if the SVG parsed
|
||||||
// to nothing renderable (issue #115's silent-blank scenario).
|
// to nothing renderable (issue #115's silent-blank scenario).
|
||||||
@@ -1849,13 +1850,30 @@
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
function sanitizeSvg(svgString) {
|
function sanitizeSvg(svgString) {
|
||||||
var doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
|
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) {
|
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) {
|
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
|
// Strip dangerous URI protocols from href/xlink:href
|
||||||
if (attr.localName === "href" &&
|
if (local === "href" &&
|
||||||
(/^\s*javascript\s*:/i.test(attr.value) ||
|
(/^\s*javascript\s*:/i.test(attr.value) ||
|
||||||
|
/^\s*vbscript\s*:/i.test(attr.value) ||
|
||||||
/^\s*data\s*:/i.test(attr.value))) {
|
/^\s*data\s*:/i.test(attr.value))) {
|
||||||
el.removeAttributeNode(attr);
|
el.removeAttributeNode(attr);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -2,6 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title data-i18n="page_title">PPT Master - Live Preview</title>
|
<title data-i18n="page_title">PPT Master - Live Preview</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
|||||||
+45
-13
@@ -42,10 +42,11 @@ Options:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
@@ -72,10 +73,37 @@ ICON_BASE_SIZES = {
|
|||||||
_ICON_IDENTIFIER_RE = re.compile(
|
_ICON_IDENTIFIER_RE = re.compile(
|
||||||
r'(?P<library>[a-z0-9][a-z0-9-]*)/(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)'
|
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
|
DEFAULT_ICON_BASE_SIZE = 24
|
||||||
BaseGeometry = float | tuple[float, float, float, float]
|
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:
|
def _get_viewbox_size(content: str) -> float:
|
||||||
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
|
"""Extract the width from viewBox attribute (assumed square). Returns 0 if not found."""
|
||||||
m = re.search(r'viewBox=["\']0 0 ([\d.]+)', content)
|
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:
|
def _attr_value(tag_text: str, attr: str) -> str | None:
|
||||||
"""Return an attribute value from a raw tag, accepting either quote style."""
|
"""Return an attribute value from a raw tag, accepting either quote style."""
|
||||||
match = re.search(
|
match = re.search(
|
||||||
rf'\b{re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
|
rf'(?<!\S){re.escape(attr)}\s*=\s*(["\'])(.*?)\1',
|
||||||
tag_text,
|
tag_text,
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
@@ -338,27 +366,27 @@ def parse_use_element(use_match: str) -> dict[str, str | float]:
|
|||||||
# Extract fill color
|
# Extract fill color
|
||||||
fill_value = _attr_value(use_match, 'fill')
|
fill_value = _attr_value(use_match, 'fill')
|
||||||
if fill_value is not None:
|
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:
|
# Stroke-style icons may be authored with natural SVG semantics:
|
||||||
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
|
# fill="none" stroke="#HEX". Keep accepting fill as the canonical color
|
||||||
# carrier, but preserve stroke so outline icons do not collapse to none.
|
# carrier, but preserve stroke so outline icons do not collapse to none.
|
||||||
stroke_value = _attr_value(use_match, 'stroke')
|
stroke_value = _attr_value(use_match, 'stroke')
|
||||||
if stroke_value is not None:
|
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
|
# Live preview direct edits may write an absolute transform matrix back to
|
||||||
# the placeholder. Preserve it so the expanded icon matches the edited
|
# the placeholder. Preserve it so the expanded icon matches the edited
|
||||||
# browser geometry instead of falling back to the original x/y placement.
|
# browser geometry instead of falling back to the original x/y placement.
|
||||||
transform_value = _attr_value(use_match, 'transform')
|
transform_value = _attr_value(use_match, 'transform')
|
||||||
if transform_value is not None:
|
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).
|
# Extract optional stroke-width override (stroke-style icons only).
|
||||||
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
|
# Tabler-outline ships at stroke-width=2; passing 1.5 reads thin, 3 reads bold.
|
||||||
stroke_width_value = _attr_value(use_match, 'stroke-width')
|
stroke_width_value = _attr_value(use_match, 'stroke-width')
|
||||||
if stroke_width_value is not None:
|
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
|
return attrs
|
||||||
|
|
||||||
@@ -370,6 +398,10 @@ def resolve_icon_color(attrs: dict[str, str | float], style: str) -> str:
|
|||||||
|
|
||||||
fill = str(attrs.get('fill', '')).strip()
|
fill = str(attrs.get('fill', '')).strip()
|
||||||
stroke = str(attrs.get('stroke', '')).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 style == 'stroke':
|
||||||
if fill and fill != 'none':
|
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 style == 'preserve':
|
||||||
if min_x or min_y:
|
if min_x or min_y:
|
||||||
inner_transform = f'translate({_format_number(-min_x)}, {_format_number(-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>'
|
elements_str = f'<g transform="{_xml_attr(inner_transform)}">\n {elements_str}\n </g>'
|
||||||
return f'''<!-- icon: {icon_name} -->
|
return f'''<!-- icon: {_xml_attr(icon_name)} -->
|
||||||
<g transform="{transform}">
|
<g transform="{_xml_attr(transform)}">
|
||||||
{elements_str}
|
{elements_str}
|
||||||
</g>'''
|
</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
|
# (and any other stroke library) so omitting the attribute reproduces
|
||||||
# pre-change visual output.
|
# pre-change visual output.
|
||||||
stroke_width = attrs.get('stroke-width', '2')
|
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:
|
else:
|
||||||
color_attrs = f'fill="{color}"'
|
color_attrs = f'fill="{_xml_attr(color)}"'
|
||||||
|
|
||||||
return f'''<!-- icon: {icon_name} -->
|
return f'''<!-- icon: {_xml_attr(icon_name)} -->
|
||||||
<g transform="{transform}" {color_attrs}>
|
<g transform="{_xml_attr(transform)}" {color_attrs}>
|
||||||
{elements_str}
|
{elements_str}
|
||||||
</g>'''
|
</g>'''
|
||||||
|
|
||||||
|
|||||||
+103
-45
@@ -2029,14 +2029,17 @@ _TEXTBOX_PADDING_MIN_PX = 0.5
|
|||||||
_TEXTBOX_PADDING_MAX_PX = 2.0
|
_TEXTBOX_PADDING_MAX_PX = 2.0
|
||||||
_TEXTBOX_PADDING_RATIO = 0.04
|
_TEXTBOX_PADDING_RATIO = 0.04
|
||||||
# Single-line auto-fit headroom interpolates between a low-caps base and an
|
# 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
|
# all-caps ceiling for each script segment of a run. The crude per-char width
|
||||||
# capitals most, so all-caps runs need the ceiling to keep wrap-ignoring
|
# estimate undercounts capitals most, so all-caps Latin segments need the
|
||||||
# renderers (LibreOffice) from folding. Applying headroom per run also prevents
|
# ceiling to keep wrap-ignoring renderers (LibreOffice) from folding. The
|
||||||
# a short serif label from forcing a conservative serif multiplier onto an
|
# serif tier follows the typeface that draws the segment (``latin`` or ``ea``
|
||||||
# otherwise sans-serif line. Values are calibrated against LibreOffice renders
|
# as resolved by parse_font_family), so a CJK segment drawn by Microsoft YaHei
|
||||||
# of all-caps bold lines, with bases left above mixed-case and CJK render
|
# never takes the Times New Roman tier of the same stack, and a short serif
|
||||||
# ratios; exact ratios shift with font substitution, so these carry deliberate
|
# label cannot force its multiplier onto an otherwise sans-serif line. Values
|
||||||
# margin rather than tracking one environment's numbers.
|
# 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_BASE = 1.06
|
||||||
_TEXT_WIDTH_HEADROOM_CAPS = 1.12
|
_TEXT_WIDTH_HEADROOM_CAPS = 1.12
|
||||||
_SERIF_TEXT_WIDTH_HEADROOM_BASE = 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}"'
|
return f' spc="{spacing}"'
|
||||||
|
|
||||||
|
|
||||||
def _is_serif_run(run: dict[str, Any]) -> bool:
|
def _is_serif_face(typeface: str) -> bool:
|
||||||
"""Return whether a text run uses a serif-like family."""
|
"""Return whether one resolved DrawingML typeface is serif-like."""
|
||||||
for family in str(run.get('font_family', '')).split(','):
|
name = typeface.strip().strip("'\"").lower()
|
||||||
name = family.strip().strip("'\"").lower()
|
|
||||||
if not name or name in {'sans-serif', 'sans serif'}:
|
if not name or name in {'sans-serif', 'sans serif'}:
|
||||||
continue
|
return False
|
||||||
if name in _SERIF_WIDTH_FAMILIES:
|
if name in _SERIF_WIDTH_FAMILIES:
|
||||||
return True
|
return True
|
||||||
if 'serif' in name and 'sans' not in name:
|
return 'serif' in name and 'sans' not in name
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
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
|
# 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_family=run.get('font_family'),
|
||||||
font_style=str(run.get('font_style', 'normal')),
|
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)
|
monospace_advance = _monospace_advance_em(run)
|
||||||
if monospace_advance is not None:
|
if monospace_advance is not None:
|
||||||
# Fixed-pitch faces ignore weight and glyph shape for Latin text.
|
# Fixed-pitch faces ignore weight and glyph shape for Latin text.
|
||||||
cluster_widths = [
|
cluster_widths = [
|
||||||
width
|
width if cjk else monospace_advance * font_size_px
|
||||||
if any(is_cjk_char(ch) for ch in cluster)
|
for cjk, width in zip(cjk_flags, cluster_widths)
|
||||||
else monospace_advance * font_size_px
|
]
|
||||||
for cluster, width in zip(
|
# The wide-face correction belongs to the Latin typeface only: CJK
|
||||||
split_project_text_clusters(text),
|
# clusters draw with the ``ea`` face, so they neither widen with the
|
||||||
cluster_widths,
|
# 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 = (
|
letter_spacing_px = (
|
||||||
drawingml_letter_spacing(
|
drawingml_letter_spacing(
|
||||||
@@ -2262,10 +2334,7 @@ def _estimate_run_text_width(run: dict[str, Any]) -> float:
|
|||||||
)
|
)
|
||||||
/ FONT_PX_TO_HUNDREDTHS_PT
|
/ FONT_PX_TO_HUNDREDTHS_PT
|
||||||
)
|
)
|
||||||
return sum(cluster_widths) * _family_width_factor(run) + letter_spacing_px * max(
|
return sum(cluster_widths) + letter_spacing_px * max(len(cluster_widths) - 1, 0)
|
||||||
len(cluster_widths) - 1,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_text_run_advances(runs: list[dict[str, Any]]) -> None:
|
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
|
``include_headroom`` is useful for single-line auto-fit boxes where a
|
||||||
renderer that measures text slightly wider would otherwise wrap. The
|
renderer that measures text slightly wider would otherwise wrap. The
|
||||||
headroom scales independently with each run's family and uppercase
|
headroom scales independently with each script segment's typeface and,
|
||||||
fraction. This keeps mixed-font lines from inheriting the most conservative
|
for Latin segments, its uppercase fraction. This keeps mixed-font and
|
||||||
run's multiplier. Paragraph boxes use this value as a wrapping constraint,
|
mixed-script lines from inheriting the most conservative segment's
|
||||||
so adding headroom there stretches the merged text frame beyond the
|
multiplier. Paragraph boxes use this value as a wrapping constraint, so
|
||||||
author's source line width.
|
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):
|
if any('_inline_dx' in run for run in runs):
|
||||||
# A negative dx moves the cursor back; it must not subtract space
|
# 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:
|
if not include_headroom:
|
||||||
return sum(_estimate_run_text_width(run) for run in runs)
|
return sum(_estimate_run_text_width(run) for run in runs)
|
||||||
|
|
||||||
width = 0.0
|
return sum(_estimate_run_width_with_headroom(run) for run in runs)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_single_line_text_frame_width(
|
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)
|
||||||
+2
-1
@@ -15,7 +15,8 @@ from xml.etree import ElementTree as ET
|
|||||||
|
|
||||||
|
|
||||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
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))
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||||
|
|
||||||
from pptx_shapes.formula import OOXML_COORDINATE_MAX # noqa: E402
|
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.assertEqual(result.stdout, f'{token}\n')
|
||||||
self.assertIn('Warning: token exceeds max width', result.stderr)
|
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:
|
def test_cjk_wrap_keeps_closing_punctuation_with_previous_cluster(self) -> None:
|
||||||
lines, widths, oversized = wrap_text('甲乙,丙丁', size=20, max_width=45)
|
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(',.;:!?)]}、,。;:!?)》」』】”’')
|
_CLOSING_PUNCTUATION = frozenset(',.;:!?)]}、,。;:!?)》」』】”’')
|
||||||
_OPENING_PUNCTUATION = frozenset('([{(《「『【“‘')
|
_OPENING_PUNCTUATION = frozenset('([{(《「『【“‘')
|
||||||
_PREFERRED_BREAK_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("'’._:/+%@#-")
|
_LATIN_TOKEN_CONNECTORS = frozenset("'’._:/+%@#-")
|
||||||
_WEIGHTS = ('normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900')
|
_WEIGHTS = ('normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900')
|
||||||
_CALIBRATION_CJK_SAMPLE = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往'
|
_CALIBRATION_CJK_SAMPLE = '天地玄黄宇宙洪荒日月盈昃辰宿列张寒来暑往'
|
||||||
@@ -239,7 +243,13 @@ def wrap_text(
|
|||||||
start += 1
|
start += 1
|
||||||
continue
|
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))
|
lines.append(_joined_units(units, start, line_end))
|
||||||
widths.append(fit_widths[line_end])
|
widths.append(fit_widths[line_end])
|
||||||
start = 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.
|
**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.
|
> **Chart pages?** Run [`verify-charts`](stages/verify-charts.md) before Step 7 to calibrate coordinates; skip without chart pages.
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user