Compare commits
20 Commits
v1.0.78
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79b2b499d | ||
|
|
e0bc29a648 | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b |
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
1
.lark-slides-on9-readback.xml
Normal file
39
CHANGELOG.md
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
@@ -1685,6 +1722,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
|
||||
@@ -66,6 +66,7 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
appendSlidesXMLQuickReference(&b, cmd, skillFS)
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +117,90 @@ const (
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
|
||||
|
||||
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
|
||||
// command guide. XML-consuming shortcuts also include the shared schema
|
||||
// reference because their command guide accepts XML but does not repeat the
|
||||
// complete element grammar.
|
||||
var slidesShortcutReferencePaths = map[string][]string{
|
||||
"+create": {
|
||||
"lark-slides/references/lark-slides-create.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+xml-get": {
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
},
|
||||
"+screenshot": {
|
||||
"lark-slides/references/lark-slides-screenshot.md",
|
||||
},
|
||||
"+media-upload": {
|
||||
"lark-slides/references/lark-slides-media-upload.md",
|
||||
},
|
||||
"+replace-slide": {
|
||||
"lark-slides/references/lark-slides-replace-slide.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+replace-pages": {
|
||||
"lark-slides/references/lark-slides-replace-pages.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+history-list": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
}
|
||||
|
||||
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
|
||||
// slides domain help. The reference file is already shipped in the skill
|
||||
// content tree, so help and the standalone skill reader share one source of
|
||||
// truth instead of maintaining a second, drifting copy in Go.
|
||||
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
|
||||
if cmd.Name() != "slides" || skillFS == nil {
|
||||
return
|
||||
}
|
||||
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
|
||||
if err != nil || len(content) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
|
||||
b.Write(content)
|
||||
}
|
||||
|
||||
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
|
||||
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
|
||||
return nil, false
|
||||
}
|
||||
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var contents []string
|
||||
for _, path := range paths {
|
||||
content, err := fs.ReadFile(skillFS, path)
|
||||
if err != nil || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
|
||||
}
|
||||
return contents, len(contents) > 0
|
||||
}
|
||||
|
||||
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
|
||||
for _, content := range contents {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(content)
|
||||
}
|
||||
}
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
@@ -171,11 +256,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
// overlay and any embedded command references — the same top layout as method
|
||||
// help (description, Risk, guidance block, related skills) minus the schema
|
||||
// pointer, which shortcuts have none of. Returns false when the command is not
|
||||
// a shortcut, or when it has neither an overlay nor an embedded reference, so
|
||||
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
@@ -191,12 +276,17 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
|
||||
|
||||
var a meta.Affordance
|
||||
hasAffordance := false
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
|
||||
a = parsed
|
||||
hasAffordance = true
|
||||
}
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
if !hasAffordance && !hasReferences {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
@@ -211,6 +301,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
appendSlidesShortcutReferences(&b, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
|
||||
@@ -264,6 +264,94 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
|
||||
cmdutil.SetRisk(sc, "read")
|
||||
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md": {
|
||||
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
|
||||
},
|
||||
}
|
||||
if !PrepareShortcutHelp(sc, skillFS) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Fetch presentation XML",
|
||||
"Risk: read",
|
||||
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"Read the presentation XML.",
|
||||
} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
PrepareShortcutHelp(sc, skillFS)
|
||||
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
|
||||
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesShortcutReferenceMapping(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"+create": "lark-slides/references/lark-slides-create.md",
|
||||
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
|
||||
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
|
||||
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
|
||||
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
|
||||
"+history-list": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
|
||||
}
|
||||
|
||||
for command, path := range want {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
sc := &cobra.Command{Use: command, Short: command}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
path: {Data: []byte("reference content")},
|
||||
}
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok || len(contents) == 0 {
|
||||
t.Fatalf("shortcut %q has no mapped reference", command)
|
||||
}
|
||||
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
|
||||
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-screenshot.md": {
|
||||
Data: []byte("# slides +screenshot\n\nSave screenshots."),
|
||||
},
|
||||
slidesXMLQuickReferencePath: {
|
||||
Data: []byte("# XML Schema Quick Reference"),
|
||||
},
|
||||
}
|
||||
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok {
|
||||
t.Fatal("screenshot shortcut should have a primary reference")
|
||||
}
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
|
||||
}
|
||||
if strings.Contains(contents[0], "XML Schema Quick Reference") {
|
||||
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -306,3 +394,51 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "slides", Short: "Slides"}
|
||||
cmdmeta.SetDomain(dom, "slides")
|
||||
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
|
||||
const quickReference = `# XML Schema Quick Reference
|
||||
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title"><p>Title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
|
||||
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
|
||||
<chart><chartPlotArea/><chartData/></chart>`
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/SKILL.md": {Data: []byte("# slides")},
|
||||
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
|
||||
}
|
||||
|
||||
if !PrepareDomainHelp(dom, skillFS) {
|
||||
t.Fatal("PrepareDomainHelp returned false for slides domain")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Embedded XML syntax quick reference:",
|
||||
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
|
||||
"<shape type=\"text\"",
|
||||
"<content",
|
||||
"topLeftX",
|
||||
"<table>",
|
||||
"<chart>",
|
||||
} {
|
||||
if !strings.Contains(dom.Long, want) {
|
||||
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
|
||||
}
|
||||
}
|
||||
PrepareDomainHelp(dom, skillFS)
|
||||
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
|
||||
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.78",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||
|
||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -157,7 +157,7 @@ func localImagePath(src string) string {
|
||||
}
|
||||
|
||||
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
|
||||
host := "internal-api-drive-stream.larkoffice.com"
|
||||
host := "internal-api-drive-stream.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "internal-api-drive-stream.larksuite.com"
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestBuildCalendarImagePreviewURL(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
hostFrag string
|
||||
}{
|
||||
{core.BrandFeishu, "larkoffice"},
|
||||
{core.BrandFeishu, "feishu.cn"},
|
||||
{core.BrandLark, "larksuite"},
|
||||
} {
|
||||
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
|
||||
|
||||
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -27,22 +28,37 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
|
||||
@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -63,21 +64,38 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets use either a legacy
|
||||
// prefix or the interleaved "OFL0X" marker and must upload with
|
||||
// "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
|
||||
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
}
|
||||
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -57,12 +57,12 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
@@ -116,6 +116,7 @@ metadata:
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +147,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,40 +2,60 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
|
||||
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
|
||||
|
||||
> [!CAUTION]
|
||||
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目,未携带的属性会回落为默认值,显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 更新一个问题的标题
|
||||
lark-cli base +form-questions-update \
|
||||
# 先读取现有题目配置,作为 read-modify-write 的基线
|
||||
lark-cli base +form-questions-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
|
||||
--form-id <form_id>
|
||||
|
||||
# 同时更新多个问题
|
||||
# 更新一个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"id":"q_001","title":"姓名(必填)","required":true},
|
||||
{"id":"q_002","title":"联系方式","required":false}
|
||||
]'
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
|
||||
|
||||
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
|
||||
|
||||
# 更新问题描述(纯文本)
|
||||
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
|
||||
# 更新问题描述(含链接)
|
||||
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
|
||||
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
|
||||
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
|
||||
|
||||
# 更新题目显隐条件(visible_rule),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
|
||||
|
||||
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
|
||||
|
||||
## `--questions` 格式
|
||||
|
||||
每个问题对象必须包含 `id`,其余字段按需传入:
|
||||
每个问题对象必须包含 `id`。注意:对象不是增量 patch,而是该题目的目标完整配置;未携带字段会按服务端默认值重建。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | **是** | 问题 ID(field_id),不可修改 |
|
||||
| `title` | 否 | 新的问题标题 |
|
||||
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
| `required` | 否 | 是否必填 |
|
||||
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
|
||||
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
|
||||
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`);省略或传空字符串都会清空描述 |
|
||||
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
|
||||
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
|
||||
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
|
||||
|
||||
## 全量覆盖语义
|
||||
|
||||
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id`、`title`、`description`、`required`、`option_display_mode`、`visible_rule`。
|
||||
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
|
||||
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false`、`visible_rule` 清空。
|
||||
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**(`{logic?, conditions?}`),共用同一套公共协议。
|
||||
|
||||
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID)。
|
||||
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
|
||||
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["q_001", "==", "是"],
|
||||
["q_003", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
|
||||
2. 构造包含 `id` 的更新数组
|
||||
3. 执行命令并报告更新结果
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
|
||||
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
|
||||
3. 构造包含 `id` 和目标完整配置的更新数组。
|
||||
4. 执行命令并报告更新结果。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -4,142 +4,13 @@
|
||||
|
||||
更新视图筛选配置。
|
||||
|
||||
## 1. 顶层规则
|
||||
## 1. filter 结构
|
||||
|
||||
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
- 支持 `filter` 的视图类型:`grid`、`kanban`、`gallery`、`calendar`、`gantt`。
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 推荐命令
|
||||
## 2. 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
|
||||
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
|
||||
```
|
||||
|
||||
## 5. JSON 写法
|
||||
## 3. JSON 写法
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 使用建议
|
||||
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 4. 使用建议
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
## 5. 易错点
|
||||
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 8. 参考
|
||||
## 6. 参考
|
||||
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md):filter/visible_rule 条件结构公共协议 SSOT
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
|
||||
@@ -16,14 +16,16 @@ metadata:
|
||||
|
||||
## 身份
|
||||
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
按**日程归属**选身份:
|
||||
|
||||
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
|
||||
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
|
||||
|
||||
```bash
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
# GOOD — user 身份查日程
|
||||
# 用户本人日程 → user
|
||||
lark-cli calendar +agenda --as user
|
||||
# bot 自建或参与的日程 → bot
|
||||
lark-cli calendar +agenda --as bot
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
@@ -188,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
|
||||
lark-cli im +chat-search --query <query> --as user
|
||||
```
|
||||
|
||||
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
|
||||
@@ -33,7 +33,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
@@ -45,7 +45,6 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
|
||||
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
|
||||
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 |
|
||||
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
## 规则
|
||||
|
||||
- 构造 `--attendee-ids` 前,先剔除 bot 参会人:bot 不占席位、无偏好,不应参与会议室推荐。
|
||||
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
|
||||
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
|
||||
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。
|
||||
|
||||
@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
|
||||
| ------------------------------- | ----- | ------------------------------------------------------------------- |
|
||||
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式,默认**当前时间**) |
|
||||
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
|
||||
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户(`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**:bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
|
||||
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
|
||||
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
|
||||
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) |
|
||||
|
||||
@@ -63,6 +63,7 @@ lark-cli calendar +update \
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--summary`、`--start`、`--end` 等日程字段。
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
- bot 可作为合法参会人添加,无需剔除。
|
||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,6 +27,7 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
@@ -120,6 +121,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
### 权限能力入口
|
||||
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
@@ -163,6 +165,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
|
||||
| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# drive +member-list(查询协作者/授权成员列表)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# URL 自动推断 type
|
||||
lark-cli drive +member-list \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 查询附加字段
|
||||
lark-cli drive +member-list \
|
||||
--token '<token>' \
|
||||
--type docx \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
|
||||
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出原样透传 API 的 `data` :
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"member_type": "openid",
|
||||
"member_id": "ou_xxx",
|
||||
"perm": "view",
|
||||
"perm_type": "container",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"external_label": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
|
||||
- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。
|
||||
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
|
||||
- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。
|
||||
- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。
|
||||
- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。
|
||||
@@ -0,0 +1,48 @@
|
||||
# drive +permission-get-setting(查询权限设置)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 URL 自动推断 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 通过 bare token 显式指定 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' \
|
||||
--type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"permission_public": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用。
|
||||
- **所需 scope**:`docs:permission.setting:read`。
|
||||
- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。
|
||||
@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id "7217780879644737539"
|
||||
--label-id '<label-id>' # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`drive +inspect` 支持 Drive folder,并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL,或直接使用 `drive +permission-get-setting --token '<folder_url>'`;传 bare folder token 时必须显式传 `--type folder`。
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
@@ -25,16 +27,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
@@ -61,14 +63,42 @@ lark-cli drive metas batch_query \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
读取权限设置:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url-or-token>' --type '<type>' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
裸 folder token 必须显式传 `--type folder`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' --type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
通过 URL 读取权限设置时可以省略 `--type`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url>' \
|
||||
--as user --format json # replace $LARK_DRIVE_URL before running
|
||||
```
|
||||
|
||||
按需读取直接协作者/授权成员列表:
|
||||
|
||||
```bash
|
||||
lark-cli drive +member-list \
|
||||
--token '<token_or_url>' \
|
||||
--type '<type>' \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
@@ -160,9 +190,9 @@ lark-cli drive +secure-label-list \
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
@@ -36,7 +36,7 @@
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
@@ -75,7 +75,7 @@
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
@@ -165,7 +165,7 @@ Evidence fields:
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
- 成功读取目标公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
@@ -233,7 +233,7 @@ URL:<url-or-token-if-url-unavailable>
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 已成功检查目标公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
@@ -355,8 +355,8 @@ Agent 必须回复:
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
@@ -407,8 +407,8 @@ Agent 必须回复:
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
@@ -419,6 +419,6 @@ Agent 必须回复:
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
|
||||
@@ -38,11 +38,11 @@ Risk / Structure: `R2` / `S2`
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
@@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2`
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
@@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2`
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;Drive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
@@ -91,21 +91,23 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap data;Drive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
|
||||
| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata,包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
@@ -119,9 +121,9 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`、`folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
@@ -133,7 +135,7 @@ Wiki space / node 发现:
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token <folder_token> --type folder` 读取该文件夹自身设置。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
@@ -141,11 +143,11 @@ Drive folder 发现:
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
@@ -162,17 +164,17 @@ Drive folder 发现:
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`,URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
@@ -182,8 +184,8 @@ Drive folder 发现:
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
@@ -194,7 +196,7 @@ Drive folder 发现:
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
|
||||
@@ -75,15 +75,17 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`lark-slides-create.md`、`slides +create` |
|
||||
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符 |
|
||||
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -141,9 +143,9 @@ lark-cli auth login --domain slides
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
调用相关命令前必须读取相关的文档以了解命令的使用方式:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
|
||||
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
|
||||
@@ -189,20 +191,6 @@ lark-cli auth login --domain slides
|
||||
- 不要在任何位置使用 emoji 图标。
|
||||
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
@@ -220,17 +208,18 @@ Step 2: 生成大纲 → 写入 slide_plan.json
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- 读 lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 lark-slides-xml-presentation-slide-create.md 逐页添加
|
||||
- 图片按 lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -313,8 +302,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT,可选一步添加页面 |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml`) |
|
||||
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
@@ -331,12 +321,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:新建演示文稿用 `slides +create`,一步创建还是两步创建按 [`lark-slides-create.md`](references/lark-slides-create.md) 判断
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
63
skills/lark-slides/pie_chart_demo.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>资产处置类型分布</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<body fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<caption fontColor="rgba(155, 158, 162, 1)" fontSize="14"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(250, 248, 242, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="800" height="40" topLeftX="80" topLeftY="40" type="text">
|
||||
<content textType="headline" fontSize="24" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)" bold="true">
|
||||
<p>资产处置类型分布</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="700" height="440" topLeftX="130" topLeftY="80">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" category="false" value="true" percentage="true" fontSize="14" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1">
|
||||
<chartSectors innerRadius="0.5" offsetRadius="0" startAngle="90"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="right" fontSize="16" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="类型">股权处置,土地经营权,停车泊位,供水污水,房产处置,保障房,林水经营</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="规模">24,19,8.5,7.5,5,4.15,2.7</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(178, 34, 34)"/>
|
||||
<color value="rgb(205, 50, 50)"/>
|
||||
<color value="rgb(224, 80, 80)"/>
|
||||
<color value="rgb(238, 110, 110)"/>
|
||||
<color value="rgb(218, 165, 32)"/>
|
||||
<color value="rgb(238, 195, 55)"/>
|
||||
<color value="rgb(248, 222, 100)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
52
skills/lark-slides/pie_chart_slide.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(250, 248, 242, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="800" height="40" topLeftX="80" topLeftY="40" type="text">
|
||||
<content textType="headline" fontSize="24" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)" bold="true">
|
||||
<p>资产处置类型分布</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="700" height="440" topLeftX="130" topLeftY="80">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" category="false" value="true" percentage="true" fontSize="14" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1">
|
||||
<chartSectors innerRadius="0.5" offsetRadius="0" startAngle="90"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="right" fontSize="16" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="类型">股权处置,土地经营权,停车泊位,供水污水,房产处置,保障房,林水经营</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="规模">24,19,8.5,7.5,5,4.15,2.7</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(178, 34, 34)"/>
|
||||
<color value="rgb(205, 50, 50)"/>
|
||||
<color value="rgb(224, 80, 80)"/>
|
||||
<color value="rgb(238, 110, 110)"/>
|
||||
<color value="rgb(218, 165, 32)"/>
|
||||
<color value="rgb(238, 195, 55)"/>
|
||||
<color value="rgb(248, 222, 100)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
@@ -3,13 +3,22 @@
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
- 禁止:从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload。
|
||||
- 推荐:提交源直接就是单页 <slide> XML;+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
|
||||
presentation 动态拆出来的数组。
|
||||
提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析、拆分、重序列化出 slide 数组再提交。
|
||||
|
||||
- 最稳:复杂 deck 默认空 deck + 单页 slide create,每次只提交一个 <slide>。
|
||||
本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides,用 `drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [lark-slides-pptx-template-workflows.md](lark-slides-pptx-template-workflows.md)。
|
||||
|
||||
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
|
||||
## 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md) 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md),必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -194,14 +194,13 @@
|
||||
<xs:simpleType name="FontSizeType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体大小, 使用正整数, 单位px
|
||||
示例:12, 14, 16, 18, 20, 24, 28, 32 等
|
||||
字体大小, 浮点数, 范围 [1, 4000], 单位px
|
||||
示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:positiveInteger">
|
||||
<xs:minInclusive value="6"/>
|
||||
<xs:maxInclusive value="400"/>
|
||||
<xs:pattern value="[0-9]+"/>
|
||||
<xs:restriction base="xs:double">
|
||||
<xs:minInclusive value="1"/>
|
||||
<xs:maxInclusive value="4000"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
@@ -211,6 +210,52 @@
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="AutoStartAtType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
有序列表起始编号, 取值范围 [1, 32767]
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:positiveInteger">
|
||||
<xs:minInclusive value="1"/>
|
||||
<xs:maxInclusive value="32767"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="BulletSizeType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
列表符号大小, 二选一:
|
||||
- 百分比字符串(相对于文本字号), 取值范围 25%-400%, 如 "100%"
|
||||
- 绝对像素值, 取值范围 6-400, 如 "14"
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:union>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="(2[5-9]|[3-9][0-9]|[1-3][0-9]{2}|400)%"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="[6-9]|[1-9][0-9]|[1-3][0-9]{2}|400"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:union>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="BulletCharType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
自定义列表符号, 如 "★", "→", "✓", "◆", 也支持 emoji
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:minLength value="1"/>
|
||||
<xs:maxLength value="8"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- 文本类型枚举 -->
|
||||
<xs:simpleType name="TextType">
|
||||
<xs:annotation>
|
||||
@@ -232,6 +277,35 @@
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- 动态文本字段类型枚举 -->
|
||||
<xs:simpleType name="FieldType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
动态文本字段类型:
|
||||
- slidenum: 当前幻灯片页码
|
||||
- datetime: 默认日期时间格式
|
||||
- datetime1-datetime13: 预定义日期时间格式
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="slidenum"><xs:annotation><xs:documentation>当前幻灯片页码</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime"><xs:annotation><xs:documentation>浏览器默认日期格式, 例如 2026/7/14</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime1"><xs:annotation><xs:documentation>日期格式 M/D/YYYY, 例如 10/12/2007</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime2"><xs:annotation><xs:documentation>日期格式 dddd, MMMM D, YYYY, 例如 Friday, October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime3"><xs:annotation><xs:documentation>日期格式 D MMMM YYYY, 例如 12 October 2007</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime4"><xs:annotation><xs:documentation>日期格式 MMMM D, YYYY, 例如 October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime5"><xs:annotation><xs:documentation>日期格式 D-MMM-YY, 例如 12-Oct-07</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime6"><xs:annotation><xs:documentation>日期格式 MMMM YY, 例如 October 07</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime7"><xs:annotation><xs:documentation>日期格式 MMM-YY, 例如 Oct-07</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime8"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm A, 例如 10/12/2007 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime9"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm:ss A, 例如 10/12/2007 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime10"><xs:annotation><xs:documentation>时间格式 HH:mm, 例如 16:28</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime11"><xs:annotation><xs:documentation>时间格式 HH:mm:ss, 例如 16:28:34</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime12"><xs:annotation><xs:documentation>时间格式 h:mm A, 例如 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="datetime13"><xs:annotation><xs:documentation>时间格式 h:mm:ss A, 例如 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- 文本对齐 -->
|
||||
<xs:simpleType name="TextAlignType">
|
||||
<xs:restriction base="xs:string">
|
||||
@@ -781,21 +855,64 @@
|
||||
<xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<!-- 裁剪方位枚举类型 -->
|
||||
<xs:simpleType name="CropAnchorType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
裁剪方位枚举, 用于指定保留原图的哪个区域
|
||||
|
||||
- top: 保留顶部, 裁掉底部多余部分
|
||||
- bottom: 保留底部, 裁掉顶部多余部分
|
||||
- left: 保留左侧, 裁掉右侧多余部分
|
||||
- right: 保留右侧, 裁掉左侧多余部分
|
||||
|
||||
居中场景不需要设置 anchor, 不设置 offset 即为默认居中裁剪
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="top"><xs:annotation><xs:documentation>保留顶部, 裁掉底部多余部分</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="bottom"><xs:annotation><xs:documentation>保留底部, 裁掉顶部多余部分</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="left"><xs:annotation><xs:documentation>保留左侧, 裁掉右侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="right"><xs:annotation><xs:documentation>保留右侧, 裁掉左侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<!-- 裁剪类型定义 -->
|
||||
<xs:complexType name="CropType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
裁剪配置: 原图填充到预裁剪区域,再根据offset裁出最终尺寸
|
||||
裁剪配置: 将原图裁剪到目标尺寸 (img 元素的 width × height)
|
||||
|
||||
可选属性:
|
||||
type: 裁剪形状,默认rect
|
||||
leftOffset, rightOffset, topOffset, bottomOffset: 边缘偏移量(px)。正值向内裁剪,负值向外扩展留白,0值对齐边缘
|
||||
presetHandlers: 控制点配置,对应ECMA预设形状的控制点。单个或多个数字,多个用逗号分隔。示例: type="rect"且presetHandlers="60"时为圆角矩形,圆角半径60px
|
||||
type: 裁剪形状, 默认 rect
|
||||
anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
|
||||
leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
|
||||
presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
|
||||
示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px
|
||||
|
||||
说明: 指定offset时,若预裁剪尺寸与原图比例不一致会产生拉伸变形。无法确定原图比例时,不要指定offset
|
||||
【推荐用法】使用 anchor 指定裁剪方位:
|
||||
- 不设置 anchor 时: 默认按图片居中裁剪
|
||||
- 设置 anchor 时: 按指定方位裁剪, 例如 anchor="top" 表示保留顶部、裁掉底部多余部分
|
||||
- 使用 anchor 后, 不需要再设置 offset
|
||||
- anchor 模式下原图按等比缩放后裁剪, 不会发生拉伸或压缩
|
||||
|
||||
【进阶用法】使用 offset 精细控制裁剪边界:
|
||||
- 适用于用户在编辑器中手动调整裁剪、或从外部协议导入的场景
|
||||
- 原图先填充到预裁剪区域, 再根据 offset 从四边裁出最终尺寸
|
||||
- 注意: 若预裁剪尺寸与原图比例不一致会产生拉伸变形; 无法确定原图比例时, 不要指定 offset
|
||||
|
||||
【优先级】
|
||||
如果同时设置了 anchor 和 offset, 以 anchor 为准, offset 被忽略
|
||||
|
||||
典型用法:
|
||||
<crop/> 居中裁剪 (默认行为)
|
||||
<crop anchor="top"/> 保留顶部
|
||||
<crop anchor="left"/> 保留左侧
|
||||
<crop type="rect" presetHandlers="60"/> 圆角矩形裁剪, 默认居中
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="type" type="sml:ShapeType" use="optional" default="rect"/>
|
||||
<xs:attribute name="anchor" type="sml:CropAnchorType" use="optional"/>
|
||||
<xs:attribute name="leftOffset" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="rightOffset" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="topOffset" type="xs:double" use="optional"/>
|
||||
@@ -1042,6 +1159,10 @@
|
||||
- underline: content 级别是否下划线
|
||||
- list: content 级别列表类型 bullet/number
|
||||
- listStyle: content 级别列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400)如 "14"
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 <p>/<ol> 可通过自身 autoStartAt 重置
|
||||
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
|
||||
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
|
||||
- autoFit: 控制文本编辑溢出时处理策略
|
||||
- baseline: 上标/下标, 相较于文本基线的偏移量
|
||||
@@ -1049,6 +1170,13 @@
|
||||
|
||||
注意:如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
|
||||
|
||||
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
|
||||
<content autoStartAt="5">
|
||||
<p list="number">A</p> <!-- A=5, 继承 content 初始值 -->
|
||||
<p list="number" autoStartAt="10">B</p> <!-- B=10, 本段显式重置 -->
|
||||
<p list="number">C</p> <!-- C=11, 沿用前序计数器递增 -->
|
||||
</content>
|
||||
|
||||
子元素:
|
||||
- p: 段落元素
|
||||
- ul: 无序列表元素
|
||||
@@ -1085,6 +1213,10 @@
|
||||
<xs:attribute name="underline" type="xs:boolean" />
|
||||
<xs:attribute name="list" type="sml:ListType" default="none"/>
|
||||
<xs:attribute name="listStyle" type="sml:ListStyleType" />
|
||||
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
|
||||
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
|
||||
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
|
||||
<xs:attribute name="anchorCenter" type="xs:boolean" default="false" /> <!-- 控制竖排文字是否在垂直方向保持居中 -->
|
||||
<xs:attribute name="autoFit" type="sml:AutoFitType" default="no-auto-fit" />
|
||||
<xs:attribute name="wrap" type="xs:boolean" default="true" />
|
||||
@@ -1096,9 +1228,9 @@
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
段落容器, 支持富文本内容
|
||||
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline)
|
||||
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline/formula/field)
|
||||
内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
|
||||
元素自嵌套:除a元素外,其余内联元素支持自身嵌套,当shadow和outline自嵌套时,渲染效果遵循就近原则,以内层定义的样式为准
|
||||
元素自嵌套:除a/formula元素外,其余内联元素支持自身嵌套,当shadow和outline自嵌套时,渲染效果遵循就近原则,以内层定义的样式为准
|
||||
|
||||
空格处理规则:
|
||||
- 文本内的连续空格会被合并为单个空格
|
||||
@@ -1119,6 +1251,8 @@
|
||||
- a: 超链接
|
||||
- shadow: 文本阴影
|
||||
- outline: 文本轮廓
|
||||
- formula: 科学公式(支持数学、物理等)
|
||||
- field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
|
||||
属性说明:
|
||||
- textAlign: 文本对齐方式
|
||||
- lineSpacing: 行间距
|
||||
@@ -1127,6 +1261,10 @@
|
||||
- level: 段落级别, 取值范围 [1,10]
|
||||
- list: 列表类型(bullet/number)
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
|
||||
- bulletChar: 自定义列表符号字符, 可选
|
||||
- marginLeft: 段落左侧缩进宽度
|
||||
- indent: 首行缩进宽度
|
||||
</xs:documentation>
|
||||
@@ -1134,6 +1272,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1142,6 +1281,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
<xs:attribute name="textAlign" type="sml:TextAlignType" />
|
||||
<xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
|
||||
@@ -1151,6 +1291,10 @@
|
||||
<xs:attribute name="level" type="sml:LevelType" default="1"/>
|
||||
<xs:attribute name="list" type="sml:ListType" default="none"/>
|
||||
<xs:attribute name="listStyle" type="sml:ListStyleType"/>
|
||||
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
|
||||
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
|
||||
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
|
||||
<xs:attribute name="marginLeft" type="xs:double" use="optional" />
|
||||
<xs:attribute name="indent" type="sml:NonNegativeDouble" use="optional"/>
|
||||
</xs:complexType>
|
||||
@@ -1160,7 +1304,14 @@
|
||||
<!-- 无序列表 -->
|
||||
<xs:element name="ul">
|
||||
<xs:annotation>
|
||||
<xs:documentation>无序列表</xs:documentation>
|
||||
<xs:documentation>
|
||||
无序列表
|
||||
属性说明:
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
@@ -1173,13 +1324,23 @@
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="listStyle" type="sml:UnorderedListStyle" default="circle-hollow-square"/>
|
||||
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
|
||||
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
<!-- 有序列表 -->
|
||||
<xs:element name="ol">
|
||||
<xs:annotation>
|
||||
<xs:documentation>有序列表, 可指定序号</xs:documentation>
|
||||
<xs:documentation>
|
||||
有序列表, 可指定序号
|
||||
属性说明:
|
||||
- listStyle: 列表样式
|
||||
- bulletColor: 列表符号颜色(纯色), 可选
|
||||
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
|
||||
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 <li@index> 可覆盖单项编号
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
@@ -1193,6 +1354,9 @@
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="listStyle" type="sml:OrderedListStyle" default="number-lower-alpha-lower-roman"/>
|
||||
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
|
||||
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
@@ -1383,7 +1547,7 @@
|
||||
alpha: 不透明度[0, 1]
|
||||
|
||||
可选子元素:
|
||||
crop: 裁剪。无标签或所有offset未设置时从左上角自适应裁到width×height
|
||||
crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
|
||||
reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
|
||||
shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
|
||||
border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
|
||||
@@ -1500,7 +1664,7 @@
|
||||
td 子元素:
|
||||
- borderTop/borderRight/borderBottom/borderLeft: 单元格边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(实线边框, 颜色为rgba(221, 222, 223, 1), 宽度为1)
|
||||
- fill: 单元格填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(255, 255, 255, 1))
|
||||
- content: 单元格内容
|
||||
- content: 单元格内容。内容默认不反向修改表格几何尺寸; 当内容高度大于当前行高时, 需要手动修改行高
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
@@ -1558,13 +1722,15 @@
|
||||
<xs:complexType/>
|
||||
</xs:element>
|
||||
|
||||
<xs:element name="strong">
|
||||
<xs:element name="field">
|
||||
<xs:annotation>
|
||||
<xs:documentation>粗体/加重文本</xs:documentation>
|
||||
<xs:documentation>
|
||||
动态文本字段。
|
||||
type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1574,6 +1740,70 @@
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
</xs:choice>
|
||||
<xs:attribute name="type" type="sml:FieldType" use="required"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
|
||||
|
||||
<xs:element name="formula">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
通用公式元素。
|
||||
用于展示各类科学公式。
|
||||
|
||||
结构说明:
|
||||
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
|
||||
- 当前版本支持格式:<latex>
|
||||
|
||||
示例:
|
||||
- 基础公式:
|
||||
<formula>
|
||||
<latex><![CDATA[ E = mc^2 ]]></latex>
|
||||
</formula>
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType>
|
||||
<xs:choice minOccurs="1" maxOccurs="1">
|
||||
<xs:element name="latex">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
LaTeX 格式的公式内容。
|
||||
本元素包含的 LaTeX 字符串必须严格符合附件中定义的宏集范围。
|
||||
|
||||
内容语法:
|
||||
- 语法范围:仅使用附件白名单中明确支持的宏。
|
||||
- 表达建议:优先使用基础运算符、分式(\frac)、根号(\sqrt)、矩阵(matrix)等标准数学环境。
|
||||
- 格式要求:必须使用 CDATA 包裹内容,且 CDATA 内部严禁进行 XML 转义(如 &lt;, &amp;)。
|
||||
- 空白处理:解析器将保留 CDATA 内的所有换行和缩进,建议利用此特性保持 LaTeX 源码的结构化和可读性。
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
<xs:element name="strong">
|
||||
<xs:annotation>
|
||||
<xs:documentation>粗体/加重文本</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
<xs:element ref="sml:span"/>
|
||||
<xs:element ref="sml:del"/>
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
|
||||
@@ -1590,6 +1820,7 @@
|
||||
<xs:extension base="sml:ShadowType">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1598,6 +1829,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
@@ -1617,6 +1849,7 @@
|
||||
<xs:extension base="sml:OutlineType">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1625,6 +1858,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
@@ -1638,6 +1872,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1646,6 +1881,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
@@ -1657,6 +1893,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1665,6 +1902,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
@@ -1676,6 +1914,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1684,6 +1923,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
@@ -1698,6 +1938,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1706,6 +1947,7 @@
|
||||
<xs:element ref="sml:a"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
<xs:attribute name="color" type="sml:Color" use="optional"/>
|
||||
<xs:attribute name="backgroundColor" type="sml:Color" use="optional"/>
|
||||
@@ -1729,6 +1971,7 @@
|
||||
<xs:complexType mixed="true">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element ref="sml:br"/>
|
||||
<xs:element ref="sml:formula"/>
|
||||
<xs:element ref="sml:strong"/>
|
||||
<xs:element ref="sml:em"/>
|
||||
<xs:element ref="sml:u"/>
|
||||
@@ -1736,6 +1979,7 @@
|
||||
<xs:element ref="sml:del"/>
|
||||
<xs:element ref="sml:shadow"/>
|
||||
<xs:element ref="sml:outline"/>
|
||||
<xs:element ref="sml:field"/>
|
||||
</xs:choice>
|
||||
<xs:attribute name="href" use="required">
|
||||
<xs:simpleType>
|
||||
@@ -1823,36 +2067,116 @@
|
||||
<!-- 有序列表样式枚举 -->
|
||||
<xs:simpleType name="OrderedListStyle">
|
||||
<xs:annotation>
|
||||
<xs:documentation>有序列表样式</xs:documentation>
|
||||
<xs:documentation>
|
||||
有序列表样式
|
||||
|
||||
分为两类:
|
||||
1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马,超过层级数后循环
|
||||
2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<!-- 复合样式(按层级循环) -->
|
||||
<xs:enumeration value="number-lower-alpha-lower-roman"><xs:annotation><xs:documentation>1. a. i. - 数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="number-lower-alpha-lower-roman-paren"><xs:annotation><xs:documentation>1) a) i) - 带括号版本</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="hierarchical-number"><xs:annotation><xs:documentation>1. 1.1. 1.1.1. - 多级数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="upper-alpha-lower-alpha-lower-roman"><xs:annotation><xs:documentation>A. a. i. - 大写字母/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="upper-roman-upper-alpha-number"><xs:annotation><xs:documentation>I. A. 1. - 大写罗马/大写字母/数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="zero-padded-lower-alpha-lower-roman"><xs:annotation><xs:documentation>01. a. i. - 补零数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation> 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation>圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="lower-alpha-paren"><xs:annotation><xs:documentation>a) b) c) - 小写字母带括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="lower-alpha-dot"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="chinese-formal"><xs:annotation><xs:documentation>一、二、三、 - 中文数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
|
||||
<!-- 单一样式(所有层级使用同一格式,不随层级循环) -->
|
||||
<!-- 拉丁字母 Latin -->
|
||||
<xs:enumeration value="alpha-lc-paren-both"><xs:annotation><xs:documentation>(a) (b) (c) - 小写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="alpha-uc-paren-both"><xs:annotation><xs:documentation>(A) (B) (C) - 大写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="alpha-lc-paren-r"><xs:annotation><xs:documentation>a) b) c) - 小写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="alpha-uc-paren-r"><xs:annotation><xs:documentation>A) B) C) - 大写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="alpha-lc-period"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="alpha-uc-period"><xs:annotation><xs:documentation>A. B. C. - 大写字母带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 阿拉伯数字 Arabic Numeral -->
|
||||
<xs:enumeration value="arabic-paren-both"><xs:annotation><xs:documentation>(1) (2) (3) - 数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-paren-r"><xs:annotation><xs:documentation>1) 2) 3) - 数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-period"><xs:annotation><xs:documentation>1. 2. 3. - 数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-plain"><xs:annotation><xs:documentation>1 2 3 - 纯数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-db-period"><xs:annotation><xs:documentation>1.2.3.- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation>1 2 3 - 全角纯数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic1-minus"><xs:annotation><xs:documentation>أ- ب- ت- - 阿拉伯语字母(现代序)带后横线</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic2-minus"><xs:annotation><xs:documentation>-أ- -ب- -ج- - 阿拉伯语字母(Abjadi序)带双横线</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 罗马数字 Roman -->
|
||||
<xs:enumeration value="roman-lc-paren-both"><xs:annotation><xs:documentation>(i) (ii) (iii) - 小写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="roman-uc-paren-both"><xs:annotation><xs:documentation>(I) (II) (III) - 大写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="roman-lc-paren-r"><xs:annotation><xs:documentation>i) ii) iii) - 小写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="roman-uc-paren-r"><xs:annotation><xs:documentation>I) II) III) - 大写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="roman-lc-period"><xs:annotation><xs:documentation>i. ii. iii. - 小写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="roman-uc-period"><xs:annotation><xs:documentation>I. II. III. - 大写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 圆圈数字 Circle -->
|
||||
<xs:enumeration value="circle-num-db-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="circle-num-wd-black-plain"><xs:annotation><xs:documentation>❶ ❷ ❸ - 实心圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="circle-num-wd-white-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字(1-10 循环, 字形与 circle-num-db-plain 相同但超过 10 后不降级为纯数字)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 东亚 East Asian -->
|
||||
<xs:enumeration value="ea1-chs-period"><xs:annotation><xs:documentation>一. 二. 三. - 简体中文带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-chs-plain"><xs:annotation><xs:documentation>一 二 三 - 简体中文</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-cht-period"><xs:annotation><xs:documentation>一. 二. 三. - 繁体中文带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-cht-plain"><xs:annotation><xs:documentation>一 二 三 - 繁体中文</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-jpn-chs-db-period"><xs:annotation><xs:documentation>一.二.三.- CJK汉字数字带全角点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-jpn-kor-plain"><xs:annotation><xs:documentation>一 二 三 - CJK汉字数字</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="ea1-jpn-kor-period"><xs:annotation><xs:documentation>一. 二. 三. - CJK汉字数字带半角点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 希伯来语 Hebrew -->
|
||||
<xs:enumeration value="hebrew2-minus"><xs:annotation><xs:documentation>א- ב- ג- - 希伯来字母带横线</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 泰语 Thai -->
|
||||
<xs:enumeration value="thai-alpha-period"><xs:annotation><xs:documentation>ก. ข. ค. - 泰语字母带点(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="thai-alpha-paren-r"><xs:annotation><xs:documentation>ก) ข) ค) - 泰语字母带右括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="thai-alpha-paren-both"><xs:annotation><xs:documentation>(ก) (ข) (ค) - 泰语字母带双括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="thai-num-period"><xs:annotation><xs:documentation>๑. ๒. ๓. - 泰语数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="thai-num-paren-r"><xs:annotation><xs:documentation>๑) ๒) ๓) - 泰语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="thai-num-paren-both"><xs:annotation><xs:documentation>(๑) (๒) (๓) - 泰语数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<!-- 印地语 Hindi -->
|
||||
<xs:enumeration value="hindi-alpha-period"><xs:annotation><xs:documentation>अ. आ. इ. - 印地语元音字母带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="hindi-num-period"><xs:annotation><xs:documentation>१. २. ३. - 印地语数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="hindi-num-paren-r"><xs:annotation><xs:documentation>१) २) ३) - 印地语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="hindi-alpha1-period"><xs:annotation><xs:documentation>क. ख. ग. - 印地语辅音字母带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- 无序列表样式枚举 -->
|
||||
<xs:simpleType name="UnorderedListStyle">
|
||||
<xs:annotation>
|
||||
<xs:documentation>无序列表样式</xs:documentation>
|
||||
<xs:documentation>
|
||||
无序列表样式
|
||||
|
||||
分为两类:
|
||||
1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形,超过层级数后循环
|
||||
2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<!-- 复合样式(按层级循环) -->
|
||||
<xs:enumeration value="circle-hollow-square"><xs:annotation><xs:documentation>实心圆 空心圆 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>棱形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>菱形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="hollow-square-all"><xs:annotation><xs:documentation>空心方形 空心方形 空心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心棱形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心菱形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="star-hollow-circle-square"><xs:annotation><xs:documentation>实心五角星 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="triangle-hollow-circle-square"><xs:annotation><xs:documentation>三角形 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="solid-square-all"><xs:annotation><xs:documentation>实心方形 实心方形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="solid-diamond-all"><xs:annotation><xs:documentation>实心菱形 实心菱形 实心菱形</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="check-all"><xs:annotation><xs:documentation>对勾 对勾 对勾</xs:documentation></xs:annotation></xs:enumeration>
|
||||
|
||||
<!-- 单一样式(所有层级使用同一图标,不随层级循环) -->
|
||||
<xs:enumeration value="pptx-circle"><xs:annotation><xs:documentation>● 实心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-square"><xs:annotation><xs:documentation>■ 方块(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-diamond"><xs:annotation><xs:documentation>◆ 菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-square-empty"><xs:annotation><xs:documentation>□ 空心方框(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-check"><xs:annotation><xs:documentation>✓ 对勾(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-triangle"><xs:annotation><xs:documentation>► 右三角(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-bullet"><xs:annotation><xs:documentation>• 小圆点(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
|
||||
<xs:enumeration value="pptx-circle-empty"><xs:annotation><xs:documentation>○ 空心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-diamond-empty"><xs:annotation><xs:documentation>◇ 空心菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-arrow-right"><xs:annotation><xs:documentation>➔ 右箭头(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-star"><xs:annotation><xs:documentation>★ 星形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="pptx-square-shadow"><xs:annotation><xs:documentation>❑ 带右下阴影的 3D 方框(所有层级,对应 PPTX Wingdings 'q')</xs:documentation></xs:annotation></xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
@@ -2096,6 +2420,19 @@
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="ChartGradientKindType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表渐变类型
|
||||
可选值: linear(线性渐变) | radial(径向渐变)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="linear"/>
|
||||
<xs:enumeration value="radial"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="ChartRadarShapeType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
@@ -2217,6 +2554,7 @@
|
||||
|
||||
属性:
|
||||
- textAlign: 文本对齐方式(left|center|right), 默认left
|
||||
- fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
|
||||
- fontSize: 字号大小
|
||||
- bold: 是否加粗
|
||||
- italic: 是否斜体, 默认false
|
||||
@@ -2230,6 +2568,7 @@
|
||||
<xs:complexContent>
|
||||
<xs:extension base="sml:ChartFontStyleType">
|
||||
<xs:attribute name="textAlign" type="sml:ChartTextAlignType" use="optional" default="left"/>
|
||||
<xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
@@ -2298,10 +2637,10 @@
|
||||
图表背景配置
|
||||
|
||||
属性:
|
||||
- color: 背景颜色, 默认透明 rgba(0,0,0,0)
|
||||
- color: 背景颜色,省略时使用图表默认背景;无填充可使用透明色 rgba(0,0,0,0)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartBorderType">
|
||||
@@ -2311,7 +2650,7 @@
|
||||
|
||||
属性:
|
||||
- color: 边框颜色,默认 rgb(222, 224, 227)
|
||||
- width: 边框宽度(像素), 默认 1
|
||||
- width: 边框宽度(像素), 默认 1;无边框可设置为0,或不设置chartBorder
|
||||
- style: 边框样式(solid|dashed|dotted), 默认 solid
|
||||
- radius: 圆角半径(像素), 默认 6
|
||||
</xs:documentation>
|
||||
@@ -2322,6 +2661,61 @@
|
||||
<xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartGradientStopType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表渐变色标
|
||||
|
||||
属性:
|
||||
- offset: 色标位置比例[0,1]
|
||||
- color: 色标颜色
|
||||
- opacity: 色标透明度[0,1]
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="offset" type="sml:RatioType" use="required"/>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="required"/>
|
||||
<xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartGradientStopsType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表渐变色标列表,至少需要2个色标
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartGradientType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表渐变配置
|
||||
|
||||
属性:
|
||||
- type: 渐变类型(linear|radial)
|
||||
- x0/y0/x1/y1: 线性渐变起止点坐标
|
||||
- r0/r1: 径向渐变半径
|
||||
- gradientMethod: 渐变算法/插值方式
|
||||
|
||||
子元素:
|
||||
- stops: 渐变色标列表
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
|
||||
<xs:attribute name="x0" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="y0" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="x1" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="y1" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="r0" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="r1" type="xs:double" use="optional"/>
|
||||
<xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartColorThemeType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
@@ -2431,12 +2825,16 @@
|
||||
- size: 该系列所有点的大小
|
||||
|
||||
子元素:
|
||||
- fillGradient: 该系列所有点的填充渐变(可选)
|
||||
- strokeGradient: 该系列所有点的边框/描边渐变(可选)
|
||||
- chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="sml:ChartGlobalPointsType">
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
<xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:complexType>
|
||||
<xs:annotation>
|
||||
@@ -2448,8 +2846,14 @@
|
||||
- color: 该点的颜色
|
||||
- shape: 该点的形状(circle|square|triangle|diamond|rect)
|
||||
- size: 该点的大小(像素)
|
||||
|
||||
子元素:
|
||||
- fillGradient: 该点填充渐变(可选)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
|
||||
@@ -2462,7 +2866,7 @@
|
||||
</xs:complexType>
|
||||
|
||||
<!-- 线条配置 -->
|
||||
<xs:complexType name="ChartLineType">
|
||||
<xs:complexType name="ChartGlobalLineType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表全局线条配置(第一层:所有系列的默认样式)
|
||||
@@ -2479,8 +2883,27 @@
|
||||
<xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartSeriesLineType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表系列线条配置(第二层:单系列统一配置)
|
||||
继承ChartGlobalLineType的所有属性
|
||||
|
||||
子元素:
|
||||
- strokeGradient: 该系列线条渐变(可选)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="sml:ChartGlobalLineType">
|
||||
<xs:sequence>
|
||||
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
|
||||
<!-- 面积配置 -->
|
||||
<xs:complexType name="ChartAreaType">
|
||||
<xs:complexType name="ChartGlobalAreaType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表全局面积配置(第一层:所有系列的默认填充样式)
|
||||
@@ -2493,6 +2916,25 @@
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="ChartSeriesAreaType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
图表系列面积配置(第二层:单系列统一配置)
|
||||
继承ChartGlobalAreaType的所有属性
|
||||
|
||||
子元素:
|
||||
- fillGradient: 该系列面积填充渐变(可选)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="sml:ChartGlobalAreaType">
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
|
||||
<!-- 柱子配置 -->
|
||||
<xs:complexType name="ChartGlobalBarsType">
|
||||
<xs:annotation>
|
||||
@@ -2533,12 +2975,16 @@
|
||||
- borderStyle: 该系列所有柱子的边框样式
|
||||
|
||||
子元素:
|
||||
- fillGradient: 该系列所有柱子的填充渐变(可选)
|
||||
- strokeGradient: 该系列所有柱子的边框渐变(可选)
|
||||
- chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:complexContent>
|
||||
<xs:extension base="sml:ChartGlobalBarsType">
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
<xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:complexType>
|
||||
<xs:annotation>
|
||||
@@ -2551,8 +2997,14 @@
|
||||
- borderColor: 该柱子的边框颜色
|
||||
- borderWidth: 该柱子的边框宽度(像素)
|
||||
- borderStyle: 该柱子的边框样式(solid|dashed|dotted)
|
||||
|
||||
子元素:
|
||||
- fillGradient: 该柱子的填充渐变(可选)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
|
||||
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
|
||||
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
|
||||
@@ -2577,8 +3029,14 @@
|
||||
- offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
|
||||
- borderColor: 扇区边框颜色
|
||||
- color: 扇区填充颜色
|
||||
|
||||
子元素:
|
||||
- fillGradient: 扇区填充渐变(可选)
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
|
||||
<xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
|
||||
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
|
||||
@@ -2598,10 +3056,12 @@
|
||||
- startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0
|
||||
|
||||
子元素:
|
||||
- fillGradient: 所有扇区的统一填充渐变(可选)
|
||||
- chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
|
||||
<xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
|
||||
@@ -2648,8 +3108,8 @@
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
|
||||
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
|
||||
<xs:element name="chartArea" type="sml:ChartAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
|
||||
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
|
||||
<xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
|
||||
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
|
||||
@@ -2850,8 +3310,8 @@
|
||||
</xs:annotation>
|
||||
<xs:all>
|
||||
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
|
||||
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
|
||||
<xs:element name="chartAreas" type="sml:ChartAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
|
||||
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
|
||||
<xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
|
||||
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
|
||||
<xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>
|
||||
|
||||
@@ -129,6 +129,15 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<a>`
|
||||
- `<shadow>`
|
||||
- `<outline>`
|
||||
- `<formula>`
|
||||
|
||||
公式写法:
|
||||
|
||||
```xml
|
||||
<p>公式:<formula><latex><![CDATA[ E = mc^2 ]]></latex></formula></p>
|
||||
```
|
||||
|
||||
`<formula>` 是内联元素;当前只支持一个 `<latex>` 子元素。LaTeX 内容必须放在 `CDATA` 中,且 `CDATA` 内不要写 XML 转义;宏只使用服务端支持范围内的写法,优先用基础运算符、`\frac`、`\sqrt`、`matrix`。
|
||||
|
||||
示例:
|
||||
|
||||
@@ -312,6 +321,36 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
`<chart>` 直接子元素必须有 `<chartPlotArea>`(绘图区)和 `<chartData>`(数据);`<chartTitle>`、`<chartSubTitle>`、`<chartStyle>`、`<chartLegend>`、`<chartTooltip>` 可选,如果想不展示标题、副标题、图例或悬浮提示,省略相应元素标签即可。
|
||||
|
||||
`<chartStyle>` 常用子元素:
|
||||
|
||||
- `<chartBackground>`:`color` 省略时由渲染端决定默认背景;需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
|
||||
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素
|
||||
|
||||
#### 图表渐变 `<fillGradient>` / `<strokeGradient>`
|
||||
|
||||
图表支持渐变填充/描边,`<fillGradient>` 用于面积、柱子、数据点、扇区填充,`<strokeGradient>` 用于线条、数据点边框、柱子边框。渐变只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。
|
||||
|
||||
可挂载位置:
|
||||
|
||||
- 系列级:`<chartBars>` / `<chartPoints>` 支持 `<fillGradient>` 与 `<strokeGradient>`;`<chartLine>` 只支持 `<strokeGradient>`;`<chartArea>` / `<chartSectors>` 只支持 `<fillGradient>`
|
||||
- 单元素级:`<chartBar index="...">` / `<chartPoint index="...">` / `<chartSector index="...">` 只支持 `<fillGradient>`
|
||||
- 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>` 不支持渐变
|
||||
|
||||
结构要点:`type` 必填,可为 `linear` 或 `radial`;`linear` 用 `x0` / `y0` / `x1` / `y1`,`radial` 用 `r0` / `r1`;`<stops>` 至少包含 2 个 `<stop>`,`offset` 与 `opacity` 取值均为 `[0, 1]`。
|
||||
|
||||
```xml
|
||||
<chartSeries index="1">
|
||||
<chartBars>
|
||||
<fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
|
||||
<stops>
|
||||
<stop offset="0" color="rgb(28, 71, 120)"/>
|
||||
<stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
|
||||
</stops>
|
||||
</fillGradient>
|
||||
</chartBars>
|
||||
</chartSeries>
|
||||
```
|
||||
|
||||
隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`。
|
||||
|
||||
详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
|
||||
|
||||
186
skills/lark-slides/scripts/chart_overlay_harness.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Black-box harness for the `chart_external_overlay` lint rule.
|
||||
|
||||
Runs xml_text_overlap_lint over every fixture named in a manifest and compares
|
||||
the observed `chart_external_overlay` issues against each page's expectation.
|
||||
|
||||
Usage:
|
||||
python3 chart_overlay_harness.py --plan <plan-dir> [--input-xml <file>] [--out <dir>]
|
||||
|
||||
--plan directory holding manifest.json and slides/ (the fixtures).
|
||||
--input-xml optional single presentation XML to lint instead of per-fixture
|
||||
files (used to lint a server readback deck); pages are matched to
|
||||
the manifest by slide order.
|
||||
--out output directory for results.json and report.md (default: --plan).
|
||||
|
||||
Exit code is 0 when false-negatives == 0 and false-positives == 0, else 1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
import xml_text_overlap_lint as lint # noqa: E402
|
||||
|
||||
CODE = "chart_external_overlay"
|
||||
|
||||
|
||||
def chart_issues(slide_result: dict) -> list[dict]:
|
||||
return [i for i in slide_result.get("issues", []) if i.get("code") == CODE]
|
||||
|
||||
|
||||
def other_error_codes(slide_result: dict) -> list[str]:
|
||||
return sorted(
|
||||
{
|
||||
i.get("code")
|
||||
for i in slide_result.get("issues", [])
|
||||
if i.get("level") == "error" and i.get("code") != CODE
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def slides_from_presentation(xml: str) -> list[str]:
|
||||
return re.findall(r"<slide\b[\s\S]*?</slide>", xml)
|
||||
|
||||
|
||||
def lint_page(slide_xml: str) -> dict:
|
||||
wrapped = (
|
||||
'<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">'
|
||||
+ slide_xml
|
||||
+ "</presentation>"
|
||||
)
|
||||
result = lint.lint_xml(wrapped)
|
||||
return result["slides"][0] if result.get("slides") else {"issues": []}
|
||||
|
||||
|
||||
def evaluate(manifest: dict, plan_dir: Path, readback_xml: str | None) -> dict:
|
||||
pages = manifest["pages"]
|
||||
readback_slides = slides_from_presentation(readback_xml) if readback_xml else None
|
||||
rows = []
|
||||
fn = fp = unrelated = 0
|
||||
for idx, page in enumerate(pages):
|
||||
if readback_slides is not None:
|
||||
if idx >= len(readback_slides):
|
||||
rows.append({**_row_base(page), "status": "MISSING_IN_READBACK",
|
||||
"observed_issue_count": None})
|
||||
fn += 1 if page["expected_issue_count"] else 0
|
||||
continue
|
||||
slide_result = lint_page(readback_slides[idx])
|
||||
else:
|
||||
slide_xml = (plan_dir / page["fixture"]).read_text(encoding="utf-8")
|
||||
slide_result = lint_page(slide_xml)
|
||||
|
||||
issues = chart_issues(slide_result)
|
||||
observed = len(issues)
|
||||
expected = page["expected_issue_count"]
|
||||
others = other_error_codes(slide_result)
|
||||
if others:
|
||||
unrelated += 1
|
||||
|
||||
# A page is "correct" when observed presence matches expectation.
|
||||
expected_present = expected > 0
|
||||
observed_present = observed > 0
|
||||
if expected_present and not observed_present:
|
||||
status = "FALSE_NEGATIVE"
|
||||
fn += 1
|
||||
elif not expected_present and observed_present:
|
||||
status = "FALSE_POSITIVE"
|
||||
fp += 1
|
||||
elif expected_present and observed != expected:
|
||||
status = "COUNT_MISMATCH" # right presence, wrong number of chart issues
|
||||
else:
|
||||
status = "OK"
|
||||
|
||||
rows.append({
|
||||
**_row_base(page),
|
||||
"observed_issue_count": observed,
|
||||
"observed_elements": [i.get("elements") for i in issues],
|
||||
"unrelated_error_codes": others,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
return {
|
||||
"mode": "readback" if readback_xml else "fixture",
|
||||
"summary": {
|
||||
"page_count": len(pages),
|
||||
"false_negatives": fn,
|
||||
"false_positives": fp,
|
||||
"pages_with_unrelated_errors": unrelated,
|
||||
"pass": fn == 0 and fp == 0,
|
||||
},
|
||||
"pages": rows,
|
||||
}
|
||||
|
||||
|
||||
def _row_base(page: dict) -> dict:
|
||||
return {
|
||||
"case_id": page["case_id"],
|
||||
"slide_number": page["slide_number"],
|
||||
"chart_type": page.get("chart_type"),
|
||||
"rendered_occlusion": page.get("rendered_occlusion"),
|
||||
"rendered_occlusion_source": page.get("rendered_occlusion_source"),
|
||||
"expected_issue_count": page["expected_issue_count"],
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(results: dict) -> str:
|
||||
s = results["summary"]
|
||||
lines = [
|
||||
f"# chart_external_overlay black-box results ({results['mode']} mode)",
|
||||
"",
|
||||
f"- pages: **{s['page_count']}**",
|
||||
f"- false negatives: **{s['false_negatives']}**",
|
||||
f"- false positives: **{s['false_positives']}**",
|
||||
f"- pages with unrelated errors: **{s['pages_with_unrelated_errors']}**",
|
||||
f"- suite pass: **{s['pass']}**",
|
||||
"",
|
||||
"| # | case | type | occ | occ_src | exp | obs | unrelated | status |",
|
||||
"|--:|------|------|:---:|:-------:|:---:|:---:|-----------|--------|",
|
||||
]
|
||||
for r in results["pages"]:
|
||||
lines.append(
|
||||
f"| {r['slide_number']} | {r['case_id']} | {r.get('chart_type','')} | "
|
||||
f"{r.get('rendered_occlusion')} | {r.get('rendered_occlusion_source','')} | "
|
||||
f"{r['expected_issue_count']} | {r.get('observed_issue_count')} | "
|
||||
f"{','.join(r.get('unrelated_error_codes') or []) or '-'} | {r['status']} |"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plan", required=True)
|
||||
ap.add_argument("--input-xml")
|
||||
ap.add_argument("--out")
|
||||
args = ap.parse_args()
|
||||
|
||||
plan_dir = Path(args.plan).resolve()
|
||||
manifest = json.loads((plan_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
readback_xml = Path(args.input_xml).read_text(encoding="utf-8") if args.input_xml else None
|
||||
|
||||
results = evaluate(manifest, plan_dir, readback_xml)
|
||||
|
||||
out_dir = Path(args.out).resolve() if args.out else plan_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "_readback" if readback_xml else ""
|
||||
(out_dir / f"results{suffix}.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
(out_dir / f"report{suffix}.md").write_text(render_markdown(results), encoding="utf-8")
|
||||
|
||||
s = results["summary"]
|
||||
print(f"[{results['mode']}] pages={s['page_count']} FN={s['false_negatives']} "
|
||||
f"FP={s['false_positives']} unrelated={s['pages_with_unrelated_errors']} "
|
||||
f"pass={s['pass']}")
|
||||
return 0 if s["pass"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
642
skills/lark-slides/scripts/collect_width_data.py
Normal file
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect text width measurement data from slides presentation for character width estimation optimization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRESENTATION_ID = "BUFBsLX2ZlzyMTdprLicd7rpneg"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CSV_OUTPUT = SCRIPT_DIR / "width_measurement_data.csv"
|
||||
MD_OUTPUT = SCRIPT_DIR / "width_measurement_report.md"
|
||||
|
||||
|
||||
def estimate_character_width(character: str, font_size: int | float) -> int | float:
|
||||
if character.isspace():
|
||||
return font_size * 0.33
|
||||
if unicodedata.east_asian_width(character) in {"F", "W"}:
|
||||
return font_size
|
||||
return font_size * 0.55
|
||||
|
||||
|
||||
def estimate_text_width(text: str, font_size: int | float) -> int | float:
|
||||
return sum(estimate_character_width(character, font_size) for character in text)
|
||||
|
||||
|
||||
def extract_attribute(tag_source: str, name: str) -> str | None:
|
||||
match = re.search(
|
||||
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1) if match.group(1) is not None else match.group(2)
|
||||
|
||||
|
||||
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
raw = extract_attribute(tag_source, name)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
|
||||
def extract_bool_attribute(tag_source: str, name: str) -> bool:
|
||||
value = extract_attribute(tag_source, name)
|
||||
return value in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
|
||||
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
||||
if preserve_line_breaks:
|
||||
stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
|
||||
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
||||
stripped = stripped.replace(" ", " ")
|
||||
stripped = stripped.replace("&", "&")
|
||||
stripped = stripped.replace("<", "<")
|
||||
stripped = stripped.replace(">", ">")
|
||||
stripped = stripped.replace(""", '"')
|
||||
stripped = stripped.replace("'", "'")
|
||||
if preserve_line_breaks:
|
||||
return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
|
||||
return re.sub(r"\s+", " ", stripped).strip()
|
||||
|
||||
|
||||
def strip_xml_paragraphs(value: str) -> str:
|
||||
paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
|
||||
if paragraphs:
|
||||
return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
|
||||
return strip_xml(value, preserve_line_breaks=True)
|
||||
|
||||
|
||||
def normalize_text_no_whitespace(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def classify_text_type(text: str) -> str:
|
||||
has_chinese = bool(re.search(r"[\u4e00-\u9fff]", text))
|
||||
has_english = bool(re.search(r"[A-Za-z]", text))
|
||||
has_digit = bool(re.search(r"[0-9]", text))
|
||||
has_punctuation = bool(re.search(r"[^\u4e00-\u9fffA-Za-z0-9\s]", text))
|
||||
|
||||
categories = []
|
||||
if has_chinese:
|
||||
categories.append("中文")
|
||||
if has_english:
|
||||
categories.append("英文")
|
||||
if has_digit:
|
||||
categories.append("数字")
|
||||
if has_punctuation and not categories:
|
||||
categories.append("标点")
|
||||
if not categories:
|
||||
return "其他"
|
||||
return "+".join(categories)
|
||||
|
||||
|
||||
def extract_text_spans(content: str, default_font_size: int | float, default_font_family: str | None,
|
||||
default_bold: bool, default_italic: bool) -> list[dict[str, Any]]:
|
||||
spans = []
|
||||
|
||||
p_matches = list(re.finditer(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", content))
|
||||
if not p_matches:
|
||||
p_matches = [re.match(r"()([\s\S]*)", content)]
|
||||
|
||||
for p_match in p_matches:
|
||||
p_attrs, p_body = p_match.groups()
|
||||
p_text = strip_xml(p_body, preserve_line_breaks=True)
|
||||
if not p_text:
|
||||
continue
|
||||
|
||||
span_matches = list(re.finditer(r"<span\b([^>]*)>([\s\S]*?)</span\s*>", p_body))
|
||||
if not span_matches:
|
||||
span_text = strip_xml(p_body, preserve_line_breaks=True)
|
||||
if span_text:
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
font_family = extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
|
||||
spans.append({
|
||||
"text": span_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
else:
|
||||
last_end = 0
|
||||
for span_match in span_matches:
|
||||
span_attrs, span_body = span_match.groups()
|
||||
span_text = strip_xml(span_body, preserve_line_breaks=True)
|
||||
if not span_text:
|
||||
last_end = span_match.end()
|
||||
continue
|
||||
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
if extract_numeric_attribute(span_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(span_attrs, "fontSize")
|
||||
|
||||
font_family = extract_attribute(span_attrs, "fontFamily") or \
|
||||
extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(span_attrs, "latinFont") or extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(span_attrs, "eaFont") or extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(span_attrs, "csFont") or extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(span_attrs, "bold") or \
|
||||
extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(span_attrs, "italic") or \
|
||||
extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(span_attrs, "letterSpacing") or \
|
||||
extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
|
||||
spans.append({
|
||||
"text": span_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
last_end = span_match.end()
|
||||
|
||||
tail_text = strip_xml(p_body[last_end:], preserve_line_breaks=True)
|
||||
if tail_text:
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
font_family = extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
spans.append({
|
||||
"text": tail_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
return spans
|
||||
|
||||
|
||||
def extract_text_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements = []
|
||||
|
||||
for match in re.finditer(r"<(shape)\b([^>]*)>", slide_xml):
|
||||
kind, attrs = match.group(1), match.group(2)
|
||||
is_self_closing = attrs.rstrip().endswith("/")
|
||||
content = ""
|
||||
if kind in {"shape"} and not is_self_closing:
|
||||
close_index = slide_xml.find(f"</{kind}>", match.end())
|
||||
if close_index != -1:
|
||||
content = slide_xml[match.end() : close_index]
|
||||
|
||||
element_type = extract_attribute(attrs, "type")
|
||||
if element_type != "text":
|
||||
continue
|
||||
|
||||
element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
|
||||
x = extract_numeric_attribute(attrs, "topLeftX")
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if any(v is None for v in [x, y, width, height]):
|
||||
continue
|
||||
|
||||
content_attrs_match = re.search(r"<content\b([^>]*)>", content)
|
||||
content_attrs = content_attrs_match.group(1) if content_attrs_match else ""
|
||||
|
||||
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = extract_numeric_attribute(attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = 16
|
||||
|
||||
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
|
||||
latin_font = extract_attribute(content_attrs, "latinFont")
|
||||
ea_font = extract_attribute(content_attrs, "eaFont")
|
||||
cs_font = extract_attribute(content_attrs, "csFont")
|
||||
bold = extract_bool_attribute(content_attrs, "bold") or extract_bool_attribute(attrs, "bold")
|
||||
italic = extract_bool_attribute(content_attrs, "italic") or extract_bool_attribute(attrs, "italic")
|
||||
letter_spacing = extract_numeric_attribute(content_attrs, "letterSpacing")
|
||||
|
||||
wrap = extract_attribute(content_attrs, "wrap")
|
||||
if wrap is None:
|
||||
wrap = "true"
|
||||
auto_fit = extract_attribute(content_attrs, "autoFit")
|
||||
if auto_fit is None:
|
||||
auto_fit = "none"
|
||||
text_align = extract_attribute(content_attrs, "textAlign")
|
||||
vertical_align = extract_attribute(content_attrs, "verticalAlign") or "middle"
|
||||
|
||||
padding_left = extract_numeric_attribute(content_attrs, "paddingLeft") or 0
|
||||
padding_right = extract_numeric_attribute(content_attrs, "paddingRight") or 0
|
||||
padding_top = extract_numeric_attribute(content_attrs, "paddingTop") or 0
|
||||
padding_bottom = extract_numeric_attribute(content_attrs, "paddingBottom") or 0
|
||||
|
||||
full_text = strip_xml_paragraphs(content)
|
||||
clean_text = normalize_text_no_whitespace(full_text)
|
||||
if not clean_text:
|
||||
continue
|
||||
|
||||
content_inner = ""
|
||||
if content_attrs_match:
|
||||
content_start = content_attrs_match.end()
|
||||
content_end = content.find("</content>", content_start)
|
||||
if content_end != -1:
|
||||
content_inner = content[content_start:content_end]
|
||||
|
||||
spans = extract_text_spans(content_inner, font_size, font_family, bold, italic)
|
||||
|
||||
hard_lines = full_text.split("\n")
|
||||
max_line_estimated_width = 0
|
||||
max_line_text = ""
|
||||
for line in hard_lines:
|
||||
line_clean = normalize_text_no_whitespace(line)
|
||||
if not line_clean:
|
||||
continue
|
||||
line_width = 0
|
||||
for span in spans:
|
||||
if span["text"] in line or line in span["text"]:
|
||||
line_width += estimate_text_width(normalize_text_no_whitespace(span["text"]), span["font_size"])
|
||||
if line_width == 0:
|
||||
line_width = estimate_text_width(line_clean, font_size)
|
||||
if line_width > max_line_estimated_width:
|
||||
max_line_estimated_width = line_width
|
||||
max_line_text = line_clean
|
||||
|
||||
available_width = width - padding_left - padding_right
|
||||
|
||||
elements.append({
|
||||
"id": element_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"padding_left": padding_left,
|
||||
"padding_right": padding_right,
|
||||
"padding_top": padding_top,
|
||||
"padding_bottom": padding_bottom,
|
||||
"available_width": available_width,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
"wrap": wrap,
|
||||
"auto_fit": auto_fit,
|
||||
"text_align": text_align,
|
||||
"vertical_align": vertical_align,
|
||||
"text_raw": full_text,
|
||||
"text_clean": clean_text,
|
||||
"max_line_text": max_line_text,
|
||||
"estimated_width": max_line_estimated_width,
|
||||
"width_ratio": max_line_estimated_width / available_width if available_width > 0 else None,
|
||||
"spans": spans,
|
||||
})
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def run_lark_cli(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
cmd = ["lark-cli"] + args
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(SCRIPT_DIR),
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def get_presentation_xml(env: dict[str, str]) -> str:
|
||||
result = run_lark_cli(
|
||||
["slides", "+xml-get", "--presentation", PRESENTATION_ID, "--raw"],
|
||||
env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Error getting presentation XML: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_slide_ids(xml: str) -> list[tuple[int, str]]:
|
||||
slides = []
|
||||
for index, match in enumerate(re.finditer(r"<slide\b([^>]*)>", xml)):
|
||||
attrs = match.group(1)
|
||||
slide_id = extract_attribute(attrs, "id")
|
||||
if slide_id:
|
||||
slides.append((index + 1, slide_id))
|
||||
return slides
|
||||
|
||||
|
||||
def get_slide_xml(slide_id: str, env: dict[str, str]) -> str:
|
||||
result = run_lark_cli(
|
||||
["slides", "+xml-get", "--presentation", PRESENTATION_ID, "--slide-id", slide_id, "--raw"],
|
||||
env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Error getting slide {slide_id} XML: {result.stderr}", file=sys.stderr)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
|
||||
def take_screenshots(slide_ids: list[str], output_dir: Path, env: dict[str, str]) -> None:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
batch_size = 10
|
||||
for i in range(0, len(slide_ids), batch_size):
|
||||
batch = slide_ids[i:i + batch_size]
|
||||
args = [
|
||||
"slides", "+screenshot",
|
||||
"--presentation", PRESENTATION_ID,
|
||||
"--output-dir", str(output_dir.relative_to(SCRIPT_DIR)),
|
||||
]
|
||||
for sid in batch:
|
||||
args.extend(["--slide-id", sid])
|
||||
result = run_lark_cli(args, env)
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: screenshot failed for batch {i//batch_size + 1}: {result.stderr}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env = os.environ.copy()
|
||||
env["LARKSUITE_CLI_NO_UPDATE_NOTIFIER"] = "1"
|
||||
env["LARKSUITE_CLI_NO_SKILLS_NOTIFIER"] = "1"
|
||||
|
||||
print(f"Fetching presentation {PRESENTATION_ID}...")
|
||||
full_xml = get_presentation_xml(env)
|
||||
slides_info = get_slide_ids(full_xml)
|
||||
print(f"Found {len(slides_info)} slides")
|
||||
|
||||
screenshot_dir = SCRIPT_DIR / ".lark-slides" / "screenshots"
|
||||
print(f"Taking screenshots (this may take a while)...")
|
||||
take_screenshots([sid for _, sid in slides_info], screenshot_dir, env)
|
||||
|
||||
all_samples = []
|
||||
|
||||
for slide_num, slide_id in slides_info:
|
||||
print(f"Processing slide {slide_num} ({slide_id})...")
|
||||
slide_xml = get_slide_xml(slide_id, env)
|
||||
if not slide_xml:
|
||||
continue
|
||||
|
||||
elements = extract_text_elements(slide_xml)
|
||||
for elem in elements:
|
||||
text_type = classify_text_type(elem["text_clean"])
|
||||
is_single_line_hard = "\n" not in elem["text_raw"]
|
||||
wrap_enabled = elem["wrap"] not in {"false", "0"}
|
||||
has_auto_fit = elem["auto_fit"] in {"normal-auto-fit", "shape-auto-fit"}
|
||||
|
||||
sample = {
|
||||
"slide_number": slide_num,
|
||||
"slide_id": slide_id,
|
||||
"element_id": elem["id"],
|
||||
"x": round(elem["x"], 2),
|
||||
"y": round(elem["y"], 2),
|
||||
"shape_width": round(elem["width"], 2),
|
||||
"shape_height": round(elem["height"], 2),
|
||||
"padding_left": elem["padding_left"],
|
||||
"padding_right": elem["padding_right"],
|
||||
"available_width": round(elem["available_width"], 2),
|
||||
"font_size": elem["font_size"],
|
||||
"font_family": elem["font_family"] or "",
|
||||
"bold": str(elem["bold"]).lower(),
|
||||
"italic": str(elem["italic"]).lower(),
|
||||
"wrap": elem["wrap"] or "",
|
||||
"auto_fit": elem["auto_fit"] or "",
|
||||
"text_align": elem["text_align"] or "",
|
||||
"text_clean": elem["text_clean"],
|
||||
"text_length": len(elem["text_clean"]),
|
||||
"text_type": text_type,
|
||||
"is_single_line_hard": str(is_single_line_hard).lower(),
|
||||
"estimated_width": round(elem["estimated_width"], 2),
|
||||
"width_ratio": round(elem["width_ratio"], 4) if elem["width_ratio"] is not None else "",
|
||||
"likely_wraps_actual": "",
|
||||
"notes": "",
|
||||
}
|
||||
if elem["width_ratio"] is not None and not has_auto_fit:
|
||||
ratio = elem["width_ratio"]
|
||||
if not wrap_enabled:
|
||||
sample["likely_wraps_actual"] = "no_wrap"
|
||||
sample["notes"] = "wrap=false,不自动换行"
|
||||
elif ratio < 0.7:
|
||||
sample["likely_wraps_actual"] = "no_slack"
|
||||
sample["notes"] = f"估算宽度 < 70% 可用宽度({ratio:.2f}),明显有留白"
|
||||
elif ratio < 0.85:
|
||||
sample["likely_wraps_actual"] = "no"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,大概率单行"
|
||||
elif ratio <= 1.0:
|
||||
sample["likely_wraps_actual"] = "tight_single"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,接近填满,需确认是否单行"
|
||||
elif ratio <= 1.2:
|
||||
sample["likely_wraps_actual"] = "borderline"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,边界情况,需截图确认是否换行"
|
||||
elif ratio <= 1.5:
|
||||
sample["likely_wraps_actual"] = "likely_wrap"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,大概率换2行"
|
||||
else:
|
||||
sample["likely_wraps_actual"] = "yes"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,肯定换行(多行)"
|
||||
elif has_auto_fit:
|
||||
sample["likely_wraps_actual"] = "autofit"
|
||||
sample["notes"] = "autoFit开启,字体会自动缩放"
|
||||
else:
|
||||
sample["likely_wraps_actual"] = "unknown"
|
||||
sample["notes"] = "无法判断"
|
||||
|
||||
all_samples.append(sample)
|
||||
|
||||
print(f"\nCollected {len(all_samples)} text shape samples")
|
||||
|
||||
csv_fields = [
|
||||
"slide_number", "slide_id", "element_id", "x", "y",
|
||||
"shape_width", "shape_height", "padding_left", "padding_right", "available_width",
|
||||
"font_size", "font_family", "bold", "italic", "wrap", "auto_fit", "text_align",
|
||||
"text_clean", "text_length", "text_type", "is_single_line_hard",
|
||||
"estimated_width", "width_ratio", "likely_wraps_actual", "notes",
|
||||
]
|
||||
|
||||
with open(CSV_OUTPUT, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=csv_fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(all_samples)
|
||||
print(f"CSV saved to: {CSV_OUTPUT}")
|
||||
|
||||
key_categories = {"tight_single", "borderline", "likely_wrap", "yes"}
|
||||
tight_samples = [s for s in all_samples if s["likely_wraps_actual"] == "tight_single"]
|
||||
borderline_samples = [s for s in all_samples if s["likely_wraps_actual"] == "borderline"]
|
||||
likely_wrap_samples = [s for s in all_samples if s["likely_wraps_actual"] == "likely_wrap"]
|
||||
yes_wrap_samples = [s for s in all_samples if s["likely_wraps_actual"] == "yes"]
|
||||
|
||||
type_stats = defaultdict(list)
|
||||
for s in all_samples:
|
||||
if s["width_ratio"] != "":
|
||||
type_stats[s["text_type"]].append(s)
|
||||
|
||||
md_lines = [
|
||||
"# 字符宽度测量数据报告",
|
||||
"",
|
||||
f"**Presentation**: {PRESENTATION_ID}",
|
||||
f"**总样本数**: {len(all_samples)}",
|
||||
f"**关键测量样本(比值≥0.85)**: {len(tight_samples) + len(borderline_samples) + len(likely_wrap_samples) + len(yes_wrap_samples)}",
|
||||
f" - 接近填满(0.85-1.0): {len(tight_samples)}",
|
||||
f" - 边界情况(1.0-1.2): {len(borderline_samples)}",
|
||||
f" - 大概率换行(1.2-1.5): {len(likely_wrap_samples)}",
|
||||
f" - 肯定换行(>1.5): {len(yes_wrap_samples)}",
|
||||
"",
|
||||
"## 按文本类型统计(所有样本)",
|
||||
"",
|
||||
"| 文本类型 | 样本数 | 平均估算/可用比 | 最小比值 | 最大比值 |",
|
||||
"|----------|--------|------------------|----------|----------|",
|
||||
]
|
||||
|
||||
for text_type in sorted(type_stats.keys()):
|
||||
samples = type_stats[text_type]
|
||||
ratios = [float(s["width_ratio"]) for s in samples if s["width_ratio"] != ""]
|
||||
if not ratios:
|
||||
continue
|
||||
avg_ratio = sum(ratios) / len(ratios)
|
||||
min_ratio = min(ratios)
|
||||
max_ratio = max(ratios)
|
||||
md_lines.append(
|
||||
f"| {text_type} | {len(samples)} | {avg_ratio:.4f} | {min_ratio:.4f} | {max_ratio:.4f} |"
|
||||
)
|
||||
|
||||
def add_sample_table(title: str, samples: list[dict], max_rows: int = 50):
|
||||
md_lines.extend([
|
||||
"",
|
||||
f"## {title}",
|
||||
"",
|
||||
"| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |",
|
||||
"|------|--------|------|------|------|----------|--------|------|----------|----------|------|",
|
||||
])
|
||||
for idx, s in enumerate(samples[:max_rows]):
|
||||
text_preview = s["text_clean"][:50] + ("..." if len(s["text_clean"]) > 50 else "")
|
||||
md_lines.append(
|
||||
f"| {s['slide_number']} | {s['element_id']} | {s['font_family']} | {s['font_size']} | "
|
||||
f"{s['bold']} | {s['text_type']} | {s['is_single_line_hard']} | {text_preview} | {s['estimated_width']} | "
|
||||
f"{s['available_width']} | {s['width_ratio']} |"
|
||||
)
|
||||
if len(samples) > max_rows:
|
||||
md_lines.append(f"| ... | ... | ... | ... | ... | ... | ... | (共 {len(samples)} 个样本) | ... | ... | ... |")
|
||||
|
||||
add_sample_table("接近填满样本(估算比值 0.85-1.0,最适合校准单行宽度)", tight_samples)
|
||||
add_sample_table("边界换行样本(估算比值 1.0-1.2,需截图确认是否换行)", borderline_samples)
|
||||
add_sample_table("大概率换行样本(估算比值 1.2-1.5)", likely_wrap_samples)
|
||||
add_sample_table("肯定换行样本(估算比值 > 1.5)", yes_wrap_samples, max_rows=20)
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 字体统计",
|
||||
"",
|
||||
"| 字体 | 样本数 |",
|
||||
"|------|--------|",
|
||||
])
|
||||
|
||||
font_stats = defaultdict(int)
|
||||
for s in all_samples:
|
||||
font = s["font_family"] or "(default)"
|
||||
font_stats[font] += 1
|
||||
for font, count in sorted(font_stats.items(), key=lambda x: -x[1]):
|
||||
md_lines.append(f"| {font} | {count} |")
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 字号+粗体统计",
|
||||
"",
|
||||
"| 字号 | Bold | 样本数 | 平均比值 |",
|
||||
"|------|------|--------|----------|",
|
||||
])
|
||||
|
||||
size_bold_stats = defaultdict(list)
|
||||
for s in all_samples:
|
||||
if s["width_ratio"] != "":
|
||||
key = (s["font_size"], s["bold"])
|
||||
size_bold_stats[key].append(float(s["width_ratio"]))
|
||||
for (font_size, bold), ratios in sorted(size_bold_stats.items()):
|
||||
avg = sum(ratios) / len(ratios)
|
||||
md_lines.append(f"| {font_size} | {bold} | {len(ratios)} | {avg:.4f} |")
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 说明",
|
||||
"",
|
||||
"- **估算宽度**: 使用当前 `estimate_character_width` 函数计算(中文=1em,西文=0.55em,空格=0.33em)",
|
||||
"- **可用宽度**: shape.width - paddingLeft - paddingRight",
|
||||
"- **width_ratio**: 估算宽度 / 可用宽度(针对最长硬换行段落计算)",
|
||||
"- **硬换行**: 文本中是否包含显式 \\n 分段",
|
||||
f"- 截图保存在: {screenshot_dir}",
|
||||
"",
|
||||
"### 比值解读建议",
|
||||
"- ratio < 0.7: 明显留白,估算宽度可能偏宽,或文本确实很短",
|
||||
"- 0.7-0.85: 大概率单行,有少量留白",
|
||||
"- 0.85-1.0: 接近填满,是校准西文/中文字符宽度系数的最佳样本",
|
||||
"- 1.0-1.2: 边界情况,需要看截图确认:是刚好填满单行还是换行了",
|
||||
"- 1.2-1.5: 大概率换2行",
|
||||
"- >1.5: 肯定换行(多行文本)",
|
||||
"",
|
||||
"### 校准建议",
|
||||
"1. 先看 ratio 0.85-1.0 的样本:如果截图中这些文本**确实单行且接近填满**,说明当前估算大致准确;如果有较多留白,说明估算偏宽,需要减小西文字符系数",
|
||||
"2. 再看 ratio 1.0-1.2 的样本:结合截图判断实际是单行还是换行,反推合理系数",
|
||||
"3. 重点关注纯英文、纯数字、纯中文、中英混合这几类分别统计",
|
||||
"4. Bold 字体通常比常规字体稍宽,Italic 稍窄,需要分别考虑",
|
||||
"",
|
||||
"请人工核对截图确认边界样本的实际换行情况,用于校准字符宽度系数。",
|
||||
])
|
||||
|
||||
with open(MD_OUTPUT, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(md_lines))
|
||||
print(f"Markdown report saved to: {MD_OUTPUT}")
|
||||
|
||||
print("\n=== Summary ===")
|
||||
print(f"Total samples: {len(all_samples)}")
|
||||
print(f" Tight single (0.85-1.0): {len(tight_samples)}")
|
||||
print(f" Borderline (1.0-1.2): {len(borderline_samples)}")
|
||||
print(f" Likely wrap (1.2-1.5): {len(likely_wrap_samples)}")
|
||||
print(f" Definite wrap (>1.5): {len(yes_wrap_samples)}")
|
||||
print()
|
||||
for text_type in sorted(type_stats.keys()):
|
||||
samples = type_stats[text_type]
|
||||
ratios = [float(s["width_ratio"]) for s in samples if s["width_ratio"] != ""]
|
||||
if ratios:
|
||||
avg_ratio = sum(ratios) / len(ratios)
|
||||
print(f" {text_type}: {len(samples)} samples, avg ratio = {avg_ratio:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
265
skills/lark-slides/scripts/text-measure/cli.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// CLI: measure single-line text width via CanvasKit, reusing ee/slide's core
|
||||
// measure function so widths match the on-line renderer.
|
||||
//
|
||||
// Input is a Slide XML string (or an explicit --text). XML paragraph/style
|
||||
// extraction mirrors xml_text_overlap_lint.py (extract_text_paragraphs /
|
||||
// extract_max_span_font_size / style attribute names).
|
||||
//
|
||||
// Usage:
|
||||
// tsx cli.ts --font "Noto Sans SC=/abs/NotoSansSC.ttf" [--font ...] --file slide.xml
|
||||
// tsx cli.ts --font "Arial=/abs/Arial.ttf" --text "Hello" --font-size 16 [--bold] [--italic] [--letter-spacing 0]
|
||||
// cat slide.xml | tsx cli.ts --font "Arial=/abs/Arial.ttf"
|
||||
//
|
||||
// Output: JSON to stdout. Progress/warnings to stderr.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { StandaloneMeasureRuntime, type FontSpec } from './runtime.ts';
|
||||
import { measureSingleLineText, type SingleLineStyle } from './measure.ts';
|
||||
|
||||
interface CliArgs {
|
||||
fonts: FontSpec[];
|
||||
file?: string;
|
||||
xml?: string;
|
||||
text?: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
letterSpacing: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
shapeId: string | null;
|
||||
text: string;
|
||||
style: SingleLineStyle;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
fonts: [],
|
||||
fontFamily: '',
|
||||
fontSize: '16px',
|
||||
letterSpacing: '0px',
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = () => {
|
||||
const value = argv[++i];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for ${arg}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
switch (arg) {
|
||||
case '--font': {
|
||||
// "Family Name=/abs/path.ttf" — split on the FIRST '=' so family names
|
||||
// may not contain '=', which is fine for font-family strings.
|
||||
const spec = next();
|
||||
const eq = spec.indexOf('=');
|
||||
if (eq < 0) {
|
||||
throw new Error(`--font expects "family=path", got: ${spec}`);
|
||||
}
|
||||
args.fonts.push({ family: spec.slice(0, eq).trim(), path: spec.slice(eq + 1).trim() });
|
||||
break;
|
||||
}
|
||||
case '--file':
|
||||
args.file = next();
|
||||
break;
|
||||
case '--xml':
|
||||
args.xml = next();
|
||||
break;
|
||||
case '--text':
|
||||
args.text = next();
|
||||
break;
|
||||
case '--font-family':
|
||||
args.fontFamily = next();
|
||||
break;
|
||||
case '--font-size':
|
||||
args.fontSize = next();
|
||||
break;
|
||||
case '--letter-spacing':
|
||||
args.letterSpacing = next();
|
||||
break;
|
||||
case '--bold':
|
||||
args.bold = true;
|
||||
break;
|
||||
case '--italic':
|
||||
args.italic = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
// --- XML extraction (mirrors xml_text_overlap_lint.py) ---
|
||||
|
||||
function stripXml(value: string, preserveLineBreaks: boolean): string {
|
||||
let stripped = value.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
stripped = stripped
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
if (preserveLineBreaks) {
|
||||
return stripped
|
||||
.split('\n')
|
||||
.map(line => line.replace(/\s+/g, ' ').trim())
|
||||
.join('\n');
|
||||
}
|
||||
return stripped.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function extractAttribute(attrs: string, name: string): string | null {
|
||||
const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function extractNumericAttribute(attrs: string, name: string): number | null {
|
||||
const raw = extractAttribute(attrs, name);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const value = parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function extractBoolAttribute(attrs: string, name: string): boolean {
|
||||
return extractAttribute(attrs, name) === 'true';
|
||||
}
|
||||
|
||||
// Largest span fontSize wins, falling back to the content default (mirrors
|
||||
// extract_max_span_font_size).
|
||||
function extractMaxSpanFontSize(body: string, defaultFontSize: number): number {
|
||||
const sizes = [defaultFontSize];
|
||||
for (const [, spanAttrs] of body.matchAll(/<span\b([^>]*)>/gi)) {
|
||||
const size = extractNumericAttribute(spanAttrs, 'fontSize');
|
||||
if (size !== null) {
|
||||
sizes.push(size);
|
||||
}
|
||||
}
|
||||
return Math.max(...sizes);
|
||||
}
|
||||
|
||||
function detectAnySpanBool(body: string, name: string): boolean {
|
||||
for (const [, spanAttrs] of body.matchAll(/<span\b([^>]*)>/gi)) {
|
||||
if (extractBoolAttribute(spanAttrs, name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract one segment per <p> inside each <shape ...><content ...>. Each hard
|
||||
// paragraph is measured independently (single-line scope).
|
||||
function extractSegments(xml: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
for (const [, shapeAttrs, shapeBody] of xml.matchAll(/<shape\b([^>]*)>([\s\S]*?)<\/shape\s*>/gi)) {
|
||||
const shapeId = extractAttribute(shapeAttrs, 'id');
|
||||
const contentMatch = shapeBody.match(/<content\b([^>]*)>([\s\S]*?)<\/content\s*>/i);
|
||||
if (!contentMatch) {
|
||||
continue;
|
||||
}
|
||||
const [, contentAttrs, contentBody] = contentMatch;
|
||||
const contentFontSize = extractNumericAttribute(contentAttrs, 'fontSize') ?? 16;
|
||||
const contentFontFamily = extractAttribute(contentAttrs, 'fontFamily') ?? '';
|
||||
const contentLetterSpacing = extractNumericAttribute(contentAttrs, 'letterSpacing');
|
||||
const contentBold = extractBoolAttribute(contentAttrs, 'bold');
|
||||
const contentItalic = extractBoolAttribute(contentAttrs, 'italic');
|
||||
|
||||
const paragraphs = [...contentBody.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p\s*>/gi)];
|
||||
const iter = paragraphs.length > 0 ? paragraphs : [['', '', contentBody] as unknown as RegExpMatchArray];
|
||||
|
||||
for (const [, , body] of iter) {
|
||||
const text = stripXml(body, true);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const fontSize = extractMaxSpanFontSize(body, contentFontSize);
|
||||
const bold = contentBold || detectAnySpanBool(body, 'bold');
|
||||
const italic = contentItalic || detectAnySpanBool(body, 'italic');
|
||||
const letterSpacing = contentLetterSpacing ?? 0;
|
||||
segments.push({
|
||||
shapeId,
|
||||
text,
|
||||
style: {
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: contentFontFamily,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
bold,
|
||||
italic,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const runtime = new StandaloneMeasureRuntime();
|
||||
await runtime.ensureReady(args.fonts);
|
||||
|
||||
// Each segment may itself contain hard line breaks; measure each visual line
|
||||
// and report the max width (single-line scope: no wrapping).
|
||||
const measureSegment = (text: string, style: SingleLineStyle) => {
|
||||
const lines = text.split('\n');
|
||||
const widths = lines.map(line => measureSingleLineText(line, style, runtime));
|
||||
return { widthPx: Math.max(...widths, 0), lineWidths: widths };
|
||||
};
|
||||
|
||||
let output: unknown;
|
||||
|
||||
if (args.text !== undefined) {
|
||||
const style: SingleLineStyle = {
|
||||
fontSize: args.fontSize,
|
||||
fontFamily: args.fontFamily,
|
||||
letterSpacing: args.letterSpacing,
|
||||
bold: args.bold,
|
||||
italic: args.italic,
|
||||
};
|
||||
const { widthPx, lineWidths } = measureSegment(args.text, style);
|
||||
output = { mode: 'text', text: args.text, style, widthPx, lineWidths };
|
||||
} else {
|
||||
const xml = args.xml ?? (args.file ? await readFile(args.file, 'utf8') : await readStdin());
|
||||
if (!xml.trim()) {
|
||||
throw new Error('No XML input provided (use --file, --xml, --text, or pipe via stdin).');
|
||||
}
|
||||
const segments = extractSegments(xml);
|
||||
output = {
|
||||
mode: 'xml',
|
||||
segmentCount: segments.length,
|
||||
segments: segments.map(seg => {
|
||||
const { widthPx, lineWidths } = measureSegment(seg.text, seg.style);
|
||||
return { shapeId: seg.shapeId, text: seg.text, style: seg.style, widthPx, lineWidths };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
runtime.dispose();
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
95
skills/lark-slides/scripts/text-measure/measure.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Core single-line text measurement, transcribed verbatim from ee/slide
|
||||
// modules/text-measure-module/src/util/canvaskit/text-layout.ts
|
||||
// (function measureSingleLineText, lines 1717-1776) so that the width produced
|
||||
// here is identical to what the Slides renderer computes on-line.
|
||||
//
|
||||
// Only the single-line path is reproduced (per the agreed scope). The multi-run
|
||||
// measureTextWithCanvasKit path — which needs an editor-kit ZoneDelta — is not
|
||||
// included here.
|
||||
|
||||
import type { CanvasKit, FontCollection, TypefaceFontProvider } from 'canvaskit-wasm';
|
||||
|
||||
/**
|
||||
* Read-only runtime view the measure function depends on. Mirrors
|
||||
* CanvasKitMeasureRuntime in text-measure-module/src/type/canvaskit.type.ts.
|
||||
* Initialization (WASM load, font registration) is the runtime's job, not the
|
||||
* measure function's.
|
||||
*/
|
||||
export interface CanvasKitMeasureRuntime {
|
||||
readonly getCanvasKit: () => CanvasKit | null;
|
||||
readonly getFontProvider: () => TypefaceFontProvider | null;
|
||||
readonly getFontCollection: () => FontCollection | null;
|
||||
readonly getDefaultFontFamily: () => string;
|
||||
readonly isFontRegistered: (fontFamily: string) => boolean;
|
||||
readonly hasWghtAxis: (fontFamily: string) => boolean;
|
||||
}
|
||||
|
||||
export interface SingleLineStyle {
|
||||
fontSize: string;
|
||||
fontFamily: string;
|
||||
letterSpacing: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim transcription of measureSingleLineText from text-layout.ts:1717.
|
||||
* Returns the natural (unwrapped) width in px via Skia's getMaxIntrinsicWidth.
|
||||
*/
|
||||
export function measureSingleLineText(
|
||||
text: string,
|
||||
style: SingleLineStyle,
|
||||
runtime: CanvasKitMeasureRuntime,
|
||||
): number {
|
||||
const ck = runtime.getCanvasKit();
|
||||
const fontProvider = runtime.getFontProvider();
|
||||
if (!ck || !fontProvider) {
|
||||
throw new Error('CanvasKit runtime not ready, call ensureReady() before measuring');
|
||||
}
|
||||
|
||||
const fontSize = parseInt(style.fontSize, 10) || 16;
|
||||
const letterSpacing = parseFloat(style.letterSpacing) || 0;
|
||||
const rawFontFamily = style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
|
||||
const defaultFont = runtime.getDefaultFontFamily();
|
||||
const fontFamilies = runtime.isFontRegistered(rawFontFamily) ? [rawFontFamily, defaultFont] : [defaultFont];
|
||||
|
||||
const textStyle = new ck.TextStyle({
|
||||
fontSize,
|
||||
fontFamilies,
|
||||
letterSpacing,
|
||||
fontStyle: {
|
||||
weight: style.bold ? ck.FontWeight.Bold : ck.FontWeight.Normal,
|
||||
slant: style.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright,
|
||||
},
|
||||
});
|
||||
|
||||
const paraStyle = new ck.ParagraphStyle({
|
||||
textStyle: {
|
||||
fontSize,
|
||||
fontFamilies,
|
||||
},
|
||||
});
|
||||
|
||||
const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, fontProvider);
|
||||
|
||||
try {
|
||||
builder.pushStyle(textStyle);
|
||||
builder.addText(text);
|
||||
builder.pop();
|
||||
|
||||
const paragraph = builder.build();
|
||||
|
||||
try {
|
||||
// Unbounded width: measure the full natural width of a single line.
|
||||
paragraph.layout(Infinity);
|
||||
return paragraph.getMaxIntrinsicWidth();
|
||||
} finally {
|
||||
paragraph.delete();
|
||||
}
|
||||
} finally {
|
||||
builder.delete();
|
||||
}
|
||||
}
|
||||
21
skills/lark-slides/scripts/text-measure/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "lark-slides-text-measure",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone CanvasKit single-line text measurement, reusing ee/slide text-measure-module's core measure function.",
|
||||
"bin": {
|
||||
"measure-text": "./cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"measure": "tsx cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "*",
|
||||
"tsx": "*",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
133
skills/lark-slides/scripts/text-measure/runtime.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Standalone CanvasKit runtime that satisfies CanvasKitMeasureRuntime without
|
||||
// the @slide/modular DI container. The CanvasKit init and font-provider setup
|
||||
// are distilled from ee/slide text-measure-module:
|
||||
// - createCanvasKit() <- provide/canvaskit/canvaskit.service.implement.ts:64
|
||||
// - TypefaceFontProvider <- provide/canvaskit-font/canvaskit-font.service.base.ts:67
|
||||
// - registerFont() <- canvaskit-font.service.base.ts:108
|
||||
// - Node wasm path resolve <- provide/canvaskit/canvaskit.node.util.ts:6
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { CanvasKit, FontCollection, TypefaceFontProvider } from 'canvaskit-wasm';
|
||||
import type { CanvasKitMeasureRuntime } from './measure.ts';
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
function resolveNodeCanvasKitWasmPath(): string {
|
||||
return nodeRequire.resolve('canvaskit-wasm/bin/canvaskit.wasm');
|
||||
}
|
||||
|
||||
export interface FontSpec {
|
||||
/** font-family name the XML references (matched by measureSingleLineText). */
|
||||
family: string;
|
||||
/** absolute path to the font file (.ttf/.otf/.ttc). */
|
||||
path: string;
|
||||
/** true for variable fonts exposing a `wght` axis. */
|
||||
hasWghtAxis?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal runtime: loads CanvasKit WASM, creates one TypefaceFontProvider, and
|
||||
* registers caller-supplied font files. The first registered font becomes the
|
||||
* default family (mirrors registerDefaultFont in the base service).
|
||||
*/
|
||||
export class StandaloneMeasureRuntime implements CanvasKitMeasureRuntime {
|
||||
private canvasKit: CanvasKit | null = null;
|
||||
private fontProvider: TypefaceFontProvider | null = null;
|
||||
private fontCollection: FontCollection | null = null;
|
||||
private readonly registeredFonts = new Set<string>();
|
||||
private readonly wghtAxisFonts = new Set<string>();
|
||||
private defaultFontFamily = 'sans-serif';
|
||||
|
||||
async ensureReady(fonts: readonly FontSpec[]): Promise<void> {
|
||||
if (!this.canvasKit) {
|
||||
const canvasKitModule = await import('canvaskit-wasm');
|
||||
const wasmUrl = resolveNodeCanvasKitWasmPath();
|
||||
this.canvasKit = await canvasKitModule.default({
|
||||
locateFile: (file: string) => (file.endsWith('.wasm') ? wasmUrl : file),
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.fontProvider) {
|
||||
this.fontProvider = this.canvasKit.TypefaceFontProvider.Make();
|
||||
}
|
||||
|
||||
if (!this.fontCollection) {
|
||||
this.fontCollection = this.canvasKit.FontCollection.Make();
|
||||
this.fontCollection.setDefaultFontManager(this.fontProvider);
|
||||
this.fontCollection.enableFontFallback();
|
||||
}
|
||||
|
||||
for (const [index, font] of fonts.entries()) {
|
||||
await this.registerFont(font, index === 0);
|
||||
}
|
||||
|
||||
if (!this.hasAnyRegisteredFont()) {
|
||||
// CanvasKit embeds no fonts; with none registered Skia finds no glyphs and
|
||||
// measures 0 width while resolving successfully — worse than failing.
|
||||
throw new Error('No font registered; measurement would be unreliable. Pass at least one --font family=path.');
|
||||
}
|
||||
}
|
||||
|
||||
private async registerFont(font: FontSpec, isDefault: boolean): Promise<void> {
|
||||
if (!this.fontProvider) {
|
||||
throw new Error('Font provider not initialized');
|
||||
}
|
||||
const data = await readFile(font.path);
|
||||
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const result = this.fontProvider.registerFont(buffer, font.family) as null | undefined;
|
||||
if (result === null) {
|
||||
console.warn(`[CanvasKit] registerFont failed for ${font.family} (${font.path})`);
|
||||
return;
|
||||
}
|
||||
this.registeredFonts.add(font.family);
|
||||
if (font.hasWghtAxis) {
|
||||
this.wghtAxisFonts.add(font.family);
|
||||
}
|
||||
if (isDefault) {
|
||||
this.defaultFontFamily = font.family;
|
||||
}
|
||||
}
|
||||
|
||||
hasAnyRegisteredFont(): boolean {
|
||||
return this.registeredFonts.size > 0;
|
||||
}
|
||||
|
||||
getCanvasKit(): CanvasKit | null {
|
||||
return this.canvasKit;
|
||||
}
|
||||
|
||||
getFontProvider(): TypefaceFontProvider | null {
|
||||
return this.fontProvider;
|
||||
}
|
||||
|
||||
getFontCollection(): FontCollection | null {
|
||||
return this.fontCollection;
|
||||
}
|
||||
|
||||
getDefaultFontFamily(): string {
|
||||
return this.defaultFontFamily;
|
||||
}
|
||||
|
||||
isFontRegistered(fontFamily: string): boolean {
|
||||
return this.registeredFonts.has(fontFamily);
|
||||
}
|
||||
|
||||
hasWghtAxis(fontFamily: string): boolean {
|
||||
return this.wghtAxisFonts.has(fontFamily);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.fontCollection?.delete();
|
||||
this.fontProvider?.delete();
|
||||
this.fontCollection = null;
|
||||
this.fontProvider = null;
|
||||
this.canvasKit = null;
|
||||
this.registeredFonts.clear();
|
||||
this.wghtAxisFonts.clear();
|
||||
}
|
||||
}
|
||||
15
skills/lark-slides/scripts/text-measure/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
354
skills/lark-slides/scripts/verify_width_calibration.py
Normal file
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify character width calibration by comparing new estimates against visual evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import xml_text_overlap_lint as lint
|
||||
|
||||
|
||||
def old_estimate_character_width(character: str, font_size: float) -> float:
|
||||
if character.isspace():
|
||||
return font_size * 0.33
|
||||
if unicodedata.east_asian_width(character) in {"F", "W"}:
|
||||
return font_size
|
||||
return font_size * 0.55
|
||||
|
||||
|
||||
def old_estimate_text_width(text: str, font_size: float) -> float:
|
||||
return sum(old_estimate_character_width(c, font_size) for c in text)
|
||||
|
||||
|
||||
def make_element(
|
||||
width: float,
|
||||
height: float,
|
||||
text: str,
|
||||
font_size: float,
|
||||
*,
|
||||
padding_left: float = 0,
|
||||
padding_right: float = 0,
|
||||
padding_top: float = 0,
|
||||
padding_bottom: float = 0,
|
||||
bold: bool = False,
|
||||
wrap: str = "true",
|
||||
auto_fit: str | None = None,
|
||||
font_family: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": "test",
|
||||
"kind": "shape",
|
||||
"type": "text",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"paddingLeft": padding_left,
|
||||
"paddingRight": padding_right,
|
||||
"paddingTop": padding_top,
|
||||
"paddingBottom": padding_bottom,
|
||||
"fontSize": font_size,
|
||||
"fontFamily": font_family,
|
||||
"bold": bold,
|
||||
"italic": False,
|
||||
"text": text,
|
||||
"wrap": wrap,
|
||||
"autoFit": auto_fit,
|
||||
"letterSpacing": 0,
|
||||
"paragraphs": [
|
||||
{
|
||||
"text": p,
|
||||
"fontSize": font_size,
|
||||
"lineSpacing": None,
|
||||
"beforeLineSpacing": None,
|
||||
"afterLineSpacing": None,
|
||||
"letterSpacing": None,
|
||||
}
|
||||
for p in text.split("\n")
|
||||
if p
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def count_chars(text: str) -> dict[str, int]:
|
||||
counts = {"cjk": 0, "upper": 0, "lower": 0, "digit": 0, "punct": 0, "space": 0}
|
||||
for c in text:
|
||||
if c.isspace():
|
||||
counts["space"] += 1
|
||||
elif unicodedata.east_asian_width(c) in {"F", "W"}:
|
||||
counts["cjk"] += 1
|
||||
elif c.isupper():
|
||||
counts["upper"] += 1
|
||||
elif c.islower():
|
||||
counts["lower"] += 1
|
||||
elif c.isdigit():
|
||||
counts["digit"] += 1
|
||||
else:
|
||||
counts["punct"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def test_case(
|
||||
name: str,
|
||||
text: str,
|
||||
available_width: float,
|
||||
font_size: float,
|
||||
*,
|
||||
expected_single_line: bool | None = None,
|
||||
note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
elem = make_element(available_width, 1000, text, font_size)
|
||||
new_width = lint.estimate_text_max_line_width(elem)
|
||||
new_ratio = new_width / available_width
|
||||
new_lines = lint.estimate_text_line_count_for_text(elem, text)
|
||||
|
||||
old_width = max(old_estimate_text_width(p, font_size) for p in text.split("\n") if p)
|
||||
old_ratio = old_width / available_width
|
||||
|
||||
counts = count_chars(text)
|
||||
|
||||
result = {
|
||||
"name": name,
|
||||
"text_preview": text[:60] + ("..." if len(text) > 60 else ""),
|
||||
"font_size": font_size,
|
||||
"available_width": available_width,
|
||||
"char_counts": counts,
|
||||
"new_estimated_width": round(new_width, 2),
|
||||
"new_ratio": round(new_ratio, 4),
|
||||
"new_line_count": new_lines,
|
||||
"old_estimated_width": round(old_width, 2),
|
||||
"old_ratio": round(old_ratio, 4),
|
||||
"expected_single_line": expected_single_line,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
if expected_single_line is True:
|
||||
result["new_correct"] = new_ratio <= 1.02
|
||||
result["old_correct"] = old_ratio <= 1.02
|
||||
elif expected_single_line is False:
|
||||
result["new_correct"] = new_ratio > 1.0
|
||||
result["old_correct"] = old_ratio > 1.0
|
||||
else:
|
||||
result["new_correct"] = None
|
||||
result["old_correct"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_result(r: dict[str, Any]) -> None:
|
||||
status_new = ""
|
||||
status_old = ""
|
||||
if r["expected_single_line"] is not None:
|
||||
status_new = "✓" if r["new_correct"] else "✗"
|
||||
status_old = "✓" if r["old_correct"] else "✗"
|
||||
|
||||
print(f"--- {r['name']} {status_new} ---")
|
||||
print(f" Text: {r['text_preview']}")
|
||||
print(f" Font: {r['font_size']}pt, Available: {r['available_width']}px")
|
||||
cc = r["char_counts"]
|
||||
print(f" Chars: CJK={cc['cjk']} Upper={cc['upper']} Lower={cc['lower']} "
|
||||
f"Digit={cc['digit']} Punct={cc['punct']} Space={cc['space']}")
|
||||
print(f" NEW: width={r['new_estimated_width']}px ratio={r['new_ratio']:.4f} lines={r['new_line_count']} {status_new}")
|
||||
print(f" OLD: width={r['old_estimated_width']}px ratio={r['old_ratio']:.4f} {status_old}")
|
||||
if r["note"]:
|
||||
print(f" Note: {r['note']}")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 70)
|
||||
print("Character Width Calibration Verification")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
results = []
|
||||
|
||||
# Test cases based on visual evidence from screenshots
|
||||
# Page 17 line 2: "在 Natural Language Processing 领域,大语言模型 LLM 的出现彻底改变了人机交互方式。"
|
||||
# Visually: single line, fills ~93-95% of 800px
|
||||
results.append(test_case(
|
||||
"p17 bbR line2 (mixed CJK+English, 18pt, 800px, expected single)",
|
||||
"在 Natural Language Processing 领域,大语言模型 LLM 的出现彻底改变了人机交互方式。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~93% fill"
|
||||
))
|
||||
|
||||
# Page 17 line 3: "从 ChatGPT 到 文心一言,从 GPT-4 到 Claude 3,AI 助手正在成为人们工作生活的标配。"
|
||||
# Visually: single line
|
||||
results.append(test_case(
|
||||
"p17 bbR line3 (mixed CJK+English, 18pt, 800px, expected single)",
|
||||
"从 ChatGPT 到 文心一言,从 GPT-4 到 Claude 3,AI 助手正在成为人们工作生活的标配。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line"
|
||||
))
|
||||
|
||||
# Page 6 paragraph 3: "中文与英文混排测试:The quick brown fox jumps over the lazy dog. 敏捷的棕色狐狸跳过了懒狗。"
|
||||
# Visually: single line, very tight (was borderline with old model)
|
||||
results.append(test_case(
|
||||
"p6 bNf para3 (mixed tight line, 18pt, 800px, expected single)",
|
||||
"中文与英文混排测试:The quick brown fox jumps over the lazy dog. 敏捷的棕色狐狸跳过了懒狗。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Borderline case - visually fits on one line"
|
||||
))
|
||||
|
||||
# Page 6 paragraph 2 (English): "Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed."
|
||||
# Visually: wraps to 2 lines
|
||||
results.append(test_case(
|
||||
"p6 bNf para2 (English paragraph, 18pt, 800px, wraps)",
|
||||
"Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to 2 lines"
|
||||
))
|
||||
|
||||
# Page 28 right column line 1: "This is the right column body text in 15pt size."
|
||||
# Visually: single line, ~92% fill of 400px
|
||||
results.append(test_case(
|
||||
"p28 bZQ line1 (English, 15pt, 400px, expected single)",
|
||||
"This is the right column body text in 15pt size.",
|
||||
available_width=400,
|
||||
font_size=15,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~92% fill"
|
||||
))
|
||||
|
||||
# Page 28 right column paragraph: "Multi-column layout optimizes space and information density."
|
||||
# Visually: wraps to 2 lines ("density." on its own line)
|
||||
results.append(test_case(
|
||||
"p28 bZQ para (English, 15pt, 400px, wraps)",
|
||||
"Multi-column layout optimizes space and information density.",
|
||||
available_width=400,
|
||||
font_size=15,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps - 'density.' on next line"
|
||||
))
|
||||
|
||||
# Page 28 left column line 1: "这是左栏的正文内容,使用 15pt 字号,1.7 倍行间距。"
|
||||
# Visually: single line in 420px column
|
||||
results.append(test_case(
|
||||
"p28 bZm line1 (CJK+digits, 15pt, 420px, expected single)",
|
||||
"这是左栏的正文内容,使用 15pt 字号,1.7 倍行间距。",
|
||||
available_width=420,
|
||||
font_size=15,
|
||||
expected_single_line=True,
|
||||
note="Visually single line"
|
||||
))
|
||||
|
||||
# Page 23: 2.0 line spacing body (14pt, 270px) - wraps to multiple lines
|
||||
# "这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。"
|
||||
results.append(test_case(
|
||||
"p23 bZc body (CJK+digits, 14pt, 270px, wraps)",
|
||||
"这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。",
|
||||
available_width=270,
|
||||
font_size=14,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to ~4 lines"
|
||||
))
|
||||
|
||||
# Page 21 English line: "Underline is used for links and key annotations, strikethrough for deleted content."
|
||||
# Visually: single line at 18pt in 800px, fills ~92%
|
||||
results.append(test_case(
|
||||
"p21 bbT English line (18pt, 800px, expected single)",
|
||||
"Underline is used for links and key annotations, strikethrough for deleted content.",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~92% fill"
|
||||
))
|
||||
|
||||
# Page 12 Helvetica paragraph: "Sans-serif fonts are clean, modern, and highly legible. Widely used in UI design, branding, and digital media."
|
||||
# 16pt, 800px, wraps to 2 lines
|
||||
results.append(test_case(
|
||||
"p12 bak paragraph (English, 16pt, 800px, wraps)",
|
||||
"Sans-serif fonts are clean, modern, and highly legible. Widely used in UI design, branding, and digital media.",
|
||||
available_width=800,
|
||||
font_size=16,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to 2 lines"
|
||||
))
|
||||
|
||||
# Pure uppercase test: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" at 28pt bold, should be ~50% of 800px
|
||||
results.append(test_case(
|
||||
"p12 uppercase alphabet (28pt bold, 800px, single)",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Centered test string, ~50% width"
|
||||
))
|
||||
|
||||
# Pure lowercase test: "abcdefghijklmnopqrstuvwxyz" at 28pt
|
||||
results.append(test_case(
|
||||
"p12 lowercase alphabet (28pt bold, 800px, single)",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Centered test string, similar width to uppercase"
|
||||
))
|
||||
|
||||
# Digits+symbols: "0123456789!@#$%^&*()" at 28pt, shorter than alphabets
|
||||
results.append(test_case(
|
||||
"p12 digits+symbols (28pt bold, 800px, single)",
|
||||
"0123456789!@#$%^&*()",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Shorter than alphabet lines"
|
||||
))
|
||||
|
||||
for r in results:
|
||||
print_result(r)
|
||||
|
||||
# Summary statistics
|
||||
print("=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
new_correct = sum(1 for r in results if r["new_correct"] is True)
|
||||
old_correct = sum(1 for r in results if r["old_correct"] is True)
|
||||
total_evaluated = sum(1 for r in results if r["expected_single_line"] is not None)
|
||||
print(f"Test cases with known expectation: {total_evaluated}")
|
||||
print(f"New model correct: {new_correct}/{total_evaluated}")
|
||||
print(f"Old model correct: {old_correct}/{total_evaluated}")
|
||||
print()
|
||||
|
||||
# Show ratio distribution for single-line cases
|
||||
print("Single-line cases (ratio should be 0.85-1.02):")
|
||||
for r in results:
|
||||
if r["expected_single_line"] is True:
|
||||
print(f" {r['name'][:50]:50s} new_ratio={r['new_ratio']:.4f} old_ratio={r['old_ratio']:.4f}")
|
||||
print()
|
||||
|
||||
print("Wrapping cases (ratio should be >1.0):")
|
||||
for r in results:
|
||||
if r["expected_single_line"] is False:
|
||||
print(f" {r['name'][:50]:50s} new_ratio={r['new_ratio']:.4f} old_ratio={r['old_ratio']:.4f}")
|
||||
print()
|
||||
|
||||
# Check for tight_single samples (0.85-1.0) ratio target
|
||||
tight_single = [r for r in results if r["expected_single_line"] is True and r["new_ratio"] >= 0.80]
|
||||
if tight_single:
|
||||
avg_new_ratio = sum(r["new_ratio"] for r in tight_single) / len(tight_single)
|
||||
avg_old_ratio = sum(r["old_ratio"] for r in tight_single) / len(tight_single)
|
||||
print(f"Average ratio for near-full single lines:")
|
||||
print(f" New model: {avg_new_ratio:.4f}")
|
||||
print(f" Old model: {avg_old_ratio:.4f}")
|
||||
print(f" Target: ~0.90-0.98 (some margin but mostly filled)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
skills/lark-slides/scripts/width_measurement_data.csv
Normal file
@@ -0,0 +1,159 @@
|
||||
slide_number,slide_id,element_id,x,y,shape_width,shape_height,padding_left,padding_right,available_width,font_size,font_family,bold,italic,wrap,auto_fit,text_align,text_clean,text_length,text_type,is_single_line_hard,estimated_width,width_ratio,likely_wraps_actual,notes
|
||||
1,pmm,bNA,80,160,800,120,0,0,800,64,思源黑体,true,false,true,none,center,字体排版测试样张,8,中文,true,512,0.64,no_slack,估算宽度 < 70% 可用宽度(0.64),明显有留白
|
||||
1,pmm,bNj,80,300,800,60,0,0,800,24,思源黑体,true,false,true,none,center,TypographyTestSample·30Pages,28,英文+数字,true,369.6,0.462,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
1,pmm,bNV,80,420,800,40,0,0,800,16,思源黑体,false,false,true,none,center,包含字号/字体/中英文/数字/样式/间距/颜色等全面测试,28,中文,true,404.8,0.506,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
2,pmZ,bNt,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,02·超大字号测试72pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
2,pmZ,bNh,60,140,840,110,0,0,840,72,思源黑体,true,false,true,none,center,汉字测试ABC123,10,中文+英文+数字,true,525.6,0.6257,no_slack,估算宽度 < 70% 可用宽度(0.63),明显有留白
|
||||
2,pmZ,bNn,60,270,840,100,0,0,840,72,思源黑体,true,false,true,none,center,排版设计Typography,14,中文+英文,true,684.0,0.8143,no,估算宽度 0.81x 可用宽度,大概率单行
|
||||
2,pmZ,bNk,60,400,840,60,0,0,840,14,思源黑体,false,false,true,none,center,72pt超大字号·用于标题展示·测试字重与字间距,24,中文+英文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
3,pmY,bNw,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,03·大字号测试48pt,12,中文+英文+数字,true,247.8,0.295,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
3,pmY,bNv,60,130,840,80,0,0,840,48,思源黑体,true,false,true,none,center,中华人民共和国2024,11,中文+数字,true,441.6,0.5257,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
3,pmY,bNe,60,230,840,80,0,0,840,48,思源黑体,true,false,true,none,center,TheQuickBrownFox,16,英文,true,422.4,0.5029,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
3,pmY,bNz,60,330,840,80,0,0,840,48,思源黑体,true,false,true,none,center,0123456789数字测试,14,中文+数字,true,456.0,0.5429,no_slack,估算宽度 < 70% 可用宽度(0.54),明显有留白
|
||||
3,pmY,bNp,60,440,840,50,0,0,840,14,思源黑体,false,false,true,none,center,48pt大字号·常用于主标题·中英文数字混排测试,24,中文+英文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
4,pma,bNc,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,04·中大号字号测试36pt,14,中文+英文+数字,true,303.8,0.3617,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
4,pma,bNZ,80,130,800,60,0,0,800,36,思源黑体,true,false,true,none,,科技创新驱动未来发展Innovation,20,中文+英文,true,558.0,0.6975,no_slack,估算宽度 < 70% 可用宽度(0.70),明显有留白
|
||||
4,pma,bNd,80,210,800,60,0,0,800,36,思源黑体,true,false,true,none,,人工智能改变生活方式AI2024,16,中文+英文+数字,true,478.8,0.5985,no_slack,估算宽度 < 70% 可用宽度(0.60),明显有留白
|
||||
4,pma,bNB,80,290,800,60,0,0,800,36,思源黑体,true,false,true,none,,数据可视化DataVisualization,22,中文+英文,true,516.6,0.6458,no_slack,估算宽度 < 70% 可用宽度(0.65),明显有留白
|
||||
4,pma,bNN,80,370,800,60,0,0,800,36,思源黑体,true,false,true,none,,云计算CloudComputing99%,20,中文+英文+数字,true,444.6,0.5558,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
4,pma,bNb,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,36pt中大号·副标题级·多行对比测试,19,中文+英文+数字,true,228.2,0.2853,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
5,pmW,bNs,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,05·中号字号测试28pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
5,pmW,bNT,80,120,800,50,0,0,800,28,思源黑体,true,false,true,none,,一、项目背景与目标Background,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNI,80,185,800,50,0,0,800,28,思源黑体,true,false,true,none,,二、市场分析与调研Market2024,19,中文+英文+数字,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNY,80,250,800,50,0,0,800,28,思源黑体,true,false,true,none,,三、技术方案与架构Technology,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNq,80,315,800,50,0,0,800,28,思源黑体,true,false,true,none,,四、实施计划与时间表Plan12月,17,中文+英文+数字,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
5,pmW,bNr,80,380,800,50,0,0,800,28,思源黑体,true,false,true,none,,五、预期效果与收益BenefitROI,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNM,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,28pt中号·章节标题级·目录式排列测试,20,中文+英文+数字,true,242.2,0.3027,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
6,pmL,bNO,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,06·正文字号测试18pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
6,pmL,bNf,80,120,800,320,0,0,800,18,思源黑体,false,false,true,none,,"这是一段正文测试文字,使用18pt字号,是幻灯片中最常用的正文字号。Typographyistheartandtechniqueofarrangingtypetomakewrittenlanguagelegible,readable,andappealingwhendisplayed.中文与英文混排测试:Thequickbrownfoxjumpsoverthelazydog.敏捷的棕色狐狸跳过了懒狗。数字测试:2024年12月25日,增长率12.5%,用户数1,234,567人。",242,中文+英文+数字,false,1079.1,1.3489,likely_wrap,估算宽度 1.35x 可用宽度,大概率换2行
|
||||
6,pmL,bNE,80,460,800,30,0,0,800,14,思源黑体,false,false,true,none,center,18pt正文·行间距1.8倍·中英文数字混排,22,中文+英文+数字,true,251.3,0.3141,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
7,pmy,bNK,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,07·小号字号测试14pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
7,pmy,bNU,80,120,800,340,0,0,800,14,思源黑体,false,false,true,none,,"小号正文测试14pt这是一段使用14pt字号的正文文字,适合用于内容较多的页面或说明性文字。在信息密度较高的幻灯片中,14pt是一个兼顾可读性与信息量的选择。Thisisaparagraphofbodytextin14ptsize.Itiscommonlyusedfordetaileddescriptions,footnotes,orcontent-heavyslideswhereinformationdensitymatters.中英文数字混排:2024年度报告显示,公司营收达到1,234.56万元,同比增长23.45%,用户满意度98.6%。常用标点符号测试:逗号,句号。感叹号!问号?冒号:分号;引号""""括号()省略号……破折号——",322,中文+英文+数字,false,1070.3,1.3379,likely_wrap,估算宽度 1.34x 可用宽度,大概率换2行
|
||||
7,pmy,bNF,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,14pt小号正文·行间距1.6倍·高密度信息展示,24,中文+英文+数字,true,239.4,0.2992,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
8,pmX,bNX,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,08·极小字号测试10pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
8,pmX,bNm,80,120,800,360,0,0,800,10,思源黑体,false,false,true,none,,"极小字号测试10pt—用于脚注、注释、数据来源说明等本页测试10pt极小字号的可读性。在正式演示中,10pt通常仅用于数据来源标注、脚注说明、版权信息等非核心内容,不建议用于正文。Datasource:NationalBureauofStatistics,2024AnnualReport.AllfiguresareinRMB10,000unlessotherwisenoted.Growthratesarecalculatedyear-over-year.数据来源:国家统计局2024年度报告。所有金额单位为万元,另有说明除外。增长率按同比计算。样本量n=10,234,置信区间95%。©2024TypographyTestLab.Allrightsreserved.版权所有,翻印必究。",345,中文+英文+数字,false,764.5,0.9556,tight_single,估算宽度 0.96x 可用宽度,接近填满,需确认是否单行
|
||||
8,pmX,bNl,80,490,800,30,0,0,800,10,思源黑体,false,false,true,none,center,10pt极小字号·脚注/注释级·测试极限可读性,23,中文+英文+数字,true,198.5,0.2481,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
9,pmE,baa,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,09·字号阶梯对比测试,11,中文+数字,true,270.2,0.3217,no_slack,估算宽度 < 70% 可用宽度(0.32),明显有留白
|
||||
9,pmE,bao,100,115,760,70,0,0,760,60,思源黑体,true,false,true,none,,60pt标题字号Title,13,中文+英文+数字,true,537.0,0.7066,no,估算宽度 0.71x 可用宽度,大概率单行
|
||||
9,pmE,bac,100,185,760,55,0,0,760,44,思源黑体,true,false,true,none,,44pt大标题Headline,15,中文+英文+数字,true,422.4,0.5558,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
9,pmE,bNg,100,245,760,45,0,0,760,32,思源黑体,true,false,true,none,,32pt副标题Sub-headline,19,中文+英文+数字,true,377.6,0.4968,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
9,pmE,bNL,100,295,760,38,0,0,760,24,思源黑体,false,false,true,none,,24pt小标题Section,14,中文+英文+数字,true,217.2,0.2858,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
9,pmE,baN,100,340,760,32,0,0,760,18,思源黑体,false,false,true,none,,18pt正文字号BodyText,16,中文+英文+数字,true,190.8,0.2511,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
9,pmE,bab,100,380,760,28,0,0,760,14,思源黑体,false,false,true,none,,14pt小号正文SmallBody,17,中文+英文+数字,true,156.1,0.2054,no_slack,估算宽度 < 70% 可用宽度(0.21),明显有留白
|
||||
9,pmE,bad,100,415,760,24,0,0,760,11,思源黑体,false,false,true,none,,11pt注释字号Caption/Footnote,24,中文+英文+数字,true,165.0,0.2171,no_slack,估算宽度 < 70% 可用宽度(0.22),明显有留白
|
||||
9,pmE,baB,100,455,760,30,0,0,760,13,思源黑体,false,false,true,none,center,从60pt到11pt·七级字号阶梯对比·一目了然,24,中文+英文+数字,true,253.5,0.3336,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
10,pms,baw,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,10·中文字体测试·黑体,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
10,pms,bav,80,120,800,80,0,0,800,48,思源黑体,true,false,true,none,center,思源黑体SourceHanSans,17,中文+英文,true,535.2,0.669,no_slack,估算宽度 < 70% 可用宽度(0.67),明显有留白
|
||||
10,pms,bae,80,220,800,60,0,0,800,32,思源黑体,true,false,true,none,center,现代简洁清晰易读专业稳重,12,中文,true,384,0.48,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
10,pms,baz,80,300,800,150,0,0,800,18,思源黑体,false,false,true,none,center,黑体字笔画均匀、结构方正,具有现代感和力量感。广泛应用于标题、标语、UI界面等场景。Thequickbrownfoxjumpsoverthelazydog.0123456789,88,中文+英文+数字,false,455.4,0.5692,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
10,pms,bap,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,思源黑体·无衬线中文字体·现代商务风格首选,21,中文,true,241.2,0.3015,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
11,pmV,baA,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,11·中文字体测试·宋体,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
11,pmV,baj,80,120,800,80,0,0,800,48,思源宋体,true,false,true,none,center,思源宋体SourceHanSerif,18,中文+英文,true,561.6,0.702,no,估算宽度 0.70x 可用宽度,大概率单行
|
||||
11,pmV,bau,80,220,800,60,0,0,800,32,思源宋体,true,false,true,none,center,典雅端庄文化底蕴传统韵味,12,中文,true,384,0.48,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
11,pmV,baW,80,300,800,150,0,0,800,18,思源宋体,false,false,true,none,center,宋体字横细竖粗,笔画末端有装饰性衬线,具有传统文化气息,适合正式、庄重的场合。Thequickbrownfoxjumpsoverthelazydog.0123456789,85,中文+英文+数字,false,455.4,0.5692,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
11,pmV,baJ,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,思源宋体·衬线中文字体·学术文化风格首选,20,中文,true,229.2,0.2865,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
12,pmD,ban,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,12·英文字体测试·无衬线体,14,中文+数字,true,341.6,0.4067,no_slack,估算宽度 < 70% 可用宽度(0.41),明显有留白
|
||||
12,pmD,bay,80,120,800,80,0,0,800,52,Helvetica,true,false,true,none,center,HelveticaNeue,13,英文,true,371.8,0.4648,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
12,pmD,baf,80,210,800,50,0,0,800,28,Helvetica,true,false,true,none,center,ABCDEFGHIJKLMNOPQRSTUVWXYZ,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
12,pmD,baU,80,265,800,50,0,0,800,28,Helvetica,true,false,true,none,center,abcdefghijklmnopqrstuvwxyz,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
12,pmD,bah,80,320,800,50,0,0,800,28,Helvetica,true,false,true,none,center,0123456789!@#$%^&*(),20,数字,true,308.0,0.385,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
12,pmD,bak,80,390,800,60,0,0,800,16,Helvetica,false,false,true,none,center,"Sans-seriffontsareclean,modern,andhighlylegible.WidelyusedinUIdesign,branding,anddigitalmedia.",94,英文,false,422.4,0.528,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
12,pmD,baO,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,无衬线体Sans-serif·现代简洁·数字界面首选,26,中文+英文,true,247.2,0.309,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
13,pmA,bai,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,13·英文字体测试·衬线体,13,中文+数字,true,313.6,0.3733,no_slack,估算宽度 < 70% 可用宽度(0.37),明显有留白
|
||||
13,pmA,bam,80,120,800,80,0,0,800,52,Georgia,true,false,true,none,center,GeorgiaSerif,12,英文,true,343.2,0.429,no_slack,估算宽度 < 70% 可用宽度(0.43),明显有留白
|
||||
13,pmA,bal,80,210,800,50,0,0,800,28,Georgia,true,false,true,none,center,ABCDEFGHIJKLMNOPQRSTUVWXYZ,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
13,pmA,baq,80,265,800,50,0,0,800,28,Georgia,true,false,true,none,center,abcdefghijklmnopqrstuvwxyz,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
13,pmA,bar,80,320,800,50,0,0,800,28,Georgia,true,false,true,none,center,0123456789!@#$%^&*(),20,数字,true,308.0,0.385,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
13,pmA,baM,80,390,800,60,0,0,800,16,Georgia,false,false,true,none,center,"Seriffontshavesmalllinesattheendsofcharacters,conveyingtradition,elegance,andauthority.",87,英文,false,404.8,0.506,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
13,pmA,baC,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,衬线体Serif·优雅传统·印刷出版首选,20,中文+英文,true,202.2,0.2527,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
14,pmi,baY,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,14·数字测试·阿拉伯数字,13,中文+数字,true,313.6,0.3733,no_slack,估算宽度 < 70% 可用宽度(0.37),明显有留白
|
||||
14,pmi,baK,80,120,800,100,0,0,800,72,思源黑体,true,false,true,none,center,0123456789,10,数字,true,396.0,0.495,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
14,pmi,baP,80,240,800,60,0,0,800,36,思源黑体,true,false,true,none,center,0123456789零壹贰叁肆伍陆柒捌玖,20,中文+数字,true,558.0,0.6975,no_slack,估算宽度 < 70% 可用宽度(0.70),明显有留白
|
||||
14,pmi,bax,80,320,800,120,0,0,800,20,思源黑体,false,false,true,none,center,"金额:¥12,345,678.90元百分比:99.99%增长率:+23.45%日期:2024-12-25时间:14:30:00",63,中文+数字,false,318.0,0.3975,no_slack,估算宽度 < 70% 可用宽度(0.40),明显有留白
|
||||
14,pmi,baD,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,阿拉伯数字·等宽比例·金额/百分比/日期格式测试,24,中文,true,266.4,0.333,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
15,pmK,bag,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,15·数字测试·中文数字,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
15,pmK,bba,80,120,800,80,0,0,800,48,思源黑体,true,false,true,none,center,小写:〇一二三四五六七八九十,14,中文,true,672,0.84,no,估算宽度 0.84x 可用宽度,大概率单行
|
||||
15,pmK,baX,80,210,800,80,0,0,800,48,思源黑体,true,false,true,none,center,大写:零壹贰叁肆伍陆柒捌玖拾,14,中文,true,672,0.84,no,估算宽度 0.84x 可用宽度,大概率单行
|
||||
15,pmK,baG,80,310,800,130,0,0,800,20,思源黑体,false,false,true,none,center,人民币壹佰贰拾叁万肆仟伍佰陆拾柒元捌角玖分二千零二十四年十二月二十五日第一百二十届第三季度百分之八十五,51,中文,false,420,0.525,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
15,pmK,bbN,80,465,800,30,0,0,800,12,思源黑体,false,false,true,none,center,中文数字·大小写·财务/正式文书场景,18,中文,true,199.8,0.2497,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
16,pmQ,bbc,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,16·数字测试·罗马数字,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
16,pmQ,bbd,80,130,800,80,0,0,800,48,思源黑体,true,false,true,none,center,IIIIIIIVVVIVIIVIIIIXX,21,英文,true,554.4,0.693,no_slack,估算宽度 < 70% 可用宽度(0.69),明显有留白
|
||||
16,pmQ,bbB,80,225,800,60,0,0,800,36,思源黑体,true,false,true,none,center,XLLXCCCDDCMM,12,英文,true,237.6,0.297,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
16,pmQ,bbv,80,300,800,140,0,0,800,20,思源黑体,false,false,true,none,center,ChapterXXIV·VolumeIII·EditionIXKingHenryVIII·PopeJohnPaulII第XXI届冬季奥林匹克运动会·第III季度报告MMXXIV年·MCMLXXI年,98,中文+英文,false,397.0,0.4963,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
16,pmQ,bbb,80,465,800,30,0,0,800,12,思源黑体,false,false,true,none,center,罗马数字·古典风格·章节编号/正式命名场景,21,中文,true,235.8,0.2947,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
17,pme,bbe,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,17·中英文混排测试,10,中文+数字,true,242.2,0.2883,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
17,pme,bbz,80,120,800,60,0,0,800,32,思源黑体,true,false,true,none,center,人工智能ArtificialIntelligence技术,28,中文+英文,true,579.2,0.724,no,估算宽度 0.72x 可用宽度,大概率单行
|
||||
17,pme,bbR,80,200,800,200,0,0,800,18,思源黑体,false,false,true,none,,随着AI技术的快速发展,MachineLearning与DeepLearning已经渗透到各行各业。在NaturalLanguageProcessing领域,大语言模型LLM的出现彻底改变了人机交互方式。从ChatGPT到文心一言,从GPT-4到Claude3,AI助手正在成为人们工作生活的标配。2024年被称为AI应用元年,GenerativeAI创造了无限可能。,184,中文+英文+数字,false,709.2,0.8865,tight_single,估算宽度 0.89x 可用宽度,接近填满,需确认是否单行
|
||||
17,pme,bbp,80,420,800,60,0,0,800,16,思源黑体,false,false,true,none,center,"测试中英文之间的间距、基线对齐、字号协调等混排效果Testingspacing,baselinealignment,andsizeharmonybetweenCJKandLatinscripts",97,中文+英文,false,633.6,0.792,no,估算宽度 0.79x 可用宽度,大概率单行
|
||||
18,pmF,bbj,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,18·中英文数字混排测试,12,中文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
18,pmF,bbu,80,115,800,55,0,0,800,28,思源黑体,true,false,true,none,,2024Q4季度业绩报告QuarterlyReport,27,中文+英文+数字,true,491.4,0.6142,no_slack,估算宽度 < 70% 可用宽度(0.61),明显有留白
|
||||
18,pmF,bbA,80,180,800,250,0,0,800,17,思源黑体,false,false,true,none,,"核心数据KeyMetrics:总营收Revenue:¥1,234.56万元,同比增长+23.45%用户数Users:567,890人,月活MAU达89%净利润NetProfit:¥234.56万元,利润率19.0%客户满意度CSAT:4.8/5.0分,NPS达72产品迭代Version:v3.2.1,发布于2024-12-15",164,中文+英文+数字,false,402.05,0.5026,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
18,pmF,bbW,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,中文+英文+数字+符号+列表·综合混排测试,21,中文,true,262.5,0.3281,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
19,pmz,bbD,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,19·字重测试·粗体Bold,14,中文+英文+数字,true,291.2,0.3467,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
19,pmz,bbs,80,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,常规Regular,9,中文+英文,true,187.2,0.4926,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
19,pmz,bbF,500,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,粗体Bold,6,中文+英文,true,134.4,0.3537,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
19,pmz,bbK,80,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,正常字重400,7,中文+数字,true,180.8,0.4758,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
19,pmz,bbI,500,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,加粗字重700,7,中文+数字,true,180.8,0.4758,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
19,pmz,bbY,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"在正文中,粗体文字用于强调关键信息,引导读者视线。Inbodytext,boldtexthighlightskeyinformationandguidesthereader.数字加粗:1234567890对比常规:1234567890",117,中文+英文+数字,false,613.8,0.7672,no,估算宽度 0.77x 可用宽度,大概率单行
|
||||
19,pmz,bbU,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,粗体vs常规·字重对比·强调效果测试,18,中文+英文,true,194.4,0.243,no_slack,估算宽度 < 70% 可用宽度(0.24),明显有留白
|
||||
20,pmd,bbE,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,20·字体样式测试·斜体Italic,18,中文+英文+数字,true,378.0,0.45,no_slack,估算宽度 < 70% 可用宽度(0.45),明显有留白
|
||||
20,pmd,bbS,80,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,正体Upright,9,中文+英文,true,187.2,0.4926,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
20,pmd,bbk,500,120,380,60,0,0,380,32,思源黑体,true,true,true,none,,斜体Italic,8,中文+英文,true,169.6,0.4463,no_slack,估算宽度 < 70% 可用宽度(0.45),明显有留白
|
||||
20,pmd,bbO,80,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,NormalText,10,英文,true,176.0,0.4632,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
20,pmd,bbf,500,200,380,60,0,0,380,32,思源黑体,true,true,true,none,,ItalicText,10,英文,true,176.0,0.4632,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
20,pmd,bbJ,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"斜体常用于引用、书名、外来词等场景,增添文字的韵律感。Italictextisoftenusedforquotes,booktitles,andforeignwords.数字斜体:1234567890对比正体:1234567890",115,中文+英文+数字,false,574.2,0.7177,no,估算宽度 0.72x 可用宽度,大概率单行
|
||||
20,pmd,bbn,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,斜体vs正体·引用/书名/强调场景,17,中文+英文,true,177.0,0.2213,no_slack,估算宽度 < 70% 可用宽度(0.22),明显有留白
|
||||
21,pmG,bbM,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,21·文字装饰测试·下划线/删除线,17,中文+数字,true,413.0,0.4917,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
21,pmG,bbq,80,120,800,60,0,0,800,32,思源黑体,true,false,true,none,center,下划线文字UnderlineText,18,中文+英文,true,388.8,0.486,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
21,pmG,bbi,80,200,800,60,0,0,800,32,思源黑体,true,false,true,none,center,删除线文字StrikethroughText,22,中文+英文,true,459.2,0.574,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
21,pmG,bbT,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"下划线常用于链接、重点标注等场景,删除线用于表示已删除或作废的内容。Underlineisusedforlinksandkeyannotations,strikethroughfordeletedcontent.组合效果:加粗加下划线·斜体加下划线",124,中文+英文,false,712.8,0.891,tight_single,估算宽度 0.89x 可用宽度,接近填满,需确认是否单行
|
||||
21,pmG,bbC,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,下划线·删除线·组合样式测试,14,中文,true,157.2,0.1965,no_slack,估算宽度 < 70% 可用宽度(0.20),明显有留白
|
||||
22,pmq,bZa,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,22·字间距测试LetterSpacing,21,中文+英文+数字,true,386.4,0.46,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
22,pmq,bbg,80,120,800,50,0,0,800,28,思源黑体,true,false,true,none,center,紧凑字间距-2pxTight,14,中文+英文+数字,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
22,pmq,bbG,80,185,800,50,0,0,800,28,思源黑体,true,false,true,none,center,正常字间距0pxNormal,14,中文+英文+数字,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
22,pmq,bZN,80,250,800,50,0,0,800,28,思源黑体,true,false,true,none,center,宽松字间距3pxLoose,13,中文+英文+数字,true,263.2,0.329,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
22,pmq,bZb,80,315,800,50,0,0,800,28,思源黑体,true,false,true,none,center,超宽字间距8pxWide,12,中文+英文+数字,true,247.8,0.3098,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
22,pmq,bbl,80,390,800,80,0,0,800,16,思源黑体,false,false,true,none,center,字间距影响文字的呼吸感和阅读节奏标题常用宽松字间距营造高级感,正文用正常字间距保证可读性,44,中文,false,448,0.56,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
22,pmq,bbX,80,485,800,25,0,0,800,11,思源黑体,false,false,true,none,center,从-2px到8px·四级字间距对比,17,中文+英文+数字,true,147.4,0.1842,no_slack,估算宽度 < 70% 可用宽度(0.18),明显有留白
|
||||
23,pmS,bZB,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,23·行间距测试LineSpacing,19,中文+英文+数字,true,355.6,0.4233,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
23,pmS,bZZ,60,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,1.0倍行距这是一段测试文字,用于展示1.0倍行间距的效果。行间距较小时,文字显得紧凑,但可能影响可读性。紧凑排版适合空间有限的场景。,67,中文+数字,false,639.1,2.367,yes,估算宽度 2.37x 可用宽度,肯定换行(多行)
|
||||
23,pmS,bZo,345,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,1.5倍行距这是一段测试文字,用于展示1.5倍行间距的效果。这是最常用的行间距设置,兼顾可读性和信息密度。适合大多数正文排版场景。,65,中文+数字,false,639.1,2.367,yes,估算宽度 2.37x 可用宽度,肯定换行(多行)
|
||||
23,pmS,bZc,630,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,2.0倍行距这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。,64,中文+数字,false,322,1.1926,borderline,估算宽度 1.19x 可用宽度,边界情况,需截图确认是否换行
|
||||
23,pmS,bZH,60,475,840,30,0,0,840,12,思源黑体,false,false,true,none,center,1.0/1.5/2.0倍行距对比·三列并排展示,23,中文+数字,true,211.2,0.2514,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
24,pmM,bZR,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,24·对齐方式测试Alignment,18,中文+英文+数字,true,352.8,0.42,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
24,pmM,bZu,80,115,800,50,0,0,800,24,思源黑体,true,false,true,none,left,←左对齐LeftAlign,13,中文+英文,true,204.0,0.255,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
24,pmM,bZe,80,180,800,50,0,0,800,24,思源黑体,true,false,true,none,center,居中对齐CenterAlign,15,中文+英文,true,241.2,0.3015,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
24,pmM,bZz,80,245,800,50,0,0,800,24,思源黑体,true,false,true,none,right,右对齐RightAlign→,14,中文+英文,true,217.2,0.2715,no_slack,估算宽度 < 70% 可用宽度(0.27),明显有留白
|
||||
24,pmM,bZp,80,310,800,120,0,0,800,16,思源黑体,false,false,true,none,justify,两端对齐Justify:这是一段用于测试两端对齐效果的较长文字,文字的左右两边都会对齐,形成整齐的文字块边缘,适合报纸、杂志等正式排版。Thequickbrownfoxjumpsoverthelazydog.Typographyistheartandtechniqueofarrangingtype.,150,中文+英文,true,1759.2,2.199,yes,估算宽度 2.20x 可用宽度,肯定换行(多行)
|
||||
24,pmM,bZV,80,455,800,30,0,0,800,12,思源黑体,false,false,true,none,center,左对齐·居中·右对齐·两端对齐·四种对齐方式,22,中文,true,242.4,0.303,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
25,pmP,bZt,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,25·文字颜色测试Colors,15,中文+英文+数字,true,306.6,0.365,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
25,pmP,bZk,80,115,800,45,0,0,800,26,思源黑体,true,false,true,none,center,深蓝DarkBlue·主色Primary,20,中文+英文,true,332.8,0.416,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
25,pmP,bZO,80,170,800,45,0,0,800,26,思源黑体,true,false,true,none,center,亮蓝BrightBlue·辅色Secondary,24,中文+英文,true,390.0,0.4875,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
25,pmP,bZA,80,225,800,45,0,0,800,26,思源黑体,true,false,true,none,center,青绿Teal·成功色Success,17,中文+英文,true,301.6,0.377,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
25,pmP,bZJ,80,280,800,45,0,0,800,26,思源黑体,true,false,true,none,center,琥珀Amber·警告色Warning,18,中文+英文,true,315.9,0.3949,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
25,pmP,bZn,80,335,800,45,0,0,800,26,思源黑体,true,false,true,none,center,红色Red·危险色Danger,15,中文+英文,true,273.0,0.3413,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
25,pmP,bZS,80,390,800,45,0,0,800,26,思源黑体,true,false,true,none,center,灰色Gray·辅助色Neutral,17,中文+英文,true,301.6,0.377,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
25,pmP,bZy,80,455,800,30,0,0,800,12,思源黑体,false,false,true,none,center,六色系统·主/辅/成功/警告/危险/中性·色彩对比测试,27,中文,true,286.2,0.3577,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
26,pmh,bZU,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,26·特殊符号测试Symbols,16,中文+英文+数字,true,322.0,0.3833,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
26,pmh,bZD,80,115,800,50,0,0,800,28,思源黑体,true,false,true,none,center,"标点符号:,。!?:;""""''()【】",29,中文+英文,true,635.6,0.7945,no,估算宽度 0.79x 可用宽度,大概率单行
|
||||
26,pmh,bZE,80,180,800,50,0,0,800,28,思源黑体,true,false,true,none,center,数学符号:+-×÷=≈≠≤≥±%,16,中文,true,347.2,0.434,no_slack,估算宽度 < 70% 可用宽度(0.43),明显有留白
|
||||
26,pmh,bZF,80,245,800,50,0,0,800,28,思源黑体,true,false,true,none,center,货币符号:¥$€£₩₹¢,12,中文,true,247.8,0.3098,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
26,pmh,bZK,80,310,800,50,0,0,800,28,思源黑体,true,false,true,none,center,单位符号:℃℉°‰㎡kgms,14,中文+英文,true,291.2,0.364,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
26,pmh,bZI,80,375,800,50,0,0,800,28,思源黑体,true,false,true,none,center,其他符号:@#&*§¶©®™,14,中文,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
26,pmh,bZP,80,450,800,30,0,0,800,12,思源黑体,false,false,true,none,center,标点·数学·货币·单位·特殊符号·全面测试,21,中文,true,225.0,0.2812,no_slack,估算宽度 < 70% 可用宽度(0.28),明显有留白
|
||||
27,pml,bZY,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,27·段落与列表测试Lists,15,中文+英文+数字,true,319.2,0.38,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
27,pml,bZq,80,115,380,340,0,0,380,16,思源黑体,false,false,true,none,,无序列表UnorderedList第一项ItemOne第二项ItemTwo第三项ItemThree第四项ItemFour第五项ItemFive,71,中文+英文,false,178.4,0.4695,no_slack,估算宽度 < 70% 可用宽度(0.47),明显有留白
|
||||
27,pml,bZi,500,115,380,340,0,0,380,16,思源黑体,false,false,true,none,,有序列表OrderedList第一步StepOne第二步StepTwo第三步StepThree第四步StepFour第五步StepFive,69,中文+英文,false,160.8,0.4232,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
27,pml,bZr,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,无序列表·有序列表·中英文列表项对比,18,中文,true,205.2,0.2565,no_slack,估算宽度 < 70% 可用宽度(0.26),明显有留白
|
||||
28,pmB,bZl,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,28·综合排版测试Layout,15,中文+英文+数字,true,306.6,0.365,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
28,pmB,bZG,60,110,420,50,0,0,420,26,思源黑体,true,false,true,none,,左栏标题LeftColumn,14,中文+英文,true,247.0,0.5881,no_slack,估算宽度 < 70% 可用宽度(0.59),明显有留白
|
||||
28,pmB,bZM,500,110,400,50,0,0,400,26,思源黑体,true,false,true,none,,右栏标题RightColumn,15,中文+英文,true,261.3,0.6533,no_slack,估算宽度 < 70% 可用宽度(0.65),明显有留白
|
||||
28,pmB,bZm,60,170,420,280,0,0,420,15,思源黑体,false,false,true,none,,"这是左栏的正文内容,使用15pt字号,1.7倍行间距。多栏排版可以有效利用页面空间,提升信息密度。要点一:重点信息加粗显示要点二:引用内容斜体标注要点三:关键数据下划线强调数字测试:123,456,789·百分比:99.9%",112,中文+英文+数字,false,357.75,0.8518,tight_single,估算宽度 0.85x 可用宽度,接近填满,需确认是否单行
|
||||
28,pmB,bZQ,500,170,400,280,0,0,400,15,思源黑体,false,false,true,none,,"Thisistherightcolumnbodytextin15ptsize.Multi-columnlayoutoptimizesspaceandinformationdensity.Point1:BoldforemphasisPoint2:ItalicforquotesPoint3:UnderlineforkeydataNumbers:123,456,789·Percent:99.9%",196,英文+数字,false,445.5,1.1138,borderline,估算宽度 1.11x 可用宽度,边界情况,需截图确认是否换行
|
||||
28,pmB,bZg,60,470,840,30,0,0,840,12,思源黑体,false,false,true,none,center,双栏布局·中英对照·列表+强调+数字·综合测试,23,中文,true,249.0,0.2964,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
29,pmU,bdZ,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,29·字符密度测试Density,16,中文+英文+数字,true,322.0,0.3833,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
29,pmU,bdN,60,110,840,360,0,0,840,13,思源黑体,false,false,true,none,,"高密度文字排版测试·小字号大信息量场景排版设计(Typography)是一门关于文字排列和视觉呈现的艺术与技术。它涉及字体选择、字号大小、行间距、字间距、对齐方式、颜色搭配等多个方面。好的排版设计不仅能让文字更易读,还能传递情感和品牌调性。Typographyistheartandtechniqueofarrangingtypetomakewrittenlanguagelegible,readable,andappealingwhendisplayed.Thearrangementoftypeinvolvesselectingtypefaces,pointsizes,linelengths,line-spacing,andletter-spacing,andadjustingthespacebetweenpairsofletters.中文排版与英文排版有许多不同之处:中文是方块字,每个字符宽度基本一致;英文是比例字体,字符宽度各不相同。中文排版需要考虑标点符号的位置、中英文混排的间距等问题。数字0123456789在中英文混排时也需要特别注意基线对齐和宽度协调。数据:2024年全球字体市场规模达$12.34亿美元,年增长率8.5%。Top5字体:Helvetica,Arial,TimesNewRoman,Calibri,Garamond。",578,中文+英文+数字,false,1801.8,2.145,yes,估算宽度 2.15x 可用宽度,肯定换行(多行)
|
||||
29,pmU,bda,60,485,840,25,0,0,840,11,思源黑体,false,false,true,none,center,13pt高密度·多段落·中英文数字混排·信息密度测试,26,中文+英文+数字,true,251.35,0.2992,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
30,pmv,bdB,80,180,800,100,0,0,800,56,思源黑体,true,false,true,none,center,测试样张·完,6,中文,true,310.8,0.3885,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
30,pmv,bdc,80,300,800,50,0,0,800,22,思源黑体,true,false,true,none,center,EndofTypographyTestSample,25,英文,true,302.5,0.3781,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
30,pmv,bdd,80,380,800,80,0,0,800,14,思源黑体,false,false,true,none,center,共30页·涵盖字号/字体/中英文/数字/样式/间距/颜色/排版30Pages·Size/Font/CJK&Latin/Numbers/Style/Spacing/Color/Layout,93,中文+英文+数字,false,477.4,0.5967,no_slack,估算宽度 < 70% 可用宽度(0.60),明显有留白
|
||||
|
115
skills/lark-slides/scripts/width_measurement_report.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 字符宽度测量数据报告
|
||||
|
||||
**Presentation**: BUFBsLX2ZlzyMTdprLicd7rpneg
|
||||
**总样本数**: 158
|
||||
**关键测量样本(比值≥0.85)**: 12
|
||||
- 接近填满(0.85-1.0): 4
|
||||
- 边界情况(1.0-1.2): 2
|
||||
- 大概率换行(1.2-1.5): 2
|
||||
- 肯定换行(>1.5): 4
|
||||
|
||||
## 按文本类型统计(所有样本)
|
||||
|
||||
| 文本类型 | 样本数 | 平均估算/可用比 | 最小比值 | 最大比值 |
|
||||
|----------|--------|------------------|----------|----------|
|
||||
| 中文 | 24 | 0.4099 | 0.1965 | 0.8400 |
|
||||
| 中文+数字 | 21 | 0.6316 | 0.2514 | 2.3670 |
|
||||
| 中文+英文 | 38 | 0.5405 | 0.2213 | 2.1990 |
|
||||
| 中文+英文+数字 | 56 | 0.4992 | 0.1842 | 2.1450 |
|
||||
| 数字 | 3 | 0.4217 | 0.3850 | 0.4950 |
|
||||
| 英文 | 14 | 0.4805 | 0.2970 | 0.6930 |
|
||||
| 英文+数字 | 2 | 0.7879 | 0.4620 | 1.1138 |
|
||||
|
||||
## 接近填满样本(估算比值 0.85-1.0,最适合校准单行宽度)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 8 | bNm | 思源黑体 | 10 | false | 中文+英文+数字 | false | 极小字号测试10pt—用于脚注、注释、数据来源说明等本页测试10pt极小字号的可读性。在正式演示中,... | 764.5 | 800 | 0.9556 |
|
||||
| 17 | bbR | 思源黑体 | 18 | false | 中文+英文+数字 | false | 随着AI技术的快速发展,MachineLearning与DeepLearning已经渗透到各行各业。... | 709.2 | 800 | 0.8865 |
|
||||
| 21 | bbT | 思源黑体 | 18 | false | 中文+英文 | false | 下划线常用于链接、重点标注等场景,删除线用于表示已删除或作废的内容。Underlineisusedf... | 712.8 | 800 | 0.891 |
|
||||
| 28 | bZm | 思源黑体 | 15 | false | 中文+英文+数字 | false | 这是左栏的正文内容,使用15pt字号,1.7倍行间距。多栏排版可以有效利用页面空间,提升信息密度。要... | 357.75 | 420 | 0.8518 |
|
||||
|
||||
## 边界换行样本(估算比值 1.0-1.2,需截图确认是否换行)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 23 | bZc | 思源黑体 | 14 | false | 中文+数字 | false | 2.0倍行距这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验... | 322 | 270 | 1.1926 |
|
||||
| 28 | bZQ | 思源黑体 | 15 | false | 英文+数字 | false | Thisistherightcolumnbodytextin15ptsize.Multi-colum... | 445.5 | 400 | 1.1138 |
|
||||
|
||||
## 大概率换行样本(估算比值 1.2-1.5)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 6 | bNf | 思源黑体 | 18 | false | 中文+英文+数字 | false | 这是一段正文测试文字,使用18pt字号,是幻灯片中最常用的正文字号。Typographyisthea... | 1079.1 | 800 | 1.3489 |
|
||||
| 7 | bNU | 思源黑体 | 14 | false | 中文+英文+数字 | false | 小号正文测试14pt这是一段使用14pt字号的正文文字,适合用于内容较多的页面或说明性文字。在信息密... | 1070.3 | 800 | 1.3379 |
|
||||
|
||||
## 肯定换行样本(估算比值 > 1.5)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 23 | bZZ | 思源黑体 | 14 | false | 中文+数字 | false | 1.0倍行距这是一段测试文字,用于展示1.0倍行间距的效果。行间距较小时,文字显得紧凑,但可能影响可... | 639.1 | 270 | 2.367 |
|
||||
| 23 | bZo | 思源黑体 | 14 | false | 中文+数字 | false | 1.5倍行距这是一段测试文字,用于展示1.5倍行间距的效果。这是最常用的行间距设置,兼顾可读性和信息... | 639.1 | 270 | 2.367 |
|
||||
| 24 | bZp | 思源黑体 | 16 | false | 中文+英文 | true | 两端对齐Justify:这是一段用于测试两端对齐效果的较长文字,文字的左右两边都会对齐,形成整齐的文... | 1759.2 | 800 | 2.199 |
|
||||
| 29 | bdN | 思源黑体 | 13 | false | 中文+英文+数字 | false | 高密度文字排版测试·小字号大信息量场景排版设计(Typography)是一门关于文字排列和视觉呈现的... | 1801.8 | 840 | 2.145 |
|
||||
|
||||
## 字体统计
|
||||
|
||||
| 字体 | 样本数 |
|
||||
|------|--------|
|
||||
| 思源黑体 | 145 |
|
||||
| Helvetica | 5 |
|
||||
| Georgia | 5 |
|
||||
| 思源宋体 | 3 |
|
||||
|
||||
## 字号+粗体统计
|
||||
|
||||
| 字号 | Bold | 样本数 | 平均比值 |
|
||||
|------|------|--------|----------|
|
||||
| 10 | false | 2 | 0.6018 |
|
||||
| 11 | false | 3 | 0.2335 |
|
||||
| 12 | false | 17 | 0.2784 |
|
||||
| 13 | false | 2 | 1.2393 |
|
||||
| 14 | false | 12 | 0.8339 |
|
||||
| 15 | false | 2 | 0.9828 |
|
||||
| 16 | false | 8 | 0.7480 |
|
||||
| 17 | false | 1 | 0.5026 |
|
||||
| 18 | false | 8 | 0.7501 |
|
||||
| 20 | false | 3 | 0.4729 |
|
||||
| 22 | true | 1 | 0.3781 |
|
||||
| 24 | false | 1 | 0.2858 |
|
||||
| 24 | true | 4 | 0.3225 |
|
||||
| 26 | true | 8 | 0.4544 |
|
||||
| 28 | true | 49 | 0.4030 |
|
||||
| 32 | true | 14 | 0.4931 |
|
||||
| 36 | true | 6 | 0.5820 |
|
||||
| 44 | true | 1 | 0.5558 |
|
||||
| 48 | true | 8 | 0.6644 |
|
||||
| 52 | true | 2 | 0.4469 |
|
||||
| 56 | true | 1 | 0.3885 |
|
||||
| 60 | true | 1 | 0.7066 |
|
||||
| 64 | true | 1 | 0.6400 |
|
||||
| 72 | true | 3 | 0.6450 |
|
||||
|
||||
## 说明
|
||||
|
||||
- **估算宽度**: 使用当前 `estimate_character_width` 函数计算(中文=1em,西文=0.55em,空格=0.33em)
|
||||
- **可用宽度**: shape.width - paddingLeft - paddingRight
|
||||
- **width_ratio**: 估算宽度 / 可用宽度(针对最长硬换行段落计算)
|
||||
- **硬换行**: 文本中是否包含显式 \n 分段
|
||||
- 截图保存在: /Users/bytedance/go/src/github.com/larksuite/cli/skills/lark-slides/scripts/.lark-slides/screenshots
|
||||
|
||||
### 比值解读建议
|
||||
- ratio < 0.7: 明显留白,估算宽度可能偏宽,或文本确实很短
|
||||
- 0.7-0.85: 大概率单行,有少量留白
|
||||
- 0.85-1.0: 接近填满,是校准西文/中文字符宽度系数的最佳样本
|
||||
- 1.0-1.2: 边界情况,需要看截图确认:是刚好填满单行还是换行了
|
||||
- 1.2-1.5: 大概率换2行
|
||||
- >1.5: 肯定换行(多行文本)
|
||||
|
||||
### 校准建议
|
||||
1. 先看 ratio 0.85-1.0 的样本:如果截图中这些文本**确实单行且接近填满**,说明当前估算大致准确;如果有较多留白,说明估算偏宽,需要减小西文字符系数
|
||||
2. 再看 ratio 1.0-1.2 的样本:结合截图判断实际是单行还是换行,反推合理系数
|
||||
3. 重点关注纯英文、纯数字、纯中文、中英混合这几类分别统计
|
||||
4. Bold 字体通常比常规字体稍宽,Italic 稍窄,需要分别考虑
|
||||
|
||||
请人工核对截图确认边界样本的实际换行情况,用于校准字符宽度系数。
|
||||
@@ -60,6 +60,13 @@ GHOST_TEXT_MIN_FONT_SIZE = 96
|
||||
GHOST_TEXT_MAX_ALPHA = 0.5
|
||||
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
|
||||
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
|
||||
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
|
||||
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
|
||||
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
|
||||
LINE_TEXT_GRAZE_MIN_PX = 2.0
|
||||
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
|
||||
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
|
||||
LINE_MIN_VISIBLE_ALPHA = 0.08
|
||||
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
|
||||
# visible defect; keep this well under 1px so real overflow is still always caught.
|
||||
CANVAS_OVERFLOW_TOLERANCE = 0.5
|
||||
@@ -928,6 +935,96 @@ def estimate_text_width(
|
||||
return base + max(len(text) - 1, 0) * letter_spacing
|
||||
|
||||
|
||||
def is_cjk_char(character: str) -> bool:
|
||||
"""CJK-like characters may wrap between any two adjacent glyphs.
|
||||
|
||||
Mirrors the isCJKLike ranges used by ee/slide text-measure-module so that
|
||||
line-count estimation matches DOM/Skia wrapping: CJK breaks per glyph while
|
||||
latin words stay atomic.
|
||||
"""
|
||||
code = ord(character)
|
||||
return (
|
||||
0x2E80 <= code <= 0x9FFF
|
||||
or 0x3000 <= code <= 0xD7AF
|
||||
or 0xF900 <= code <= 0xFAFF
|
||||
or 0xFE30 <= code <= 0xFE4F
|
||||
or 0xFF01 <= code <= 0xFF60
|
||||
or 0xFFE0 <= code <= 0xFFE6
|
||||
)
|
||||
|
||||
|
||||
def tokenize_for_wrap(text: str) -> list[tuple[str, str]]:
|
||||
"""Split a hard line into wrap tokens: latin words are atomic, CJK glyphs
|
||||
are individually breakable, whitespace runs are collapse points."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
index = 0
|
||||
length = len(text)
|
||||
while index < length:
|
||||
character = text[index]
|
||||
if character.isspace():
|
||||
start = index
|
||||
while index < length and text[index].isspace():
|
||||
index += 1
|
||||
tokens.append(("space", text[start:index]))
|
||||
elif is_cjk_char(character):
|
||||
tokens.append(("cjk", character))
|
||||
index += 1
|
||||
else:
|
||||
start = index
|
||||
while index < length and not text[index].isspace() and not is_cjk_char(text[index]):
|
||||
index += 1
|
||||
tokens.append(("word", text[start:index]))
|
||||
return tokens
|
||||
|
||||
|
||||
def count_wrapped_lines(
|
||||
text: str,
|
||||
font_size: int | float,
|
||||
letter_spacing: int | float,
|
||||
bold: bool,
|
||||
font_family: str | None,
|
||||
available_width: int | float,
|
||||
) -> int:
|
||||
"""Greedy word-aware wrapped line count.
|
||||
|
||||
Unlike ceil(width / available), latin words are never split mid-word (unless
|
||||
a single word is wider than the line, in which case it breaks like DOM
|
||||
overflow-wrap:break-word). This avoids under-counting lines for word-heavy
|
||||
text and matches how ee/slide reconciles Skia wrapping with the DOM.
|
||||
"""
|
||||
tokens = tokenize_for_wrap(text)
|
||||
if not tokens:
|
||||
return 1
|
||||
lines = 1
|
||||
current = 0.0
|
||||
|
||||
def token_width(token: str) -> int | float:
|
||||
return estimate_text_width(token, font_size, letter_spacing, bold, font_family)
|
||||
|
||||
for kind, token in tokens:
|
||||
width = token_width(token)
|
||||
# letter-spacing applies at every glyph boundary, including the seam
|
||||
# between two tokens on the same line; add it back so a packed line
|
||||
# matches estimate_text_width of the concatenated run.
|
||||
junction = letter_spacing if current > 0 else 0
|
||||
if kind == "space":
|
||||
if current > 0:
|
||||
current += junction + width
|
||||
continue
|
||||
if current > 0 and current + junction + width <= available_width:
|
||||
current += junction + width
|
||||
continue
|
||||
if current > 0:
|
||||
lines += 1
|
||||
if kind == "word" and width > available_width:
|
||||
extra = math.ceil(width / available_width) - 1
|
||||
lines += extra
|
||||
current = width - extra * available_width
|
||||
else:
|
||||
current = width
|
||||
return lines
|
||||
|
||||
|
||||
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
|
||||
if paragraph is not None:
|
||||
value = paragraph.get("letterSpacing")
|
||||
@@ -960,6 +1057,11 @@ def is_short_metric_text(text: str) -> bool:
|
||||
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
|
||||
|
||||
|
||||
def is_labeled_short_metric_text(text: str) -> bool:
|
||||
"""Return whether a short metric contains a separate label before its value."""
|
||||
return is_short_metric_text(text) and re.search(r"[A-Za-z]+\s+[+\-–—]?\d", text) is not None
|
||||
|
||||
|
||||
def is_single_line_visual_candidate(
|
||||
element: dict[str, Any],
|
||||
paragraph: dict[str, Any] | None,
|
||||
@@ -969,7 +1071,7 @@ def is_single_line_visual_candidate(
|
||||
) -> bool:
|
||||
if "\n" in text or logical_width <= effective_width:
|
||||
return False
|
||||
if is_short_metric_text(text):
|
||||
if is_short_metric_text(text) and not is_labeled_short_metric_text(text):
|
||||
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
|
||||
|
||||
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
|
||||
@@ -988,7 +1090,12 @@ def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
|
||||
bold = element.get("bold", False)
|
||||
font_family = element.get("fontFamily", "")
|
||||
letter_spacing = resolve_letter_spacing(element)
|
||||
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
|
||||
# Visual width ignores trailing whitespace: like Skia (which trims line-end
|
||||
# spaces), a run's rightmost visible glyph bounds the box. Counting trailing
|
||||
# spaces inflates the right edge and manufactures overlap false positives.
|
||||
paragraphs = [
|
||||
stripped for paragraph in re.split(r"\n+", element["text"]) if (stripped := paragraph.rstrip())
|
||||
]
|
||||
return max(
|
||||
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
|
||||
or [1]
|
||||
@@ -1026,7 +1133,9 @@ def estimate_text_line_count_for_text(
|
||||
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
|
||||
line_count += 1
|
||||
continue
|
||||
line_count += max(1, math.ceil(logical_width / effective_width))
|
||||
line_count += count_wrapped_lines(
|
||||
hard_line, font_size, letter_spacing, bold, font_family, effective_width
|
||||
)
|
||||
return line_count
|
||||
|
||||
|
||||
@@ -1500,8 +1609,6 @@ def detect_elements_out_of_canvas(
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
|
||||
):
|
||||
if is_ghost_text(element):
|
||||
continue
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
@@ -1611,6 +1718,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
|
||||
return issues
|
||||
|
||||
|
||||
def segment_intersects_rect(
|
||||
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
|
||||
) -> bool:
|
||||
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
|
||||
left = rect["x"]
|
||||
top = rect["y"]
|
||||
right = rect["x"] + rect["width"]
|
||||
bottom = rect["y"] + rect["height"]
|
||||
if right <= left or bottom <= top:
|
||||
return False
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return left <= x1 <= right and top <= y1 <= bottom
|
||||
t_enter, t_exit = 0.0, 1.0
|
||||
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
|
||||
if delta == 0:
|
||||
if distance < 0:
|
||||
return False
|
||||
continue
|
||||
t = distance / delta
|
||||
if delta < 0:
|
||||
t_enter = max(t_enter, t)
|
||||
else:
|
||||
t_exit = min(t_exit, t)
|
||||
if t_enter > t_exit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
|
||||
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
|
||||
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
|
||||
|
||||
|
||||
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
|
||||
width = rect["width"] - 2 * margin
|
||||
height = rect["height"] - 2 * margin
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
|
||||
|
||||
|
||||
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
|
||||
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
|
||||
return False
|
||||
if not is_text_element(text_element) or not has_text_content(text_element):
|
||||
return False
|
||||
if is_ghost_text(text_element) or is_decorative_text(text_element):
|
||||
return False
|
||||
glyph_bbox = estimate_text_visual_bbox(text_element)
|
||||
if glyph_bbox is None:
|
||||
return False
|
||||
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
|
||||
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
|
||||
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
|
||||
if target is None:
|
||||
return False
|
||||
return segment_intersects_rect(
|
||||
line["startX"], line["startY"], line["endX"], line["endY"], target
|
||||
)
|
||||
|
||||
|
||||
def detect_line_text_crossings(
|
||||
slide_xml: str, elements: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
lines = extract_line_elements(slide_xml)
|
||||
if not lines:
|
||||
return []
|
||||
text_elements = [element for element in elements if is_text_element(element)]
|
||||
issues: list[dict[str, Any]] = []
|
||||
for line in lines:
|
||||
for text_element in text_elements:
|
||||
if not line_crosses_text(line, text_element):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "bbox_overlap",
|
||||
"elements": [line["id"], text_element["id"]],
|
||||
"message": f'line {line["id"]} crosses text {text_element["id"]}',
|
||||
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
@@ -1621,6 +1815,7 @@ def lint_slide(
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
*detect_text_may_overflow_shapes(elements),
|
||||
*detect_image_text_occlusions(elements),
|
||||
*detect_line_text_crossings(slide_xml, elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
@@ -2226,7 +2421,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements: list[dict[str, Any]] = []
|
||||
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
||||
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
start_x = extract_numeric_attribute(attrs, "startX")
|
||||
start_y = extract_numeric_attribute(attrs, "startY")
|
||||
@@ -2235,6 +2430,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
||||
continue
|
||||
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
base_alpha = line_alpha if line_alpha is not None else 1
|
||||
border_alpha = 1
|
||||
if match.group(2) != "/":
|
||||
close_index = slide_xml.find("</line>", match.end())
|
||||
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
|
||||
border_attrs = extract_tag_attributes(body, "border")
|
||||
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
|
||||
if isinstance(color_alpha, (int, float)):
|
||||
border_alpha = color_alpha
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
||||
@@ -2244,8 +2448,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": min(start_y, end_y),
|
||||
"width": abs(end_x - start_x),
|
||||
"height": abs(end_y - start_y),
|
||||
"startX": start_x,
|
||||
"startY": start_y,
|
||||
"endX": end_x,
|
||||
"endY": end_y,
|
||||
"rotation": 0,
|
||||
"alpha": line_alpha if line_alpha is not None else 1,
|
||||
"alpha": base_alpha * border_alpha,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -814,6 +814,28 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_reports_labeled_short_metric_when_it_wraps(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="sheet-success" type="text" topLeftX="520" topLeftY="385" width="180" height="50">
|
||||
<content textType="headline" fontSize="32" bold="true" autoFit="no-auto-fit">
|
||||
<p>Sheet 98.5%</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(len(overflow_issues), 1)
|
||||
self.assertEqual(overflow_issues[0]["elements"], ["sheet-success"])
|
||||
|
||||
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
@@ -1026,7 +1048,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
|
||||
self.assertIn("background decoration", issues["bg-deco"]["message"])
|
||||
|
||||
def test_lint_xml_allows_shape_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
|
||||
def test_lint_xml_reports_shape_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -1042,11 +1064,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("shape_out_of_canvas", codes)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertIn("shape_out_of_canvas", codes)
|
||||
self.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_content_color_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
|
||||
def test_lint_xml_reports_content_color_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -1062,11 +1084,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("shape_out_of_canvas", codes)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertIn("shape_out_of_canvas", codes)
|
||||
self.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_faint_medium_ghost_text_out_of_canvas_and_overlap(self) -> None:
|
||||
def test_lint_xml_reports_faint_medium_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -1082,8 +1104,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("shape_out_of_canvas", codes)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertIn("shape_out_of_canvas", codes)
|
||||
self.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
|
||||
@@ -1126,7 +1148,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
codes = [issue["code"] for issue in result["issues"]]
|
||||
self.assertNotIn("whiteboard_external_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_faint_ghost_text_without_area_threshold(self) -> None:
|
||||
def test_lint_xml_reports_faint_ghost_text_out_of_canvas_without_area_threshold(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -1138,8 +1160,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
|
||||
def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -1457,6 +1479,187 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_reports_horizontal_line_crossing_headline_glyphs(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
|
||||
<content fontSize="60"><p>测试文字 ABC</p></content>
|
||||
</shape>
|
||||
<line id="strike" startX="80" startY="245" endX="560" endY="245">
|
||||
<border color="rgb(255, 0, 0)" width="4"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
crossing = [
|
||||
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"strike", "title"}
|
||||
]
|
||||
self.assertEqual(len(crossing), 1)
|
||||
self.assertEqual(crossing[0]["code"], "bbox_overlap")
|
||||
|
||||
def test_lint_xml_reports_vertical_line_crossing_multiline_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="col" type="text" topLeftX="700" topLeftY="180" width="240" height="180">
|
||||
<content fontSize="20"><p>第一行文字内容</p><p>第二行文字内容</p><p>第三行文字内容</p></content>
|
||||
</shape>
|
||||
<line id="vbar" startX="740" startY="170" endX="740" endY="360">
|
||||
<border color="rgb(0, 0, 255)" width="3"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
crossing = [
|
||||
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"vbar", "col"}
|
||||
]
|
||||
self.assertEqual(len(crossing), 1)
|
||||
|
||||
def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="para" type="text" topLeftX="80" topLeftY="400" width="420" height="140">
|
||||
<content fontSize="18"><p>这是一段测试文字用于验证线条穿过</p></content>
|
||||
</shape>
|
||||
<line id="diag" startX="80" startY="410" endX="500" endY="530">
|
||||
<border color="rgb(255, 0, 0)" width="3"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
crossing = [
|
||||
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"diag", "para"}
|
||||
]
|
||||
self.assertEqual(len(crossing), 1)
|
||||
|
||||
def test_lint_xml_ignores_diagonal_line_whose_bbox_but_not_segment_crosses_text(self) -> None:
|
||||
# The diagonal's axis-aligned bounding box overlaps the text, but the segment itself passes
|
||||
# through empty space in the opposite corner -- a naive bbox test would false-positive here.
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="corner-text" type="text" topLeftX="80" topLeftY="80" width="120" height="40">
|
||||
<content fontSize="18"><p>corner</p></content>
|
||||
</shape>
|
||||
<line id="far-diag" startX="700" startY="80" endX="90" endY="500">
|
||||
<border color="rgb(255, 0, 0)" width="3"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
crossing = [
|
||||
issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"far-diag", "corner-text"}
|
||||
]
|
||||
self.assertEqual(crossing, [])
|
||||
|
||||
def test_lint_xml_ignores_line_touching_text_frame_but_not_glyphs(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="lbl" type="text" topLeftX="80" topLeftY="80" width="300" height="200">
|
||||
<content fontSize="18" verticalAlign="top"><p>短标签</p></content>
|
||||
</shape>
|
||||
<line id="below" startX="80" startY="270" endX="380" endY="270">
|
||||
<border color="rgb(255, 0, 0)" width="2"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_invisible_line_crossing_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
|
||||
<content fontSize="60"><p>测试文字 ABC</p></content>
|
||||
</shape>
|
||||
<line id="ghost-line" startX="80" startY="245" endX="560" endY="245">
|
||||
<border color="rgba(255, 0, 0, 0.03)" width="4"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_vertical_line_grazing_text_left_edge(self) -> None:
|
||||
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 4: a vertical line sitting on the text
|
||||
# frame's left edge renders before the first glyph, so it must not be flagged.
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmm">
|
||||
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
|
||||
<p>Vertical edge graze</p>
|
||||
</content>
|
||||
</shape>
|
||||
<line id="bmX" startX="120.00000000000001" startY="90" endX="120.00000000000001" endY="150.00833275470998">
|
||||
<border color="rgba(0, 0, 0, 1)"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_polyline_crossing_text(self) -> None:
|
||||
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 6: the crossing check is scoped to
|
||||
# <line> only, so a <polyline> over text is not flagged.
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmr">
|
||||
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
|
||||
<p>Polyline target</p>
|
||||
</content>
|
||||
</shape>
|
||||
<polyline id="bmH" width="270" height="55" topLeftX="110" topLeftY="95">
|
||||
<border color="rgba(0, 0, 0, 1)"/>
|
||||
</polyline>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_ignores_line_below_visual_glyph_height(self) -> None:
|
||||
# Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 7: the shape frame is 80px tall but the
|
||||
# single 20px line of glyphs occupies only its top; a line at the frame's lower region grazes
|
||||
# under the visual glyph box (underline look) and must not be flagged.
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape width="240" height="80" topLeftX="120" topLeftY="100" type="text" id="bmB">
|
||||
<content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
|
||||
<p>Visual height target</p>
|
||||
</content>
|
||||
</shape>
|
||||
<line id="bmQ" startX="110" startY="150" endX="380.00185184550116" endY="150">
|
||||
<border color="rgba(0, 0, 0, 1)"/>
|
||||
</line>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
@@ -24,6 +24,12 @@ lark-cli task +create \
|
||||
lark-cli task +create \
|
||||
--summary "Buy milk"
|
||||
|
||||
# Create a milestone by passing an API field without a named flag
|
||||
lark-cli task +create \
|
||||
--summary "Release v2.0" \
|
||||
--due "2026-08-15" \
|
||||
--data '{"is_milestone":true}'
|
||||
|
||||
# Preview the API call without executing
|
||||
lark-cli task +create --summary "Test Task" --dry-run
|
||||
```
|
||||
@@ -39,8 +45,11 @@ lark-cli task +create --summary "Test Task" --dry-run
|
||||
| `--due <time>` | No | Due date. Supports ISO 8601, `YYYY-MM-DD`, relative time (e.g., `+2d`), or ms timestamp. `YYYY-MM-DD` and relative time will automatically set it as an all-day task. |
|
||||
| `--tasklist-id <id>` | No | The GUID of the tasklist, or a full AppLink URL (the CLI will automatically extract the `guid` parameter from the URL). |
|
||||
| `--idempotency-key <key>` | No | Client token to ensure idempotency of the request. |
|
||||
| `--data <json>` | No | JSON object merged into the task create request for API fields without dedicated flags, such as `{"is_milestone":true}`. Explicit named flags override same-named fields in this object. |
|
||||
| `--dry-run` | No | Preview the API call (JSON payload) without actually creating the task. |
|
||||
|
||||
Use `lark-cli schema task.tasks.create` to confirm that an extra field is supported before passing it through `--data`. Prefer this shortcut over the raw `tasks create` command when `--data` can express the request. Do not assume that other shortcuts support `--data`; check each shortcut's `--help` output first.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||
|
||||
189
switch_env.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TYPES_FILE="${SCRIPT_DIR}/internal/core/types.go"
|
||||
SECHEADER_FILE="${SCRIPT_DIR}/internal/cmdutil/secheader.go"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --env <boe|online|pre> --x-tt-env <value> [--build]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --env Set environment: 'boe' for feishu-boe.cn, 'online' for feishu.cn, 'pre' for feishu-pre.cn"
|
||||
echo " --x-tt-env Set x-tt-env header value. Empty string to remove the header."
|
||||
echo " --build Build and install lark-cli after switching environment"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 --env boe --x-tt-env boe_sun_ai"
|
||||
echo " $0 --env online --x-tt-env ''"
|
||||
echo " $0 --env pre --x-tt-env pre_env --build"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ENV=""
|
||||
XTTENV=""
|
||||
BUILD=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--env)
|
||||
ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--x-tt-env)
|
||||
XTTENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--build)
|
||||
BUILD=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$ENV" ]]; then
|
||||
echo "Error: --env parameter is required"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ "$ENV" != "boe" && "$ENV" != "online" && "$ENV" != "pre" ]]; then
|
||||
echo "Error: --env must be 'boe', 'online' or 'pre'"
|
||||
usage
|
||||
fi
|
||||
|
||||
update_endpoints() {
|
||||
local env="$1"
|
||||
local domain
|
||||
local mcp_domain
|
||||
|
||||
if [[ "$env" == "boe" ]]; then
|
||||
domain="feishu-boe.cn"
|
||||
mcp_domain="feishu-boe.cn"
|
||||
elif [[ "$env" == "pre" ]]; then
|
||||
domain="feishu-pre.cn"
|
||||
mcp_domain="feishu.cn"
|
||||
else
|
||||
domain="feishu.cn"
|
||||
mcp_domain="feishu.cn"
|
||||
fi
|
||||
|
||||
echo "Updating endpoints to use domain: ${domain} (MCP: ${mcp_domain})"
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|feishu-boe\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i '' "s|feishu-pre\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i '' "s|feishu\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i '' "s|TEMP_DOMAIN_PLACEHOLDER|${domain}|g" "$TYPES_FILE"
|
||||
sed -i '' "s|mcp\.${domain}|mcp.${mcp_domain}|g" "$TYPES_FILE"
|
||||
else
|
||||
sed -i "s|feishu-boe\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i "s|feishu-pre\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i "s|feishu\.cn|TEMP_DOMAIN_PLACEHOLDER|g" "$TYPES_FILE"
|
||||
sed -i "s|TEMP_DOMAIN_PLACEHOLDER|${domain}|g" "$TYPES_FILE"
|
||||
sed -i "s|mcp\.${domain}|mcp.${mcp_domain}|g" "$TYPES_FILE"
|
||||
fi
|
||||
|
||||
echo "Endpoints updated successfully"
|
||||
}
|
||||
|
||||
update_xtt_env_header() {
|
||||
local xtt_env="$1"
|
||||
|
||||
if [[ -z "$xtt_env" ]]; then
|
||||
echo "Removing x-tt-env header..."
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' '/h\.Set("x-tt-env",/d' "$SECHEADER_FILE"
|
||||
else
|
||||
sed -i '/h\.Set("x-tt-env",/d' "$SECHEADER_FILE"
|
||||
fi
|
||||
echo "x-tt-env header removed"
|
||||
else
|
||||
echo "Setting x-tt-env header to: ${xtt_env}"
|
||||
if grep -q 'h\.Set("x-tt-env",' "$SECHEADER_FILE"; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/h\.Set(\"x-tt-env\", \"[^\"]*\")/h.Set(\"x-tt-env\", \"${xtt_env}\")/g" "$SECHEADER_FILE"
|
||||
else
|
||||
sed -i "s/h\.Set(\"x-tt-env\", \"[^\"]*\")/h.Set(\"x-tt-env\", \"${xtt_env}\")/g" "$SECHEADER_FILE"
|
||||
fi
|
||||
else
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/h\.Set(HeaderUserAgent, UserAgentValue())/h.Set(HeaderUserAgent, UserAgentValue())\n\th.Set(\"x-tt-env\", \"${xtt_env}\")/g" "$SECHEADER_FILE"
|
||||
else
|
||||
sed -i "s/h\.Set(HeaderUserAgent, UserAgentValue())/h.Set(HeaderUserAgent, UserAgentValue())\n\th.Set(\"x-tt-env\", \"${xtt_env}\")/g" "$SECHEADER_FILE"
|
||||
fi
|
||||
fi
|
||||
echo "x-tt-env header updated"
|
||||
fi
|
||||
|
||||
if [[ "$xtt_env" == ppe* ]]; then
|
||||
echo "Adding x-use-ppe header (x-tt-env starts with 'ppe')..."
|
||||
if ! grep -q 'h\.Set("x-use-ppe",' "$SECHEADER_FILE"; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' 's/h\.Set("x-tt-env", "\(.*\)")/h.Set("x-tt-env", "\1")\'$'\n''\th.Set("x-use-ppe", "1")/g' "$SECHEADER_FILE"
|
||||
else
|
||||
sed -i 's/h\.Set("x-tt-env", "\(.*\)")/h.Set("x-tt-env", "\1")\n\th.Set("x-use-ppe", "1")/g' "$SECHEADER_FILE"
|
||||
fi
|
||||
fi
|
||||
echo "x-use-ppe header added"
|
||||
else
|
||||
if grep -q 'h\.Set("x-use-ppe",' "$SECHEADER_FILE"; then
|
||||
echo "Removing x-use-ppe header..."
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' '/h\.Set("x-use-ppe",/d' "$SECHEADER_FILE"
|
||||
else
|
||||
sed -i '/h\.Set("x-use-ppe",/d' "$SECHEADER_FILE"
|
||||
fi
|
||||
echo "x-use-ppe header removed"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
build_and_install() {
|
||||
echo "Building and installing lark-cli..."
|
||||
|
||||
rm -rf ~/.lark-cli/cache
|
||||
rm -f "${SCRIPT_DIR}/lark-cli"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./build.sh
|
||||
|
||||
local lark_cli_path
|
||||
lark_cli_path=$(which lark-cli 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$lark_cli_path" ]]; then
|
||||
cp "${SCRIPT_DIR}/lark-cli" "$(dirname "$lark_cli_path")/"
|
||||
echo "lark-cli installed to $(dirname "$lark_cli_path")/"
|
||||
else
|
||||
echo "Warning: lark-cli not found in PATH, binary is at ${SCRIPT_DIR}/lark-cli"
|
||||
fi
|
||||
|
||||
echo "Build and install completed"
|
||||
}
|
||||
|
||||
echo "=== Environment Switch Script ==="
|
||||
echo "Environment: ${ENV}"
|
||||
echo "x-tt-env: ${XTTENV:-<empty - will remove header>}"
|
||||
echo "Build: ${BUILD}"
|
||||
echo ""
|
||||
|
||||
update_endpoints "$ENV"
|
||||
update_xtt_env_header "$XTTENV"
|
||||
|
||||
if [[ "$BUILD" == true ]]; then
|
||||
echo ""
|
||||
build_and_install
|
||||
fi
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
|
||||
|
||||
69
tests/cli_e2e/base/base_form_questions_dryrun_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateVisibleRuleDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-create",
|
||||
"--base-token", "bascnXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--form-id", "vewXXXX",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/bascnXXXX/tables/tblXXXX/forms/vewXXXX/questions")
|
||||
assert.Contains(t, output, `"method": "POST"`)
|
||||
// visible_rule must be transcribed verbatim into the request body.
|
||||
assert.Contains(t, output, "visible_rule")
|
||||
assert.Contains(t, output, "是否需要发票")
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateVisibleRuleDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-questions-update",
|
||||
"--base-token", "bascnXXXX",
|
||||
"--table-id", "tblXXXX",
|
||||
"--form-id", "vewXXXX",
|
||||
"--questions", `[{"id":"q_002","visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/bascnXXXX/tables/tblXXXX/forms/vewXXXX/questions")
|
||||
assert.Contains(t, output, `"method": "PATCH"`)
|
||||
assert.Contains(t, output, "visible_rule")
|
||||
}
|
||||
@@ -111,18 +111,29 @@ func TestBase_RoleWorkflow(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
pollTimeout := 30 * time.Second
|
||||
pollCtx, pollCancel := context.WithTimeout(ctx, pollTimeout)
|
||||
defer pollCancel()
|
||||
|
||||
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
|
||||
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
|
||||
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
|
||||
err = clie2e.WaitForCondition(pollCtx, clie2e.WaitOptions{
|
||||
Timeout: pollTimeout,
|
||||
Interval: 3 * time.Second,
|
||||
}, func() (bool, error) {
|
||||
getResult, getErr := clie2e.RunCmd(pollCtx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if getErr != nil {
|
||||
return false, getErr
|
||||
}
|
||||
if getResult.ExitCode != 0 {
|
||||
return false, getResult.RunErr
|
||||
}
|
||||
|
||||
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
|
||||
return gjson.Valid(rolePayload) && gjson.Get(rolePayload, "role_name").String() == updatedRoleName, nil
|
||||
})
|
||||
require.NoError(t, err, "role name should converge to %q", updatedRoleName)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
|
||||
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
|
||||
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
|
||||
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
|
||||
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
|
||||
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
|
||||
|
||||
@@ -51,10 +52,10 @@
|
||||
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-create | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
|
||||
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
|
||||
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
|
||||
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
|
||||
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
|
||||
|
||||
165
tests/cli_e2e/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_MemberListDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
wantFields string
|
||||
wantPermType string
|
||||
}{
|
||||
{
|
||||
name: "bare folder token",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "fldE2E001",
|
||||
"--type", "folder",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "folder URL infers folder type",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "fields star is passed only when explicit",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "doxE2E003",
|
||||
"--type", "docx",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members",
|
||||
wantType: "docx",
|
||||
wantFields: "*",
|
||||
},
|
||||
{
|
||||
name: "wiki perm type",
|
||||
args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", "wikE2E004",
|
||||
"--type", "wiki",
|
||||
"--fields", "name,type",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members",
|
||||
wantType: "wiki",
|
||||
wantFields: "name,type",
|
||||
wantPermType: "single_page",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if tt.wantFields == "" {
|
||||
if gjson.Get(out, "data.api.0.params.fields").Exists() {
|
||||
t.Fatalf("params.fields should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := gjson.Get(out, "data.api.0.params.fields").String(); got != tt.wantFields {
|
||||
t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out)
|
||||
}
|
||||
if tt.wantPermType == "" {
|
||||
if gjson.Get(out, "data.api.0.params.perm_type").Exists() {
|
||||
t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := gjson.Get(out, "data.api.0.params.perm_type").String(); got != tt.wantPermType {
|
||||
t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberListWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+member-list",
|
||||
"--token", folderToken,
|
||||
"--type", "folder",
|
||||
"--format", "json",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedOutput, "docs:permission.member:retrieve") ||
|
||||
strings.Contains(combinedOutput, "app scope not enabled") ||
|
||||
strings.Contains(combinedOutput, "missing required scope") ||
|
||||
strings.Contains(combinedOutput, "missing_scope") ||
|
||||
strings.Contains(combinedOutput, "99991672") ||
|
||||
strings.Contains(combinedOutput, "1063002") ||
|
||||
strings.Contains(combinedOutput, "1063004") ||
|
||||
strings.Contains(combinedOutput, "permission denied") ||
|
||||
strings.Contains(combinedOutput, "no share permission") {
|
||||
t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
if strings.Contains(combinedOutput, "99992402") &&
|
||||
strings.Contains(combinedOutput, "field validation failed") {
|
||||
t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() {
|
||||
t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
}
|
||||
130
tests/cli_e2e/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_PermissionGetSettingDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "bare folder token",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "fldE2E001",
|
||||
"--type", "folder",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "folder URL",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldE2E001?from=share",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldE2E001/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", "https://example.feishu.cn/docx/doxE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxE2E001/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if gjson.Get(out, "data.folder_token").Exists() {
|
||||
t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PermissionGetSettingWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
folderToken := CreateDriveFolder(
|
||||
t,
|
||||
t,
|
||||
ctx,
|
||||
"lark-cli-e2e-drive-permission-get-setting-"+clie2e.GenerateSuffix(),
|
||||
"bot",
|
||||
"",
|
||||
)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+permission-get-setting",
|
||||
"--token", folderToken,
|
||||
"--type", "folder",
|
||||
"--format", "json",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedOutput, "docs:permission.setting:read") ||
|
||||
strings.Contains(combinedOutput, "app scope not enabled") ||
|
||||
strings.Contains(combinedOutput, "missing required scope") ||
|
||||
strings.Contains(combinedOutput, "99991672") {
|
||||
t.Skipf("skip drive permission setting workflow due to missing bot scope docs:permission.setting:read: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
if !gjson.Get(result.Stdout, "data.permission_public").Exists() {
|
||||
t.Fatalf("permission_public missing in output\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,10 @@ import (
|
||||
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
|
||||
// image-upload shortcuts emit in --dry-run output for native vs. imported
|
||||
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
|
||||
// for tokens prefixed with "fake_office_" (the synthetic token an imported
|
||||
// office spreadsheet carries) the backend requires "office_sheet_file". The
|
||||
// three covered entries — sheets +media-upload (backward), sheets
|
||||
// +cells-set-image, and sheets +create-float-image — are every image-upload
|
||||
// surface that the office/native split fans out to.
|
||||
// for tokens carrying the interleaved "OFL0X" marker the backend requires
|
||||
// "office_sheet_file". The covered entries — sheets +media-upload (backward),
|
||||
// sheets +cells-set-image, and sheets +float-image-create — are every
|
||||
// image-upload surface that the office/native split fans out to.
|
||||
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
@@ -50,11 +49,11 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
|
||||
name: "media-upload office",
|
||||
args: []string{
|
||||
"sheets", "+media-upload",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--file", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
{
|
||||
@@ -74,13 +73,30 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
|
||||
name: "cells-set-image office",
|
||||
args: []string{
|
||||
"sheets", "+cells-set-image",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--sheet-id", "sheet1",
|
||||
"--range", "A1",
|
||||
"--image", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
{
|
||||
name: "float-image-create office",
|
||||
args: []string{
|
||||
"sheets", "+float-image-create",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--sheet-id", "sheet1",
|
||||
"--image-name", "img.png",
|
||||
"--image", "img.png",
|
||||
"--position-row", "0",
|
||||
"--position-col", "A",
|
||||
"--size-width", "100",
|
||||
"--size-height", "100",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
}
|
||||
|
||||
46
tmp/ghost_text_check/npwp_slide2.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<slide id="pWs">
|
||||
<style>
|
||||
<fill id="fWs">
|
||||
<fillColor color="rgba(255, 85, 0, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="920" height="350" topLeftX="20" topLeftY="20" type="text" id="bdp">
|
||||
<content textType="title" fontSize="240" fontFamily="思源黑体" color="rgba(255, 255, 255, 0.102)" bold="true" letterSpacing="-5" textAlign="left">
|
||||
<p>PULSE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="800" height="120" topLeftX="80" topLeftY="160" type="text" id="bdX">
|
||||
<content textType="title" fontSize="72" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true" letterSpacing="2" textAlign="left">
|
||||
<p>MOVE BEYOND.</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="600" height="80" topLeftX="80" topLeftY="280" type="text" id="bdy">
|
||||
<content textType="headline" fontSize="36" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true" textAlign="left">
|
||||
<p>超越边界,脉动不止</p>
|
||||
</content>
|
||||
</shape>
|
||||
<line id="bdd" startX="80" startY="380" endX="200.00416659433122" endY="380">
|
||||
<border color="rgba(255, 255, 255, 1)" width="3"/>
|
||||
</line>
|
||||
<shape width="500" height="80" topLeftX="80" topLeftY="400" type="text" id="bdZ">
|
||||
<content fontSize="14" fontFamily="思源黑体" color="rgba(255, 255, 255, 0.851)" lineSpacing="multiple:1.8" textAlign="left">
|
||||
<p>我们相信运动不只是竞技,更是一种生活态度。</p>
|
||||
<p>PULSE 为街头而生,为每一个不甘平凡的灵魂。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="40" height="380" topLeftX="750" topLeftY="80" rotation="90" type="text" id="bdm">
|
||||
<content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(255, 255, 255, 0.6)" letterSpacing="6" textAlign="left">
|
||||
<p>BRAND MANIFESTO / 品牌宣言</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="200" height="20" topLeftX="60" topLeftY="490" type="text" id="bdc">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(255, 255, 255, 0.6)" letterSpacing="2" textAlign="left">
|
||||
<p>02 / 12</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bdH">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
89
tmp/ghost_text_check/uorc_slide7.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<slide id="pBU">
|
||||
<style>
|
||||
<fill id="fBU">
|
||||
<fillColor color="rgba(55, 42, 30, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="360" height="200" topLeftX="600" topLeftY="20" type="text" id="bqp">
|
||||
<content textType="title" fontSize="180" fontFamily="思源宋体" color="rgba(180, 130, 80, 0.078)" bold="true" textAlign="right">
|
||||
<p>土</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="150" height="30" topLeftX="80" topLeftY="60" type="text" id="bqR">
|
||||
<content textType="caption" fontSize="12" fontFamily="思源宋体" color="rgba(200, 150, 100, 1)" letterSpacing="2">
|
||||
<p>06 / 品牌价值</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="600" height="80" topLeftX="80" topLeftY="100" type="text" id="bqg">
|
||||
<content textType="title" fontSize="40" fontFamily="思源宋体" color="rgba(240, 230, 215, 1)" bold="true">
|
||||
<p>我们坚持的三件事</p>
|
||||
</content>
|
||||
</shape>
|
||||
<line id="bqx" startX="80" startY="185" endX="120.01249804748511" endY="185">
|
||||
<border color="rgba(200, 150, 100, 1)"/>
|
||||
</line>
|
||||
<shape width="60" height="60" topLeftX="80" topLeftY="230" type="text" id="bqF">
|
||||
<content textType="title" fontSize="48" fontFamily="思源宋体" color="rgba(200, 150, 100, 1)" bold="true">
|
||||
<p>壹</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="200" height="30" topLeftX="150" topLeftY="235" type="text" id="bqu">
|
||||
<content fontSize="18" fontFamily="思源宋体" color="rgba(240, 220, 190, 1)" bold="true">
|
||||
<p>全手工制作</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="200" height="30" topLeftX="150" topLeftY="268" type="text" id="bqc">
|
||||
<content fontSize="13" fontFamily="思源宋体" color="rgba(220, 205, 185, 1)">
|
||||
<p>拒绝模具量产,每一件都是孤品</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="60" height="60" topLeftX="380" topLeftY="230" type="text" id="bqj">
|
||||
<content textType="title" fontSize="48" fontFamily="思源宋体" color="rgba(200, 150, 100, 1)" bold="true">
|
||||
<p>贰</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="200" height="30" topLeftX="450" topLeftY="235" type="text" id="bqk">
|
||||
<content fontSize="18" fontFamily="思源宋体" color="rgba(240, 220, 190, 1)" bold="true">
|
||||
<p>天然原矿釉</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="200" height="30" topLeftX="450" topLeftY="268" type="text" id="bqh">
|
||||
<content fontSize="13" fontFamily="思源宋体" color="rgba(220, 205, 185, 1)">
|
||||
<p>安全无毒,可食用级釉料</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="60" height="60" topLeftX="680" topLeftY="230" type="text" id="bqC">
|
||||
<content textType="title" fontSize="48" fontFamily="思源宋体" color="rgba(200, 150, 100, 1)" bold="true">
|
||||
<p>叁</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="180" height="30" topLeftX="750" topLeftY="235" type="text" id="bqB">
|
||||
<content fontSize="18" fontFamily="思源宋体" color="rgba(240, 220, 190, 1)" bold="true">
|
||||
<p>高温烧制</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="180" height="30" topLeftX="750" topLeftY="268" type="text" id="bqV">
|
||||
<content fontSize="13" fontFamily="思源宋体" color="rgba(220, 205, 185, 1)">
|
||||
<p>1280°C高温,坚固耐用</p>
|
||||
</content>
|
||||
</shape>
|
||||
<line id="bql" startX="80" startY="350" endX="880.0006249997559" endY="350">
|
||||
<border color="rgba(200, 150, 100, 0.3)" width="1"/>
|
||||
</line>
|
||||
<shape width="800" height="80" topLeftX="80" topLeftY="380" type="text" id="bqe">
|
||||
<content fontSize="14" fontFamily="思源宋体" color="rgba(180, 165, 145, 1)" lineSpacing="multiple:2" textAlign="center">
|
||||
<p>我们相信,好的器物应该陪伴人很久很久</p>
|
||||
<p>在日复一日的使用中,包浆、温润,成为生活的一部分</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="60" height="30" topLeftX="860" topLeftY="490" type="text" id="bqW">
|
||||
<content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(140, 125, 110, 1)" textAlign="right">
|
||||
<p>07 / 10</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bqG">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
261
tmp/ghost_text_sample/readback.xml
Normal file
@@ -0,0 +1,261 @@
|
||||
<presentation id="A4fSsAeq4lEE6Gd7JbVcZZQ2n0g" xmlns="/sml/2.0" width="960" height="540">
|
||||
<title>幽灵字样张 Ghost Type Specimen</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<sub-headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<body fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<caption fontColor="rgba(155, 158, 162, 1)" fontSize="14"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide id="pqq">
|
||||
<style>
|
||||
<fill id="fqq">
|
||||
<fillColor color="rgba(6, 9, 18, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<img id="bJv" src="ZaCqbgcWZo0ByFxBg39c1IZ1nSg" width="960" height="540" topLeftX="0" topLeftY="0">
|
||||
<crop leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJN">
|
||||
<fill>
|
||||
<fillColor color="rgba(3, 7, 16, 0.48)"/>
|
||||
</fill>
|
||||
<border color="rgba(3, 7, 16, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="892" height="154" topLeftX="34" topLeftY="296" alpha="0.24" type="text" id="bJo">
|
||||
<content textType="title" fontSize="124" fontFamily="Source Han Sans" color="rgba(235, 248, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>GHOST</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="260" height="32" topLeftX="72" topLeftY="62" type="text" id="bJy">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>TYPE SPECIMEN / 01</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="560" height="102" topLeftX="70" topLeftY="126" type="text" id="bJY">
|
||||
<content textType="title" fontSize="42" fontFamily="Source Han Sans" color="rgba(247, 251, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>幽灵字样张</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="520" height="56" topLeftX="74" topLeftY="222" type="text" id="bJF">
|
||||
<content verticalAlign="top" fontSize="15" fontFamily="Source Han Sans" color="rgba(207, 224, 232, 1)" lineSpacing="multiple:1.4" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>把文字从“标题”改造成雾、残影和空间层。重叠不是事故,而是建立气氛的材料。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="214" height="86" topLeftX="672" topLeftY="64" presetHandlers="0" type="rect" id="bJZ">
|
||||
<fill>
|
||||
<fillColor color="rgba(5, 13, 25, 0.54)"/>
|
||||
</fill>
|
||||
<border color="rgba(126, 255, 238, 0.52)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="174" height="48" topLeftX="690" topLeftY="82" type="text" id="bJz">
|
||||
<content fontSize="13" fontFamily="Source Han Sans" color="rgba(229, 245, 250, 1)" lineSpacing="multiple:1.2" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>低透明度 / 大字号 / 贴边裁切 / 图文压叠</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="460" height="22" topLeftX="72" topLeftY="476" type="text" id="bJb">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(157, 179, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Sample deck generated with Lark Slides XML</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJJ">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide id="pqb">
|
||||
<style>
|
||||
<fill id="fqb">
|
||||
<fillColor color="rgba(8, 11, 20, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJL">
|
||||
<fill>
|
||||
<fillColor color="rgba(8, 11, 20, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(8, 11, 20, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<img id="bJC" src="QsRqbf41WoqOsDxkBFHcQWVAnmd" width="291.99999999999994" height="328" topLeftX="612" topLeftY="112">
|
||||
<crop type="rect" leftOffset="145.55555555555554" rightOffset="145.5555555555556" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="292" height="328" topLeftX="612" topLeftY="112" presetHandlers="0" type="rect" id="bJx">
|
||||
<fill>
|
||||
<fillColor color="rgba(1, 5, 13, 0.32)"/>
|
||||
</fill>
|
||||
<border color="rgba(120, 255, 238, 0.42)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="872" height="96" topLeftX="43.99999999999992" topLeftY="381.99999999999994" rotation="2" alpha="0.16" type="text" id="bJd">
|
||||
<content textType="headline" fontSize="78" fontFamily="Source Han Sans" color="rgba(224, 247, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>LAYER</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="420" height="48" topLeftX="56" topLeftY="35" type="text" id="bJK">
|
||||
<content textType="headline" fontSize="30" fontFamily="Source Han Sans" color="rgba(244, 250, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Layer Grammar</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="360" height="34" topLeftX="542" topLeftY="42" type="text" id="bJQ">
|
||||
<content textType="caption" fontSize="11" fontFamily="Source Han Sans" color="rgba(156, 178, 189, 1)" lineSpacing="multiple:1.0" textAlign="right" autoFit="normal-auto-fit">
|
||||
<p>幽灵字不是压住信息,而是给信息制造深度。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="166" height="242" topLeftX="56" topLeftY="112" presetHandlers="0" type="rect" id="bJH">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="74" topLeftY="132" type="text" id="bJk">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>01 / SAFE TEXT</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="116" topLeftX="74" topLeftY="174" type="text" id="bJf">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>主信息必须留在安全区。它可以靠近幽灵字,但不依赖幽灵字才能被读懂。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="74" topLeftY="304" type="text" id="bJW">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Readability first</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="166" height="242" topLeftX="246" topLeftY="112" presetHandlers="0" type="rect" id="bJr">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="264" topLeftY="132" type="text" id="bJP">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>02 / OVERLAP</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="116" topLeftX="264" topLeftY="174" type="text" id="bJg">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>允许图像、暗色块和大字交叠;让重叠发生在低对比背景层,而不是正文层。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="264" topLeftY="304" type="text" id="bJM">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Depth, not noise</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="146" height="242" topLeftX="436" topLeftY="112" presetHandlers="0" type="rect" id="bJE">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="110" height="28" topLeftX="454" topLeftY="132" type="text" id="bJA">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>03 / BLEED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="110" height="116" topLeftX="454" topLeftY="174" type="text" id="bJu">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>大字可靠近边界,被画布切出张力;核心内容仍保留完整轮廓。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="110" height="28" topLeftX="454" topLeftY="304" type="text" id="bJR">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Intentional crop</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="248" height="42" topLeftX="632" topLeftY="364" type="text" id="bJS">
|
||||
<content fontSize="14" fontFamily="Source Han Sans" color="rgba(238, 247, 250, 1)" bold="true" lineSpacing="multiple:1.1" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Three layers: image, ghost word, readable claim.</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="500" height="24" topLeftX="56" topLeftY="468" type="text" id="bJO">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(148, 170, 181, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Rule sheet: keep the readable layer calm, then let the atmosphere get aggressive.</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJm">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide id="pqE">
|
||||
<style>
|
||||
<fill id="fqE">
|
||||
<fillColor color="rgba(5, 7, 14, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<img id="bJl" src="WYcVbbQfgo5qitxHrsBcJqBJnKh" width="960" height="540" topLeftX="0" topLeftY="0">
|
||||
<crop leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJI">
|
||||
<fill>
|
||||
<fillColor color="rgba(2, 5, 12, 0.5)"/>
|
||||
</fill>
|
||||
<border color="rgba(2, 5, 12, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="892" height="168" topLeftX="34.000000000000014" topLeftY="78.00000000000001" rotation="1" alpha="0.22" type="text" id="bJt">
|
||||
<content textType="title" fontSize="138" fontFamily="Source Han Sans" color="rgba(238, 250, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>BLEED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="270" height="72" topLeftX="630" topLeftY="248" alpha="0.18" type="text" id="bJn">
|
||||
<content textType="headline" fontSize="54" fontFamily="Source Han Sans" color="rgba(142, 255, 239, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>OFF EDGE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="516" height="126" topLeftX="62" topLeftY="316" presetHandlers="0" type="rect" id="bJX">
|
||||
<fill>
|
||||
<fillColor color="rgba(5, 12, 24, 0.7)"/>
|
||||
</fill>
|
||||
<border color="rgba(128, 255, 240, 0.46)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="468" height="52" topLeftX="86" topLeftY="336" type="text" id="bJV">
|
||||
<content textType="headline" fontSize="28" fontFamily="Source Han Sans" color="rgba(248, 252, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>让字先离开屏幕</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="454" height="34" topLeftX="88" topLeftY="390" type="text" id="bJi">
|
||||
<content fontSize="14" fontFamily="Source Han Sans" color="rgba(204, 222, 232, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>越界感留给幽灵字;行动语句、页码、结论仍留在可读安全区。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="232" height="70" topLeftX="646" topLeftY="362" presetHandlers="0" type="rect" id="bJs">
|
||||
<fill>
|
||||
<fillColor color="rgba(122, 255, 238, 0.12)"/>
|
||||
</fill>
|
||||
<border color="rgba(122, 255, 238, 0.38)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="196" height="38" topLeftX="664" topLeftY="378" type="text" id="bJD">
|
||||
<content fontSize="13" fontFamily="Source Han Sans" color="rgba(226, 245, 249, 1)" lineSpacing="multiple:1.2" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>大胆设计可以失控,但信息层不能失联。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="270" height="28" topLeftX="62" topLeftY="60" type="text" id="bJq">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>TYPE SPECIMEN / 03</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="154" height="22" topLeftX="744" topLeftY="474" type="text" id="bJe">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(154, 176, 187, 1)" lineSpacing="multiple:1.0" textAlign="right" autoFit="normal-auto-fit">
|
||||
<p>Ghost typography sample</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJp">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
261
tmp/ghost_text_sample/readback_after_drag.xml
Normal file
@@ -0,0 +1,261 @@
|
||||
<presentation id="A4fSsAeq4lEE6Gd7JbVcZZQ2n0g" xmlns="/sml/2.0" width="960" height="540">
|
||||
<title>幽灵字样张 Ghost Type Specimen</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<sub-headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<body fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<caption fontColor="rgba(155, 158, 162, 1)" fontSize="14"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide id="pqq">
|
||||
<style>
|
||||
<fill id="fqq">
|
||||
<fillColor color="rgba(6, 9, 18, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<img id="bJv" src="ZaCqbgcWZo0ByFxBg39c1IZ1nSg" width="960" height="540" topLeftX="0" topLeftY="0">
|
||||
<crop leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJN">
|
||||
<fill>
|
||||
<fillColor color="rgba(3, 7, 16, 0.48)"/>
|
||||
</fill>
|
||||
<border color="rgba(3, 7, 16, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="892" height="154" topLeftX="380.7158671586716" topLeftY="344" alpha="0.24" type="text" id="bJo">
|
||||
<content textType="title" fontSize="124" fontFamily="Source Han Sans" color="rgba(235, 248, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>GHOST</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="260" height="32" topLeftX="72" topLeftY="62" type="text" id="bJy">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>TYPE SPECIMEN / 01</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="560" height="102" topLeftX="70" topLeftY="126" type="text" id="bJY">
|
||||
<content textType="title" fontSize="42" fontFamily="Source Han Sans" color="rgba(247, 251, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>幽灵字样张</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="520" height="56" topLeftX="74" topLeftY="222" type="text" id="bJF">
|
||||
<content verticalAlign="top" fontSize="15" fontFamily="Source Han Sans" color="rgba(207, 224, 232, 1)" lineSpacing="multiple:1.4" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>把文字从“标题”改造成雾、残影和空间层。重叠不是事故,而是建立气氛的材料。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="214" height="86" topLeftX="672" topLeftY="64" presetHandlers="0" type="rect" id="bJZ">
|
||||
<fill>
|
||||
<fillColor color="rgba(5, 13, 25, 0.54)"/>
|
||||
</fill>
|
||||
<border color="rgba(126, 255, 238, 0.52)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="174" height="48" topLeftX="690" topLeftY="82" type="text" id="bJz">
|
||||
<content fontSize="13" fontFamily="Source Han Sans" color="rgba(229, 245, 250, 1)" lineSpacing="multiple:1.2" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>低透明度 / 大字号 / 贴边裁切 / 图文压叠</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="460" height="22" topLeftX="72" topLeftY="476" type="text" id="bJb">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(157, 179, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Sample deck generated with Lark Slides XML</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJJ">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide id="pqb">
|
||||
<style>
|
||||
<fill id="fqb">
|
||||
<fillColor color="rgba(8, 11, 20, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJL">
|
||||
<fill>
|
||||
<fillColor color="rgba(8, 11, 20, 1)"/>
|
||||
</fill>
|
||||
<border color="rgba(8, 11, 20, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<img id="bJC" src="QsRqbf41WoqOsDxkBFHcQWVAnmd" width="291.99999999999994" height="328" topLeftX="612" topLeftY="112">
|
||||
<crop type="rect" leftOffset="145.55555555555554" rightOffset="145.5555555555556" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="872" height="96" topLeftX="175.99999999994805" topLeftY="369.18694025920706" rotation="2" alpha="0.16" type="text" id="bJd">
|
||||
<content textType="headline" fontSize="78" fontFamily="Source Han Sans" color="rgba(224, 247, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>LAYER</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="420" height="48" topLeftX="56" topLeftY="35" type="text" id="bJK">
|
||||
<content textType="headline" fontSize="30" fontFamily="Source Han Sans" color="rgba(244, 250, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Layer Grammar</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="360" height="34" topLeftX="542" topLeftY="42" type="text" id="bJQ">
|
||||
<content textType="caption" fontSize="11" fontFamily="Source Han Sans" color="rgba(156, 178, 189, 1)" lineSpacing="multiple:1.0" textAlign="right" autoFit="normal-auto-fit">
|
||||
<p>幽灵字不是压住信息,而是给信息制造深度。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="166" height="242" topLeftX="56" topLeftY="112" presetHandlers="0" type="rect" id="bJH">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="74" topLeftY="132" type="text" id="bJk">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>01 / SAFE TEXT</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="116" topLeftX="74" topLeftY="174" type="text" id="bJf">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>主信息必须留在安全区。它可以靠近幽灵字,但不依赖幽灵字才能被读懂。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="74" topLeftY="304" type="text" id="bJW">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Readability first</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="166" height="242" topLeftX="246" topLeftY="112" presetHandlers="0" type="rect" id="bJr">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="264" topLeftY="132" type="text" id="bJP">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>02 / OVERLAP</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="116" topLeftX="264" topLeftY="174" type="text" id="bJg">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>允许图像、暗色块和大字交叠;让重叠发生在低对比背景层,而不是正文层。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="130" height="28" topLeftX="264" topLeftY="304" type="text" id="bJM">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Depth, not noise</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="146" height="242" topLeftX="436" topLeftY="112" presetHandlers="0" type="rect" id="bJE">
|
||||
<fill>
|
||||
<fillColor color="rgba(238, 248, 255, 0.08)"/>
|
||||
</fill>
|
||||
<border color="rgba(238, 248, 255, 0.16)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="110" height="28" topLeftX="454" topLeftY="132" type="text" id="bJA">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>03 / BLEED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="110" height="116" topLeftX="454" topLeftY="174" type="text" id="bJu">
|
||||
<content verticalAlign="top" fontSize="13" fontFamily="Source Han Sans" color="rgba(231, 241, 245, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>大字可靠近边界,被画布切出张力;核心内容仍保留完整轮廓。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="110" height="28" topLeftX="454" topLeftY="304" type="text" id="bJR">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(158, 180, 190, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Intentional crop</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="248" height="42" topLeftX="632" topLeftY="364" type="text" id="bJS">
|
||||
<content fontSize="14" fontFamily="Source Han Sans" color="rgba(238, 247, 250, 1)" bold="true" lineSpacing="multiple:1.1" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Three layers: image, ghost word, readable claim.</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="500" height="24" topLeftX="56" topLeftY="468" type="text" id="bJO">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(148, 170, 181, 1)" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>Rule sheet: keep the readable layer calm, then let the atmosphere get aggressive.</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="292" height="328" topLeftX="612" topLeftY="112" presetHandlers="0" type="rect" id="bJx">
|
||||
<fill>
|
||||
<fillColor color="rgba(1, 5, 13, 0.32)"/>
|
||||
</fill>
|
||||
<border color="rgba(120, 255, 238, 0.42)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJm">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
<slide id="pqE">
|
||||
<style>
|
||||
<fill id="fqE">
|
||||
<fillColor color="rgba(5, 7, 14, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<img id="bJl" src="WYcVbbQfgo5qitxHrsBcJqBJnKh" width="960" height="540" topLeftX="0" topLeftY="0">
|
||||
<crop leftOffset="0" rightOffset="0" topOffset="0" bottomOffset="0"/>
|
||||
</img>
|
||||
<shape width="960" height="540" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect" id="bJI">
|
||||
<fill>
|
||||
<fillColor color="rgba(2, 5, 12, 0.5)"/>
|
||||
</fill>
|
||||
<border color="rgba(2, 5, 12, 0)" width="0"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="892" height="168" topLeftX="-339.39807418048235" topLeftY="203.22902033583466" rotation="1" alpha="0.22" type="text" id="bJt">
|
||||
<content textType="title" fontSize="138" fontFamily="Source Han Sans" color="rgba(238, 250, 255, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>BLEED</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="270" height="72" topLeftX="793.4022140221402" topLeftY="307" alpha="0.18" type="text" id="bJn">
|
||||
<content textType="headline" fontSize="54" fontFamily="Source Han Sans" color="rgba(142, 255, 239, 1)" bold="true" lineSpacing="multiple:0.9" textAlign="center" autoFit="normal-auto-fit">
|
||||
<p>OFF EDGE</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="516" height="126" topLeftX="62" topLeftY="316" presetHandlers="0" type="rect" id="bJX">
|
||||
<fill>
|
||||
<fillColor color="rgba(5, 12, 24, 0.7)"/>
|
||||
</fill>
|
||||
<border color="rgba(128, 255, 240, 0.46)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="468" height="52" topLeftX="86" topLeftY="336" type="text" id="bJV">
|
||||
<content textType="headline" fontSize="28" fontFamily="Source Han Sans" color="rgba(248, 252, 255, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>让字先离开屏幕</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="454" height="34" topLeftX="88" topLeftY="390" type="text" id="bJi">
|
||||
<content fontSize="14" fontFamily="Source Han Sans" color="rgba(204, 222, 232, 1)" lineSpacing="multiple:1.3" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>越界感留给幽灵字;行动语句、页码、结论仍留在可读安全区。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="232" height="70" topLeftX="646" topLeftY="362" presetHandlers="0" type="rect" id="bJs">
|
||||
<fill>
|
||||
<fillColor color="rgba(122, 255, 238, 0.12)"/>
|
||||
</fill>
|
||||
<border color="rgba(122, 255, 238, 0.38)" width="1"/>
|
||||
<content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
|
||||
</shape>
|
||||
<shape width="196" height="38" topLeftX="664" topLeftY="378" type="text" id="bJD">
|
||||
<content fontSize="13" fontFamily="Source Han Sans" color="rgba(226, 245, 249, 1)" lineSpacing="multiple:1.2" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>大胆设计可以失控,但信息层不能失联。</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="270" height="28" topLeftX="62" topLeftY="60" type="text" id="bJq">
|
||||
<content textType="caption" fontSize="12" fontFamily="Source Han Sans" color="rgba(115, 255, 235, 1)" bold="true" lineSpacing="multiple:1.0" textAlign="left" autoFit="normal-auto-fit">
|
||||
<p>TYPE SPECIMEN / 03</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape width="154" height="22" topLeftX="744" topLeftY="474" type="text" id="bJe">
|
||||
<content textType="caption" fontSize="10" fontFamily="Source Han Sans" color="rgba(154, 176, 187, 1)" lineSpacing="multiple:1.0" textAlign="right" autoFit="normal-auto-fit">
|
||||
<p>Ghost typography sample</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
<note id="bJp">
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
BIN
tmp/kimi-slides.skill
Normal file
104
tmp/kimi-slides/SKILL.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: kimi-slides
|
||||
description: 创建和编辑 PPTX 格式的演示文稿。此技能定义了 .pptd 中间格式来简化 OOXML 操作。任何涉及生成或编辑 PPTX 文件的任务都必须使用此技能,而不是其他方法。该技能还可以用于读取上传的PPTX文件并将PPTX文档转换为图像。当用户请求信息图或海报而不指定图像或 HTML 格式时,该技能同样可以用于将其创建为 PPTX 文件。
|
||||
---
|
||||
|
||||
# 定义
|
||||
kimi-slides 是 Moonshot AI 作为第一方技能构建的 ppt 生成技能。它定义了 YAML 格式的中间 DSL (.pptd),进一步抽象了 OOXML,使演示文稿生成变得毫不费力。 DSL 可用于生成和渲染 PPT,现有的 pptx 文件也可以转换为此 DSL 进行编辑。
|
||||
|
||||
## pptd 格式
|
||||
.pptd 格式是 OOXML 上的简化抽象层,遵循基本 YAML 语法。这种抽象保留了 OOXML 的核心内容(主题、页面布局、元素位置和定义等),同时删除了复杂的嵌套逻辑,例如 Masters;每个页面都是独立的——所见即所得。请阅读 reference/pptd.md 以了解此 DSL 的完整定义。
|
||||
|
||||
## 配套 CLI
|
||||
该技能还附带一个配套的 CLI 工具(预安装在环境中),用于 pptd 和 pptx 之间的双向转换、pptd 验证、pptd/pptx 屏幕截图渲染等。
|
||||
请阅读 reference/cli.md 以获取完整的 CLI 使用说明。
|
||||
|
||||
## PPT制作流程
|
||||
|
||||
### 步骤1。仔细阅读上下文
|
||||
阅读**用户上传的所有文件**、提供的URL以及pptd格式指南“reference/pptd.md”,以充分了解用户的需求。
|
||||
|
||||
### 步骤2。了解用户的需求
|
||||
根据上下文了解用户的需求:
|
||||
1.首先确定请求的目的
|
||||
- 创建 PPT:创建一个新的演示文稿(从头开始,或从现有的 pptx 模板)
|
||||
- 编辑PPT:编辑用户上传的PPT(本地修改、单页美化等)
|
||||
- 复制 PPT:将演示文稿从非 pptx 格式(图像、PDF 等)复制为 pptx 格式
|
||||
|
||||
2.然后确定设计方向
|
||||
- 自主设计:没有偏好,或者只给出简单的风格约束;您需要填写或创建设计
|
||||
- 设计系统:指定技能预设的设计系统,或者用户提供涵盖所有颜色、字体、布局和组件规格的完整详细的设计方案
|
||||
- 使用模板:提供了模板并且必须使用
|
||||
- 风格迁移:提供风格参考源(图片、网页等)
|
||||
|
||||
3.然后确定输入类型
|
||||
- 仅主题:仅给出PPT主题方向或演示的内容要求,没有具体内容
|
||||
- 完整文档:用户提供完整的文档(论文、研究报告、新闻稿等)
|
||||
- 大纲:用户提供逐页大纲、演讲稿或类似内容
|
||||
* 当“用户输入类型”为【完整文档】或【大纲】且未指定是否允许扩展时:由于逐页大纲、演讲稿或用户文档很难支持演示的全部内容,优先使用搜索来扩展更相关的材料、案例等,除非用户明确表示不扩展
|
||||
|
||||
4. 页数
|
||||
- 如果用户请求特定页数,则用户的要求优先
|
||||
- 提供逐页大纲/脚本:与大纲/脚本中的页数匹配
|
||||
- 当提供完整且相对结构化的文档时:使用ask工具与用户确认一页应涵盖多少文档内容,并给出预计的总页数;当仅提供主题时:使用询问工具建议建议的页数并与用户确认#### 澄清和后续问题
|
||||
当出现以下情况时,通过提问工具解决
|
||||
1. 要求不明确
|
||||
- 用户的意图不明确或难以理解
|
||||
- 用户提供的文件/URL 无法访问
|
||||
2. 意图冲突
|
||||
- 用户的意图相互矛盾。例如:
|
||||
* 选择设计系统的同时还要求与该设计系统完全不一致的样式(例如,使用麦肯锡样式,同时要求页面上有大面积的空白)/使用模板/引用图像样式
|
||||
* 请求“制作 10 页”和“交付 30 多页输出”
|
||||
3、无法自行确定用户的需求
|
||||
- 当目的、设计方向、输入类型、页数等难以自行确定时
|
||||
|
||||
### 步骤3。根据用户需求生成演示文稿
|
||||
|
||||
在生成之前,首先阅读“reference/pptd.md”以了解 pptd 格式定义和约束,并阅读“reference/cli.md”以了解如何使用配套的 CLI
|
||||
|
||||
#### 复制 PPT
|
||||
- 分析图像以估计元素位置、字体和大小等,并**尽可能地按 1:1 复制**。
|
||||
- 当图像包含难以直接复制且无法用图标/形状近似的元素(例如照片、头像)时,您可以使用bash或python等工具对原始图像进行裁剪和截图
|
||||
|
||||
#### 编辑 PPT
|
||||
- 将用户上传的pptx文件转换为.pptd格式
|
||||
- 截取转换后的文件的屏幕截图,然后拼接并压缩屏幕截图以获得概览。之后单独阅读几个关键页面。
|
||||
- 找到要编辑的页面,并注意不要影响预期范围之外的部分。
|
||||
> `kimi-slides Convert` 命令并不是完美的无损转换。如果用户后期报告格式错误、乱码内容等,与原pptx进行比对,并参考比对修复pptd
|
||||
|
||||
#### 生成 PPT
|
||||
制作PPT时,针对不同的用户采取不同的制作方式【设计方向】
|
||||
#####自主设计
|
||||
1.阅读设计指南`reference/slides_categories.md`,并阅读用户查询对应的场景文档
|
||||
2. 根据以上内容制作演示文稿
|
||||
|
||||
#### 生成其他格式的内容
|
||||
- 当用户明确要求信息图、海报或高度视觉化的单页设计时,请阅读“reference/general-poster.md”并将其实现为单页或几页可编辑的 PPTD;当用户只要求一张图片时,仍然先用PPTD构建,然后通过截图或渲染的方式输出图片。对于普通 PPT 请求,请勿加载此参考文件。
|
||||
|
||||
#####设计系统
|
||||
1.阅读《reference/slides_categories.md》指南的一般约束部分,并阅读用户查询对应的场景文档作为设计基础
|
||||
2. 阅读正在使用的设计系统文档作为演示风格。严禁引用或混用其他设计风格
|
||||
3. 参考上述内容制作演示文稿
|
||||
|
||||
##### 使用模板
|
||||
1.使用`kimi-slides Convert`将用户上传的pptx文件转换为pptd形式
|
||||
2. 对转换后的文件进行截图,然后拼接并压缩截图以概览,了解模板的视觉风格(配色方案、字体风格、元素特征、布局特征、内容密度等)
|
||||
3. 识别页面类型;重点阅读特殊页面,例如封面、摘要页面和章节分隔符(单页屏幕截图、.page 文件),提取其页面布局、内容结构、可重用组件(图标、形状、smartart、可重用正文布局方案等)和元素样式(例如空格/线条/卡片分隔符、方角/圆角等)
|
||||
4. 使用模板制作演示文稿##### 风格转移
|
||||
1. 分析参考文件的视觉风格(配色方案、字体风格、元素特征、布局特征、内容密度等)、页面布局、内容结构、可重用组件(图标、形状、smartart、可重用正文布局方案等)和元素样式(例如,空白/线条/卡片分隔符、方角/圆角等)。
|
||||
- 如果用户提供样式参考URL,不要只阅读文本内容;多参考和学习页面的视觉效果,有助于理解风格
|
||||
2. 使用参考文件的风格特征制作演示文稿。我们鼓励您重复使用原始 pdf/url 中的插图、字体、字体大小层次结构、元素等
|
||||
|
||||
### 步骤4。 PPT验证
|
||||
1. 使用 kimi-slides check 命令对生成的文件进行多轮验证和修复
|
||||
2.使用`kimi-slides snapshot`进行截图,
|
||||
- 截图后,先拼接并压缩单页截图,以便快速概览
|
||||
- 细化有问题的页面并运行多轮验证和修复
|
||||
|
||||
注意:当前的屏幕截图 CLI 可能存在错误,导致结果与实际渲染不同。已知问题:
|
||||
1.图标退化成圆圈
|
||||
2. 渐变文本可能会降级为纯色
|
||||
这些问题仅存在于截图CLI中,不影响用户交付;它们可以被忽略。
|
||||
|
||||
###第5步。 PPT交付
|
||||
使用 kimi_ref 将 .pptd 文件传送给用户。该路径直接指向.pptd文件; **严格禁止转换为 pptx 格式进行交付**。用户使用 .pptd 文件的唯一方法是单击下面的卡片进入编辑器,在其中可以预览、编辑、演示和导出为 pptx 格式。
|
||||
113
tmp/kimi-slides/reference/cli.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# kimi-slides cli
|
||||
|
||||
本节列出了 kimi-slides CLI 支持的所有命令。这篇文档是权威参考! `kimi-slides --help` 返回的指南不正确 - 无需运行该命令。
|
||||
|
||||
## 转换
|
||||
|
||||
```bash
|
||||
kimi-slides convert path/deck.pptx
|
||||
kimi-slides convert path/deck.pptx -o path/output/
|
||||
```
|
||||
|
||||
`-o, --output`:指定输出目录;选修的。如果省略,则会在输入旁边创建一个同名(不带扩展名)的目录:
|
||||
|
||||
```text
|
||||
path/deck.pptx -> path/deck/
|
||||
```
|
||||
|
||||
输出目录结构:
|
||||
|
||||
```text
|
||||
deck/
|
||||
deck.pptd
|
||||
pages/
|
||||
page-1.page
|
||||
media/
|
||||
<hash>.png
|
||||
```
|
||||
|
||||
> 转换过程中,PPTX 中嵌入的图像将被提取到 `media/` 目录中,并且页面文件中的图像路径将被重写为相对于输出目录的路径:
|
||||
|
||||
```yaml
|
||||
src: ./media/<hash>.png
|
||||
```
|
||||
|
||||
## 检查
|
||||
|
||||
```bash
|
||||
kimi-slides check path/deck
|
||||
kimi-slides check path/deck -p 1,3 -s all
|
||||
kimi-slides check path/deck -p 2-10 --level keep
|
||||
kimi-slides check path/deck --level auto
|
||||
```
|
||||
|
||||
`check` 的输入必须是包含 `.pptd` 文件的目录。默认情况下,它检查主 `.pptd` 文件和所有页面文件。
|
||||
|
||||
参数:
|
||||
|
||||
- `-p, --page <spec>`:指定页码,从1开始;默认为所有页面。支持`3`、`1,2`、`2-10`。
|
||||
- `-s, --severity <spec>`:指定要输出的问题。支持 `all`、`error`、`warning` 以及特定问题类型,例如`MissingField,SrcNotFound`。
|
||||
- `--level <level>`:处理级别。 `keep` 只检查不修改; `auto` 尝试安全修复。
|
||||
|
||||
目前已检查以下问题:
|
||||
- `YamlParseError`:每个检查的文件必须使用 YAML 解析器正确解析。
|
||||
- `FileReadError`:对输入目录、`.pptd` 文件、页面文件或资源路径的访问异常。
|
||||
- `MissingField`:缺少必填字段,例如`.pptd.pages`、`.pptd.size` 或必需的元素字段。
|
||||
- `InvalidType`:字段类型与其定义不匹配,例如字符串、数字、布尔值、元组、数组、填充、边框等。
|
||||
- `OutOfRange`:字段值超出可接受范围,例如非正页面大小、非正边界、非法枚举值。
|
||||
- `InvalidTheme`:引用的主题令牌不存在,例如`$primary` 不存在于 `theme.colors` 中。
|
||||
- `PageNotFound`:`.pptd.pages` 引用的 `.page` 文件不存在,或者指定的页码超出范围。
|
||||
- `UnknownField`:发现当前模式无法识别的字段。
|
||||
- `BoundsOutside`:元素的边界超出页面尺寸。
|
||||
- `SrcNotFound`:`src`引用的本地资源(图像、图像填充、自定义字体等)不存在。
|
||||
- `TextOverflow`:文本可能会溢出其文本框。
|
||||
- `TextUnderFill`:文本可能占据文本框高度的 50% 以下。
|
||||
- `TextOcclusion`:文本可能会被稍后绘制的元素遮挡。
|
||||
- `TextDrift`:文本框可以跨越其下方元素的边界。
|
||||
|
||||
> 文本相关检查当前使用具有模拟文本尺寸的启发式方法,因此可能存在一些偏差。
|
||||
|
||||
`--level auto` 仅执行确定性修复:
|
||||
|
||||
- 简单的类型转换,例如`"12"` 为数字,`12` 为字符串,`"true"` 为布尔值。
|
||||
- 删除无效的可选字段。
|
||||
- 删除无法安全修复的无效元素。
|
||||
- 重写修复文件的 YAML 格式。
|
||||
|
||||
`check` 以以下格式输出问题:
|
||||
|
||||
```text
|
||||
[whether deterministically fixed][issue type:issue name] file path id="element id" issue details
|
||||
[fixed: false][Error:InvalidType] pages/page-1.page id="title" Expected 4 numbers for elements[0].bounds
|
||||
[fixed: false][Warning:TextOverflow] pages/page-2.page id="body" Text may overflow its bounds
|
||||
```
|
||||
|
||||
## 截图
|
||||
|
||||
```bash
|
||||
kimi-slides screenshot path/deck -o path/screenshots/
|
||||
kimi-slides screenshot path/deck -p 1,3,5 -o path/screenshots/
|
||||
kimi-slides screenshot path/deck -p 2-6 -o path/screenshots/
|
||||
```
|
||||
|
||||
`screenshot` 的输入必须是包含 `.pptd` 文件的目录。此命令将 `.pptd` 演示文稿渲染为图像以检查页面的视觉结果。
|
||||
|
||||
`-o, --output` 是可选的。如果省略,则会在输入旁边创建一个屏幕截图目录:
|
||||
|
||||
```text
|
||||
path/deck/ -> path/deck-screenshots/
|
||||
```
|
||||
|
||||
输出目录结构通常为:
|
||||
|
||||
```text
|
||||
pages/
|
||||
page-1.png
|
||||
page-2.png
|
||||
page-3.png
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
- `-p, --page <spec>`:指定页码,从1开始;默认为所有页面。支持`3`、`1,2`、`2-10`。
|
||||
- `-o, --output <path>`:指定截图输出目录。
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,165 @@
|
||||
# 组织名称 — 生产力回报风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
面向高管、战略团队和研究读者的咨询思想领导报告式 PPT。支持可读的长文本、定量图表、版块导航,具有品牌化、前瞻性的视觉感受;它不是一个低字数的销售推介页面或视觉海报式的单页页面。
|
||||
|
||||
核心页面语法:
|
||||
|
||||
1. **浅蓝色报告页**:柔和的淡蓝色-浅紫色渐变背景,带有黑色长文本、多栏解释和紫色关键数字。
|
||||
2. **黑色背景证据页**:图表位于大型黑色画布上,白色文本和紫色/粉色/橙色条或线形成强烈对比。
|
||||
3. **深色摄影部分分隔线**:封面和部分过渡使用“前进路径”图像,例如光迹、山脊线/道路/人物。
|
||||
4. **固定报告骨架**:底部有白色部分导航条,左下角有紫色“>”身份标记,右边缘有垂直的报告名称,右下角有页码。
|
||||
|
||||
基本原则:
|
||||
|
||||
1. **证据优先于视觉戏剧**:图表、来源、数字和结论必须可读;黑色背景、紫色和摄影光效果不得模糊数据或削弱对比度。
|
||||
2. **高密度必须有稳定的骨架**:允许多个文本段落、多列模块和复杂的图表,但每个页面必须有标题区域、正文区域、页脚导航和足够的填充——避免自由浮动堆叠。
|
||||
3. **页面变化服务章节节奏**:摄影页面用于开场和章节过渡,黑色背景页面用于模型/数据证据,浅蓝色页面用于解释和建议;不要仅仅为了多样性而在每个页面上发明一个新模板。
|
||||
|
||||
## 2. 页面骨架- **画布比例**:宽屏 16:9;使用完整的矩形画布——没有肖像或社交媒体比例。
|
||||
- **默认边距**:内容页保留相对较宽的外边距;机身从左上向右中延伸,底部预留固定导航带。黑色图表块和摄影块可以几乎全出血,但仍必须避免页脚和页码。
|
||||
- **后台系统**:内容页面和目录采用淡蓝色、冰蓝色、浅紫色渐变;图表页面在浅蓝色页面上覆盖一个黑色矩形证据区域,或者让单个黑色块占据页面的大部分;部分/封面使用全出血深色摄影。
|
||||
- **标题区域**:通常位于左上角。浅蓝色页面上的黑色或紫色;黑色背景图表页面上黑色区域内的白色;放置在摄影页面上的黑暗或平静区域。
|
||||
- **正文区域**:分为文字说明区域、图表证据区域、图像区域和数字强调区域。内容页面大多为2-3栏;图表页面通常是一个大的黑色背景图表+说明性侧边栏;案例页面通常是文本+右侧或上方的照片。
|
||||
- **标题**:没有强大的标题栏;报告名称在右边缘显示为垂直文本,而不是水平顶部标题。
|
||||
- **页脚导航**:大多数非封面页面底部都有一个白色导航带。左下角有一个紫色的“>”符号;部分名称水平布置在中间,并用细垂直线分隔;当前部分更暗或更粗;右下角的页码。保持其高度低、线条细且位置稳定。
|
||||
- **右侧垂直报告名称**:内容页面、目录和某些图表页面将报告标题/副标题放置在右侧边缘。不要让它成为显眼的装饰——它应该小而不引人注目。
|
||||
- **徽标**:在封面右下角,使用完整的品牌文字标记(呈现为纯小文本,写新主题名称;不要绘制图形徽标);在内容页面上,紫色“>”身份标记主要取代了完整的文字标记;封底或封页可以恢复完整的品牌文字标记。
|
||||
- **目录页**:浅蓝色渐变背景,标题位于左上角,章节条目布置在多个水平列中;页码范围放置在章节标题上方或附近;关键部分可能带有小缩略图网格;列由细垂直线分隔。
|
||||
- **作者页**:浅蓝色渐变背景,左上角紫色标题;作者头像排列在网格中——头像、姓名、标题和一个小的专业社交平台图标形成了标准的作者卡。
|
||||
- **默认阅读路径**:左上角标题/引入→正文左栏文本→中心图表或数字→右栏补充/照片→确认该部分的底部导航。
|
||||
|
||||
## 3. 内容组织- **标题风格**:使用报告风格的陈述——判断语句、章节名称或“图X”图表标题;重要的部分分隔页可以使用大标题来提出问题或方向。不要追逐简短而有力的口号。
|
||||
- **关键信息句子**:在标题下方或图表上方放置 1-2 个解释性句子,说明发现、定义或如何阅读图表。除非是剖面摄影页面,否则不要将整个页面变成一个大的陈述句。
|
||||
- **正文组织**:短段落,左对齐;单个段落的长度为中短;每栏 2-4 个段落。避免长而不间断的文字墙。
|
||||
- **分组逻辑**:包括“问题—证据—含义”、“模型要素”、“行为/推荐列表”、“案例解释”、“数字比较”;使用列、浅色卡片、黑色背景区域或细分隔线来分隔它们,而不是使用大量图标。
|
||||
- **每页点数**:一个内容页包含1个主要主题加2-4个支撑点;高密度页面可以包含多个子模块,但必须有一个统一的整体标题,布局清晰。
|
||||
- **项目符号风格**:更喜欢小标题+段落解释;谨慎使用传统的项目符号列表;列表项应该简短,缩进要克制。
|
||||
- **关键字强调**:使用粗体、紫色数字或紫色副标题;避免浓重的彩色突出显示。每个模块最多突出显示 1-2 个关键字或数字。
|
||||
- **数字/单位/时间范围**:大量数字可能单独作为紫色或黑色信息块,并附有说明;百分比、倍数和时间范围必须与图表标签保持一致。数字不得用装饰性图形代替。
|
||||
- **来源脚注**:将小的来源/注释放置在图表和研究页面的底部或图表边缘,对比度低但可读。来源不得进入主标题层次结构,也不得用装饰颜色来吸引注意力。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A.浅蓝色多栏报告页面
|
||||
|
||||
用于概念解释、背景、建议、案例文本和少量关键数据。淡蓝色/浅紫色渐变背景,标题位于左上角,正文分为 2-3 列:左列包含引言或主要论点,中间列包含补充段落/项目符号,右列包含大量数字、图像或简短结论。使用紫色数字、粗体副标题和清晰的列宽来建立焦点并保持段落间距。如果需要复杂的数据比较,不要强迫它进入这种布局 - 切换到黑色背景图表页面。
|
||||
|
||||
### B. 黑色背景图表证据页+解释性侧边栏
|
||||
|
||||
适用于复杂的条形图、折线图、分组比较、模型推导和“图”型证据。一侧或中心是一个大的黑色矩形图表区域,内部有白色标题和说明文字;另一侧保留浅蓝色文本栏,以黑色正文文本解释调查结果。图表区域是视觉重心;使用紫色/粉色/橙色数据系列、白键注释和局部箭头/连接线。如果图表仅包含一个简单的数字,请不要使用大的黑色背景 - 它会感觉太重。
|
||||
|
||||
### C. 摄影部分/问题页面
|
||||
|
||||
用于封面、章节过渡、重要问题或临时结论。使用全出血暗摄影——山脊线、道路、剪影、科技设备、紫色光迹很常见;一个大的白色标题位于一个黑暗的区域上,还有一个较小的副标题。除了必要的标题、简短说明和品牌文字标记(纯小文本;不要绘制图形徽标)之外,不要覆盖长正文或复杂的图表。
|
||||
|
||||
### D.文字+图片案例页面用于行业案例、场景描述或具体证据。真实照片或部分设备/场景位于右侧或上方的大矩形区域中;文本位于左侧或旁边的列中。该图像不作为整页背景,也不使用任何插图。如果照片背景很杂乱,请勿将文本直接放在其上 - 将文本放在浅色区域。
|
||||
|
||||
### E.高密度模块/框架页面
|
||||
|
||||
适用于 3-5 个行为、元素、阶段或模型组件。使用水平卡片、垂直列、紫色底部标签或黑色背景公式区域进行组织;每个模块包含一个小标题、一个简短的解释段落以及必要的数字或关键字。当模块较多时,使用等宽的列和一致的padding;不要用复杂的 SmartArt 装饰代替文本逻辑。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
- **浅蓝色背景**:大约#D6ECFE / #E4F4FF / #DFF3FF。用于内容页、目录、作者和案例页的大背景区域;可能会逐渐渐变为浅紫色,但不得变得过饱和。可替代性低——这是该风格的核心。
|
||||
- **浅紫色背景过渡**:大约#DAE3F9 / #D8E3FA / #C2A1BC。用于背景渐变边缘、卡底或局部氛围;中低区域。同一色系内可调整;不建议改用大的暖色底色。
|
||||
- **黑色/近黑色证据基础**:大约#08151C / #201F20 / #000000。用于图表画布、公式模型、暗部页面的文本承载区域;区域范围可以从半页到大部分页;不得破碎成碎片与浅蓝色正文文本混合。
|
||||
- **主要文本黑色**:大约#111111 / #201F20。用于淡蓝色背景上的正文、副标题、页脚导航和表格文本;高对比度是强制性的。不得以浅灰色代替作为主体颜色。
|
||||
- **反转白色文本**:#FFFFFF 或接近白色。用于黑色背景图表、摄影页面标题和封面标题;仅在深色背景上。
|
||||
- **品牌紫色/强调紫色**:大约#A100FF / #8A00E6 / #7F35FF。用于“">”身份标记、关键数字、关键图表系列、标签和一些副标题;中小面积。可以向品牌的原色进行微调,但不得用多种不相关的鲜艳颜色代替。
|
||||
- **粉紫色/洋红色数据颜色**:大约#D000FF / #C000C8。用于图表或黑色背景卡片中的第二个强调系列;仅提供数据分组服务,不提供大块正文文本。
|
||||
- **橙色/珊瑚色数据颜色**:大约#D38778 / #D7AA9F。用于封面灯光效果、图表中的对比系列或暖色调;面积小;不得成为主体颜色。
|
||||
- **灰色支持**:大约#8C95A4 / #A8ABBC / #C7CAD6。用于轴、分隔符、注释、非键标签和导航分隔符;保持不显眼。
|
||||
- **桌子底座/灯卡底座**:大约#E8F3FC / #DCEFF8。用于高密度表格或信息框,与背景略有区别。
|
||||
|
||||
保留蓝紫色系列 + 黑/白/灰 + 1 种暖色数据中的单个页面。状态颜色不突出;如果需要正/负状态,则更喜欢文本标签和紫色/灰色深度变化,并谨慎引入红色/绿色。
|
||||
|
||||
## 6. 字体和文本层次结构- **字体字符**:正文主要使用Arial系列无衬线字体;标题、章节或目录条目可以使用 Times New Roman 类衬线;少量粗体投石机用于特殊标题或品牌文本。总体上避免使用手写体、手写体和圆形卡通字体。
|
||||
- **正文**:1x,用于段落、解释、作者标题和页脚导航。
|
||||
- **脚注/来源/页码**:大约0.6–0.8x,低调但可读;页码不应放大为装饰数字。
|
||||
- **副标题/模块标题**:大约 1.1–1.4 倍,可能是粗体或紫色。
|
||||
- **图表标签/轴**:约 0.7–0.9x,黑色背景上白色/灰色,浅色背景上黑色/灰色;保持清晰,不与头衔竞争。
|
||||
- **关键信息句子/重点强调**:约 1.2–1.6x,位于标题和正文之间。
|
||||
- **页面标题**:大约 2–3x,在内容页面上左上角对齐;目录/作者页标题可能会稍大一些。
|
||||
- **封面/部分大标题**:约 3–5 倍,白色或黑色,具体取决于背景;整个页面的第一个视觉层次。
|
||||
- **大数字**:约 2.5–4x,通常是紫色,搭配 0.9–1x 说明文字;数字必须带有单位或上下文。
|
||||
|
||||
文本主要是左对齐的。居中的副本可能仅出现在封面或摄影部分的页面上;避免内容页面上出现大的居中块。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
主要图表类型有条形图、分组条形图、折线/趋势图、公式/模型类型图以及少量矩阵比较。
|
||||
|
||||
- **图表画布**:更喜欢黑色或接近黑色的矩形,边缘干净,没有粗边框或阴影;浅蓝色文本区域的清晰边界。
|
||||
- **标题和编号**:将“图 X”加上解释性标题放在图表区域的左上角,为白色;标题应说明阅读图表的目的,而不仅仅是图表类型。
|
||||
- **轴和网格线**:使用低亮度的细灰色线,数量较少。不要用密集的网格产生噪音。
|
||||
- **数据系列**:主要是紫色、粉紫色和橙色/珊瑚色;非重点系列采用灰色或深紫色;系列的数量应该受到限制——避免彩虹色。
|
||||
- **标签**:关键数据可以直接标记在条形顶部或线附近;白色或浅灰色的小标签。避免所有标签堆放得难以辨认。
|
||||
- **图例**:小而清晰,放置在图表右侧或上方;色样/线段必须与系列相匹配。
|
||||
- **关键注释**:白色箭头、弯曲连接器、括号或简短注释可能会强调趋势/差距;注释必须服务于结论,而不是装饰。
|
||||
- **预测/目标/基线**:使用虚线、细线或带有文字说明的单一强调色;不要用大的半透明带覆盖数据。
|
||||
- **边界**:在图表很少的页面上不强行插入图表;当没有数据时,使用文本模块或数字卡代替。
|
||||
|
||||
## 8. 表格和高密度信息- **标题**:使用黑底白字、浅紫色/浅蓝色黑字或粗体文字;限制高度,左对齐文本。
|
||||
- **行/列结构**:用细灰线或浅色基块分隔;避免重型网格;按重要性分配列宽——第一列可能稍宽。
|
||||
- **行高**:容纳 1-3 行文本;高密度页面可以压缩,但文本绝不能接触线条或重叠。
|
||||
- **基色**:普通细胞为浅蓝色/浅紫色,高对比度证据表为黑色背景;如果使用斑马条纹,它应该非常微妙。
|
||||
- **关键行/列**:用紫色文本、紫色条、粗体或深色底色上的反白突出显示;不要同时使用多种状态颜色进行标记。
|
||||
- **数字对齐**:数字、百分比、金额和年份按列对齐,并使用统一单位;解释性文字左对齐。
|
||||
- **符号/评级点**:可以使用简单的点、短划线、勾号或紫色标记;它们必须是低装饰且明确的。避免使用彩色图标评级系统。
|
||||
- **摘要行**:使用粗体、顶部分隔线或深色底座处理 - 无凸起阴影。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
- **底部导航**:细白带+紫色“>”+水平部分名称+细垂直线+页码;不得被正文内容覆盖。
|
||||
- **右侧垂直报告名称**:小,靠近边缘,对比度低。不要把它变成一个大的侧边栏。
|
||||
- **紫色“>”品牌符号**:用于左下角并用于本地品牌提示;面积小——不能成为到处使用的大装饰箭头。
|
||||
- **标题栏/图表标题块**:位于黑色图表区域的左上方,由“图X+结论标题”组成,白色文字分层,紧凑,左对齐。
|
||||
- **数字强调块**:大的紫色百分比/值+短的黑色说明文字;通常每页 1-3 个;避免过度使用。
|
||||
- **浅色信息框**:用于“工作中的知识”式补充、定义、提示或行动建议;底色比背景稍深,填充稳定,文字密度中等。
|
||||
- **紫色标签/卡座**:用于模型或行为页面上的简短推荐或关键句子;适合作为黑色区域内的重点强调,但保持较低的计数。
|
||||
- **图片框**:矩形真实照片,边缘干净,无粗边框;可以占据页面的一半或三分之一,与文本分开。
|
||||
- **流程箭头/模型符号**:使用简单的箭头、乘号、加号、连接线来表达公式或关系;线条细,颜色主要有白色、灰色、紫色。
|
||||
- **图标**:仅使用一些功能性小图标,例如专业社交平台标记。不要扩展到彩色图标系统。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
一种扁平、内敛、类似报告的技术咨询风格,通过深色摄影、黑白对比、紫色品牌口音和数据图表来构建视觉焦点——而不是复杂的装饰。
|
||||
|
||||
**禁止**:
|
||||
|
||||
- 表情符号、卡通贴纸、社交媒体风格的图标、大型彩色插图。
|
||||
- 3D 字体、立体按钮、发光轮廓、浓重阴影、玻璃形态卡片。
|
||||
- 复杂的纹理背景、华丽的边框、彩虹渐变、无意义的装饰线条。
|
||||
- 大块居中的正文、放置在繁忙照片上的文本、低对比度的浅灰色正文。
|
||||
- 没有数据源的假图表,纯粹为了美观而添加的仪表板组件。
|
||||
|
||||
**谨慎使用**:- 渐变:仅浅蓝色-浅紫色背景或摄影光轨氛围;没有强烈的多色渐变。
|
||||
- 阴影:源文件几乎不依赖阴影;生成时避免它们;如果用于图像分层,请保持它们非常微妙。
|
||||
- 圆角:不是主要特征;卡片和图像应该有方形或略圆的角——不要成为圆角的 SaaS 风格。
|
||||
- 彩色图标:仅在功能标记中可见,例如作者页面上的专业社交平台图标;不应在正文页面中大量使用。
|
||||
- 照片:可用于内容中的案例或场景,但必须分区;不要将每一页都设置为摄影背景。
|
||||
|
||||
**可在特殊页面上使用**:
|
||||
|
||||
- 全出血深色照片、强光轨迹、大白色标题:用于封面和部分过渡。
|
||||
- 大黑色背景:用于图表证据、模型推导和深色结束/附录样式页面。
|
||||
- 完整的品牌字标(纯小文字,写新主题名称;不绘制图形标志):更适合封面和封底;内容页面主要使用紫色“>”。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 是 16:9 的报告页而不是海报/社交卡吗?
|
||||
- 页面类型是否明显属于以下之一:浅蓝色内容页、黑色背景证据、摄影部分、案例文本+图像、作者/目录?
|
||||
- 是否保留底部白色导航带,左下角有紫色“>”、章节名称和页码;内容页面右侧是否有垂直报告名称?
|
||||
- 标题是否左对齐且采用报告格式;正文中的段落是否过长?
|
||||
- 调色板是否以蓝紫黑白灰为主,暖色仅补充少量数据/光迹?
|
||||
- 紫色是否用于品牌、数字和关键点,而不是大面积泛滥?
|
||||
- 图表是否有标题、标签、图例/来源以及黑色背景的足够对比度?
|
||||
- 表格是否使用细线、统一对齐、基色克制、数字单位一致?
|
||||
- 图像是否是真正的摄影,并且与文本分开,没有将长文本压在繁忙的背景上?
|
||||
- 是否避免使用表情符号、3D、重阴影、复杂装饰、彩虹色和无意义的图标?
|
||||
- 您是否检查过文本溢出、截断、乱码、重叠、低对比度、遮挡、空内容、覆盖页脚和错误比例?
|
||||
- 每一页是否都有一个主要主题和清晰的阅读路径,且不会因模板重复或元素堆叠而削弱信息?
|
||||
- 默认情况下没有卡片:除非用户明确请求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式:线段、空白和字体大小差异是更好的解决方案
|
||||
- 无均分组合:除非没有其他可用的布局,否则不要默认三向分割、四向分割或 2×2 矩阵。这包括三部分结论,例如三栏+标题+结论
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,198 @@
|
||||
# PJ Mountain — 业务 DD 报告(组织名称)风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
适用于商业尽职调查、市场研究、战略诊断、管理决策报告等高信息密度场景。它使用白色风景画布、黑色结论标题、薄灰色分隔线、深海军蓝结构块、蓝色数据强调以及密集的图表和表格。标题区写明“本页判断/分析主题”;条形图、堆积条形图、折线图、热图、矩阵和多列文本提供了证据;页脚固定地包含来源、公司名称和页码。大图像、插图和装饰图形很少出现;品牌颜色只服务于结构和强调,而不服务于氛围。
|
||||
|
||||
**基本原则**
|
||||
|
||||
1. **证据优先于装饰。** 必须首先保留图表、表格、脚注、来源和测量基础注释;如果页面拥挤,请压缩装饰和空白,而不是删除数据标签或源。
|
||||
2. **一致性优先于每页变化。** 允许使用右侧解释栏、左侧深色意见栏、目录突出显示或基于灰色的解释区域等变体,但标题区域、页脚、分隔符、字体层次结构和品牌颜色语法必须保持稳定。
|
||||
3. **品牌颜色具有可读性。** 结构和关键类别采用深海军蓝;亮蓝色表示数据强调和选定状态;当品牌颜色降低对比度或混淆数据时,首选黑色、灰色、白色和清晰的标签。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
**画布比例:** 宽屏 16:9 横向。默认白色背景;目录页和说明栏可以使用浅灰色;部分/意见页面可以使用深海军蓝垂直块。内容页面不使用全屏图像背景。
|
||||
|
||||
**边距和网格:**保持左右外边距稳定;标题区域位于顶部,正文的矩形工作区位于标题规则下方,固定的页脚区域位于底部。正文通常使用 2 列、3 列、图像-左-文本-右、表格-左-注释-右或图表-上-上-脚注-下。
|
||||
|
||||
**标题区:**左上角的主标题通常为1-2行,采用大号黑色衬线粗体或粗体标题样式;下面是无衬线副标题,说明主题、范围、单位或测量基础。标题下的一条细灰色/黑色水平线分隔了证据区域。
|
||||
|
||||
**正文区域:**包含图表、表格、流程图/结构图、并行模块和解释性文本。通常主要证据位于左侧;右侧的解释、背景、结论或补充材料;多模块页面从上到下、从左到右阅读。
|
||||
|
||||
**页脚:**固定包含来源/脚注、公司名称和页码。来源位于左下方或图表下方,其尺寸明显小于正文; “组织名称”和页码位于右下角。页脚上方通常有一条细线将正文分开。
|
||||
|
||||
**徽标和品牌:** 封面的左上角带有品牌文字标记,以纯小文本形式显示新主题名称或留空 - 未绘制图形徽标;内容页面通过页脚公司名称来传达品牌,而不是通过在每个模块中重复放大的徽标。
|
||||
|
||||
**页面类型:**- **封面:**左侧包含品牌字标、项目名称、报告类型、日期和保密声明;右侧采用蓝色线性曲线图形作为唯一强烈的视觉元素。封面不使用内容页页脚和页码结构。
|
||||
- **目录页:**大尺寸内容位于左侧白色区域,章节结构位于右侧浅灰色区域;当前部分可能会用深海军蓝水平条突出显示。 TOC页面强调导航,没有数据图表。
|
||||
- **内容页:**以白色背景为主,顶部结论标题+规则+正文证据+底部来源/页码。
|
||||
- **部分/进度页面:**重用内容结构,并用深海军栏标记当前位置;它们可以作为各部分之间的过渡,但不得推广到普通内容页面。
|
||||
- **高密度附录/分析页面:**允许更小的文本、更密集的表格和更强的栏目,但保留标题、脚注和页码。
|
||||
|
||||
**默认阅读路径:**标题判断→副标题/单位→图表或表格中最大差异→右侧/底部说明→来源和脚注。
|
||||
|
||||
## 3. 内容组织
|
||||
|
||||
**标题风格:**使用分析性主题或结论句,而不是营销口号。常见的结构是“主题+趋势/判断+关键数字/范围/原因”,控制在1-2行;当标题已包含结论时,副标题仅添加测量依据。
|
||||
|
||||
**关键信息句子:**放置在图表上方或右侧,说明数据范围、单位、复合年增长率、市场定义、样本或情景;他们直接为图表提供服务——没有抽象的促销路线。
|
||||
|
||||
**正文组织:** 使用短段落、项目符号、编号或副标题组,每个项目符号表达一个事实或原因。避免长居中的副本。使用粗体关键字、缩进、规则和模块标题组织密集的文本。
|
||||
|
||||
**每页点数:** 一个标准的内容页围绕1个主要判断,最多包含2-4个支持论点。高密度页面可能包含多个证据块,但它们必须共享相同的分析问题;不要将不相关的图表拼贴到一页上。
|
||||
|
||||
**分组逻辑:**按市场/客户/产品/竞争/组织,或按时间、地区、细分、增长动力或影响程度分组。并行模块必须是同构的:标题位置、度量定义和基础、颜色含义一致。
|
||||
|
||||
**关键字强调:** 使用粗体、深海军蓝副标题、亮蓝色数字和浅灰色底框。避免使用彩色荧光笔、表情符号、夸张的感叹号或装饰性下划线。
|
||||
|
||||
**数字和单位:**数字必须带有单位、时间范围和必要的计量基础;增长率、复合年增长率、份额和金额在图表标题、轴标签或脚注中指定。预测、目标和实际情况通过颜色、破折号或标签来区分,而不仅仅是通过文本解释来区分。
|
||||
|
||||
**来源脚注:**所有数据图表和表格必须有来源/注释/测量基础注释。使用最小的文本级别,放置在图表底部或页脚左侧;从来不在视觉中心。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
**A。结论标题+单一主图+右侧说明栏**
|
||||
|
||||
用于核心趋势或规模判断。左边 65%–75% 持有条形图、堆积条形图或折线图;右边的 20%–30% 拥有浅灰色背景说明栏或白色标题栏,其中包含背景、增长动力和管理含义。使用亮蓝色系列、粗体数字、箭头或一些椭圆形标签来强调。不适合多主题并排布局或需要逐项扩展的复杂分类页面。
|
||||
|
||||
**B。结论标题+多栏文字分析页**用于文本论证,例如趋势、市场特征、驱动因素和风险点。正文分为 2-3 栏,每栏都有一个编号的副标题和一小段解释;列之间的间距较窄或线条较细,并保留粗体关键字。这种布局信息密集,但视觉证据较弱——不要在太多连续页面上使用它;如果有关键数字,请切换到图表页面。
|
||||
|
||||
**C。图表+机制/流程说明组合页**
|
||||
|
||||
用于从传统状态到新状态、价值链、业务流程或组织转型的迁移。趋势图位于左侧或中间;垂直流块、箭头或舞台音符位于右侧;深海军蓝/亮蓝色块标记目标状态、关键步骤或强调的链接。流程组件有助于解释——它们不能取代数据证据。
|
||||
|
||||
**D。高密度表格/矩阵页**
|
||||
|
||||
用于对客户、竞争对手、产品、功能、KBF 或机会点进行多维度比较。正文是一个大表格或矩阵,具有清晰的标题和精细的行/列分隔;关键单元格使用蓝色、灰色底座、评级点或符号标记。右侧可能有一个深度海军意见专栏,总结 2-4 个关键发现。不适合讲述单一趋势,也不适合缺乏可比维度的内容。
|
||||
|
||||
**E。深色侧边栏意见页面**
|
||||
|
||||
用于章节开头、强烈意见摘要或将“解释”与“证据”分开。左侧或右侧的深海军蓝垂直列大约占宽度的四分之一到三分之一,内部有白色文本和亮蓝色副标题;另一侧持有白色背景图表/表格。深色柱不得被滥用作为装饰背景——仅在需要指导、结论压力或部分识别时才使用它。
|
||||
|
||||
**F。目录/进度导航页面**
|
||||
|
||||
用于部分过渡。左侧为内容,右侧为浅灰色内容区域,当前部分以深海军蓝突出显示。它只负责导航——不进行身体分析。生成新主题时,保留章节编号、层次结构、页面范围和突出显示语法。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
**背景白色#FFFFFF**:内容页的主背景,面积最大。不能用彩色背景代替;除 TOC/解释列外,不建议使用大色基。
|
||||
|
||||
**浅灰色背景#E6E6E6 / #E9E9E9**:用于目录的右侧区域、侧面说明栏、表格基础和本地背景。用于结构区分;明度可能会略有调整,但不建议更换为高饱和度的品牌颜色。
|
||||
|
||||
**主要文本黑色 #000000 / 深灰色 #222222**:用于标题、正文、图表标签和表格文本。低对比度灰色不得取代正文。
|
||||
|
||||
**品牌深海军蓝#051C2C**:用于深色侧边栏、关键结构块、一些栏系列、标题栏和流程块。可以用同等深度的公司原色代替,但白色文本必须保持可读。
|
||||
|
||||
**亮蓝色#2150FE / #1F5BFF**:用于强调数据系列、当前状态、关键标签、所选项目和关键流程块。不得大面积分布;如果用品牌颜色代替,它必须保持高饱和度、高对比度,并且与深海军蓝不同。
|
||||
|
||||
**浅蓝色#BBD5EE / #DAE7F3 / #E6EEF6**:用于辅助数据系列、浅色面积图、覆盖线性图形和弱表格强调。不能携带白色正文;避免太轻而失去边缘。
|
||||
|
||||
**中性灰色#747474 / #8E9294 / #A4A4A4 / #D0D0D0**:用于辅助条形图、轴、网格线、表格线、标题和不强调的类别。灰色步骤可以被替换,但层次结构必须保持。**状态颜色边界:**没有建立红绿黄状态系统。当需要正/负状态时,更喜欢深度变化、标签和箭头;红色/绿色只能谨慎且谨慎地用于明确的正面/负面效果——它们不得改变套牌平静的蓝灰色气质。
|
||||
|
||||
**每页原色限制:**内容页使用黑/白/灰+深海军蓝+亮/浅蓝色。除非必须区分数据,否则不要引入第三种高饱和度颜色。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
**字体字符:**正文和大多数标签使用 Arial 级无衬线字体;封面主标题和内容页大标题可以使用Times New Roman Bold 级衬线粗体。斜体 Arial 很少用于注释、脚注或源代码行中的解释性文本。
|
||||
|
||||
**相对层次:**
|
||||
|
||||
- **封面项目名称:** 大约 3.5–4.5 倍正文大小,衬线粗体,甲板上最大的文本之一。
|
||||
- **内容页主标题:** 大约 2-3 倍正文大小,黑色,允许 1-2 行,结论在前。
|
||||
- **章节/TOC 标题:** 大约是正文大小的 2-3 倍,通常与大量空白或浅灰色内容区域配对。
|
||||
- **副标题/图表标题:** 大约 1.1–1.4 倍正文大小,可以是粗体;说明主题、单位和测量基础。
|
||||
- **正文和项目符号:** 定义为 1x,行间距紧凑但从不重叠。
|
||||
- **表格文本/图表标签:** 大约 0.75–1x 主体大小;数字标签可能稍小,但必须保持可读。
|
||||
- **注释、来源、脚注、页码:** 大约 0.55–0.75 倍正文大小,信息完整,但视觉上凹进。
|
||||
|
||||
**粗细规则:**粗体用于标题、副标题、表格标题、关键词和关键数字;不要加粗大段的长正文段落。斜体仅用于音符音调或来源归属,而不是主要强调手段。
|
||||
|
||||
**对齐方式:**标题和正文以左对齐为主;表格中的数字右对齐或小数点后对齐,类别文本左对齐。避免大的中心块。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
商业分析图表包括条形图、堆积条形图、折线/趋势线、组合图、市场规模预测、份额/排名图以及一些散点/气泡型标记。图表必须证明标题——它们不是装饰。
|
||||
|
||||
**坐标轴和网格:**坐标轴使用细灰色或黑色;网格线非常细,不需要时可以省略。轴标签较短;单位位于图表标题中或轴旁边。横轴通常是年份、细分市场或类别;纵轴包含金额、份额、指数等。
|
||||
|
||||
**系列颜色:**重点系列采用深海军蓝或亮蓝色;次要/历史/其他类别使用灰色或浅蓝色。堆叠栏中相同类别的相同颜色必须在页面之间保持一致 - 不要随机更改页面之间的颜色。
|
||||
|
||||
**数据标签:**直接标记关键柱、最近一年、CAGR、预测端点和比较差异。让标签靠近数据点;避免让读者依赖复杂的传说。
|
||||
|
||||
**预测和基线:**预测年份、复合年增长率、目标或基线可以用虚线、箭头、括号、椭圆标签或不同的灰蓝色调来区分。必须说明实际/预测间隔——仅靠颜色是不够的。
|
||||
|
||||
**图例:**放置在图表上方或图表内部的空白区域;系列较少,更喜欢直接标签。色样必须与图形相匹配;小无衬线文本。
|
||||
|
||||
**注释:** 避免使用短箭头、括号、CAGR 标签、圆形/椭圆形小标记。没有发光、阴影、3D 条形或透视效果。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
表格用于比较竞争对手、客户、产品、功能、市场属性和机会点——作为分析工具,而不是装饰性列表。**标题:**使用粗体、深色文本或深海军蓝/浅灰色底色。列标题可以有多行,但必须使用可比较的定义和测量基础。如果标题很长,请将其拆分为主标题 + 副标题 - 不要将其挤得难以辨认。
|
||||
|
||||
**行/列和分隔:**主要是细灰色线,水平方向多于垂直方向;浅灰色底色可以区分标题、组行或说明列。避免厚重的边框和复杂的网格。
|
||||
|
||||
**行高和密度:**允许高密度,但每行需要稳定的基线和足够的间距。长文本应该分成几行或作为片段来表述——切勿溢出单元格。
|
||||
|
||||
**关键标记:**关键行/列可以使用深海军蓝条、浅蓝色底色、亮蓝色文本、粗体、评级点、检查/符号或小标签。翼形符号可以谨慎地用于状态/标记,但不得成为彩色图标系统。
|
||||
|
||||
**对齐方式:** 文本左对齐;右对齐数字或对齐数字位置;以统一的格式保存百分比、金额和年份。排名、分数和状态点居中对齐。
|
||||
|
||||
**斑马条纹和底色:**浅灰色斑马条纹或局部浅灰色底色可以提高可扫描性,但不能太暗。表格基色必须保持黑色文本的高对比度。
|
||||
|
||||
**摘要和注释:**摘要行、平均值或关键结论位于表格底部或右侧注释栏中;来源和测量基础注释位于表格正下方。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
**细水平线:**分隔标题区域、正文区域和页脚。线条是细的、灰色的或黑色的;不要用粗彩色线代替它们。
|
||||
|
||||
**深海军蓝侧边栏:**用于高密度表格旁边的章节意见、结论摘要、解释栏或指南。白色文字,亮蓝色副标题,里面有短项目符号;它是一个信息容器,而不是装饰色块。
|
||||
|
||||
**浅灰色说明框:**用于背景、增长动力、假设和补充说明。放置在页面右侧或表格旁边;必须有副标题。
|
||||
|
||||
**目录突出显示栏:** 深海军蓝水平栏用反白文本标记当前部分。仅用于目录/进度页面。
|
||||
|
||||
**数据标签和徽章:** CAGR、最新值、预测值和关键类别可以使用小标签、省略号或胶囊标记;颜色保持深海军蓝、亮蓝色或灰色;将它们放在相应数据附近。
|
||||
|
||||
**流程块和箭头:**用于迁移路径、阶段和机制说明。形状为矩形、圆角矩形或深蓝色/亮蓝色/灰色的简单箭头。没有复杂的 SmartArt 样式、尺寸箭头或渐变箭头。
|
||||
|
||||
**图像框架:**如果使用屏幕截图/系统界面,请将它们仅作为证据材料嵌入,保留矩形边界和标题 - 没有大的情感图像。
|
||||
|
||||
**图标:**彩色图标并不是这种风格的稳定语言。如果必须显示状态,请使用单一颜色或蓝灰色系列的简单符号、点、检查、箭头或文本标签。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
总体而言,扁平、克制、数据驱动、咨询报告的质感,通过排版、线条、表格、图表和有限的色块构建专业精神。
|
||||
|
||||
**禁止:**
|
||||
|
||||
- 大照片背景、视频静态背景或情感全屏图像;
|
||||
- 表情符号、卡通插图、复杂的彩色图标、贴纸式装饰;
|
||||
- 3D 图表、透视条、发光、强烈阴影、玻璃形态、纹理背景;
|
||||
- 大渐变色块作为身体背景;
|
||||
- 大型居中文案、营销口号标题、无源数据;
|
||||
- 随机多调色板或多个高饱和度颜色在一页上竞争。
|
||||
|
||||
**谨慎使用:**- 圆角:可用于小标签、流块、椭圆标记;一定不能概括为卡片式的 UI 风格;
|
||||
- 阴影:源文件几乎不依赖它们;如果用于嵌入式屏幕截图,请使其保持极轻;
|
||||
- 红/绿状态颜色:仅用于明确的积极/消极/风险/机会,谨慎使用;
|
||||
- 屏幕截图:仅作为事实证据——它们需要标题、边界、来源或说明。
|
||||
|
||||
**可用于特殊页面:**封面可使用蓝色直线曲线图形; TOC可以使用大的浅灰色内容区域和深海军蓝高亮条;部分进度页面可以加强空白和导航,但不得引入类似海报的视觉效果。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 是 16:9 横向,以白色背景为主,有稳定的标题区、正文区和页脚区吗?
|
||||
- 标题是分析性主题还是结论句,而不是空洞的口号?是否控制在1-2行?
|
||||
- 页面是否只有一个主要判断,并有图表/表格直接支持该判断?
|
||||
- 是否保留来源、脚注、单位、时间范围以及实际与预测的基础?
|
||||
- 调色板是否仅限于黑/白/灰+深海军蓝+亮/浅蓝色,仅在关键点上使用亮蓝色?
|
||||
- 正文是否为 Arial 级无衬线字体,且标题允许采用正式衬线粗体?是否避免了对特定点大小的依赖?
|
||||
- 图表是否使用平条、堆叠条、线条、标签和细灰轴,避免 3D、渐变和过多的图例?
|
||||
- 表格是否有清晰的标题、细小的分隔符、统一的对齐方式、可读的行高和必要的关键标记?
|
||||
- 右侧的说明栏或深色侧边栏是否具有结论/背景功能而不是纯粹的装饰?
|
||||
- 是否避免使用照片背景、表情符号、复杂的图标、阴影、发光、纹理和随机颜色?
|
||||
- 您是否检查过文本溢出、截断、乱码、重叠、低对比度、遮挡、空内容、错误比例以及缺失页码/来源?
|
||||
- 生成多个页面时,目录突出显示、页脚、颜色语法、图表编码和标题层次结构是否一致 - 避免每个页面看起来像不同的模板?
|
||||
- 默认情况下没有卡片:除非用户明确请求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式:线段、空白和字体大小差异是更好的解决方案
|
||||
- 无均分组合:除非没有其他可用的布局,否则不要默认三向分割、四向分割或 2×2 矩阵。这包括三部分结论,例如三栏+标题+结论
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,141 @@
|
||||
# 组织名称 · 2023.05 报告名称 CEO 生成式 AI 风格设计系统路线图
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
咨询公司向首席执行官、高管和商业领袖进行的演讲,内容涵盖趋势洞察、战略路线图、风险识别和组织行动建议。它使用高密度的商业论证:每一页首先给出结论,然后用图表、表格、比较框、案例摘录或行动列表来支持它。
|
||||
|
||||
核心设计:带有标题的深色科技照片横幅;白色/极浅灰色的机身让阅读变得轻松;薄荷绿和品牌绿组织层次结构;带有小脚注的高密度网格保持了报告的感觉。阅读路径是“论点优先+证据展开+来源作为支撑”。
|
||||
|
||||
基本原则:
|
||||
- **论证优先于装饰**:每页的顶部必须是一个独立的判断或面向行动的标题;图表、表格和组件仅服务于该判断。
|
||||
- **绿色仅用于结构和强调**:浅绿色用于背景分区和标注块,深绿色用于关键数字、标签、路径、条形和积极状态;绿色不得扩展为任意整版装饰色。
|
||||
- **网格必须驯服高密度**:多个证据点必须位于列、行、卡片、矩阵、流或表中;避免自由浮动的分散、未对齐的拼贴和任意放大的局部元素。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
- **画布比例**:水平 16:9 宽屏,专为报告阅读而不是大型舞台演示而设计。
|
||||
- **默认背景**:内容页面使用白色或极浅灰色;浅灰色用于表格底座、模块底座或底部摘要带。封面、封底和一些章节/过渡页使用深色技术摄影。
|
||||
- **标题区域**:内容页面顶部有一个固定的深色窄横幅,嵌入深色照片或近乎黑色的基色;标题左对齐,白色,粗体,一个完整的判断语句。小节编号或进度标签(例如“1a / 1b / 2b”)可能会出现在标题的下边缘或正文的左上角。
|
||||
- **身体区域**:位于横幅下方。常见的排列方式有左右分栏、三栏卡片、象限/矩阵、图表+注释、表格+旁注、中心模型+周围说明等;边距紧凑且严格对齐,由细线、浅灰色底色或空白分隔。
|
||||
- **页脚**:底部的一个小灰色脚注/来源/版权区域,左对齐;右下角或底部边缘的小页码。脚注通过空格或细线与正文分开,并且永远不会混合到正文中。
|
||||
- **徽标和品牌**:在封面的左上角,组织名称和报告名称的纯小文本组合(写下新主题名称)或留空;封底上,品牌文字标记位于深色照片上;两者都没有绘制图形标志。内容页面没有大规模重复徽标——黑色横幅、绿色系统和脚注风格带有品牌识别度。
|
||||
- **封面骨架**:全出血暗化照片;左上角有一个浅绿色的品牌块;页面中部向左/中心的水平浅绿色半透明标题块;标题为黑色粗体;小衬线/斜体级别的日期。绿色块的下边缘可能带有细垂直条纹。
|
||||
- **章节/过渡页面**:左侧浅绿色块或深色照片块承载章节主题,右侧有项目符号列表;增加空白并保持左对齐。
|
||||
- **关闭/免责声明页面**:免责声明页面使用白色底色、浅绿色副标题和灰色正文块;后盖恢复深色摄影和白色品牌字标。
|
||||
- **默认阅读路径**:顶部横幅结论→左上角数字/模块标题→主图表或中心模型→右侧/下方解释→脚注来源。## 3. 内容组织
|
||||
|
||||
- **标题风格**:使用结论句、判断句或动作句——而不仅仅是名词;表达诸如“正在发生什么”、“为什么重要”、“首席执行官应该如何行动”、“风险在哪里”等含义。标题可以更长,但必须在横幅内的一到两行内保持可读性。
|
||||
- **关键信息句**:标题下方或正文左上角,可能会出现“数字+短语”阶段标签;数字使用绿色或黑色小标签。
|
||||
- **正文组织**:以短段落和项目符号组织的微点。避免连续全出血的长段落;将长解释分成卡片、左右列或表格行。
|
||||
- **分组逻辑**:偏好时间/阶段递进、左右对比、矩阵比较、“三并列”、“二类对比”、“核心/非核心”、“传统/新类型”、“机会/风险/行动”。
|
||||
- **每页点数**:每页一个主要判断,内有3-6个证据点、项目符号或比较维度。如果需要6个以上的并行点,请转换为表格或拆分为两页。
|
||||
- **关键字强调**:使用粗体、绿色文本、绿色标签、浅绿色基块、环或边框;风险或警告可以使用小面积的洋红色/玫瑰色标记。不要使用荧光多色、高饱和度红蓝混合或大彩虹强调。
|
||||
- **数字和单位**:数字直接位于条形图、标签、表格单元格或关键结论旁边;单位和时间范围位于图表标题、轴标题或脚注中。增长率、年份、市场规模和股票类型数据应该比解释性文字更加突出。
|
||||
- **引述和案例**:外部引述使用引号、浅绿色引述块或新闻摘录框;引用是提供证据的,不能取代页面的主要结论。
|
||||
- **来源脚注**:图表、市场规模、案例截图和报价均保留其来源;使用极小的灰色文本,最好是左对齐,远离主要阅读流程。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A. 顶部横幅 + 三列/多列意见卡
|
||||
|
||||
针对概念差异、能力特征、风险类别和组织要求。正文分为三栏或四栏,每栏都有一个小图标/副标题/简短标题/一些项目符号;列标题为绿色,列由浅灰色垂直线或空格分隔。关键列可以添加浅绿色底座或深绿色标签;每栏包含一个小要点和 2-4 个支持注释。当必须显示连续数据或复杂因果链时,请勿使用此布局。
|
||||
|
||||
### B. 主图+右侧证据/说明卡
|
||||
|
||||
了解增长趋势、市场规模、采用率和预算变化。该图表大约占一半到三分之二;解释框、新闻摘录或要点位于右侧或上方。图表采用绿色初级系列和灰色次级系列,直接标注关键数据;卡片使用浅灰色或白色底色,带有细边框和一些绿色标题。每页只有一个主要图表结论 - 避免堆叠多个不相关的图表。
|
||||
|
||||
### C. 左侧大意见块+右侧行动说明
|
||||
|
||||
针对 CEO 行动建议、路线图提示和核心问题定义。左边一个大的浅绿色基块承载着核心判断、问题或分类模型;右侧用项目符号、短段落或案例解释了行动的含义。环、双圆、箭头或分隔线可以将它们连接起来。如果页面的重点是数据证明,请减少大意见块的区域以避免挤压图表。
|
||||
|
||||
### D. 比较矩阵/表格页用于比较技术阶段、业务用例、风险维度、组织能力和部门职责。使用列标题+行尺寸;浅绿色或深绿色的标题,包含短语或项目符号的单元格。关键列/行使用绿色底色、粗体或图形标记。如果维度少于 2×2,请使用卡片而不是表格。
|
||||
|
||||
### E.中心模型+周边讲解
|
||||
|
||||
用于框架、能力系统、治理模型或价值链。圆、环或矩阵核心位于中心,周围有 4-8 个解释节点,通过细线、箭头或空间关系连接。中心通常为绿色,外围为浅灰色/浅绿色。这种布局强调结构关系,不适合长文本段落或精确值。
|
||||
|
||||
### F. 流程/路线图布局
|
||||
|
||||
对于诸如“发现——选择——构建——治理——规模”之类的行动步骤。使用水平或垂直步进条,绿色标记当前/关键阶段,灰色标记辅助阶段;每个步骤下方都有一个简短的标题。流向箭头应该是轻量且扁平的——没有复杂的尺寸箭头。如果步骤不连续,请转换为矩阵卡或并行卡。
|
||||
|
||||
### G. 新闻/案例拼贴页面
|
||||
|
||||
对于外部事件、公众情绪、风险实例或现实世界的证据。新闻卡片、截图或摘录必须遵守网格边界和统一标题横幅;屏幕截图不应填满整个页面,并应带有解释性标签。此布局是证据页面,不得成为常规内容模板。
|
||||
|
||||
### H. 联系人/附录列表页面
|
||||
|
||||
对于团队、作者、联系人和免责声明。使用头像或文本卡网格;姓名/头衔以粗体显示,角色、电子邮件和链接位于较小的级别。减少图表并强调重点。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
- **近黑色/深木炭 #0B0C0D、#0D0F0F、#191E1F**:用于标题横幅、深色照片叠加层、封底背景和主要正文文本。不能用鲜艳的品牌色来代替——那会破坏咨询报告的权威性。
|
||||
- **白色/极浅灰色#F1F1F1、#F2F2F0、#ECEDED**:用于主页背景、内容库、表格空白区域和卡片库。可以在白色和浅灰色之间切换,但不得成为高饱和度的彩色背景。
|
||||
- **薄荷浅绿色,大约#9CDDAA、#92C9A0、#8FC49B、#BFEFD0**:用于封面标题块、章节块、标注框、大左意见库和弱表格强调。文本必须保持黑色/深色;替换颜色也必须是低饱和度、浅色背景。
|
||||
- **品牌原色绿色#23B871、#35BA7A、#408A5A**:用于关键栏、标签、关键数字、图标、当前流程步骤和核心环。不要同时引入几种相似的果岭,以免造成层次混乱。
|
||||
- **深绿色#26633B、#2E6F47、#5F877B**:用于小标题标签、深色图表系列、强调边框和结构线;不适合作为长正文文本的基色。
|
||||
- **中灰色#748687、#A6AB9E、#B7B7B7、#D2D2D2**:用于分隔线、表格线、轴、辅助文本、非键系列和禁用/基线状态;不得替换主要文本颜色。
|
||||
- **洋红色/玫瑰色,大约#C2185B、#B0185A**:仅适用于风险、反例、警告和负面类别的小区域标记 - 环、标签、关键字。不得大面积使用作为第二品牌颜色。
|
||||
- **图表系列边界**:主色调为绿色;只有灰色、浅绿色和一点洋红色作为辅助色。如果数据系列超过 3-4,最好用绿色深度步长和灰色来区分 - 不要引入彩虹色。
|
||||
|
||||
## 6. 字体和文本层次结构- **字体字符**:正文使用 Arial 系列,标题和副标题为粗体,引文或注释为斜体; Trebuchet MS 很少用于脚注/版权;封面“报告名称”可以使用衬线字体。
|
||||
- **页面标题**:1.8–2.5x正文大小,粗体,白色,放置在深色横幅中;长标题可能会换行,保持左对齐且行间距较小。
|
||||
- **封面主标题**:2.5–4倍主体大小,黑色粗体,放置在浅绿色半透明块上;日期/系列名称较小,可以使用斜体或衬线。
|
||||
- **关键信息句子/引导**:1.2–1.6x 正文大小,可以是粗体或绿色,放置在正文的左上方或浅绿色块上。
|
||||
- **正文**:定义为 1x、常规 Arial、紧行距、左对齐;避免居中的长段落。
|
||||
- **小标题/卡片标题**:1.1–1.4x 正文大小,粗体、绿色或深色;文字简短而明确。
|
||||
- **表格和图表标签**:0.75–1x 主体尺寸;数字可以加粗或放大至 1.1–1.4 倍;轴标签和图例较小,保持灰色。
|
||||
- **注释/脚注/来源/页码**:0.45–0.7x 正文大小,灰色。脚注不得用绿色来吸引注意力。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
- **实际存在的类型**:主要是条形图、堆积条形图、折线/趋势图、圆环/气泡式关系图和简化坐标图。
|
||||
- **轴和网格线**:轴线和网格线极细,浅灰色;背景网格可以被削弱或省略,但必要的坐标信息被保留。
|
||||
- **系列颜色**:主系列使用品牌绿色或绿色深度的组合;辅助系列采用灰色、浅黄绿色、或浅灰色;预测、目标或关键年份使用更深的绿色和直接数字注释。
|
||||
- **数据标签**:直接注释关键条、端点、峰值或增长率;黑色或绿色的数字。避免让读者仅仅依赖图例来获取关键点。
|
||||
- **图例**:小尺寸,放置在图表上方或下方。如果只有 1-2 个系列,请直接在图形旁边进行标记。
|
||||
- **注释和解释**:使用细线、箭头、浅绿色框或小文字来解释关键转折点;新闻摘录可以作为支持证据卡。注释不得覆盖数据主体。
|
||||
- **边界**:没有 3D 条、渐变填充、重阴影、彩虹热调色板或复杂的动画图表效果;图表的美观必须服从于数据的清晰度。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
- **表格作用**:用于多维度比较、风险列表、行动职责、能力评估和用例筛选;通常是页面的主要证据结构,而不是装饰性布局。
|
||||
- **标题**:深绿色、浅绿色或深色文本,背景主要为浅绿色/白色/浅灰色;文本简短,强调维度名称而不是长句子。
|
||||
- **行/列结构**:允许多列,但需要严格对齐;使用细灰色线或浅灰色底线来分隔行 - 不要依赖粗黑色边框。
|
||||
- **底色和斑马条纹**:浅灰色或浅绿色底色区域;斑马线对比度不宜太强。
|
||||
- **关键行/列**:用浅绿色底色、绿色文本、粗体、点等级或边框进行强调;风险/负面项目使用小的洋红色标记。没有全行亮红色或高饱和度警告色。
|
||||
- **数字和文本对齐**:数字右对齐或居中,文本左对齐;短评级、符号和点可以居中;单位统一在列标题或脚注中注明。
|
||||
- **行高和密度**:紧凑的行高;每个单元格仅限于一个短语、短句或 1-2 个项目符号。如果单元格需要很长的解释,请转换为卡片页。
|
||||
- **符号标记**:可以使用最少的符号,例如点、格子、短划线和水平色块;需要图例或语义必须保持一致。
|
||||
|
||||
## 9. 组件和图形元素- **深色标题横幅**:核心识别组件,横跨顶部,包含深色技术照片或近乎黑色的底座,带有白色结论标题;所有内容页面的首选。
|
||||
- **浅绿色标题/意见块**:用于封面主标题、章节提示、关键问题和行动原则;矩形或大色块,没有复杂的效果。
|
||||
- **数字标签**:如“1a / 1b / 2b”,表示路线图进度;绿色/深色,位于主体的左上方。
|
||||
- **卡片**:白色或浅灰色底座,细边框或无边框,包含副标题、短标题、图标/数字;通过对齐和空白来区分,没有沉重的阴影。
|
||||
- **分隔线**:细灰色线、浅绿色线或列之间、表行之间的空白间隙,并隔离脚注区域;线条应该很轻,不会产生笼状网格的感觉。
|
||||
- **环形/双圆/矩阵标记**:用于核心/非核心、正/负、键/非键;绿色表示核心或积极,洋红色表示非核心/风险/警告。环内的文字必须短。
|
||||
- **图标**:少量单色绿色/灰色线性图标或简化符号,仅用于帮助模块识别。禁止使用彩色拟物图标。
|
||||
- **箭头和流线**:表达过程、因果关系或转换关系;扁平细线,绿色或灰色。避免使用粗的尺寸箭头。
|
||||
- **屏幕截图/新闻卡**:仅在案例证据页面上,放置在带有短标题的白色卡片或浅灰色区域;必须有边界和对齐——切勿随意倾斜或堆叠。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
扁平、内敛、咨询报告式的科技感;品牌感觉来自品牌绿色、深色照片横幅、Arial字体系统和严格的网格——而不是复杂的装饰。
|
||||
|
||||
- **禁止**:3D图表、发光效果、重阴影、复杂渐变、彩虹调色板、表情符号、卡通插图、彩色拟物图标、无意义的装饰线、大块居中副本、整页无网格自由格式布局、低对比度浅色文本以及跨内容页面滥用的封面式照片背景。
|
||||
- **谨慎使用**:照片、屏幕截图、洋红色警告色、环模型、流动箭头、大浅绿色基块。它们必须具有证据、分类、行动或强调功能;如果它们只填补空白,请将其删除。
|
||||
- **可用于特殊页面**:封面、封底和章节过渡页可以使用大的深色照片和浅绿色块;案例证据页面可以使用新闻截图拼贴;联系页面可以使用头像网格。常规内容页面应保留白色底色、横幅和图表/表格/卡片。
|
||||
- **圆角和阴影**:卡片应该只有轻微的圆角或接近正方形;阴影基本没用过。模块由基色、线条和空白分隔。
|
||||
- **图像依赖性**:内容页面不依赖大图像来营造气氛;图像仅出现在标题横幅或证据卡中。每张图像都需要变暗、裁剪和对齐,并且不得破坏文本的可读性。
|
||||
|
||||
## 11. 一代清单- 骨架:是16:9吗?内容页面是否有深色顶部横幅;身体是否落入清晰的网格中?页脚来源/版权/页码是否位于小底部?
|
||||
- 文案:标题是结论还是行动句;是否每一页只有一个主判断;段落是否被分成短句、项目符号、卡片或表格?
|
||||
- 层次结构:标题、关键信息、正文、副标题、图表标签和脚注是否形成相对大小差异;是否避免了特定点大小依赖性和无层次堆叠?
|
||||
- 颜色:基础为白色/浅灰色,文字为黑色,强调绿色;洋红色仅用于小风险区域;每个页面是否保持在绿色+灰色+少量警告色范围内?
|
||||
- 布局:图表、表格、卡片和流程各司其职;是否避免了多个不相关的主视觉效果之间的竞争;左右/多列排列是否对齐?
|
||||
- 图表:使用的是扁平的绿灰色系列;轴线和网格线是否亮;关键数据直接标注; 3D、渐变和过多的系列颜色是否被禁止?
|
||||
- 表格:标题、行/列、分隔符、关键行/列以及数字/文本对齐是否一致;单元格是否可以避免长段落溢出?
|
||||
- 组件:标题横幅、数字标签、浅绿色意见块、卡片、分隔线和环形/流线是否具有明确的语义;图标是单色且简单的吗?
|
||||
- 禁止物品:是否没有表情符号、卡通插图、彩色拟物化图标、发光、浓重阴影、复杂装饰、大型居中身体块或滥用的照片背景?
|
||||
- 质量:检查文本是否溢出、截断、乱码、重叠、对比度低、遮挡、内容空洞、比例错误、压在正文上的脚注以及与信息结构不匹配的模板重复。
|
||||
- 默认情况下没有卡片:除非用户明确请求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式:线段、空白和字体大小差异是更好的解决方案
|
||||
- 无均分组合:除非没有其他可用的布局,否则不要默认三向分割、四向分割或 2×2 矩阵。这包括三部分结论,例如三栏+标题+结论
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,176 @@
|
||||
#F030·顶级管理咨询与策略全套PPT模板风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
用于针对管理层、项目所有者或决策委员会提供有关战略咨询、成本优化、运营诊断、效率改进、路线图规划、机会评估和结果审查的管理报告。采用“白色咨询报告骨架+高饱和度红色焦点+灰度数据组件”:封面近黑/深红色,标题超大;内容页面使用白色或极浅灰色的底色,标题位于左上角或左上角,基于图表的校样,基于卡片的摘要和路线图进展。照片、插图和装饰图标不构成主要视觉效果。
|
||||
|
||||
基本原则:
|
||||
- **每页一个红色焦点**:它可能适用于主卡、关键栏、关键字、最高强度的热图单元或 CTA - 不要将其均匀分布在同一页面的多个区域。
|
||||
- **装饰前的结构**:通过左右分栏、卡片矩阵、图表组合、时间线、热力图来组织页面;不要依赖纹理、阴影、复杂图形或大图像。
|
||||
- **数据清晰胜过图表表演**:条形图、圆环图、热度矩阵、简单表格和数字卡直接得出结论;弱化轴、图例和网格线以突出比较关系和关键值。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
画布为水平 16:9 商业演示比例。内容页面默认为白色或#F6F6F6级别的极浅灰色背景,具有相对较宽的安全边距。基本结构为“左上标题区+中央正文区+小标题/脚注区”,包含阅读路径运行标题、红色焦点、图表/卡片/表格详细信息。
|
||||
|
||||
标题区域:通常位于左上角,在右上角或左上中的几个图表页面上,包含主题、问题或结论 - 没有装饰性标题栏。标题下方的 1-2 行可以说明分析目的、范围或行动含义;身体在其下方或右侧展开。
|
||||
|
||||
车身区域:分柱式、模块化;常见内容包括大类型定义、饼图/圆环图、条形图、热矩阵、路线图卡、胶囊标签流、数字度量卡和表格式比较。卡片保持均匀对齐和间隔;浅灰色块划分页面;红色块标记优先级。
|
||||
|
||||
标题:非强制。小品牌文字标记或小标记可能位于左上角;日期、简短说明或小标题可以位于右上角;它必须保持较低的姿态,并且决不能形成沉重的单杠。
|
||||
|
||||
页脚:没有很强的导航功能;可能包含脚注、来源、联系信息或小型 CTA。如果添加页码,请使用塞在角落里的极小的黑灰色文本——它不能从正文中窃取。
|
||||
|
||||
分隔线:使用浅灰色细线、卡片边界或背景块。避免粗黑线和复杂的边框。轴和参考线也使用浅灰色低对比度。
|
||||
|
||||
封面:近黑/深红色背景,超大白色粗体标题,红色按钮式标签,深红色大字水印,右侧少量说明文字;这种处理不得迁移到普通内容页面。
|
||||
|
||||
结束页:带有一些联系方式/CTA 的大标题,白色或浅灰色底色;红色可以强调关键动作词,但不应添加照片墙或复杂的装饰。
|
||||
|
||||
## 3. 内容组织
|
||||
|
||||
标题使用问题型、主题型或行动型短语,例如“为什么……重要”、“目标……”、“路线图概述”、“识别……机会”。更喜欢名词短语或动宾结构;避免长句子。
|
||||
|
||||
在标题附近放置 1-2 行关键信息,说明结论、分析依据或下一步行动——它们不能取代身体图。解释性内容分为简短的标题、项目符号卡或脚注。正文内容遵循“一个要点+三到五个支撑点”。支持点可以是卡片、流程阶段、度量列、图表标签或表格行;每张卡片通常包含一个小标题和一个压缩说明,以空白、浅灰色底色和编号来区分。
|
||||
|
||||
关键词可以使用红色文本、红色文本上的白色文本、黑色胶囊标签、粗体副标题、放大的数字或局部红色图表元素。红色仅对应“关键点、当前、推荐、高价值、风险或行动”——绝不对应普通装饰。
|
||||
|
||||
百分比、金额、改进幅度、节省和阶段编号等数字作为独立的块,单位和解释紧邻。时间、阶段、序号保持一致;路线图使用清晰的编号,例如 01/02/03/04,并且不会混合复杂的层次结构。
|
||||
|
||||
来源和注释使用极小级别的浅灰色或深灰色文本,放置在底部、图表旁边或标题右侧;它们仅陈述数据源、假设或测量基础——它们不包含关键结论。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A. 大字定义/意见页面
|
||||
用于介绍概念、定义问题或陈述战略立场。一个小的引入线位于顶部或左上角;正文以超大文本陈述观点,关键字标记为红色;下面可能跟随一行指标、类别或关键字模块。信息密度低到中等。如果文本超出几行或需要多个数据组,请勿使用此布局。
|
||||
|
||||
### B. 左标题+右/中图表分析页
|
||||
用于现状、分布、趋势、比较和原因分析。标题和说明文字位于左侧或右上角;主体持有圆环图、条形图、组合条形线图或小公制卡;关键部分、关键条或当前值使用红色,其他所有内容使用灰色。适合1个主图+2-4个注释点;不适合塞入多个同等权重的大型图表中。
|
||||
|
||||
### C. 路线图/阶段卡页面
|
||||
用于显示 3-5 个阶段、目标、任务或执行路径。一张大红牌标志着第一阶段、当前阶段或核心举措;其余为浅灰色卡片;放大的编号位于顶部或左侧。强调阶段名称、行动和预期结果。超过五个阶段,分页或转换为时间线;不适合无序的杂项列表。
|
||||
|
||||
### D. 热度矩阵/机会识别页面
|
||||
用于影响、复杂性、优先级或节省空间的二维评估。主体是红-粉-浅粉红色网格,深红色标记高强度或优先级,旁边有一个小图例、轴注释和红色结论卡。矩阵服务于相对定位和聚类,而不是精确的值读取;当需要精确值时,请切换到表格或条形图。
|
||||
|
||||
### E.胶囊标签流程+推荐卡页面
|
||||
用于流程节点、操作步骤、类别分组或优化机会列表。黑色胶囊标记一级类别或路径节点,红色胶囊标记关键节点,浅灰色胶囊标记普通项; 2-3 张推荐卡可能位于底部或右侧。标签必须短且避免缠绕;当标签太多时,将它们分组——没有复杂的连接线。
|
||||
|
||||
### F. 多指标仪表板/案例结果页面
|
||||
用于结果、案例、预测、节省或绩效变化。由大数字卡片、条形图、迷你圆环图、标题列表组成;红色数字卡或红色图表元素是结果焦点,灰色卡提供比较。每个大数字都必须有一个解释——不要堆叠孤立的指标。### G.简单表格/比较矩阵页面
|
||||
用于比较计划、部门、成本项目、时间段或机会类别。该表占据主体的中下部分,具有简单的标题和红色或浅红色底座的关键列/行。标题和结论句是强制性的——表格不能成为无法解释的数据堆。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
背景颜色:
|
||||
- **#FFFFFF**:主要内容基础、卡片内部和大空白区域 - 最大的区域。不能用高饱和度的颜色代替。
|
||||
- **#F6F6F6 / #F7F7F7 / #F8F8F8**:整页背景、浅色分区、表格底座或图表容器底座;必须保持白卡清晰可见。
|
||||
|
||||
主要文本颜色:
|
||||
- **#070302 / #0F0605 / #1C0B0B**:标题、重要正文、封面主标题和关键图表文本。
|
||||
- **#2F2929 / #454545 / #484545**:正文标题、副标题、图例文本和表格文本。
|
||||
|
||||
主要强调红色:
|
||||
- **#FF4D4D / #FF5454 / #FF5C5B / #FE6464**:用于红卡、关键数字、关键图表栏、当前阶段、重要标签和 CTA。每页最多一个大面积红块;其他红色仅显示为点状回声。
|
||||
- 可以更换为品牌原色,但必须保留“单一高饱和度强调+灰度系统”,并保持白字/黑字对比。
|
||||
|
||||
辅助粉红色:
|
||||
- **#FED8D8 / #FFC5C4 / #FFDFE0 / #FFF1F1 / #F1CCCC**:热图渐变、弱强调背景、面积图填充和红卡的亮度级别。不得用于小尺寸的主要正文文本 - 避免低对比度。
|
||||
|
||||
辅助灰度和分离:
|
||||
- **#BDBDBD / #C1C1C1 / #D9D6D6 / #DDDBDB / #E7E6E6**:轴、网格线、卡片边界、普通条形、表格线、禁用或次要状态;不得与正文相冲突。
|
||||
|
||||
覆盖深色:
|
||||
- **#070302 / #120706 / #210E0D / #2F0505**:仅适用于封面或极少数暗调部分页面的背景和暗红色水印。内容页面不使用大型深色底座。
|
||||
|
||||
单个页面仅使用红色+灰度的一种色调。图表系列以红色、粉色、灰色为主;避免引入蓝色/绿色/紫色的多色业务图表,除非必须区分源数据状态并保持低饱和度。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
现代无衬线商业字体。正文主要使用ArialUnicodeMS / LiberationSans-class sans-serif;封面的超大标题采用了较重的Arial-Black风格。整体干净、紧凑、理性——没有手写字体、装饰性衬线字体或卡通字体。
|
||||
|
||||
相对层次结构:
|
||||
- 封面主标题:约5-6倍正文大小,超粗体,白色,行距紧凑。
|
||||
- 内容页面主标题:大约 2.5–3 倍正文大小,常规或中等粗细,黑色,位于左上方或顶部。
|
||||
- 大字体意见/定义:大约是正文大小的 3-4 倍,可以跨多行,关键字为红色。
|
||||
- 副标题/卡片标题:大约是正文大小的 1-1.2 倍,通常为粗体或更深的黑色。
|
||||
- 正文标题:body=1x,短线条,低装饰。
|
||||
- 表格文本/图表标签:大约是正文尺寸的 0.75–0.9 倍。
|
||||
- 脚注、来源、标题小文本、页码:大约 0.55–0.7 倍正文大小,灰色或黑灰色。
|
||||
- 数量指标:大约是身体尺寸的 2-4 倍;单位和解释下降到正文或注释级别。
|
||||
|
||||
文本主要是左对齐;大的中心通道不是系统的一部分;封面可以局部使用左对齐的超大标题。避免段落过长,并避免卡片内出现多级项目符号。在同一页面中使用一种一致的大写样式。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
实际出现的图表主要是圆环图/饼图、条形图、组合条线图、面积/趋势图、热矩阵、小仪表和公制卡——服务于咨询分析,而不是追求复杂的可视化。
|
||||
|
||||
圆环图/饼图:使用红色、深红色、灰色和黑灰色扇形,中间有一个短标签或核心值;简短的外部注释,低存在的图例。红色部分标记关键成本项目或关键对象。条形图:红色条标记关键点、当前值、目标或高值;灰色条是比较。轴极小,网格线浅灰色,条形间距清晰;短值或标签可以位于条形顶部——避免密集的注释。组合图表中的线条使用细笔划和小节点作为趋势或目标参考。
|
||||
|
||||
热矩阵:浅粉色到红色表示强度;轴文本保持简单;网格边界被软化。太多的暗块会破坏优先判断。需要解释颜色强度的图例或注释。
|
||||
|
||||
公制卡/迷你图表:嵌入红卡、灰卡或白色容器中。大量数字引人注目,并有简短的标题解释了它们发生变化的原因。迷你条形图和圆环图只是补充——它们不能成为页面的第二个主要焦点。
|
||||
|
||||
图例和注释:图例保持较小且靠近图表,颜色与系列相匹配。关键点可以使用红色胶囊、小点、箭头或短标签,但不能使用复杂的标注。预测线、目标线或基线线使用浅灰色线或带有简短注释的细红色线。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
表格用于“证明、比较、分解和优先排序”;它们通常位于正文的中下或右侧,与标题和结论句配对。
|
||||
|
||||
表头:浅灰色底,或白色底细线;文本加粗或加暗。关键列可以使用红色标题或红色顶栏,但整个表格不得高饱和度。
|
||||
|
||||
行和列:行高均匀,表述为短句。保持较低的列数 - 优先考虑名称、指标、影响、操作和状态字段。较长的解释应分解为卡片或脚注;避免细胞内多线拥挤。
|
||||
|
||||
分隔符和基色:使用浅灰色细线,例如#E7E6E6、#DDDBDB;浅灰色块可以对内容进行分组;斑马条纹不是一个强大的系统——如果使用,请保持非常微妙。关键行/列可以使用浅红色底座或红色条标记。
|
||||
|
||||
对齐方式:文本左对齐,数字右对齐或在小数/单位上视觉对齐。百分比、金额和变化值保持一致的单位。状态使用红点、黑点、浅灰色标签或短徽章 - 没有复杂的图标集。
|
||||
|
||||
摘要和要点:摘要行使用深色文本、浅灰色底色或细红色线。不要同时用多种颜色标记级别;喜欢用红/粉/灰三个级别来表示高/中/低或重点/普通/弱。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
标题组件:左上角有一个大的黑色标题+下面或旁边的标题——没有沉重的标题栏。封面标题是一种特殊的超大白色文本例外。
|
||||
|
||||
红色钥匙卡:当前阶段的关键结论、核心指标、建议行动或 CTA;可以是红色底上的白色文本,也可以是红色到浅色底色上的黑色文本。每页仅保留一张主红卡;其他红色元素降级为标签或图表点。
|
||||
|
||||
浅灰/白卡:用于并行信息、阶段说明、度量注释和比较内容。光边界,内敛圆角,无阴影依赖;均匀的间距和对齐方式形成网格。
|
||||
|
||||
胶囊标签:黑色、红色、浅灰色三种。黑色标记类别或主路径节点,红色标记关键节点,浅灰色标记普通节点。标签文本很短,不包含长句子,并且不会包装成多行胶囊。
|
||||
|
||||
编号部分:使用01/02/03/04或大数字。红底白字标记当前或初级阶段;其余部分用灰黑标记。数字必须与卡片标题绑定在一起,而不是孤立的装饰。
|
||||
|
||||
分隔符和轴线:浅灰色细线组织图表、表格和模块;它们不得变厚或变成装饰性边框。
|
||||
|
||||
徽章/按钮:封面和 CTA 可以使用红色按钮式标签;内容页面可以使用小的红色/黑色标签。按钮不具有真正的交互性——没有浓重的阴影或高光。图标:彩色图标不是系统的核心。如果使用的话,它们只能是极小的单色功能标记——点、箭头、加号、位置点——用于指导或注释;禁止使用彩色插图图标集。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
整体扁平、克制、类似咨询报告,通过强烈的对齐、空白、红色焦点和数据结构构建视觉吸引力。
|
||||
|
||||
禁止:
|
||||
- 大面积照片背景、大人物图片或场景摄影作为内容页面的主视觉。
|
||||
- 表情符号、卡通插图、3D 图形、发光、光泽玻璃纹理、复杂纹理。
|
||||
- 彩色图标集,混合多种品牌颜色,如蓝色/绿色/紫色,彩虹式图表。
|
||||
- 浓重的阴影、强烈的渐变背景、复杂的边框、装饰曲线和华丽。
|
||||
- 大型居中副本、无层次结构的长段落、整页项目符号列表。
|
||||
|
||||
谨慎使用:
|
||||
- 渐变:仅适用于红色到浅红色的卡基、热图或面积图,不适用于整页耀斑背景。
|
||||
- 阴影:如果必须区分卡片,请使用极其微妙的阴影或简单的浅灰色底色;不要形成浮卡风格。
|
||||
- 圆角:可用于胶囊标签和按钮;卡片的角应仅有轻微的倒圆角;不得变成互联网UI风格。
|
||||
- 图标/箭头:仅用于流向、关键点注释或状态点——数量少,单色。
|
||||
|
||||
适用于特殊页面:封面可使用深色背景、深红色水印、超大标题、按钮式红色标签;结束页面可能会使用更大的 CTA 消息以及联系方式。章节分隔页可以少量采用封面的深色氛围或白底大字,但不能取代内容页系统。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 骨架:是否是16:9的横向商业页面;标题区域、正文区域和说明/脚注区域是否清晰;内容是否远离边缘?
|
||||
- 文案:每一页是否只有一个要点;标题是否简短且具体;标题是否控制在 1-2 行;卡片上的文字是短句吗?
|
||||
- 层次结构:标题、关键信息、正文、图表标签和脚注是否显示出明显的相对大小差异;是否避免了无层次结构的文本堆叠?
|
||||
- 颜色:是否保留白色/浅灰色背景+黑灰色文本+单一红色强调;红色是否只对应关键点?是否避免使用多色图表?
|
||||
- 布局:在大字体意见、图表分析、路线图、热矩阵、标签流、指标案例或简单表格中选择合适的方案;不相关的信息是否会被强制放到同一页面上?
|
||||
- 图表:红色仅标记关键系列;轴和网格线是否软化;标签是否可读;图例是否简单且接近图表?
|
||||
- 表格:标题、分隔符、关键行/列、数字对齐是否一致;是否避免了长单元格段落和过于密集的线框?
|
||||
- 部件:红钥匙卡、浅灰卡、胶囊标签、编号、分隔符是否功能清晰;没有添加任何组件只是为了装饰吗?
|
||||
- 禁止物品:照片背景、表情符号、3D、发光、浓阴影、复杂纹理、彩色图标和彩虹调色板是否不存在?
|
||||
- 质量:检查文本溢出、截断、乱码、重叠、对比度低、图表遮挡、空卡、错误比例、模板过多相同或红色焦点过多。
|
||||
|
After Width: | Height: | Size: 456 KiB |
@@ -0,0 +1,151 @@
|
||||
# 组织名称·2025.08 通信功能的GenAI变革STYLE DESIGN SYSTEM
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
用于咨询公司数据包、功能转型评估、人工智能/技术影响量化、方法论解释和执行层报告。针对企业主、沟通/职能领导者和咨询项目团队;每一页都必须快速得出结论,同时图表定义和测量基础、方法假设和来源仍然可追溯。
|
||||
|
||||
一言以蔽之:**一种深色的咨询数据包风格,深灰色的画布和深绿色的科技氛围承载着密集的分析,青绿色的数据编码凸显了 GenAI 的转型机会和方法论水平。**
|
||||
|
||||
稳定的功能:深灰色/深绿色全底色、TrebuchetMS 字体系统、左上角的结论式标题、大正文图表/矩阵、右侧的短段落解释、底部的微小来源/版权线、细线框架、荧光绿/青绿色口音以及封面/结束页上的 AI 肖像视觉效果。
|
||||
|
||||
- **结论为先,证据为正文**:标题直接陈述判断;图表、表格、矩阵或流程具有说服力;装饰不能代替证据。
|
||||
- **深色统一,绿色层次感**:深灰色和深绿色定义了整个甲板;亮绿色仅标记关键数据、标签、图表系列和状态——没有无意义的颜色填充。
|
||||
- **高密度但可扫描**:使用栏、分隔符、标题、标签、脚注和对齐来构建阅读路径;避免无层次结构的文本堆叠。
|
||||
|
||||
## 2. 页面骨架- **画布比例**:宽屏横向 16:9。所有页面都为脚注、版权和页码保留底部空间。
|
||||
- **默认背景**:内容页面位于深灰色#2C2C2C上,覆盖深绿色#0A3021–#0D4430区域或右上角/右侧的绿色光芒;部分分隔符和免责声明页面可以使用近乎纯的深灰色。
|
||||
- **边际**:狭窄的安全边际;标签和标题位于顶部,来源和页码位于底部,正文位于中间。
|
||||
- **标题区域**:左上角的一个小选项卡/层次结构标签,后面是主标题和一行副标题或关键消息。左对齐,在页面顶部跨越大约一半到全宽;从未居中。
|
||||
- **正文区**:内容页主要是左侧或中心的图表/表格+右侧解读;方法论页面使用水平流或矩阵;概念页面使用多列/节点关系。标题区域和正文之间的间距很小。
|
||||
- **标题/导航**:左上角有一个绿色小标签,例如功能级别、任务级别、方法论;它仅表示部分和分析级别——没有大的导航栏。
|
||||
- **页脚**:固定在底部,包含来源、假设注释和版权,其大小明显小于正文;右下角的页码。脚注可以水平跨页或聚集在图表下方。
|
||||
- **Logo**:品牌标志主要出现在封面和封页上;内容页不放大Logo。
|
||||
- **分隔线**:细线、表格线、轴线、矩形轮廓;颜色为深灰色、灰绿色或低饱和度绿色——仅用于组织功能。
|
||||
- **背景图形**:封面和尾页可能使用人工智能肖像/生成视觉效果;内容页面仅使用抽象的绿色发光或渐变 - 没有大的人物图像。
|
||||
- **页面类型骨架**:
|
||||
- 封面:左侧为深灰色品牌和标题栏,右侧为带有深绿色光效的AI人像;标题位于细线框架或半框架区域内。
|
||||
- 内容数据页:标题位于左上角,大中央图表,解释位于右侧,来源位于底部。
|
||||
- 方法论部分分隔线:近乎纯深灰色,左下/中左的大标题,一些小方块作为视觉锚点。
|
||||
- 方法论内容页面:标签+标题位于顶部,流程、矩阵或步骤卡位于下方,补充测量基础注释位于底部。
|
||||
- 免责声明页面:左侧大标题,右侧密集法律文本,深灰色背景。
|
||||
- 结束页:品牌标识结合AI人像/绿光效果,信息量极少。
|
||||
- **默认阅读路径**:左上标签→主标题/副标题→主图表或矩阵核心数据→右侧解释→底部来源/测量基础注释→页码。
|
||||
|
||||
## 3. 内容组织- **标题风格**:使用咨询式的结论句,直接陈述排名、影响、机会、方法范围或比较判断;避免使用“概述”或“背景”等模糊标题。可能包括百分比、排名、主题和结果。
|
||||
- **副标题/关键信息**:在主标题下方,一小行说明量化范围、阶段、指标定义或基础,或“为什么重要”;颜色为浅绿色或浅灰色。
|
||||
- **正文结构**:短段落、短句子和项目符号;右侧解释通常有 2-4 段,每段支持一个发现。不要使用大块的中心叙述。
|
||||
- **分组逻辑**:按功能、任务类别、影响类型、AI 类型、自动化阶段、方法步骤、成本池或工作流程分组;标题更喜欢简短的名词短语。
|
||||
- **每页点数**:每页 1 个要点,搭配 2-4 个子发现;高密度的表格页可以添加更多条目,但一个标题结论必须引导所有条目。
|
||||
- **正文密度**:允许更多标签、脚注和数据,按列宽、标题、行组和色块分层。当文本超过 4-5 个短段落时,拆分页面或切换到表格/矩阵。
|
||||
- **项目符号样式**:使用短项目符号、短破折号、分号或换行符;以动词或名词短语开头;避免完整的长段落。
|
||||
- **关键字强调**:使用亮绿色、青绿色、粗体或在深色基础上局部突出显示百分比、排名、目标对象和舞台名称;没有下划线、发光轮廓或多色强调。
|
||||
- **数字/单位/时间范围**:突出显示百分比、范围、近似值、排名和阶段差异。数字和单位保持相邻;尽可能直接在图表内进行注释,而不是依赖图例。
|
||||
- **来源脚注**:底部必须保留来源、估计假设、样本范围、版权等;多个源用分号或短语水平压缩。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A.左图+右解释
|
||||
|
||||
用于定量结论,例如排名、横截面比较、成本影响、生产力提升和功能基准测试。左侧或中间 60-70% 包含水平条形图、堆叠条形图或范围条形图;右侧 20-30% 包含解释性文字;顶部是结论标题和测量依据,底部是来源。目标物体采用亮绿色/青绿色,其他物体采用灰白色或深绿色;细垂直线可以标记平均值、目标或阶段边界。不适合维度太多且标签无法保持可读性的场景,也不适合需要长解释的叙事页面。
|
||||
|
||||
### B.全角高密度表/矩阵
|
||||
|
||||
用于任务列表、工作流程、AI 类型比较、自动化潜力、成本池和方法输入/输出。身体几乎填满了宽度;列标题位于深绿色或青绿色底座上,行组位于深灰色侧边栏或色带上。文本列左对齐,数字列右对齐或居中,状态列带有点/检查十字/色块;通过青绿色底座、亮绿色数字、红/绿色状态点或粗体行来强调。不要在数据少于 3×3 的情况下使用它——它看起来会是空的;如果比较很复杂但数字较少,请切换到卡片或流程。
|
||||
|
||||
### C. 堆叠条形/瀑布式分解
|
||||
|
||||
用于分解总机会。左侧列出类别,右侧水平堆叠条显示阶段、类型或潜在范围,底部有一个小样本图例解释该系列。关键行标签或条形使用高亮绿色;非关键部分保持低饱和度。不要使用太多系列颜色——避免将深色页面变成彩色噪音。
|
||||
|
||||
### D. 方法流程用于诊断模型、评估步骤、量化逻辑和输入到输出路径。使用 4-5 个水平步骤卡或由箭头/线条连接的节点;每个步骤都包含一个简短的标题、一个类似图标的圆圈或小卡片以及 2-3 个说明点。使用绿色标题、圆形节点、细线箭头和阶段标签来表达层次结构。不适合携带大量精确数字。
|
||||
|
||||
### E. 多列概念/比较
|
||||
|
||||
对于影响类型、传统 AI/GenAI/Agent AI 差异、评估维度或能力分类。使用 3-4 栏,每栏一个概念,里面有短句、状态标记或小矩阵;列标题使用绿条、白卡或圆形色块。适合中等信息密度,不适用于复杂的财务建模。
|
||||
|
||||
### F. 部分分隔符/分隔页
|
||||
|
||||
用于方法论和免责声明等结构转变。使用空的深灰色字段,大标题左对齐或位于左下角,仅保留最少的装饰方块/线条。不要添加图表、卡片组或解释性段落——这会破坏部分分隔符的节奏。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
- **主背景深灰色#2C2C2C**:内容页面、分区分隔线和免责声明页面的基色;不能用浅色背景代替——风格会被篡改。
|
||||
- **深绿色背景#0A3021 / #0B3928 / #0D4430**:用于内容页面、图表底板以及封面/结束页面的右侧氛围上的渐变区域。可大面积低饱和度使用;可以在同一系列内进行微调 - 不要切换到蓝紫色或橙红色。
|
||||
- **中绿色#125E42 / #176F4E / #197352**:用于主要图表系列、标题标签、模块基础和分组栏。可以用类似品牌的绿色代替,但必须与深绿色背景形成足够的对比。
|
||||
- **明亮的青绿色#1A835B / #35AA7E / #74D1A9**:用于关键数据、目标对象、强调栏、状态点、图例突出显示和一些强调文本。中小区域——避免填满整个页面;不适用于普通正文。
|
||||
- **白色/近白色#F2F4F2**:用于主标题、正文、表格文本和图表标签。不要将所有正文替换为纯灰色 - 可读性会下降。
|
||||
- **浅灰色 #B8B9B9 / #D7D7D7**:用于辅助标签、非关键栏、轴标签和脚注。
|
||||
- **深灰绿色#35594C / #5B7067 / #61746C**:用于网格线、表格分隔、弱化模块和边界线 - 仅结构颜色。
|
||||
- **红色/粉色状态,#C83A5A 周围**:仅在非常小的区域内标记低能力、限制、风险或负面状态的矩阵中。禁止作为主要装饰色。
|
||||
- **图表系列边界**:单页停留在绿色系列中,以灰色/白色为参考;红色状态仅作为例外。不要一次引入多个蓝色/黄色/紫色数据系列;如果不可避免地需要更多系列,请选择相同绿色的亮度阶梯。
|
||||
|
||||
## 6. 字体和文本层次结构- **字体字符**:整个牌组使用TrebuchetMS,具有TrebuchetMS-粗体和斜体建筑层次结构。不要混合使用衬线或手写字体。
|
||||
- **层次结构比率**:主体 = 1x;主标题约 1.7–2.4 倍;封面标题约 2.5–3.2 倍;部分分隔符大标题约 3-4 倍;字幕/关键信息约 1.2–1.5 倍;图表轴标签和表格主体约 0.8–1x;脚注、版权、页码约为 0.45–0.65x。
|
||||
- **标题**:左对齐,行距较紧,可以部分加粗或换行;不超过三行。
|
||||
- **关键信息/副标题**:在标题下方,以浅绿色或浅灰色说明测量基础 - 不要重复标题。
|
||||
- **正文**:以常规权重为主;关键短语可以是粗体或亮绿色;段落短,线宽适中,右侧解释左对齐。
|
||||
- **副标题/标签**:较小尺寸,绿色底座或绿色文本;保持文字简短。
|
||||
- **表格文本**:列标题可以是粗体,正文是常规的;文本列左对齐,数字/百分比列居中或右对齐。表格内没有频繁出现的斜体。
|
||||
- **图表标签**:轴标签和数据标签小于正文,但必须保持清晰;关键数据可能会放大到接近身体大小,并以亮绿色强调。
|
||||
- **注释/脚注/页码**:极小的级别,位于底部或右边缘垂直页码区域;必须清晰可辨,并且绝不能混入正文中。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
图表主要是水平条形图、堆积条形图、矩阵比较和一些流程图/节点图——没有复杂的多图表系统。
|
||||
|
||||
- **条形图**:深色基底,水平布局;左侧为类别标签,右侧为条形图和值。使用绿色梯子或浅灰色参考,关键栏采用明亮的青绿色;轴线和网格线细且对比度低。
|
||||
- **范围/阶段表达**:在同一行内,平行条或范围条可以表达当前与未来阶段、部署与重新发明。用不同的绿色亮度台阶或灰绿色基数进行区分,并直接在图例或条形上进行注释。
|
||||
- **堆叠条**:用于任务组合或工作流程分解。片段来自相同的绿色序列;标签嵌入或靠近条形,底部有一个简单的图例。
|
||||
- **矩阵图**:清晰的行/列标题,列标题可以使用绿条;单元格使用点、勾/叉或短文本短语来表示状态。绿色为正极,红色为负极/受限,灰白色为普通参考。
|
||||
- **流程/节点图**:圆形节点、白卡和细线箭头;节点可以包含简化的图标或首字母视觉效果,但它们必须服务于步骤的含义。
|
||||
- **图例**:位于图表的上方、下方或底部,绝不位于视觉中心;样本小而整齐,文字短。
|
||||
- **注释**:使用右侧解释文本而不是复杂的气泡标注;图表内仅保留必要的数字、基线和阶段注释。禁止大量浮动音符覆盖小节。
|
||||
- **图表边界**:无 3D 图表、饼图、圆环图、透视图或装饰曲线图;没有证据支持复杂的动画图表或插图图表。
|
||||
|
||||
## 8. 表格和高密度信息- **表格作用**:承载方法、任务、类型、评分、多维度对比——核心证据载体。
|
||||
- **标题**:深绿色或中绿色底座,白色/近白色文本,短列名称;多层标题可以使用不同的绿色步骤或细线分区。
|
||||
- **行/列结构**:行标签左对齐,指标/状态列居中,值列右对齐或居中。复杂的任务描述需要更宽的栏目;评级/潜力栏变得狭窄。
|
||||
- **行高和密度**:紧凑行高但保持间距;将长文本压缩为短语——单元格中没有完整的段落。
|
||||
- **分隔符**:细灰线、灰绿线或深色边界来分隔行组、列组和标题 - 没有厚重的外框。
|
||||
- **底色**:深灰色为底色;组行或关键列可以使用深绿色/青绿色底座;一般避免使用大的灯座以保护暗系统。
|
||||
- **关键行/列**:目标行、汇总列和关键百分比可以使用亮绿色、粗体、基色块或边框;仅选择 1-2 个焦点——切勿突出整个表格。
|
||||
- **斑马条纹**:允许极其微妙的深灰/深绿交替,绝不明显艳丽;当细线分隔已经存在时省略它。
|
||||
- **符号标记**:点、勾/叉、红绿状态点和评级色块可以表达能力/风险/自动化水平;需要图例或者必须可以从上下文推断出含义。
|
||||
- **摘要/脚注**:注释、来源和假设可能位于表格底部;摘要行或结果列必须与普通行区分开来。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
- **左上选项卡**:一个小的绿色矩形或文本标签,显示功能级别、任务级别、方法等。宽度跟随文本 - 无长横幅。
|
||||
- **标题框/封面细线框**:封面标题位于绿色/灰绿色细线框或半框内;内容页面很少使用大标题框。
|
||||
- **右侧解读栏**:在图表旁边,包含短段落、关键词突出显示和一些数字;它解释了“那又怎样”,并且不重复所有数据。
|
||||
- **卡片/步骤块**:方法页面使用深色卡片、小白色卡片或包含步骤名称和项目符号的绿色标题栏;边界清晰,四舍五入非常克制(如果有的话)。
|
||||
- **圆形节点**:对于影响类型或方法步骤,颜色为绿色、黄绿色、蓝绿色或小红色节点。数量很少——不是大量的装饰性气泡。
|
||||
- **分隔符和轮廓**:用于图表轴、表格边界和模块分区;薄、低对比度、功能至上。
|
||||
- **图例样本**:小矩形/点+解释阶段、状态、系列的短标签;保持靠近图表的底部或顶部。
|
||||
- **页码/版权垂直侧边栏**:页码位于右下角或沿右边缘,版权/来源沿底部。
|
||||
- **图像框架**:仅在封面和封页上放置AI肖像;如果图像出现在内容页面上,它必须保持深绿色覆盖层 - 没有屏幕截图拼贴。
|
||||
- **图标**:方法页面可能使用一些线性/简化的图标或圆形图标。禁止使用彩色拟物图标库;图标必须与步骤/状态直接相关。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
整体质感**黑暗、扁平、内敛、数据密集、技术咨询**;视觉焦点来自绿光、数据亮点和结构化信息。- **禁止**:大浅色背景、彩虹调色板、3D图表、发光轮廓文本、复杂纹理、表情符号、卡通插图、社交媒体风格贴纸、无意义的大图标、长居中副本、装饰波浪线、过度圆形卡片、强阴影浮动层。
|
||||
- **谨慎使用**:渐变、阴影、圆形节点、图标、红色状态。渐变仅适用于深绿色背景氛围;阴影,如果有的话,也极其微弱;红色仅针对负/限制状态;仅用于方法步骤的图标。
|
||||
- **可在特殊页面上使用**:人工智能肖像/生成图像仅在封面和结束页上,作为主题视觉效果;部分分隔线可以使用最小的几何正方形;免责声明页面可能会使用密集的法律文本。
|
||||
- **图像依赖边界**:内容页面不依赖照片、屏幕截图或插图来讲述故事,除非图像本身就是数据证据;否则转换为图表、表格或流程。
|
||||
- **品牌感觉边界**:品牌标志不应在每页上放大和重复;品牌感觉来自于深色基底、绿色系统、版式和脚注结构。
|
||||
- **密度控制**:永远不要牺牲对齐、对比度和脚注可读性。如果文本重叠、表格挤压或图表标签变得难以辨认,请先剪切内容,而不是将其缩小到难以阅读。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 这是一个 16:9 的深色咨询报告页面,而不是海报、信息图表或社交卡吗?
|
||||
- 左上角是否有清晰的标签/标题区域,标题是否是结论句而不是模糊的主题词?
|
||||
- 单页是否仅包含 1 个要点,并通过图表、表格、矩阵或流程证明?
|
||||
- 背景是否以深灰色和深绿色为主,亮绿色仅用于关键数据/标签?
|
||||
- 正文是否组织为以下之一:左图 + 右解释、全角矩阵、流程步骤或多列比较?
|
||||
- 右侧解释是带有关键词突出显示的短段落,而不是长篇正文吗?
|
||||
- 图表是否使用水平条形图、堆叠条形图、矩阵或流节点;是否避免使用 3D、饼图和艳丽的系列颜色?
|
||||
- 表格是否有清晰的标题、分组、对齐、细分隔符和关键行/列?
|
||||
- 页脚是否保留来源、假设、版权和页码而不与正文重叠?
|
||||
- 该类型是否保留 TrebuchetMS 特征,并通过相对大小、重量和颜色表示层次结构?
|
||||
- 是否避免了照片/屏幕截图/插图滥用,人工智能肖像仅出现在封面或结束页上?
|
||||
- 是否出现溢出、截断、乱码、重叠、对比度低、遮挡、内容空、比例错误、机械模板重复等情况?
|
||||
- 如果更换品牌颜色,深色底色、高对比度白色文字和单一基色的原则是否仍然有效?
|
||||
|
After Width: | Height: | Size: 404 KiB |
@@ -0,0 +1,121 @@
|
||||
# 制图策略·地图策略风格设计系统
|
||||
|
||||
> 预期主页标签:战略规划/战略
|
||||
> 视觉隐喻:当代图集+策略室
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
国际化、区域扩张、业务组合、生态系统布局、资源配置和长期路线图。它不是传统的咨询蓝白模板,而是“将策略放入空间”的地图式叙事:温暖的白色纸张、深邃的海军蓝、内敛的钴蓝色、朱红色的坐标,通过路线、区域、比例、编号和图例构建方向感。
|
||||
|
||||
基本原则:
|
||||
|
||||
1. 每一页首先回答一个决策问题,然后显示位置、路径或优先级证据。
|
||||
2. 地图语言服务于比较和顺序——等高线和经纬网格不是装饰壁纸。
|
||||
3、一张主图引领页面;标题、数字和标签都是围绕它组织的。
|
||||
4. 当地理信息不足时,使用抽象的位置图、路线图或合成地图——切勿捏造精确的边界。
|
||||
5. 标题读起来就像管理层实际上会说的判断——避免使用“战略蓝图”或“共同走向未来”等模糊口号。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
画布为 16:9 横向。内容页面以暖白色为主;部分分隔页可以使用深海军蓝。外部利润保持稳定;微小的坐标刻度可能会出现在四个角,但内容永远不会接触边缘。
|
||||
|
||||
顶部是标题和页码坐标区,约占页面的14%~18%;中间是主地图工作区,65%–72%;底部为来源、假设和图例保留了空间。标题下没有全角粗线,而是用短刻度线、页码和细线代替。
|
||||
|
||||
封面采用“大标题+抽象区域图+一条主要路线”。标题位于左侧或左下角;地图占据右侧的 45%–60%。内容页面在地图、矩阵、路线和时间线之间切换,但始终保持相同的坐标和图例语法。
|
||||
|
||||
## 3. 内容组织
|
||||
|
||||
标题使用结论句或明确的问题,例如“中东提供最快的首次进入验证窗口”。副标题添加范围、年份、评估维度或数据定义和测量基础。
|
||||
|
||||
一页只有一个战略判断,最多有3个支持证据。正文使用短句、编号和直接标签——没有长的解释性段落。
|
||||
|
||||
空间分组优先考虑区域、市场、阶段、进入模式、能力节点和风险水平。每条路线都必须有起点、目的地、顺序和意义;每种颜色都必须在图例中进行解释。
|
||||
|
||||
数字紧邻其对象,带有单位、时间和测量基础。来源和假设位于页脚中,而不是隐藏在视觉中心。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A. 战略地图页面
|
||||
|
||||
左边25%~32%持有结论和2~3个关键解释;右边有一张抽象的区域地图。点标记市场或能力节点,线宽表示优先级,实线/虚线区分已经到位的内容和必须构建的内容。适合市场选择、网络布局、资源迁移。
|
||||
|
||||
### B. 三个市场比较区间
|
||||
|
||||
页面水平划分为三个平行区域,每个区域包含一个市场名称、一个主号码、一张小位置图和2-3个评价维度。三列必须同构;推荐的选项可以用钴色边缘线或朱红色坐标点来强调。
|
||||
|
||||
### C. 机会-难度矩阵
|
||||
|
||||
矩阵占据了页面的60%~70%,推荐结论在右侧。点标签直接命名市场,而不依赖于复杂的图例。象限名称是可操作的判断,例如“首先进入”、“建立伙伴关系”、“持续关注”——而不是抽象的明星/摇钱树陈词滥调。
|
||||
|
||||
### D. 路线和阶段页面一条从左下到右上的主干道连接着3-5个阶段。每个阶段都有其时间安排、关键操作、阈值和退出条件。路线可能会转弯;不使用等宽堆叠工艺卡。
|
||||
|
||||
### E. 能力网页
|
||||
|
||||
中央功能锚定页面,连接渠道、交付、合规性、产品和人才节点。连接线表达依赖性;节点大小表示优先级。仅显示真正影响实施的 5-8 个节点。
|
||||
|
||||
### F. 决策页面
|
||||
|
||||
左边有一个大结论;右侧是“立即决定/进入条件/90 天行动”的三部分列表。缩小的路线图可以回顾整个旅程——这不是一个普通的感谢页面。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
- 暖纸白色#F4F0E8:主要内容背景。
|
||||
- 深海军#0B1F33:标题、章节基础、主要路线和核心文本。
|
||||
- 钴蓝色#2F5BFF:推荐选项、关键路径和关键数字。
|
||||
- Vermilion #E4572E:风险、关键坐标和必须决定的节点;它的面积严格小于钴。
|
||||
- 雾青#9BD5D8:次要区域、历史阶段和去强调的数据。
|
||||
- 石墨灰色 #5B6570:标题、轴、来源和未选择的选项。
|
||||
|
||||
常规页面使用纸白+海军蓝+钴色;朱红色只出现一次。禁止使用彩虹图、荧光渐变和大型红绿对立。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
中文使用思源黑体或苹果方;英语使用 Arial/Helvetica 级无衬线字体;部分分隔符大标题可以与一行乔治亚级衬线英文搭配以获得地图集的感觉,但切勿混合使用两种以上的字体。
|
||||
|
||||
- 封面标题:4–5 倍正文大小,粗体,最多两行。
|
||||
- 页面标题:正文大小的 2–2.6 倍,左对齐。
|
||||
- 关键数字:主体尺寸的 2.5–3.5 倍,优选等宽或压缩数字。
|
||||
- 正文:1x,短句。
|
||||
- 坐标、图例、来源:0.55–0.75x,保持可读。
|
||||
|
||||
数字、年份和区域代码可以使用等宽字体。没有全大写的长句子、斜体长段落或多层阴影。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
喜欢点图、气泡图、斜率图、路径图、二维矩阵、区域小倍数地图和带有直接注释的条形图。
|
||||
|
||||
图表背景保持透明或纸白色;网格线采用极细的雾灰色。重点系列使用钴;历史/基线使用雾青色或灰色;风险阈值使用单一朱红色线。
|
||||
|
||||
地图必须带有图例、方向或比例提示。抽象地图必须清楚地标记为“示意图”——不允许出现虚假的管理精度。关键差异直接在点旁边注释,减少了独立的图例。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
表格提供市场评分、进入条件和风险列表。标题使用深海军蓝文本或浅雾青色底色;行与行之间有细线,没有粗边框。
|
||||
|
||||
推荐的行通过左侧的钴坐标条进行强调;风险单元格使用小朱红色点或文本标签——切勿将整个单元格涂成红色。数字右对齐,文本左对齐,评分标准和依据在表底部注明。
|
||||
|
||||
高密度内容最好分成“地图/矩阵+右侧解释”——不要将其压缩成十列宽的表格。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
稳定的组件包括坐标点、路线、区域轮廓、短刻度、页码代码、图例、比例式分隔符和细线注释。
|
||||
|
||||
节点采用实心圆或同心圆;线路分为三种类型——主线路、备用线路和依赖线路。仅当方向很重要时才会出现箭头。
|
||||
|
||||
照片不是默认元素。如果使用城市或场景照片,则仅允许一张经过去饱和处理的大图像 - 不允许使用库存照片拼贴。
|
||||
|
||||
## 10. 质地和违禁物品总体而言就像一本当代战略地图集:精确、安静、方向明确。纸基可能具有极轻的质感;深色页面保持纯净;无玻璃态。
|
||||
|
||||
禁止:默认咨询蓝卡墙、随机世界地图水印、假国家边界、3D地球仪、发光路线、彩虹热图、圆形UI面板、表情符号、装饰指南针和没有结论的整页地图。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 每个页面都可以用一句话说明它要求做出什么决定吗?
|
||||
- 地图、矩阵或路线是否真正具有比较关系?
|
||||
- 所有颜色、线条样式和节点大小是否都有明确的含义?
|
||||
- 区域和路径是否有明确标记,没有捏造的精确地理信息?
|
||||
- 推荐的选项是否清晰突出,而替代方案仍然可见?
|
||||
- 页面是否保持纸白色、海军蓝、钴蓝色和朱红色的稳定语法?
|
||||
- 标题、图例、来源、单位和假设是否完整?
|
||||
- 是否避免使用卡片墙、3D 地球仪、随机装饰线和类似模板的标语?
|
||||
- 您是否检查过重叠、裁剪、乱码、低对比度和文本换行?
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,166 @@
|
||||
# 项目报告名称 — 组织名称联合备忘录风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
私募股权/投资银行备忘录类型,高密度报告风格,适用于银团备忘录、投资委员会材料、尽职调查摘要、交易批准、融资方案和管理业务计划材料。针对金融和行业分析读者,支持快速审查交易条款、投资逻辑、客户和市场证据、财务预测、估值、风险和执行计划;目标是“可审查、可接受后续质疑、可追溯”。
|
||||
|
||||
固定样式:16:9风景,高利用率画布;浅奶油白色背景;左侧有一条细橙色垂直线;顶部灰色/深海军蓝分段导航;右上角的小品牌文字标记(显示新主题名称的纯小文本;不要绘制图形徽标);一个大的橙色标题;标题下有一个灰色的信息句。正文使用表格、条形图、桥图、并排模块、评级点、流程框和文本证据块。字体绝对以Arial家族为主;粗体用于标题、表格标题、字段名称和关键数字;斜体仅用于注释或特别强调。
|
||||
|
||||
基本原则:
|
||||
|
||||
1. 信息完整性是第一位的,但必须将其结构化为表格、列、编号、色块标题、脚注和细分隔线系统;长段文字不得自由浮动。
|
||||
2、橙色仅用于页面标题、当前章节、关键数据、关键栏、左侧竖线和少量标签;它不用作大面积的装饰填充,并且不会牺牲正文的可读性。
|
||||
3、身体面积可能有所不同;导航、标题区域、身份标记、左边界和脚注/页码保持稳定。图表美观必须服从于数据的清晰度、可比性和可追溯性。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
画布为16:9横向宽屏,页边距较窄,正文区域占据绝大多数。默认背景是浅乳白色/近白色,大约`#FCF8F5`、`#FEFBF7`、`#F4F0EC`;允许使用非常微弱的品牌几何水印或浅灰色图形,但不透明度较低,因此不会影响数字的阅读。
|
||||
|
||||
封面采用全出血的深蓝色城市夜景照片,叠加半透明的深蓝色品牌几何图形。标题组位于右中区域:主标题为白色粗体,副标题为白色常规字体,日期位于一个橙色小标签内;垂直保密/时间/电子邮件信息位于左侧,橙色品牌字标位于右上角(纯小文本呈现新主题名称;请勿绘制图形徽标)。封面不使用内容页顶部导航。
|
||||
|
||||
部分分隔线使用浅乳白色背景、左侧橙色线、右上角的身份标记(表示新主题名称的纯小文本;不要绘制图形徽标)和大量空白。章节编号是一个大的橙色数字,章节标题是大的深海军字体;它们只是切换部分,并不进行复杂的论证。示例包括“行业和公司概况”、“商业计划”、“估值和退出”以及“融资、尽职调查和关键 IC 发现”。
|
||||
|
||||
标准内容页面骨架:- 顶部导航:页面最顶部的水平分段箭头/带;当前部分具有深海军蓝背景和白色文本,其他部分具有浅灰色背景或低对比度灰色文本。它的高度很低,仅用于定向。
|
||||
- 右上角身份标记:一个橙色的小品牌文字标记(表示新主题名称的纯小文本;不要绘制图形徽标),固定在右上角,周围保留清晰的空间,不重叠图表标题或导航。
|
||||
- 左侧橙色线:大多数内容页面从上到下保留一条细橙色垂直线;内容从右侧开始。
|
||||
- 标题区域:导航下方左对齐;主标题通常是橙色粗体,后面是单个灰色/深灰色信息句子。
|
||||
- 正文区域:紧接标题下方,包含表格、图表、模块、流程、并排证据或长文本,底部保留狭窄的脚注带。
|
||||
- 页脚:底部的来源、注释、保密措辞和页码。脚注明显小于正文,呈浅灰色;页码通常位于右下角或底部边缘。
|
||||
|
||||
阅读路径:顶部导航→橙色标题→信息句→正文区域从左到右、从上到下→底部脚注。
|
||||
|
||||
## 3. 内容组织
|
||||
|
||||
标题使用判断型或主题型短语,例如“交易摘要”、“执行摘要”、“为什么我们喜欢组织名称”、“客户概述”、“商业计划:并购”;可以直接陈述强有力的结论,例如“强大的游戏发布渠道推动游戏开发支出”——没有营销口号。
|
||||
|
||||
标题下的信息句通常为一行,用于陈述结论、数据依据或页面的意义;它的颜色比正文浅,语气专业克制,没有感叹号、修辞或大段居中的段落。
|
||||
|
||||
身体规则:
|
||||
|
||||
- 长文本使用粗体副标题+正文解释+项目符号/编号点;每个段落都必须有明确的副标题或左侧标签。
|
||||
- 摘要页面可以使用左侧垂直导航卡或右侧带有密集项目符号的编号框;每个项目符号首先给出一个粗体关键字,然后给出解释。
|
||||
- 按交易条款、投资亮点、行业/客户、财务、估值、风险、融资和尽职调查等专业维度进行分组。
|
||||
- 将单个页面压缩为3-6个模块或4-8个表行;不鼓励只包含一个大语句的页面。
|
||||
- 使用粗体、橙色数字、深蓝色表格标题、浅色方框或点来强调;没有荧光突出、手绘圆圈或夸张的贴纸。
|
||||
- 保持数据旁边的数字、单位和时基,例如 FY、CY、24E、25E、CAGR、EV、EBITDA、bps、%;表格列标题解释了基础。
|
||||
- 来源脚注以小号浅灰色字体位于底部;多个碱基通过上标或括号连接——不要在正文中堆放长音符。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
### A. 交易摘要/条款清单类型
|
||||
|
||||
用于交易概述、资本结构、估值、来源和用途以及关键术语。主体是一个大表:左列包含深色或灰色字段名称,右侧包含解释和数据;中间可能会嵌入一个带有橙色标题的小型评估表或来源和使用表。阅读顺序为字段名→值→注释。适合多基础事实;不适合复杂的因果关系或多阶段过程。
|
||||
|
||||
### B. 执行摘要/多结论长文本类型
|
||||
|
||||
用于投资亮点、主要风险和论点摘要。垂直部分的卡片/编号模块位于左侧,密集的子弹位于右侧;深海军色卡片标记主要类别,橙色卡片标记当前或关键项目。每个项目符号都以粗体关键字开头。适合专业读者扫描;不适合非专业观众或谈话式演示。### C. 投资理由/逐项论证类型
|
||||
|
||||
典型的页面是“为什么我们喜欢组织名称”。正文用圆形 A/B/C/D 编号或标签组织水平参数带;每个波段都包含一个深海军蓝字幕栏、一个橙色数字、正文项目符号和关键人物。适合3-5个并行原因;不适合超过 6 个项目或每个项目都需要复杂图表的内容。
|
||||
|
||||
### D. 水平比较表/对等矩阵类型
|
||||
|
||||
用于比较公司、客户、产品、竞争对手或同行指标。左列包含公司名称或类别标签,顶部包含指标,单元格包含数字、文本、评级点或短标签。标头可以使用浅橙色/深蓝色;关键行和列可以使用浅蓝色或浅橙色填充。适合横向比较;不适合长时间的定性解释。
|
||||
|
||||
### E.图表+说明混合类型
|
||||
|
||||
用于收入趋势、市场规模、增长率、客户渗透率、财务预测、桥图和运营 KPI。主图表占据了大部分宽度,上面有结论栏/标题,下面或侧面有说明框。橙色突出核心系列,深蓝色/青色用于对比系列,灰色用于背景或历史项目。适合数据论证;没有明确的数据基础,不适合概念图。
|
||||
|
||||
### F. 流程/业务计划类型
|
||||
|
||||
用于并购渠道、运营计划和价值创造计划。由水平流程框、阶段箭头、小表格和底部图表组成;深蓝色条作为阶段标题,橙色节点标记关键操作或关键结果,灯箱带有说明。适用于阶段、路径、漏斗、执行杆;它不应该被滥用于普通列表。
|
||||
|
||||
### G. 高密度并排模块类型
|
||||
|
||||
用于客户概述、管理/顾问观点、风险缓解和尽职调查结果。页面分为多个相同大小的卡片或水平模块,每个模块包含副标题、短项目符号、图标式标记或微型图表;模块间距小,用细线和浅色填充进行分隔。不适合单个大图像或情感故事讲述。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
- 背景颜色:浅乳白色/近白色`#FCF8F5`、`#FEFBF7`、`#F4F0EC`。用于内容页面的大面积基础;可以用相同亮度的暖白色代替——不要切换到高饱和度背景。
|
||||
- 正文:深灰色`#4D4E53`、`#3E3E42`。用于正文、表格文本和注释文本;必须保持高对比度,不得用浅灰色代替。
|
||||
- 标题/品牌重音橙色:`#C4794A`、`#D8752A`、`#F07A00`。用于主标题、左侧垂直线、日期标签、当前项目、关键数字、重要栏和表格标题;中小面积。可以用品牌原色代替,但暖色调的作用必须保留。
|
||||
- 结构深海军蓝:`#12234E`、`#263660`、`#293B60`。用于当前导航部分、深色侧边栏、表格标题、流程阶段栏和封面覆盖;通常与白色文本配对。可以用深品牌蓝色代替,但必须保持严肃和高对比度。
|
||||
- 辅助灰度:`#BEB6B3`、`#D9D5D5`、`#E8E3E1`、`#C6C6C9`。用于分隔线、表格边框、非当前导航、填充、轴和网格线。
|
||||
- 辅助浅蓝色/青色:`#5E9BB9`、`#9BA8AF`、`#C6CED6`。用于辅助图表系列、客户/竞争对手比较、关键行块或状态区域;它不得与橙色竞争主要口音角色。
|
||||
- 正/负/状态颜色:主要是橙色、深蓝色、青色和灰色,少量绿色表示成长或正数。应谨慎使用绿色以实现积极的改变;红色不属于稳定的原色系统。将单页的主色调控制为橙色、深蓝色、灰色以及少量的水青色。图表系列不应扩展到彩虹色;状态颜色仅用于判断,而不是装饰。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
字体以 Arial / Arial Bold 为主。 Helvetica Bold 仅用于封面或特殊保密覆盖文本; Calibri、Segoe UI 等不是主系统的一部分。更喜欢 Arial 或等效的无衬线字体。
|
||||
|
||||
- 封面主标题:3–4x 正文,白色粗体。
|
||||
- 内容页面主标题:2.5–3x 正文,橙色粗体,左对齐。
|
||||
- 部分分隔标题:3–4x 正文,深海军蓝;部分编号可能更大,呈橙色。
|
||||
- 信息句/副标题:1–1.2x 正文,灰色常规或半粗体,位于标题下方。
|
||||
- 正文:1x,深灰色;行距要紧,但不能重叠。
|
||||
- 副标题/字段名称/表标题:1–1.15x 正文文本,粗体;可能是深蓝色背景上的白色文本或浅色背景上的深灰色文本。
|
||||
- 表格和图表标签:0.75–0.95x 正文;必要时压缩但保持清晰。
|
||||
- 注释、来源、页码:0.55–0.7x 正文,浅灰色。
|
||||
|
||||
文本主要是左对齐的;表数字右对齐或按小数点视觉对齐;根据数据类型,列标题居中或左对齐。禁止大型中心身体通道。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
使用堆积柱形图、分组柱形图、水平条形图、桥图、时间序列柱线组合、小评级/点阵以及矩阵表内的迷你图;图表嵌入在内容页面的正文区域中,而不是全屏的单个图表。
|
||||
|
||||
轴线和网格线使用浅灰色;图例位于图表顶部附近或标题旁边,使用小色样+短标签。数据标签直接放置在列或条的末端或表格单元格内。
|
||||
|
||||
橙色标志着核心系列、当前项目或关键增长;深蓝色标记基线/历史/主要比较;青色-蓝色标记第二个比较;灰色标记背景、其他项目或非关键数据。预测、目标和估计年份通过 E、FY、CY、虚线、浅色阴影区域或列标题中的明确标签和定义来表达,而不是通过复杂的视觉效果。
|
||||
|
||||
关键数据可以使用橙色数字、粗体、注释框或深色标签。请勿使用 3D 图表、渐变柱、维度饼图、重阴影或没有数据标签的装饰图形。如果图表无法比表格更清楚地表达比较,则更喜欢表格。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
表格包含条款、财务、同行、客户、尽职调查结论和模型假设。标题使用深海军蓝背景搭配白色文本、橙色背景搭配白色文本或浅橙色背景搭配深色文本;左字段列使用深灰色/深蓝色背景和白色文本,或浅灰色背景和粗体文本。
|
||||
|
||||
行列紧凑,行高低;边框是浅灰色的细线。背景在白色、浅乳白色、浅灰色、浅橙色和浅蓝色之间交替形成区域;宽桌子可以使用斑马条纹,但不宜太浓。关键行和列使用橙色标题、浅橙色填充、深蓝色侧边栏或粗体数字;摘要行使用较暗的填充或顶部边框线。
|
||||
|
||||
金额、百分比、倍数和年份尽可能在其列内右对齐或居中;文本左对齐;单位写在列标题或第一行中,而不是在每个单元格中重复。评级点、圆圈、复选标记或短标签在视觉上位于同一列的中心。
|
||||
|
||||
脚注和上标向下移动到底部,不会挤压数据区域。避免将多行长句子塞入单元格中;当需要解释时,请使用表格下方的注释或右侧的说明框。## 9. 组件和图形元素
|
||||
|
||||
- 顶部导航栏:当前部分为深蓝色,其他部分为灰色;它不能变成一个大的菜单或图标导航。
|
||||
- 左侧橙色竖线:不承载任何内容的品牌边界;保留在大多数内容页面上。
|
||||
- 橙色主标题:页面主题的入口点;必须左对齐,与正文保持清晰的间距。
|
||||
- 信息句:标题下的一行判断语句。
|
||||
- 深蓝色字幕栏:用于分组、参数带、流程阶段和表格模块标题,带有简短的白色文本标题。
|
||||
- 橙色编号/圆形徽章:用于 A/B/C/D、步骤、要点或强调的项目。
|
||||
- 光填充卡/模块盒:用于并排证据、客户模块、风险和缓解措施;浅灰色边框,填充浅奶油色/浅灰色/浅橙色。
|
||||
- 流程箭头和连接器:用于并购、管道和价值创造步骤;线条平坦,没有阴影。
|
||||
- 公司标识符:用于客户、同行和竞争对手页面上表格的左列;以统一大小的文本名称或中性占位符块的形式呈现,避免掩盖内容 - 不要绘制图形徽标。
|
||||
- 脚注/来源带:位于底部,采用小浅灰色类型。
|
||||
|
||||
彩色图标并不是一种稳定的语言;如果必须使用,请使用很少的、单一颜色或品牌颜色的,以服务于餐桌识别。可以使用矩阵标记、点等级和复选标记,但需要统一的样式。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
总体而言,保持扁平、克制、专业和轻度品牌化。除了封面照片外,内容页面几乎不依赖大图像背景;照片、屏幕截图和插图不是主要语言。圆角仅用在少数标签或卡片上,半径较小;阴影、渐变、发光、3D 和复杂纹理不属于此风格。
|
||||
|
||||
禁止:
|
||||
|
||||
- 内容页面上的大照片背景或图像上压有低对比度文本。
|
||||
- 3D 图表、渐变柱、发光边框、浓重阴影、拟物化按钮。
|
||||
- 表情符号、卡通插图、社交媒体风格的贴纸、手绘涂鸦、高饱和度彩虹图标。
|
||||
- 大段居中的文案、无结构的长段落、纯粹的口号页面。
|
||||
- 一页上的原色过多,或与橙色/深海军蓝系统冲突的强烈颜色。
|
||||
|
||||
谨慎使用:
|
||||
|
||||
- 公司标识符和客户标识符:仅在比较表和客户页面上,以统一大小的文本名称或中性占位符块形式呈现 - 请勿绘制图形徽标。
|
||||
- 浅色水印/品牌几何形状:可以用作低不透明度背景,但不得穿过文本或表格。
|
||||
- 绿色/红色状态颜色:仅用于明确的语义,例如增长、下降或风险。
|
||||
- 圆形标签和小图标:它们可能仅有助于识别;它们不能取代文本证据。
|
||||
|
||||
特殊页面:封面可以使用全出血的深蓝色照片,并叠加半透明的几何图形;部分分隔符可以使用大量的空白和大数字;内容页面必须返回到表格化、基于证据、导航一致的系统。
|
||||
|
||||
## 11. 一代清单- 骨架:是16:9吗?内容页面是否保留顶部导航、右上角品牌文字标记、左侧橙色线、标题区域和底部脚注/页码?
|
||||
- 文案:标题简短且专业;信息句是否在一行中陈述了判断;正文是由字段、项目符号、表格还是模块组成的?
|
||||
- 层次结构:橙色标题、深蓝色分组、正文和注释是否具有明显的相对大小差异;是否避免了对特定字体大小的依赖?
|
||||
- 颜色:背景为浅乳白色;原色仅限于橙色、深蓝色、灰色和少量的青色;橙色只是为了强调吗?
|
||||
- 布局:正文是否可从左到右、从上到下扫描;高密度内容是否有列、标题、编号和分隔线?
|
||||
- 图表:是否使用扁平柱/条/桥/矩阵;轴和网格是浅灰色的;关键数据是否直接标注;是否避免使用 3D 和彩虹色?
|
||||
- 表格:标题、字段列、边框、填充、关键行和列、数字对齐和单位基数是否一致?
|
||||
- 组件:导航栏、深蓝色标题栏、橙色编号、浅色卡片和脚注来源是否统一,而不是逐页任意重塑?
|
||||
- 禁止项目:内容页面上是否没有表情符号、插画风格的图标、浓重的阴影、渐变、发光、复杂的纹理或大面积的照片?
|
||||
- 技术质量:检查是否有溢出、截断、乱码、重叠、低对比度、遮挡、空内容或比例错误;当连续使用相同的模板时,身体结构应该发生变化,而骨架则不会。
|
||||
- 没有默认使用卡片:除非用户明确要求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式 - 线段、空白以及字体和字体大小差异是更好的解决方案。
|
||||
- 无等分组合:除非没有其他布局可用,否则不要默认三向分割、四向分割或2×2矩阵——包括三向分割+标题+结论等三部分结构。
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,116 @@
|
||||
# PJ Mountain — 投资备忘录(组织名称风格)风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
适用于私募股权投资委员会、交易审批、尽职调查报告和投资备忘录的高密度专业 PPT 风格,适合具有财务、战略、运营和会计背景的读者。每一页构建了一条可追溯的“结论-证据-假设-来源”链,强调数据密度、比较维度和可审查性。
|
||||
|
||||
核心是白色背景的全画布、左上角的标题、标题下的斜体结论线、紫色结构条、蓝色/青色/灰色分类颜色、细网格表格、紧凑的 Calibri 系列字体以及底部脚注和页码。正文主要由图表、表格、流程框、并排模块和少量证据图像组成;装饰极少,颜色仅起到剖面、分类、数据系列和风险提示的作用。
|
||||
|
||||
基本原则:
|
||||
|
||||
1. 清晰胜过空白:优先保留关键结论、数据、测量基础、来源和比较维度,通过分隔线、标题栏、浅灰色填充和层次结构维持顺序。
|
||||
2、颜色是信息词典,而不是装饰:紫色用于结构和标题;蓝色/青色/灰色表示业务类别或图表系列;红色仅表示风险、差异、敏感假设或黑框强调。
|
||||
3. 页面可能很复杂,但骨架必须保持稳定:正文可以混合图表、表格、流程、地图和图像,但标题区域、结论行、底部来源、页码和颜色语法在整个甲板上保持一致。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
画布为 16:9 横向,默认白色背景;内容页面使用窄边距和高内容利用率。顶部是标题区域,底部是脚注和页码区域,中间是主要证据区域。阅读路径:左上部分标签/标题→标题下斜体结论线→主要区域从左到右,从上到下→底部脚注→右下页码。
|
||||
|
||||
封面页:左侧有纯文本小字体身份标记(写新主题名称;不要绘制图形标志)、项目名称、文档类型和日期;右边是一个大的蓝色线条艺术抽象图形;左下角有红色斜体的保密说明。它使用大量的空白和品牌线条艺术,并且不采用内容页脚注系统;这种抽象线条仅适合封面,不应扩散到内容页面背景中。
|
||||
|
||||
免责声明页面:左上角有一个大标题;正文是一列密集的小字体段落,几乎没有颜色或图形,只保留页码。充分披露优先;不得将其强制放入显示型模块中。
|
||||
|
||||
内容页:左上角可能有一个细边框矩形或一个小编号部分标签;主标题位于其下方或右侧,呈紫色或深灰色;标题下方有一条斜体灰色结论线。正文分为2-4个模块,模块标题采用紫色横条或细边框标题栏。底部保留了来源、注释和假设陈述,右下角有一个很小的页码。
|
||||
|
||||
部分/分隔页保留白色背景和左上角标题逻辑,没有大图像背景。没有持久的顶部标题导航,没有整页水印,也没有大照片背景。来源和脚注是固定的底部信息层,不得被图表或表格覆盖。
|
||||
|
||||
## 3. 内容组织
|
||||
|
||||
标题采用陈述性、分析性的“主题+判断维度”措辞,如公司概况、市场展望、对标分析、结构性解决方案等;避免广告式的短标题。在标题下,一两个斜体信息行表达最重要的判断、发现或问题;它们不具备正文的全部功能。主体被组织成证据块。每个模块必须有一个清晰的副标题,说明比较对象、指标或结论的方向;在里面,使用项目符号、短句、数字、表格或图表。一页可以包含多个要点,但它们必须属于一个主要问题;当平行点超过 4 组时,使用编号、色带或列来建立路径。
|
||||
|
||||
段落应该简短,最好使用项目符号和缩写表达;每一个项目符号包含一个行动/判断+一个事实依据;不要写连续的散文。关键词可以用粗体、紫色、红色框或背景颜色强调,但每页不得超过两个强调级别。
|
||||
|
||||
数字、单位和时基与数据相邻,并在图表标题、表标题或脚注中说明。财务和运营数据保持列对齐;百分比、倍数、年份和货币单位不得混合。来源脚注以小灰色字体位于底部;多个外部数据源按图表或模块排序。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
1. **基于文本的摘要页面**:适合执行摘要、投资论文和主要发现。结构:大标题+斜体概述+多组紫色字幕+项目符号;每组仅包含一个判断类别,并通过字幕、粗体关键词和稀疏编号来强调;不要插入破坏摘要阅读的复杂图表。
|
||||
|
||||
2. **左侧编号导航 + 右侧结论列表**:适合并排展示 4 个尽职调查主题、投资论文或风险应对。左侧放置垂直色块或编号标签;右侧有相应的结论和项目符号。色块代表部分或问题类别,不能纯粹是装饰性的;如果主题没有明确的分组关系,请勿使用此布局。
|
||||
|
||||
3. **多图表仪表板页面**:适合市场规模、业务组合、成本基准和增长动力等多指标比较。正文采用2×2网格或一大二小垂直排列,每个区块有一个紫色标题栏;旁边的简短注释框可以解释异常值。避免拼贴不相关的图表;所有图表必须共同回答标题下的信息线。
|
||||
|
||||
4. **图表+右侧说明/注释页**:适合趋势线、柱形图、箱线图、瀑布图。将主图表放在左侧或中间;在右侧,使用亮色框、红色框或短项目符号来标记结论;红色笔划、箭头、标签或对比数据点可用于强调。如果图表本身已经很拥挤,请将说明文字移至底部或拆分页面。
|
||||
|
||||
5. **表格/矩阵页面**:适合财务模型、可比公司、交易结构、尽职调查清单和收益桥梁。主体是一张大桌子或几张小桌子;紫色标题建立主要分组,浅灰色填充用于辅助标题或摘要行。仅用于自然具有行列维度的信息;不要将叙述性文本强行放入表格中。
|
||||
|
||||
6. **流程/结构图页面**:适合交易结构、融资路径、组织关系或价值创造路径。使用矩形框、细线箭头和少量颜色来区分阶段或聚会。箭头代表真实流程或资本/信息流,不得用作装饰性运动线。复杂的结构图保留图例和脚注解释。
|
||||
|
||||
7. **证据图像/姓名文字混合页面**:可放置少量地图、办公地点、人物、客户姓名文字列表或业务截图。图像必须受边框、标题和说明文字的约束,并且通常面积小于图表/表格主体;它们不是情感背景或整页视觉主角。
|
||||
|
||||
## 5. 颜色系统- **背景:#FFFFFF**。所有内容页、摘要页、表格页的绝对主背景。无法更换为深色背景;如果模块需要区分,请使用浅灰色填充,而不是重新着色整个页面。
|
||||
- **正文:#333333–#4A4A4A**。用于正文、表格内容和图表标签;每个输出设备可能会稍微变暗,但不应该使用纯黑色,因为这样感觉太压抑。
|
||||
- **标题/结构紫色:#470D59**。用于中小型区域的内容页标题、部分标签、模块标题栏、表格标题和关键分隔线;不应覆盖整个页面。可以用深色品牌颜色代替,但必须保持高对比度和金融机构的严肃性。
|
||||
- **封面/商业深蓝色:#0020AC / #0126AE**。用于封面的抽象线条、部分分类颜色、柱形图系列、强调块;可以用作部分颜色或主图表系列颜色,但其在内容页面上的区域应保持在白色和灰色背景下方。
|
||||
- **辅助蓝色:#3E58B5 / #7B8DC7**。用于辅助图表系列、堆叠柱、标签和浅色调类别;不得与紫色争夺冠军角色。
|
||||
- **青色/蓝绿色:大约#00A6B2–#20B8C6**。用于业务类别、流程阶段、图表系列以及部分模块彩条;可以用品牌辅助色代替,但必须与紫色和深蓝色区分开来。
|
||||
- **浅灰色背景/表格填充:#F2F2F3、#F6F7F7、#E8EAEB**。用于表格着色、模块背景、图表绘图区域和灯光分区;可能会占据大面积。不能用高饱和度的颜色代替。
|
||||
- **分隔线/网格灰色:#C8CACE、#D0D4D7、#BBBBBB**。用于表格线、轴、模块边框和分隔线;应该是细的、轻的、低存在感的——避免粗黑线。
|
||||
- **强调/风险红色:大约#B00020–#C00000**。用于封面保密说明、负面警报、敏感假设和红框注释;仅小面积,不是常规系列颜色。
|
||||
|
||||
将单个页面的主色调保持为紫色+蓝色/青色+灰色,红色仅用于警告。图表系列颜色在同一页面上保持一致的字典;避免使用相同的颜色来表示不同的企业或州。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
字体以 Calibri Light、Calibri 和 Calibri Italic/Bold 为主。浅色用于封面和大标题,常规用于正文和表格,粗体用于关键词和表格标题,斜体用于结论行和免责声明。不要引入衬线字体、手写字体或个性很强的中文字体。
|
||||
|
||||
正文 = 1x:覆盖主标题约。 3–4 倍;内容页面主标题大约。 2–2.8 倍;页面信息行约1.1–1.3x,大部分是斜体;模块标题大约1–1.2x,通常设置在颜色条内; 1x 身体子弹;表格内容和图表标签大约。 0.75–0.9x;注释、来源和页码大约。 0.45–0.65 倍。免责声明页面正文文本可能低于正常正文大小,但必须保持可读。
|
||||
|
||||
文本主要是左对齐的。表中的数字右对齐或按小数点对齐;年份、类别和指标名称可以居中或左对齐。大段居中的文案不属于这种风格;除盖部分外,不使用它。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
真实图表主要有堆积柱形图、分组柱形图、折线图、箱线图/分布图、散点图/气泡图、桥图、面积/成分图和基准比较图。图表嵌入模块框架中,上方有短标题或紫色标题栏,底部注明数据源和测量基础。轴线和网格线使用浅灰色细线;避免使用粗黑轴。图例位于图表的上方、下方或右侧,使用带有系列色样的小字体文本。柱形图系列以深蓝色、浅蓝色、青色、灰色、紫色为主;堆叠顺序保持稳定。折线图可以并行放置多条线,但颜色、线型和结束标签应该会降低阅读成本。
|
||||
|
||||
关键数据使用红色笔划框、红色注释框、箭头、小标签或深色数据标签。预测、目标和基线使用虚线、浅色线或单独的标签,不得与实际数据混淆。图表美化不能牺牲数字清晰度:避免3D、渐变柱、阴影、夸张的圆角和无意义的图标。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
标题使用深紫色背景搭配白色文字或浅灰色背景搭配深色文字;第一级分组使用紫色水平条,第二级分组使用浅灰色填充。行列分隔板又薄又轻;表格行高紧凑,但文本不得重叠。
|
||||
|
||||
数字列右对齐或按小数位对齐;文本列左对齐;年份、阶段和评级符号可以居中。重要的列、摘要行、关键假设或输出结果可以用浅灰色填充、紫色文本、粗体或边框来强调。负值、风险项或需要注意的差异可以使用红色,但不能全表大面积变红。
|
||||
|
||||
斑马条纹不是强制性的;对于许多行,可以使用极浅灰色的交替背景。矩阵式页面可以使用点、复选标记、评级符号、色块和短标签,但每个符号必须有图例或直观易懂。表格担负着“证据和模型”的作用,切不可作为纯粹的布局装饰。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
- **标题栏/章节标签**:左上角的小矩形标签,通常带有数字、章节名称和紫色/蓝色/青色边框或填充。其功能是定位甲板的结构;不要在页面之间随意移动它。
|
||||
- **模块标题栏**:深紫色或剖面色水平栏,带有白色或深色文本;紧凑的条形高度、宽度与模块匹配。
|
||||
- **卡片/盒装模块**:白色或浅灰色背景,带有细灰色边框,用于图表、说明、地图和流程节点。四舍五入难以察觉或不存在;没有阴影来营造卡片的感觉。
|
||||
- **注释/标志框**:红色或紫色细边框,带有解释异常、假设或风险的短句。只针对本地高价值信息;不要框定每一个结论。
|
||||
- **流向箭头**:连接矩形节点的细线箭头,表示真实的顺序、因果关系、资金流向或组织关系;颜色遵循部分颜色或灰色。避免使用粗的装饰箭头。
|
||||
- **图像框架和名称网格**:图像需要边界、标题和说明文字;客户、合作伙伴或类似公司的名称被排列为纯文本小型网格(或用中性占位符块替换)作为证据 - 没有绘制图形徽标。禁止使用彩色图标作为装饰性卖点。
|
||||
- **分隔线**:表格的浅灰色细线、脚注的上边缘和模块分隔。紫色粗线仅用于表标题或模块标题。
|
||||
|
||||
## 10. 质地和违禁物品
|
||||
|
||||
整体扁平、内敛、制度化、高密度;几乎不依赖照片、插图、纹理、灯光效果或尺寸效果。品牌感觉来自紫色结构条、蓝色封面线条艺术、字体和脚注系统。禁止:大面积图像背景、3D图表、发光、复杂渐变、重阴影、表情符号、卡通插图、装饰性彩色图标、大片居中副本、无来源的数据图表、低对比度浅色文本以及与信息无关的背景纹理。
|
||||
|
||||
谨慎使用:照片、屏幕截图、地图、肖像、客户姓名文本、红色注释、圆形卡片。它们必须作为证据或解释工具出现,并控制面积和数量。
|
||||
|
||||
允许特殊页面:封面可以使用大面积的蓝色抽象线条和更多的留白;免责声明可能使用极高密度的单栏法律文本;附录/模型页面可以使用更密集的表格,但仍必须保留标题、来源和页码。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 是否为 16:9、白色背景、窄边距、高内容利用率、保留顶部标题区域和底部脚注/页码区域?
|
||||
- 标题是一个分析性主题,下面有一个清晰的斜体结论行或一页论文吗?
|
||||
- 紫色是否仅用于标题、部分、标题和结构重点,而蓝色/青色/灰色则按类别或数据系列稳定使用?
|
||||
- 页面的要点是否围绕一个主要问题,模块之间有清晰的分组逻辑吗?
|
||||
- 图表是否有标题、图例、轴/标签、来源和测量基础,没有 3D、渐变、阴影或其他装饰?
|
||||
- 表格的行列是否对齐,标题是否清晰,分隔线是否清晰,并且对关键行和列的强调有所限制?
|
||||
- 组件是否具有信息功能:用于定位的标签、用于分组的彩条、用于聚合的框架、用于流向的箭头、用于风险警报的红框?
|
||||
- 是否避免使用照片背景、表情符号、说明性图标、复杂纹理、眩光和无意义的大色块?
|
||||
- 您是否检查过溢出、截断、乱码、重叠、低对比度、遮挡、空模块、页码缺失、比例错误和来源缺失?
|
||||
- 模板变体是否服务于信息结构而不是仅仅使页面看起来不同?
|
||||
- 没有默认使用卡片:除非用户明确要求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式 - 线段、空白以及字体和字体大小差异是更好的解决方案。
|
||||
- 无等分组合:除非没有其他布局可用,否则不要默认三向分割、四向分割或2×2矩阵——包括三向分割+标题+结论等三部分结构。
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,171 @@
|
||||
# 组织名称 — 报告名称 2025 风格设计系统
|
||||
|
||||
## 1.风格定位
|
||||
|
||||
社论年度研究出版物PPT风格:温暖的米白色纸张、超重的黑色观点标题、部分彩色荧光突出、密集的数据图表和稳定的页脚。适合行业年度报告、投资研究、技术趋势、市场格局和政策/生态系统分析。每一页都先给出判断,然后给出证据。
|
||||
|
||||
固定特性:16:9风景画布;机身多采用类似#F2EFE6的暖白色背景;左上角有一个小部分标签;顶部有强烈观点的标题;由部分颜色矩形支持的关键字;正文使用图表、表格、时间线、卡片或案例网格;底部带有固定的品牌文字标记、来源和版权。部分分隔线使用带有像素块的深色/高饱和度背景,而内容页面则保持克制。
|
||||
|
||||
1. **观点在前,证据在后。** 每一页都必须有一个独立的判断标题;图表/表格/模块的存在只是为了证明判断;没有证据就没有口号页。
|
||||
2. **高密度来自结构,而不是堆积的文字。** 使用栏、表格、卡片、图例、注释和脚注来分层信息,清晰地分隔标题区域、正文区域和页脚区域。
|
||||
3. **颜色承载信息作用。** 暖白、黑、灰处理阅读;部分颜色处理识别和强调;图表系列颜色区分数据。品牌颜色不得牺牲轴、标签和正文的可读性。
|
||||
|
||||
## 2. 页面骨架
|
||||
|
||||
**画布和边距**:标准宽屏 16:9。内容页使用稳定、相当窄的左、右、上安全边距,底部保留页脚带;正文水平展开,避免居中的窄栏长文本。
|
||||
|
||||
**默认内容页面骨架**:
|
||||
- 左上角的小部分标签:小色块/短条或小标题+大写部分名称。
|
||||
- 左上角标题区域约占页面高度的15%–25%;超重的黑色判断语句可以换行,关键词可以用半透明截面颜色矩形突出显示。
|
||||
- 标题下方是正文区域,包含图表、表格、矩阵、左右栏或案例模块。
|
||||
- 页脚横跨底部:品牌文字标记位于左下角,来源/来源位于中左下角,版权位于右下角。
|
||||
- 阅读路径:左上章节标签→大标题判断→高亮关键词→正文证据→图例/注释→来源和版权。
|
||||
|
||||
**封面**:暖白色背景,中间有水平绿色像素块;超大的黑色标题堆叠并居中,带有斜体衬线“of”;年份在下方居中。可以覆盖一些浅蓝色/紫色半透明方块。没有照片,没有纹理,没有复杂的背景。
|
||||
|
||||
**披露/声明页**:全版高饱和度绿色背景;左上角有一个小期刊名称和一个超大的黑色标题;多个小字长文本段落覆盖中下区域。仅用于合法披露——请勿迁移到普通内容页面。
|
||||
|
||||
**目录页**:暖白色背景;左侧有一个大的内容标题和绿色像素块;右侧包含由数字、部分名称、细规则和末尾的小部分颜色块组成的部分列表。没有缩略图样式的目录。
|
||||
|
||||
**部分分隔线**:深蓝色、紫色、绿色等纯色背景;左边或中间有章节编号和大标题;像素块可能位于右上角或角落;没有复杂的数据。
|
||||
|
||||
**水印/背景图形**:除了封面、目录和部分分隔符上的像素块外,内容页面不使用水印或大背景图形。
|
||||
|
||||
## 3. 内容组织**标题写作**:使用完整的观点句子,而不是名词标签,例如“某种趋势正在增长/趋同/占主导地位”。保持两到三行,仅突出显示一个关键短语或单词,并避免给整个句子着色。
|
||||
|
||||
**信息行和正文**:使用短段落或小注释,放置在图表或内部卡片的旁边或下方;它们说明了测量基础、样本范围、例外情况或结论的含义。避免大范围的居中副本。
|
||||
|
||||
**分组逻辑**:趋势图解释随时间的变化,堆积图解释组成,表格解释比较,卡片解释案例,时间线解释阶段。多模块页面使用等宽列、等高卡片或统一表格框架。
|
||||
|
||||
**每页点数**:每页1个主要判断点,2-4个从属支撑点;不要同时提出多个不相关的结论。
|
||||
|
||||
**正文密度**:允许高密度,但文本块必须短,并且必须保留图表标签和来源。除了一张主图表之外,更喜欢左右栏或两个堆叠的证据区域。
|
||||
|
||||
**项目符号书写**:谨慎使用传统的点列表;喜欢图表旁注、卡片标题、表格列标题、图例和标签。使用粗体、色块背景或局部彩色文本来强调关键字——没有下划线或花哨的标记。
|
||||
|
||||
**数字/单位/时间基准**:数字接近图表或卡片;单位、时间范围和估计基础都包含在副标题、轴或脚注中。估计范围可以使用高可见度的彩色文本或引导线。
|
||||
|
||||
**来源脚注**:每个数据页底部必须有来源/来源;本地图表注释无法替代页脚源。
|
||||
|
||||
## 4.内容页面布局系统
|
||||
|
||||
**A。单张大图+右侧说明栏**
|
||||
左边/中间有一个大的折线图、面积图、堆积面积图或柱形图;右侧窄栏包含大胆的结论、关键数字或图例解释。使用标题突出显示、图表内注释点、结束标签或右侧的彩色说明框进行强调。不适合比较具有不同测量基础的多个图表。
|
||||
|
||||
**B。热门观点+全角图表**
|
||||
标题位于顶部,图表横跨整个正文宽度,具有清晰的轴和网格线。适合时间序列、市场份额和长期变化;不要在图表上覆盖太多卡片。
|
||||
|
||||
**C。左文本右图表/左图表右文本两列**
|
||||
用于“判断+证据+解释”。文本栏包含短段落、关键数字和源补充;图表栏是主要证据,比率可能有利于图表。正文栏不能变成一篇长文章——最多是几个要点。
|
||||
|
||||
**D。高密度并排模块/卡网格**
|
||||
用于案例、公司、流程链或链接、产品或指标比较。卡片大小统一,用浅色填充或细线分隔;标题为粗体黑色,内部包含小图像、数字或简短注释;部分颜色小标签或卡片背景颜色可以提供强调。如果每张卡片都需要很长的解释,请拆分页面。
|
||||
|
||||
**E。表格/矩阵式页面**
|
||||
用于多实体、多指标比较。清晰的标题、细线和浅色填充区分行和列;关键列可以使用部分颜色、深色填充或图标点。表格是证据的主角,不会与大图表竞争同一页。
|
||||
|
||||
**F。半圆/仪表式估算图**
|
||||
用于表达范围估计、层次关系以及从大量群体到较小活跃群体的嵌套。必须与左侧的数字标签和引导线配对;不适合精确的趋势比较。**G。图表 + 案例备注卡**
|
||||
将特定项目/案例放在趋势图旁边。案例卡具有浅色或白色背景,带有略圆/近矩形的角,包含项目文本名称/产品图像、一个短段落和 2-3 个关键指标。案例卡是辅助证据,不得掩盖主图表。
|
||||
|
||||
## 5. 颜色系统
|
||||
|
||||
**背景 — 温暖的米白色#F2EFE6 / #F5F2E9**:用于页面基础、浅表填充和图表背景。除非源图像/嵌入图表需要,否则不能用纯白色或冷灰色替换。
|
||||
|
||||
**主要文本 — 近乎黑色 #111111 / #1A1A1A**:用于标题、正文、关键轴文本和页脚品牌字标。主标题不得替换为浅灰色。
|
||||
|
||||
**辅助文本/线条 — 灰色 #83817D / #9B9D9D / #C5C0BA**:用于脚注、来源、网格线、表格分隔符和辅助标签;不包含主要结论。
|
||||
|
||||
**品牌/部分绿色 — #52DA92 / #79D497**:用于封面像素块、披露页面背景、目录像素块、部分颜色和标题关键字突出显示。大面积使用仅限于封面、目录、披露页或章节页;在内容页面上,它仅用作小区域突出显示、标签和图表系列。
|
||||
|
||||
**深蓝色/部分暗色调 - 大约#342B56 / #39374F / #0B2D3A**:用于部分分隔符背景、深色图表系列和强调块。深色背景使用白色或亮色文本;在内容页面上谨慎使用大面积区域。
|
||||
|
||||
**蓝色系列 — #2351EC / #4168C5 / #A4C9FE**:用于图表系列、范围填充、部分颜色和强调标签。浅蓝色可以填充较大的图形区域;深蓝色用于线条和关键系列。
|
||||
|
||||
**橙色系列 — #F6A031 / #F6931C / #F9961F**:用于重要的图表系列、标签、正增长或一个资产/类别标识符。不应在一页上与太多其他高饱和度颜色一起出现。
|
||||
|
||||
**紫色/洋红色系列 — 大约 #DB76ED / #9D8C7F / #7B3FF2**:用于分区分隔线、图表系列和标签卡;与橙色/蓝色形成鲜明对比,在内容页面上控制区域。
|
||||
|
||||
**绿色图表系列 — 大约 #70AF87 / #2FBF72**:用于面积图、增长趋势和状态强调。品牌绿色用于部分/突出显示;绿色数据代表系列。
|
||||
|
||||
**每页颜色边界**:内容页面仅限于 1 个部分颜色 + 2-4 个数据系列颜色。高饱和度的颜色只是为了区分数据,而不是随意的装饰。没有强烈的正/负状态颜色系统;在表达涨跌时,更喜欢图例文字解释,避免采用红绿金融惯例。
|
||||
|
||||
## 6. 字体和文本层次结构
|
||||
|
||||
强大的无衬线出版系统:标题极其粗体、紧凑、黑色;正文和脚注采用清晰的无衬线字体;封面上的“of”为斜体衬线。
|
||||
|
||||
- **主标题**:大约。 2.5–4x 正文,超重,紧行距,主要左对齐;盖子可能会更大。
|
||||
- **部分分隔符标题/编号**:可能超过正文文本的 4 倍,粗体黑色或白色。
|
||||
- **小部分标签**:约。 0.7–0.9x 正文,全部大写或短语,通常带有小色块。
|
||||
- **正文解释**:正文 = 1x,黑色或深灰色短段落,行间距宽松;避免长而密集的列。
|
||||
- **图表标题/副标题**:大约。 0.9–1.2x 正文文本,粗体或半粗体,放置在图表上方。
|
||||
- **表格文本/图例标签**:大约。 0.75–0.95x 正文;数字可以是半粗体。
|
||||
- **注释/来源/页脚**:大约。 0.55–0.7x 正文,灰色或低粗黑色;必须保持可读性。
|
||||
- **页码**:不是显眼的导航设备;如果添加,它们应该被抑制并折叠到页脚中——它们不能成为视觉焦点。
|
||||
|
||||
## 7. 图表语言
|
||||
|
||||
主要图表类型:堆积面积图、折线图、柱形图、组合柱线图、半圆形嵌套/仪表式估计图、小时间序列、比较图。**轴和网格**:细的灰色轴;浅色、规则的网格线;暖白色或浅色背景。轴标签小而清晰;时间轴通常水平延伸。
|
||||
|
||||
**图例和标签**:图例位于图表的内边缘或图表旁边,使用色块/线条样本+类别名称。关键数据直接标注在图表上或右侧说明栏中。
|
||||
|
||||
**系列颜色**:采用橙色、蓝色、紫色、绿色、浅蓝色等高辨识度颜色。堆积面积图必须保持边界清晰;折线图中的强调线可能更粗或更饱和。
|
||||
|
||||
**强调注释**:使用部分颜色突出显示、水平基线、解释框、关键数字标签或本地彩色卡;注释靠近相关数据点,不会作为装饰而浮动。
|
||||
|
||||
**预测/目标/基线**:新预测使用虚线/浅色阴影范围,并在图例中进行解释;不要将它们与坚实的历史线索混淆。
|
||||
|
||||
**边界**:没有 3D 图表、透视图、发光图表或过度拟物化的仪表。轴和源不得省略。
|
||||
|
||||
## 8. 表格和高密度信息
|
||||
|
||||
表格用于专业证据和多实体比较。标题使用粗体文本、浅色填充或顶部分隔线;行和列由细灰色线划分;填充物有暖白色、浅米色或浅灰色;关键单元格可以使用部分颜色或深色块。
|
||||
|
||||
**行列规则**:行高紧凑,不拥挤;长文本左对齐,数字右对齐或按小数点/单位视觉对齐;栏目标题保持简短;多列指标保持统一的单位和时间基础。
|
||||
|
||||
**强调处理**:使用突出显示的列、彩色标签、粗体数字、深色摘要行或小点标记。突出显示仅用于关键比较——不要对整个表格进行模式化。
|
||||
|
||||
**斑马条纹和分隔**:更喜欢细线和浅色填充分区;如果使用斑马条纹,它必须很轻。避免厚重的边框。
|
||||
|
||||
**高密度模块**:并排卡片、公制块、格子和表格必须具有统一的尺寸、标题级别和间距;每个模块仅包含必要的数字、注释和图例。
|
||||
|
||||
## 9. 组件和图形元素
|
||||
|
||||
**像素块**:由阶梯式矩形块组成,通常用于封面、目录和部分分隔符,很少用于导航。大面积使用仅限于结构页面;在内容页面上,不要重复铺上像素块作为背景装饰。
|
||||
|
||||
**章节标签**:一个小色块+大写的章节名称,固定在左上角,用于章节定位。
|
||||
|
||||
**标题高亮条**:位于关键字后面的半透明或实心矩形,具有像荧光笔一样的直边。仅突出显示关键字或短语;切勿突出显示整个正文段落。
|
||||
|
||||
**卡片/标注框**:用于案例、图例解释、关键数字和类别注释。矩形或略圆角;填充可以是橙色、紫色、深色或浅色,并带有高对比度文本;放置在相应图表附近。
|
||||
|
||||
**分隔线**:用于目录、表格、图表网格和模块边界的灰色细线。线条内敛、笔直;没有装饰曲线。
|
||||
|
||||
**图标/身份标记**:仅少量用作小类别图标;品牌/项目标识符以纯小文本形式呈现(写下新主题名称)——不绘制图形徽标。彩色图标不是一种全球语言;它们只能用于案例卡、图例或项目文本标识符。
|
||||
|
||||
**流程箭头/矩阵标记**:用于流程或阶段说明;保持它们平坦、细线、颜色少;不要构建复杂的流程图。
|
||||
|
||||
**图像框架**:放置在卡片内或图表旁边的少量产品/设备图像,仅辅助 - 绝不是整页背景。
|
||||
|
||||
## 10. 质地和违禁物品整体扁平、出版级、内敛、高密度、带有强烈的品牌色彩;识别来自于字体重量、温暖的米白色纸张、像素块和部分颜色,而不是来自照片跨页或维度纹理。
|
||||
|
||||
**禁止**:大面积摄影背景、复杂插图背景、表情符号、3D图表、发光效果、玻璃形态、强烈阴影、复杂纹理、花边装饰、随意渐变、大片居中副本、无源数据图表、低对比度浅色文本以及无标题判断的纯图像页面。
|
||||
|
||||
**谨慎使用**:彩色图标、产品屏幕截图、圆形卡片、整页深色背景、渐变填充、高度饱和的多色图表。它们必须提供信息功能并仅限于案例/图表/章节页面范围。
|
||||
|
||||
**允许特殊页面**:整版高饱和度绿色用于披露;用于部分分隔符的整页深蓝色/紫色/绿色背景;封面和目录上的大面积像素块;案例页面上有少量物理/产品图像。普通内容页面仍然以暖白色背景和数据证据为中心。
|
||||
|
||||
## 11. 一代清单
|
||||
|
||||
- 骨架:是16:9吗?它是否保留左上角部分标签、顶部意见标题、正文证据区域和底部品牌字标/来源/版权?
|
||||
- 文案:标题是否为完整的判断语句;每页有一个要点吗?关键词高亮是否受到限制?
|
||||
- 层次结构:标题、图表标题、正文、注释和页脚是否有明显的相对大小差异;页码不用作主要导航。
|
||||
- 颜色:背景是暖米白色;部分颜色仅用于识别/强调;一页上是否有太多高饱和度颜色;文字对比度是否足够?
|
||||
- 布局:图表、表格和卡片是否对齐;利润率是否稳定;页脚没有被身体挤压吗?
|
||||
- 图表:是否有轴、图例、单位、时间范围和来源;重点注释是否接近数据;没有使用 3D/发光/无意义的装饰?
|
||||
- 表格:标题、分隔符、数字对齐和单位基数是否清晰;突出显示仅用于关键行和列吗?
|
||||
- 组件:是仅在封面/目录/部分分隔符上使用或用于稀疏导航的像素块;卡片是否起到解释作用?图标有实际意义吗?
|
||||
- 禁止使用的物品:禁止使用大图像背景、表情符号、复杂插图、重阴影、玻璃态、过度渐变或大段居中副本。
|
||||
- 质量:检查溢出、截断、乱码、重叠、低对比度、遮挡、空内容、错误比例和机械模板重复。
|
||||
- 没有默认使用卡片:除非用户明确要求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式 - 线段、空白以及字体和字体大小差异是更好的解决方案。
|
||||
- 无等分组合:除非没有其他布局可用,否则不要默认三向分割、四向分割或2×2矩阵——包括三向分割+标题+结论等三部分结构。
|
||||
|
After Width: | Height: | Size: 780 KiB |
@@ -0,0 +1,192 @@
|
||||
#设计系统:消费趋势监测·景观呈现版
|
||||
|
||||
> 逆向工程来自:36页肖像(美国信件612×792)投资监测季刊的真实视觉证据(封面+所有35页的逐页图像)。
|
||||
> 该系统仅保留其**视觉方法** - 报纸风格的网格、与数据图表混合的衬线标题、青色/琥珀色/深红色图表语言和纸米色页脚 -
|
||||
> 不复制任何第三方徽标、品牌名称、商标或源文本;根据任务要求,将纵向报告语法转换为16:9横向呈现系统。
|
||||
> 任何无法从像素证据中精确确认的值都被标记为“近似值”。
|
||||
|
||||
---
|
||||
|
||||
## 1.视觉DNA(一句话)
|
||||
|
||||
**财经报纸式的数据监测报告**:内敛的黑白版式+大衬线标题+密集的柱线组合图表+深海蓝色封面米色页脚;整体气质是“值得信赖的行业仪表板”,而不是营销海报。
|
||||
|
||||
四项可执行原则:
|
||||
1. **结论用斜体衬线表达,证据由图表给出**——每个信息块都是“斜体衬线结论句+图表/正文”的配对。
|
||||
2. **图表是主角**:页面区域先到图表;文本是图表的注释。
|
||||
3. **颜色仅编码,从不装饰**:青色=主要系列,深青色=次要系列,琥珀色/黄色=比较线,深红色=警告或第二主题,米色仅出现在页脚和分隔带中。
|
||||
4. **密集但不拥挤**:小字体,多列,紧密的前导 - 但模块之间有清晰的空白。
|
||||
|
||||
---
|
||||
|
||||
## 2.画布和网格(16:9横向转换)
|
||||
|
||||
|项目 |价值|注意|
|
||||
|---|---|---|
|
||||
|帆布| 960 × 540 像素 | 16:9 演示标准 |
|
||||
|左/右页边距 | 44 像素 |源纵向边距 ≈ 页面宽度的 7%,引入横向(大约)|
|
||||
|内容区 | y = 20 ~ 508 | y = 20 ~ 508 |米色页脚带上方|
|
||||
|页脚带| y = 512 ~ 540 (28 高) |米色水平带,参见§9|
|
||||
|立柱系统| 12 列心理网格,通常分为 2 / 3 列;装订线 24 px |源码正文采用固定3窄列布局|
|
||||
|基线节律| 4 px 的倍数 |所有间距均为 4 的倍数(近似值)|
|
||||
|
||||
纵向→横向转换规则:
|
||||
- 来源“全页大图像 + 下面三栏正文” → 横向变为“左侧 60% 图表 + 右侧 40% 文本栏”或“图表在顶部,文本在下方,图表跨越整个宽度”。
|
||||
- 源 2×2 图表矩阵页面(垂直排列)→ 景观变成自然的 2×2 网格 — 天生适合。
|
||||
- 源码唯一的横向页面(区域地图+两侧堆叠条)证明本系统支持横向;将密度与该页面对齐。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字体层次结构
|
||||
|
||||
中文字体受运行时字体库约束;映射如下(字体名称必须与库完全匹配):
|
||||
|
||||
|角色 |字体|尺寸/款式 |用途 |证据|
|
||||
|---|---|---|---|---|
|
||||
|显示衬线 | `{latin: Oranienbaum, ea: 思源宋体}`,粗体 |封面 40~48 / 页标题 26~28 |封面主标题、各页大标题|源标题是乔治亚型粗体衬线(近似映射)|
|
||||
|结论线/图表标题 |与上面相同,斜体+粗体| 14~15 |每页的结论线、每个图表的斜体标题 |源图表标题为斜体衬线(近似值)|
|
||||
|无衬线体 (Sans) | `MiSans` | 10~10.5,行高1.45~1.5 |多列正文、图例、轴标签 |源正文 ≈ 9px 无衬线三列(近似值)|
|
||||
|眉毛| `MiSans`,字母间距2~3 | 9~10,全部大写感觉被粗体取代了 |页面标题上方的列标识符 |来源“DEALS BY SECTOR”式小型眉毛|
|
||||
|数据标签| `MiSans` 粗体 | 9~10 |列内/行内的值 |来源列内白色粗体值 |
|
||||
|大数字(统计)|显示衬线粗体或 MiSans 粗体 | 28~36 |关键指标卡|源码封底和内页大数字处理(近似值)|
|
||||
|页脚/来源注释| `MiSans` | 8~9,灰色 |页脚带文本,图表下源注释 |来源页脚的小号大写灰色类型 |
|
||||
|
||||
规则:
|
||||
- 一页内,衬线仅用于“标题+结论线+图表标题”;正文和图表注释始终为无衬线。
|
||||
- 没有超出上述两个系列的字体;没有书法/手写的面孔。
|
||||
|
||||
---
|
||||
|
||||
## 4. 颜色标记(十六进制;都是像素采样近似值)
|
||||
|
||||
|代币|十六进制 |角色 |抽样依据|
|
||||
|---|---|---|---|
|
||||
| `ink` 深海蓝| `#06223F` |封面/章节页底、表格标题、最暗图表系列、强调文本 |封面底座#051C38,标题#2C4057,最暗的地图状态#001830 |
|
||||
| `ink-soft` | `#14324F` |深蓝色辅助(卡片、辅助标题)|近似|
|
||||
| `teal` | `#2FBFC4` |主系列列颜色、主线、强调图标 |主图表栏#30C0C0~#3CC0C0 |
|
||||
| `teal-dark` 深青色 | `#1B6E72` |二级系列立柱,第二堆叠段 |深青色柱#186C6C~#246C78 |
|
||||
| `teal-pale` 浅青色 | `#BFE5E6` |最轻堆叠段、浅面积图表层、图表背景块 |浅青色段#C0E4E4 |
|
||||
| `amber` | `#D9A62E` |比较线(柱线组合中的“线”)、增长率注释 |金线#CCA80C~#E4C09C |
|
||||
| `yellow`亮黄色| `#DCC83C` |单系列黄柱(行业页原色),堆叠段|黄柱#D6D470~#CCA80C |
|
||||
| `orange` | `#E5862E` |第三主题栏颜色、堆叠段|橙色柱#E48430 |
|
||||
| `crimson` | `#76101A` |警告/第二主题列颜色,深色堆叠段|深红柱#6C0000~#780C0C |
|
||||
| `salmon` | `#F0A89A` |中间堆叠部分(深红色和浅青色之间)|粉红色段样本#FEF1EE 增亮估计(近似值)|
|
||||
| `slate` 石板蓝| `#5E84A0` |地图/图表中间色调、辅助线 |中色调地图状态#60849C |
|
||||
| `paper`纸白| `#FFFFFF` |内容页库 | — |
|
||||
| `beige` | `#EDE7D6` |页脚带、水平分隔带、注释框填充 |页脚带#EFEBDE |
|
||||
| `beige-line` | `#D8D2C2` |米色带顶边,细桌线|带边缘#D2D1C9 |
|
||||
| `text` 机身墨水 | `#262626` |正文 |近似|
|
||||
| `muted` 灰色 | `#8A8A8A` |轴标签、图例、源注释、页脚文本 |近似|
|
||||
| `grid` 格灰| `#E7E7E7` |桌子外框,薄隔板 |近似|
|
||||
|
||||
对比地板:在 `ink` 上,仅白色/浅青色文字;深 `teal`/`crimson`/`orange` 色谱柱上的数据标签为白色或以深灰色放置在色谱柱上方;在米色带上,仅显示 `#6B6B6B` 深度或更暗的文本。
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面骨架(内容页面通用)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ▪ wordmark "Specialty Coffee Consumption Monitor" │ ← header row y≈20~44
|
||||
│ version at right: 2026 Annual Insights · sample setting │
|
||||
│ │
|
||||
│ Eyebrow (9px, wide letter spacing, gray) │ ← y≈56
|
||||
│ Serif display headline 26~28px │ ← y≈74~104
|
||||
│ Italic serif conclusion line 14px (the page's only conclusion) │ ← directly under the title
|
||||
│ │
|
||||
│ ┌────────────────── content area y≈130~486 ────────────────────────┐ │
|
||||
│ │ chart / multi-column body / table / cards (choose layout per §6) │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ page number (centered, 9px serif gray) │ ← y≈496
|
||||
│ ▔▔▔▔▔▔▔▔▔▔ beige footer band: report name centered 9px ▔▔▔▔▔▔▔▔▔▔ │ ← y≈512~540
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 标题左侧文字标记:实心青色小方块 (10×10) + 粗体无衬线报告名称;右侧是一个小的灰色版本标识符。 **禁止任何真实的品牌标志。**
|
||||
- 页码位于米色带上方的中心(源样式);页脚区域以小字体显示居中的完整报告名称。
|
||||
|
||||
---
|
||||
|
||||
## 6. 可重用布局 (8)
|
||||
|
||||
1. **封面(深海蓝色)**:全出血 `ink` 底座 + 右侧同心细弧/细线信号图案(低不透明度蓝灰色椭圆笔划,完全在画布内)+ 大衬线标题(可以混合斜体和罗马字)+ 一行版本信息 + 底部关键数字带(3 个统计数据,青色/白色文本)。
|
||||
2. **结论页(封面变体)**:与封面相同的骨架,但标题是页面的结论句,下面有3~4个数据统计带作为证据。
|
||||
3. **大图表+文字栏**:左边55~60%持有一张柱线组合图表(斜体图表标题+图例+来源注释);右 40% 拥有一栏正文 + 一张统计卡或小图表。
|
||||
4. **全角图表+三栏正文**:顶部为全角组合图表(占高度的45~50%),下方三栏10px正文文本,每栏可选择以粗体开头句开头。
|
||||
5. **2×2图表矩阵**:每个象限一个小图表,每个图表标题为“斜体衬线图表标题+小字体副标题+图表+源注释”,象限之间有24px空白。
|
||||
6. **横条+右侧统计轨迹**:左侧60%为横条排名图;右 40% 垂直堆叠 2~3 张统计卡(米色或浅青色填充、大衬线数字 + 小字体注释)。
|
||||
7. **比较配对栏+解释栏**:中间偏左的比较图,有2~4个大栏(在栏上方和栏外标记值),右侧1个解释文本栏+1个“x倍差”箭头注释。
|
||||
8. **表格页/卡片页**:一个深青色标题白色文本表格(参见§7)或2~3张等宽操作卡(卡片=浅青色顶栏+衬线字幕+无衬线正文+底部数据锚芯片)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 图表和表格语言
|
||||
|
||||
### 一般图表
|
||||
- 图表标题:斜体衬线粗体 14px `ink`,左对齐;下面的行是一个 9 像素的灰色字幕(公制)。
|
||||
- 轴:没有 y 轴网格线 (`gridLine: false`)、没有轴线或只有一条细 `#E7E7E7` 基线;轴标签 9px `muted`;货币轴带有单位缩写。
|
||||
- 图例:图表下方水平,小色样+ 9px 灰色文本。
|
||||
- 来源说明:图表右下角8px灰色文字——“中国精品咖啡消费趋势监测·样本数据(内部估算)”。
|
||||
- 值标签:9~10px;列系列标签最好在列内使用白色粗体,或者在不适合时在列上方使用深灰色(如果渲染器不支持位置控制,请将它们均匀地放置在上方和外部,视为源的近似值)。
|
||||
- 线系列标签位于端点上方,呈 `muted` 灰色。
|
||||
|
||||
### 图表型食谱(按证据频率排序)
|
||||
1. **柱线组合(本系统的标志图)**:实心柱(`teal` / `crimson` / `orange` / `yellow`之一)+对比细线(`amber`或`teal`),双轴;标有金额的列,标有计数/比率的行。
|
||||
2. **多线**:4~5条1.5~2px的细线,颜色顺序`ink`→`teal`→`amber`→`crimson`→`slate`,端点值直接标记在右端。
|
||||
3. **100% 堆叠条**:从 `teal`/`teal-pale`/`salmon`/`crimson` 或 `teal`/`teal-dark`/`ink` 的每个主题分段颜色,分段内带有白色 % 文本。
|
||||
4. **水平条排名**:单色系列(`teal` 或 `ink`),值标记在条末端,类别轴位于左侧。
|
||||
5. **分层区域**:`ink`→`teal-dark`→`teal`→`yellow`自下而上,仅用于“库存积累”语义。
|
||||
6. **比较配对列**:深色和浅色的两个同族列,它们之间有“x 倍差异”箭头和文本。
|
||||
|
||||
### 表格
|
||||
- 标题:`ink` 背景,白色粗体 9~10px 文本,左对齐(数字列右对齐)。
|
||||
- 数据行:白色背景,行间只有 `#E7E7E7` 0.75~1px 基线,没有垂直线,没有斑马条纹(源表没有斑马条纹)。
|
||||
- 行高 22~26px,单元格内边距左右各 8px。
|
||||
- 表格下面还有源注释。
|
||||
|
||||
---
|
||||
|
||||
## 8. 图像裁剪和图形组件
|
||||
|
||||
- **图像**:只有封面/章节页面可以使用全帧图像,始终为 `fit: cover` 全出血裁剪;风景中的人像图像仅取中间波段(`crop: {top, bottom}` 各修剪 15~25%);内容页面不使用照片,而是使用图表和图形组件。源肖像为右栏小方块(约120×150)+灰文简介;仅在真正需要时才使用相同规格的占位符。
|
||||
- **信号图案(封面装饰)**:3~4个同心椭圆细笔画(`slate`/`teal`,不透明度0.15~0.3),所有边界在画布内;或一条 2px 青色“脉冲”折线。
|
||||
- **统计卡**:`teal-pale` 或 `beige` 填充(无描边,无阴影),大衬线 `ink` 数字 + 9px 灰色注释;在深蓝色页面上切换为透明填充+青色大数字。
|
||||
- **动作卡**:白色底座 + 4px `teal` 实心顶栏 + 衬线字幕 + 10px 正文 + 底部数据芯片(`beige` 填充圆形小字体)。
|
||||
- **图标**:仅限 Font Awesome 免费实心 (`fas:`),单色 `teal` 或 `ink`,16~20px,无混合样式。
|
||||
- **分隔**:始终为 1 像素 `#E7E7E7` 细线或空白;无阴影,无渐变块(覆盖信号图案除外)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 密度、间距和页脚
|
||||
|
||||
- 信息密度与来源一致:1个结论+1~2个图表+每页不超过3栏正文;正文10px,行高1.45,段落间距6~8px。
|
||||
- 模块间距:标题区域→内容区域20~24px;图表矩阵象限相距 24px;卡片间隔 16~20px。
|
||||
- 页脚:米色带(`beige`,28px高)横跨整个宽度,居中9px `muted`报告名称“中国精品咖啡消费趋势监测·2026”;页码为 9 像素衬线灰色,中心位于条带上方 6 像素处。
|
||||
- 每页右下角的图表注释均使用统一的 §7 源注释措辞。
|
||||
|
||||
---
|
||||
|
||||
## 10. 禁止事项
|
||||
|
||||
1. 不含第三方徽标、品牌名称、赞助商标志、商标或源手稿原文。
|
||||
2. 份额排名没有饼图/圆环图(来源根本没有饼图);股票始终使用 100% 堆叠条形图或水平条形图。
|
||||
3. 没有阴影、渐变填充块、磨砂玻璃、3D 图表或动画风格的图表装饰。
|
||||
4. 没有圆角半径大于8px的卡片;图表列保持直角。
|
||||
5. 文本不得压在图像上,图表元素不得渗出画布(所有边界必须保持在 960×540 以内)。
|
||||
6、米色页脚带以外的正文区域不得大面积米色填充;米色只是一个“带”,而不是一个“块”。
|
||||
7. 禁止捏造数字:所有值必须来自任务查询;定性判断必须标注“内部判断”。
|
||||
---
|
||||
|
||||
## 11. 交付前检查表
|
||||
|
||||
- [ ] 每一页都有一个且仅有一个明确的结论,以斜体衬线句子的形式直接位于标题下方。
|
||||
- [ ] 6页构成一个完整的叙述:结论→市场结构→消费者细分→渠道和会员经济学→机会图→行动建议。
|
||||
- [ ] 每个号码都可以一一追踪到query.md;没有捏造的东西,没有外部数据。
|
||||
- [ ] 所有颜色均来自 §4 标记;图表着色遵循 §7 配方;没有渐变/阴影。
|
||||
- [ ] 只有两种字体系列:serif `{Oranienbaum, 思源宋体}` 和 sans-serif `MiSans`;尺寸遵循§3。
|
||||
- [ ] 页眉文字标记、米色页脚带 + 页码和图表下源注释出现在每个页面上的一致位置。
|
||||
- [ ] `kimi-ppt check` 报告没有错误;逐页屏幕截图显示没有重叠、没有裁剪、没有溢出、没有意外换行;深色背景白色文本对比通过。
|
||||
- [ ] 最终目录:`deck.pptx`(唯一的PPTX)+完整的PPTD捆绑包+`final-screenshots/pages/1.jpg~6.jpg`。
|
||||
|
||||
- 没有默认使用卡片:除非用户明确要求,否则严格禁止使用圆角矩形或矩形卡片来构建层次结构或对齐方式 - 线段、空白以及字体和字体大小差异是更好的解决方案。
|
||||
- 无等分组合:除非没有其他布局可用,否则不要默认三向分割、四向分割或2×2矩阵——包括三向分割+标题+结论等三部分结构。
|
||||
|
After Width: | Height: | Size: 1.0 MiB |