mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
21 Commits
feat/sessi
...
test/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad25048d0 | ||
|
|
0897d84c5b | ||
|
|
a7262ef2fc | ||
|
|
dd8255d59d | ||
|
|
6aac6a1c4f | ||
|
|
f859de0490 | ||
|
|
4222f998d3 | ||
|
|
9b4077f3e9 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 |
46
.github/workflows/semantic-review.yml
vendored
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");
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
@@ -1667,6 +1685,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.78",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.78",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.77",
|
||||
"version": "1.0.78",
|
||||
"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"
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
|
||||
const createHint = "verify --app-type is html, frontend or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
|
||||
|
||||
// AppsCreate creates a new app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
@@ -23,6 +23,7 @@ var AppsCreate = common.Shortcut{
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
|
||||
`Example: lark-cli apps +create --name "工具页" --app-type frontend --description "纯前端工具"`,
|
||||
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
@@ -30,7 +31,7 @@ var AppsCreate = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "app display name", Required: true},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "frontend", "full_stack"}},
|
||||
{Name: "description", Desc: "app description"},
|
||||
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
|
||||
},
|
||||
@@ -59,7 +60,7 @@ var AppsCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
// --app-type is constrained to the lowercase enum (html / full_stack) by the
|
||||
// --app-type is constrained to the lowercase enum (html / frontend / full_stack) by the
|
||||
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
|
||||
// a server concern and is intentionally not surfaced by the CLI.
|
||||
agent := envvars.AgentName()
|
||||
|
||||
@@ -363,3 +363,18 @@ func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
|
||||
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCreate_AcceptsFrontend pins that --app-type frontend is a valid
|
||||
// enum value and flows through to the request body as "frontend" verbatim.
|
||||
func TestAppsCreate_AcceptsFrontend(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCreate,
|
||||
[]string{"+create", "--name", "Demo", "--app-type", "frontend", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("frontend dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"app_type": "frontend"`) {
|
||||
t.Fatalf("expected app_type frontend in body, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest"
|
||||
miaodaCLIPkg = "@lark-apaas/miaoda-cli@0.1.24-alpha.03a64f0"
|
||||
npmRegistry = "https://registry.npmmirror.com"
|
||||
metaRelPath = ".spark/meta.json"
|
||||
steeringRelPath = ".agent/skills/steering"
|
||||
@@ -86,6 +86,10 @@ var appTypePolicies = map[string]appTypePolicy{
|
||||
// no startup env vars to pull, no steering skills to sync, and no app sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
// frontend (vite-react, a buildable front-end app) is intentionally NOT
|
||||
// listed here: it takes the zero-value policy (install deps, pull env, sync
|
||||
// skills) like full_stack, since it needs a build step — it is not a static
|
||||
// HTML site and must not skip those steps.
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -438,6 +442,9 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
// --skip-install is appended per the app_type's policy (see appTypePolicy):
|
||||
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
|
||||
// install; others run it as usual.
|
||||
// appType is forwarded verbatim (including "frontend") — the CLI does not
|
||||
// translate the app type; mapping the app type to a concrete tech stack is the
|
||||
// downstream tool's responsibility.
|
||||
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
|
||||
at := appType
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "keyword", Desc: "fuzzy match on app name"},
|
||||
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
|
||||
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
|
||||
{Name: "app-type", Desc: "app type filter (html, frontend or full_stack)", Enum: []string{"html", "frontend", "full_stack"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
|
||||
// an app_id or a meta_token — the server resolves both. The server returns
|
||||
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
|
||||
// uppercase app_type values ("HTML", "FRONTEND", "FULL_STACK", "MODERN_HTML");
|
||||
// this function normalizes to lowercase. Returns an error when the API
|
||||
// is unavailable or the response is malformed — callers must not proceed
|
||||
// with a fallback type to avoid creating the wrong project scaffold.
|
||||
|
||||
@@ -250,7 +250,7 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
backfillDescriptionRich(e)
|
||||
collapseDescription(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if descriptionRich := descriptionRichToSend(runtime); descriptionRich != "" {
|
||||
eventData["description_rich"] = descriptionRich
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
@@ -120,8 +120,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
|
||||
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -234,7 +233,7 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionRichImages(runtime, calendarId); err != nil {
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
backfillDescriptionRich(out)
|
||||
collapseDescription(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,8 +988,9 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
// The deprecated, hidden --description folds into description_rich; the CLI
|
||||
// never sends the plain description field (mutually exclusive downstream).
|
||||
// --description is the unified field, treated as rich text and sent as
|
||||
// description_rich; the CLI never sends the plain description field
|
||||
// (mutually exclusive downstream).
|
||||
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
|
||||
t.Fatalf("unexpected patch body: %#v", body)
|
||||
}
|
||||
@@ -1411,18 +1412,17 @@ func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Read keeps both fields: description (plain) and description_rich (rich).
|
||||
if !strings.Contains(out, "\"description\": \"[测试]\\n友情提醒\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich description surfaced, got: %s", out)
|
||||
// Read exposes a single unified description field: it carries the rich
|
||||
// (Markdown) value when present, and the plain text otherwise. The internal
|
||||
// description_rich key is never surfaced.
|
||||
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description retained for plain-only event, got: %s", out)
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3438,7 +3438,8 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read keeps both description (plain) and description_rich (rich).
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -3462,17 +3463,16 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"[表格]\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\":") {
|
||||
t.Errorf("expected description_rich in output, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, description_rich is backfilled from it
|
||||
// and the plain description is still returned.
|
||||
t.Run("only plain backfills rich", func(t *testing.T) {
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -3495,10 +3495,10 @@ func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description retained, got: %s", out)
|
||||
t.Errorf("expected plain description surfaced, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description_rich\": \"just text\"") {
|
||||
t.Errorf("expected description_rich backfilled from plain, got: %s", out)
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "deprecated: plain-text description; use --description-rich (Markdown) instead", Hidden: true},
|
||||
{Name: "description-rich", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -72,7 +71,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if !hasCalendarUpdateOperation(runtime) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -114,10 +113,7 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
body["description_rich"] = runtime.Str("description-rich")
|
||||
hasFields = true
|
||||
} else if runtime.Cmd.Flags().Changed("description") {
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
@@ -362,11 +358,8 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
// Upload any local images referenced in --description-rich and rewrite them
|
||||
// to drive URLs before the description is sent (the service cannot read
|
||||
// local files).
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
if err := resolveDescriptionRichImages(runtime, calendarID); err != nil {
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -443,19 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
// Surface both description fields on read: description holds plain text,
|
||||
// description_rich holds the rich (Markdown) version, backfilled from plain
|
||||
// when the service returned no rich value.
|
||||
description, _ := event["description"].(string)
|
||||
if description != "" {
|
||||
result["description"] = description
|
||||
}
|
||||
descriptionRich, _ := event["description_rich"].(string)
|
||||
if descriptionRich == "" {
|
||||
descriptionRich = description
|
||||
}
|
||||
if descriptionRich != "" {
|
||||
result["description_rich"] = descriptionRich
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
@@ -27,8 +27,8 @@ const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionRichImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description-rich")
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
if md == "" || !strings.Contains(md, "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
@@ -233,7 +233,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description-rich", "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
@@ -253,7 +253,7 @@ func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
|
||||
// yields a typed --description-rich validation error before any API call.
|
||||
// yields a typed --description validation error before any API call.
|
||||
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description-rich", "",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr == nil {
|
||||
@@ -273,7 +273,7 @@ func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
if !errors.As(runErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
|
||||
}
|
||||
if ve.Param != "--description-rich" {
|
||||
t.Errorf("param = %q, want --description-rich", ve.Param)
|
||||
if ve.Param != "--description" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,20 +30,23 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func backfillDescriptionRich(event map[string]interface{}) {
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
if descRich, _ := event["description_rich"].(string); descRich == "" {
|
||||
if desc, _ := event["description"].(string); desc != "" {
|
||||
event["description_rich"] = desc
|
||||
}
|
||||
rich, _ := event["description_rich"].(string)
|
||||
plain, _ := event["description"].(string)
|
||||
delete(event, "description_rich")
|
||||
switch {
|
||||
case rich != "":
|
||||
event["description"] = rich
|
||||
case plain != "":
|
||||
event["description"] = plain
|
||||
default:
|
||||
delete(event, "description")
|
||||
}
|
||||
}
|
||||
func descriptionRichToSend(runtime *common.RuntimeContext) string {
|
||||
if v := runtime.Str("description-rich"); v != "" {
|
||||
return v
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
|
||||
@@ -63,22 +63,28 @@ lark-cli auth login --domain apps
|
||||
|
||||
新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。
|
||||
|
||||
**app_type 三类边界**(先判"要不要把数据存到服务端",再判"纯展示还是有交互"):
|
||||
|
||||
| 信号 | 判定 |
|
||||
|---|---|
|
||||
| 静态展示 / 单页 / PPT/deck / demo / 落地页 / 仪表盘 / UI mockup / 可交互原型 / 线框图 / 视觉探索 / 无后端状态 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
|
||||
| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` |
|
||||
| 含数据库 / 后端持久化:登录 / 增删改查 / 报名·投票·站会存记录 / 多人协作 / 泛称"系统·工具"且明确要存数据 | `app_type=full_stack` |
|
||||
| 纯静态展示(给人"看"的物料,无 JS 交互):PPT/deck / demo / 落地页 / 海报 / UI mockup / 线框图 / 静态仪表盘 / 视觉探索 | `app_type=html`,加载 [`creative-design/creative-design.md`](creative-design/creative-design.md)(含完整开发与发布流程) |
|
||||
| 有 JS 交互但无数据库(给人"用"的前端应用):可交互原型 / SPA / 表单校验 / 动态计算 / 调用外部 API / 泛称"工具·系统"但未明确要存数据 | `app_type=frontend`(**默认倾向**:用户未明确提出数据库需求时默认引导 frontend,不默认 full_stack) |
|
||||
| 类型模糊(尤其"要不要存数据"不清) | **追问**,话术偏向 frontend,例:"看起来是个前端应用,需要保存数据吗?";确认要存数据再转 full_stack,确认纯展示再转 html |
|
||||
| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地开发,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) |
|
||||
| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` |
|
||||
| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 |
|
||||
| 修改已有 + 有云端偏好 | 云端会话;未表达偏好且非本地项目 → 默认本地;判不准先问 |
|
||||
|
||||
**类型升级**:`frontend` 应用后续需要数据库/后端能力时,本地 CLI 不提供类型升级;引导用户到云端会话(打开 `https://miaoda.feishu.cn/app/{app_id}`),用自然语言描述后端需求(如"给这个应用加登录和数据存储")即可触发升级,无需特殊指令。
|
||||
|
||||
## 发布态护栏
|
||||
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(仅 full_stack 应用):进应用编辑/开发态、管理与继续开发应用的入口。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
|
||||
- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html 和 full_stack 统一走 `+release-get`)。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`(full_stack / frontend 应用):进应用编辑/开发态、管理与继续开发应用的入口,也是 frontend 升级为 full_stack 的入口(云端会话)。创意模式(html)应用开发态和发布态是同一个链接,无需额外提供开发态链接。
|
||||
- 发布态链接来源:`+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`(html / frontend / full_stack 统一走 `+release-get`)。
|
||||
- html 应用的主链路是创意模式开发方式:按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 初始化仓库、在仓库内产出 HTML 及关联文件,并通过 git commit / git push / `+release-create` / `+release-get` 发布部署。任何 git 操作(clone / pull / push)报错时,先执行 `lark-cli apps +git-credential-init --app-id <app_id> --as user` 刷新本地 Git 凭证,再重试原 git 命令。如果刷新凭证也失败,**停止并向用户报告**:原始 git 错误、凭证刷新失败原因,以及是否可能是当前环境(操作系统、沙箱)限制导致(如 macOS Keychain 在沙箱中不可用、Linux 加密文件目录不可写等)。不要改走 `+html-publish`,也不要把 `+html-publish` 当作本地开发链路的 fallback。
|
||||
- 创意模式(html)应用的链接格式为 `https://{租户域名}/page/{meta_token}`,**开发态和发布态是同一个链接**(区别于 full_stack 应用两者分开)。此链接形似飞书文档链接。`+get --app-id <meta_token>` 可获取应用信息(含 `app_id`),`+get --app-id <app_id>` 可获取 `meta_token`。看到 `/page/xxx` 链接时,它是妙搭创意模式应用,不要当成飞书文档跳过。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 命令骨架
|
||||
|
||||
- 必填:`--name`、`--app-type`。
|
||||
- app type 语义取值为 `html` / `full_stack`;CLI 会把输入归一成小写后校验。
|
||||
- app type 取值为小写 `html` / `frontend` / `full_stack`;框架按枚举精确校验(不做大小写归一),非法值直接报错。
|
||||
- 可选:`--description`、`--icon-url`。
|
||||
|
||||
## 示例
|
||||
@@ -17,6 +17,9 @@
|
||||
```bash
|
||||
lark-cli apps +create --name "客户调研问卷" --app-type html
|
||||
|
||||
lark-cli apps +create --name "JSON 格式化工具" --app-type frontend \
|
||||
--description "纯前端交互工具,无需数据库"
|
||||
|
||||
lark-cli apps +create --name "审批系统" --app-type full_stack \
|
||||
--description "部门审批系统,支持登录、提交申请、多级审批"
|
||||
|
||||
@@ -35,5 +38,5 @@ lark-cli apps +create --name "Demo" --app-type html --dry-run
|
||||
|
||||
创建后按用户路径继续:
|
||||
|
||||
- 本地应用开发(含 html 和 full_stack):读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
- 本地应用开发(含 html / frontend / full_stack):读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。
|
||||
- 云端 Agent 生成/迭代:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。
|
||||
|
||||
@@ -26,7 +26,7 @@ lark-cli apps +get --app-id app_xxx -q '.data.app.app_type'
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app_id` | string | 应用唯一标识 |
|
||||
| `app_type` | string | 应用类型(如 HTML、FULL_STACK、MODERN_HTML) |
|
||||
| `app_type` | string | 应用类型(如 HTML、FRONTEND、FULL_STACK、MODERN_HTML) |
|
||||
| `name` | string | 应用显示名称 |
|
||||
| `description` | string | 应用功能说明 |
|
||||
| `icon_url` | string | 应用图标 URL |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 支持 `--keyword` 按应用名模糊搜索。
|
||||
- `--ownership` 枚举:`all` / `mine` / `shared`(默认 `all` = 我创建的 + 共享给我的;`mine` = 仅我创建;`shared` = 仅共享给我)。
|
||||
- `--app-type` 枚举:`html` / `full_stack`。
|
||||
- `--app-type` 枚举:`html` / `frontend` / `full_stack`。
|
||||
- 分页:`--page-size` 默认 20,`--page-token` 传上一页 cursor。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# lark-apps 本地开发
|
||||
|
||||
适用:用户要把妙搭应用(full_stack 或 html)源码拉到本地,用本地 code agent/IDE 开发、调试数据库,再发布。
|
||||
适用:用户要把妙搭应用(full_stack、frontend 或 html)源码拉到本地,用本地 code agent/IDE 开发、再发布。其中调试数据库仅 full_stack 适用(frontend / html 无数据库)。
|
||||
|
||||
## 新建 vs 已有应用
|
||||
|
||||
@@ -36,6 +36,32 @@ git push origin sprint/default
|
||||
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
|
||||
```
|
||||
|
||||
### frontend
|
||||
|
||||
纯前端应用(vite-react,无数据库)。流程与 full_stack 基本一致——`+init` 装依赖、`npm run dev`、commit/push/release——差别是无 `+db-*` 调库步骤。后续需要数据库/后端能力时不在本地升级,按 SKILL.md「类型升级」引导到云端会话。
|
||||
|
||||
```bash
|
||||
# 新建 frontend 应用
|
||||
lark-cli apps +create --as user --name "JSON 格式化工具" --app-type frontend \
|
||||
--description "纯前端交互工具,无需数据库"
|
||||
|
||||
# 初始化本地仓库(--dir 取值见下方「领域规则」,勿照抄此处示例值)
|
||||
lark-cli apps +init --as user --app-id app_xxx --dir ./json-tool
|
||||
|
||||
# 进入仓库后按项目脚手架启动(vite-react)
|
||||
cd ./json-tool
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 开发完成后:提交本次改动 -> git push origin sprint/default -> +release-create
|
||||
git add <本次开发的文件>
|
||||
git commit -m "feat: ..."
|
||||
git push origin sprint/default
|
||||
lark-cli apps +release-create --as user --app-id app_xxx --branch sprint/default
|
||||
# 发布是异步的:用 +release-get 轮询到 status=finished 才算部署完成、拿到 online_url
|
||||
lark-cli apps +release-get --as user --app-id app_xxx --release-id <上一步返回的 release_id>
|
||||
```
|
||||
|
||||
### html
|
||||
|
||||
#### 首次开发(无 app,无代码)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 何时用
|
||||
|
||||
用于把应用的代码分支推进到发布流程(html 和 full_stack 统一走此入口)。
|
||||
用于把应用的代码分支推进到发布流程(html / frontend / full_stack 统一走此入口)。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ lark-cli calendar +agenda --as user
|
||||
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
|
||||
```
|
||||
|
||||
读取日程时同时返回 `description`(纯文本)和 `description_rich`(**Markdown** 富文本)两个字段:`description` 存纯文本,`description_rich` 存富文本;仅有纯文本描述时,`description_rich` 会用该纯文本兜底填充(两字段值相同)。创建/更新日程时只传 `description_rich`(Markdown)。
|
||||
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
|
||||
|
||||
### `+search-event` — 按关键词、时间范围和参会人搜索日程
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description-rich <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ` 到 `### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(直接粘贴裸链接,或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径` 或 `-`(stdin)读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。|
|
||||
| `--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 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
|
||||
> `--description-rich` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
|
||||
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
|
||||
| 步骤 | 命令 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description-rich ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description-rich ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||
|
||||
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
lark-cli calendar +update \
|
||||
--event-id "<EVENT_ID>" \
|
||||
--summary "产品评审" \
|
||||
--description-rich "评审需求范围、排期与风险" \
|
||||
--description "评审需求范围、排期与风险" \
|
||||
--start "2026-03-12T14:00+08:00" \
|
||||
--end "2026-03-12T15:00+08:00"
|
||||
|
||||
@@ -43,7 +43,7 @@ lark-cli calendar +update \
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description-rich <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL(裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。 |
|
||||
| `--start <time>` | 否 | 新开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
|
||||
| `--end <time>` | 否 | 新结束时间(ISO 8601)。更新日程时间时必须同时传 `--start` |
|
||||
| `--rrule <rrule>` | 否 | 新重复规则(RFC5545)。**不要使用 COUNT;如需限制次数,推算后转为 UNTIL** |
|
||||
@@ -52,13 +52,13 @@ lark-cli calendar +update \
|
||||
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
至少需要提供一个动作:`--summary`、`--description-rich`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
至少需要提供一个动作:`--summary`、`--description`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description-rich`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description-rich`(Markdown)。`--description`(纯文本)已废弃并从帮助中隐藏。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
|
||||
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--summary`、`--start`、`--end` 等日程字段。
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
|
||||
@@ -26,11 +26,16 @@
|
||||
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
>
|
||||
> **标题词 + 正文词联合搜索**:如果用户同时给出标题关键词和正文关键词,并要求同一资源同时满足两项条件,优先执行一条普通联合搜索:`lark-cli drive +search --query "标题词 正文词"`,并在同一条命令中叠加用户指定的 `--folder-tokens`、`--doc-types` 等过滤条件。不要把这种联合搜索拆成“标题搜索 + 正文搜索”后自行拼交集;也不要把 `--only-title` 或 `intitle:` 用作主候选路径。只有用户明确只查标题时,才使用 `--only-title` 或 `intitle:`。
|
||||
>
|
||||
> 用户要求最终返回 N 条时,N 是输出上限,不等于 `--page-size N`。逐页根据 `title` 和 `summary_highlighted` 保留同时满足两项条件的候选;有效候选不足 N 且 `has_more=true` 时,保持同一 query 和过滤条件,使用 `--page-token` 继续,最多检查 3 页。摘要不足以判断正文条件时,只对标题已匹配的候选串行读取正文,确认一个再处理下一个,找到 N 条后停止;不要并发拉取正文。检查 3 页后仍不足时,返回已确认结果并建议用户调整标题词、正文词或搜索范围,不要无界扫描。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|---|---|
|
||||
| 标题含某词且正文含某词,限定文件夹内最多 N 个结果(N 为最终输出上限;按上文规则分页筛选,勿作为 `--page-size`) | `lark-cli drive +search --query "标题词 正文词" --folder-tokens <FOLDER_TOKEN>` |
|
||||
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
|
||||
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
|
||||
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
|
||||
@@ -217,7 +222,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
|
||||
- **日历表达**("上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**:CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
|
||||
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
|
||||
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
|
||||
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"才继续。单轮翻页上限 5 页。
|
||||
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"时继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
|
||||
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
|
||||
|
||||
## 权限
|
||||
|
||||
@@ -49,6 +49,17 @@ ROUNDTRIP_SXSD_ATTRS = {
|
||||
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
|
||||
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
||||
DEFAULT_TABLE_ROW_HEIGHT = 37
|
||||
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
|
||||
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
|
||||
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
|
||||
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
|
||||
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
|
||||
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
|
||||
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
|
||||
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
|
||||
# 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
|
||||
@@ -106,6 +117,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | 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 extract_color_alpha(color: str | None) -> int | float | None:
|
||||
if color is None:
|
||||
return None
|
||||
normalized = re.sub(r"\s+", "", color).lower()
|
||||
if normalized == "transparent":
|
||||
return 0
|
||||
rgba_match = re.fullmatch(
|
||||
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
|
||||
normalized,
|
||||
)
|
||||
if rgba_match is None:
|
||||
return None
|
||||
try:
|
||||
alpha = float(rgba_match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return int(alpha) if alpha.is_integer() else alpha
|
||||
|
||||
|
||||
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
|
||||
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
|
||||
color_alpha = extract_color_alpha(text_color)
|
||||
if not isinstance(color_alpha, (int, float)):
|
||||
return base_alpha
|
||||
return base_alpha * color_alpha
|
||||
|
||||
|
||||
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
|
||||
for tag_name in style_tags:
|
||||
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
|
||||
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
|
||||
if extract_bool_attribute(attrs, attr_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sum_sizes(sizes: list[int | float]) -> int | float:
|
||||
return sum(sizes)
|
||||
|
||||
@@ -218,6 +275,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
|
||||
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
|
||||
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
|
||||
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
|
||||
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
|
||||
}
|
||||
)
|
||||
return paragraphs
|
||||
@@ -694,6 +752,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = extract_numeric_attribute(attrs, "fontSize")
|
||||
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
|
||||
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
|
||||
bold = (
|
||||
extract_bool_attribute(content_attrs, "bold")
|
||||
or extract_bool_attribute(attrs, "bold")
|
||||
or detect_inline_style_presence(content, {"strong", "b"})
|
||||
or detect_any_span_bool_attribute(content, "bold")
|
||||
)
|
||||
italic = (
|
||||
extract_bool_attribute(content_attrs, "italic")
|
||||
or extract_bool_attribute(attrs, "italic")
|
||||
or detect_inline_style_presence(content, {"i", "em"})
|
||||
or detect_any_span_bool_attribute(content, "italic")
|
||||
)
|
||||
element.update(
|
||||
{
|
||||
"textType": extract_attribute(content_attrs, "textType"),
|
||||
@@ -705,11 +777,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
|
||||
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
|
||||
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
|
||||
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
|
||||
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
|
||||
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
|
||||
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
|
||||
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
|
||||
"fontSize": font_size if font_size is not None else 16,
|
||||
"fontFamily": font_family or "",
|
||||
"color": text_color,
|
||||
"textAlpha": effective_text_alpha(alpha, text_color),
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"text": strip_xml_paragraphs(content),
|
||||
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
|
||||
}
|
||||
@@ -745,7 +823,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
|
||||
|
||||
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
|
||||
text_elements = [
|
||||
element
|
||||
for element in elements
|
||||
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
|
||||
]
|
||||
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
|
||||
for text_element in text_elements:
|
||||
for image_element in image_elements:
|
||||
@@ -782,22 +864,135 @@ def normalize_text_for_overlap(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def estimate_character_width(character: str, font_size: int | float) -> int | float:
|
||||
SERIF_FONT_PATTERNS = {
|
||||
"song", "songti", "simsun", "ming", "mincho",
|
||||
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
|
||||
"source han serif", "思源宋体", "宋体", "明体",
|
||||
}
|
||||
|
||||
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
|
||||
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
|
||||
|
||||
|
||||
def classify_font_family(font_family: str | None) -> str:
|
||||
if not font_family:
|
||||
return "sans"
|
||||
family_lower = font_family.lower()
|
||||
for marker in SANS_EXPLICIT_MARKERS:
|
||||
if marker in family_lower:
|
||||
return "sans"
|
||||
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
|
||||
for pattern in serif_keywords:
|
||||
if pattern in family_lower:
|
||||
return "serif"
|
||||
return "sans"
|
||||
|
||||
|
||||
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
|
||||
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
|
||||
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
|
||||
}
|
||||
|
||||
|
||||
def estimate_character_width(
|
||||
character: str,
|
||||
font_size: int | float,
|
||||
bold: bool = False,
|
||||
font_family: str | None = None,
|
||||
) -> int | float:
|
||||
bold_multiplier = 1.05 if bold else 1.0
|
||||
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
|
||||
return font_size * 0.33 * bold_multiplier
|
||||
ea_width = unicodedata.east_asian_width(character)
|
||||
if ea_width in {"F", "W"}:
|
||||
return font_size * bold_multiplier
|
||||
category = classify_font_family(font_family)
|
||||
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
|
||||
if character.isupper():
|
||||
return font_size * coeffs["upper"] * bold_multiplier
|
||||
if character.islower():
|
||||
return font_size * coeffs["lower"] * bold_multiplier
|
||||
if character.isdigit():
|
||||
return font_size * coeffs["digit"] * bold_multiplier
|
||||
return font_size * coeffs["punct"] * bold_multiplier
|
||||
|
||||
|
||||
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 estimate_text_width(
|
||||
text: str,
|
||||
font_size: int | float,
|
||||
letter_spacing: int | float = 0,
|
||||
bold: bool = False,
|
||||
font_family: str | None = None,
|
||||
) -> int | float:
|
||||
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
|
||||
return base + max(len(text) - 1, 0) * letter_spacing
|
||||
|
||||
|
||||
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")
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
value = element.get("letterSpacing")
|
||||
return value if isinstance(value, (int, float)) else 0
|
||||
|
||||
|
||||
def text_wrap_width_tolerance() -> int | float:
|
||||
return TEXT_WRAP_WIDTH_TOLERANCE_PX
|
||||
|
||||
|
||||
def text_height_overflow_tolerance() -> int | float:
|
||||
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
|
||||
|
||||
|
||||
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
|
||||
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
|
||||
|
||||
|
||||
def is_short_metric_text(text: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", text)
|
||||
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
|
||||
return False
|
||||
if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
|
||||
return True
|
||||
if re.search(r"[,.,+\-–—/%%]", compact) is None:
|
||||
return False
|
||||
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
|
||||
|
||||
|
||||
def is_single_line_visual_candidate(
|
||||
element: dict[str, Any],
|
||||
paragraph: dict[str, Any] | None,
|
||||
text: str,
|
||||
logical_width: int | float,
|
||||
effective_width: int | float,
|
||||
) -> bool:
|
||||
if "\n" in text or logical_width <= effective_width:
|
||||
return False
|
||||
if is_short_metric_text(text):
|
||||
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
|
||||
|
||||
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
|
||||
compact_len = len(re.sub(r"\s+", "", text))
|
||||
if text_align == "center" and compact_len <= 32:
|
||||
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
|
||||
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
|
||||
return False
|
||||
|
||||
|
||||
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
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]
|
||||
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
|
||||
return max(
|
||||
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
|
||||
or [1]
|
||||
)
|
||||
|
||||
|
||||
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
@@ -810,8 +1005,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
|
||||
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
|
||||
|
||||
|
||||
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
|
||||
def estimate_text_line_count_for_text(
|
||||
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
|
||||
) -> int:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
bold = element.get("bold", False)
|
||||
font_family = element.get("fontFamily", "")
|
||||
letter_spacing = resolve_letter_spacing(element, paragraph)
|
||||
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
|
||||
hard_lines = text.split("\n")
|
||||
if not text:
|
||||
return 0
|
||||
@@ -820,8 +1021,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
|
||||
if element.get("wrap") in {"false", "0"}:
|
||||
line_count += 1
|
||||
continue
|
||||
logical_width = max(estimate_text_width(hard_line, font_size), 1)
|
||||
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
|
||||
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
|
||||
effective_width = available_width + text_wrap_width_tolerance()
|
||||
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))
|
||||
return line_count
|
||||
|
||||
|
||||
@@ -831,7 +1036,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
|
||||
|
||||
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
line_spacing = line_spacing or "multiple:1.5"
|
||||
if line_spacing is None:
|
||||
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
|
||||
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
||||
if match is None:
|
||||
return None
|
||||
@@ -839,12 +1045,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
|
||||
return font_size * float(value) if spacing_type == "multiple" else float(value)
|
||||
|
||||
|
||||
def adjust_dense_body_line_height(
|
||||
element: dict[str, Any],
|
||||
line_spacing: str | None,
|
||||
line_height: int | float,
|
||||
paragraph_count: int,
|
||||
) -> int | float:
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
if paragraph_count < 4 or font_size > 14 or not line_spacing:
|
||||
return line_height
|
||||
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
|
||||
if match is None:
|
||||
return line_height
|
||||
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
|
||||
|
||||
|
||||
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
continue
|
||||
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
|
||||
if has_explicit_height_auto_fit(element):
|
||||
continue
|
||||
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
@@ -860,10 +1081,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
estimated_height = 0.0
|
||||
line_heights: list[int | float] = []
|
||||
for paragraph in paragraphs:
|
||||
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
|
||||
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
|
||||
if paragraph_line_count == 0:
|
||||
continue
|
||||
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
|
||||
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
|
||||
line_height = estimate_text_line_height(element, resolved_line_spacing)
|
||||
before_spacing = estimate_text_line_height(
|
||||
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
|
||||
)
|
||||
@@ -873,6 +1095,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
if line_height is None or before_spacing is None or after_spacing is None:
|
||||
line_count = 0
|
||||
break
|
||||
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
|
||||
first_line_height = font_size if line_count == 0 else line_height
|
||||
line_count += paragraph_line_count
|
||||
line_heights.append(line_height)
|
||||
@@ -883,12 +1106,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
continue
|
||||
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
|
||||
overflow = estimated_height - available_height
|
||||
if overflow <= 0:
|
||||
if overflow <= text_height_overflow_tolerance():
|
||||
continue
|
||||
|
||||
is_background = is_background_decorative_text(element, elements)
|
||||
if is_background:
|
||||
level = "info"
|
||||
else:
|
||||
level = "error" if overflow > 10 else "warning"
|
||||
message = (
|
||||
f'text shape {element["id"]} may overflow its own content box '
|
||||
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
||||
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
||||
)
|
||||
if is_background:
|
||||
message += " (likely background decoration: large font, low alpha, underneath other text)"
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"level": level,
|
||||
"code": "text_may_overflow_shape",
|
||||
"elements": [element["id"]],
|
||||
"line_count": line_count,
|
||||
@@ -896,11 +1131,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
"estimated_height": estimated_height,
|
||||
"available_height": available_height,
|
||||
"overflow": overflow,
|
||||
"message": (
|
||||
f'text shape {element["id"]} may overflow its own content box '
|
||||
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
|
||||
'consider setting content wrap="true" autoFit="normal-auto-fit"'
|
||||
),
|
||||
"message": message,
|
||||
"hint": (
|
||||
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
|
||||
"autoFit=\"normal-auto-fit\". "
|
||||
@@ -911,6 +1142,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
|
||||
return issues
|
||||
|
||||
|
||||
def is_background_decorative_text(
|
||||
element: dict[str, Any], elements: list[dict[str, Any]]
|
||||
) -> bool:
|
||||
if not is_ghost_text(element):
|
||||
return False
|
||||
for other in elements:
|
||||
if other is element:
|
||||
continue
|
||||
if not is_text_element(other) or not has_text_content(other):
|
||||
continue
|
||||
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
|
||||
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
|
||||
continue
|
||||
if other["order"] <= element["order"]:
|
||||
continue
|
||||
if intersects(element, other):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_ghost_text(element: dict[str, Any]) -> bool:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
return False
|
||||
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
|
||||
text_alpha = element.get("textAlpha", element.get("alpha", 1))
|
||||
if not isinstance(text_alpha, (int, float)):
|
||||
return False
|
||||
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
|
||||
return True
|
||||
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
|
||||
|
||||
|
||||
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
|
||||
return None
|
||||
@@ -1022,6 +1285,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
return False
|
||||
if not (has_text_content(left) and has_text_content(right)):
|
||||
return False
|
||||
if is_ghost_text(left) or is_ghost_text(right):
|
||||
return False
|
||||
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
|
||||
return False
|
||||
|
||||
@@ -1038,13 +1303,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
return False
|
||||
|
||||
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
|
||||
padding_left = source.get("paddingLeft", 0)
|
||||
padding_right = source.get("paddingRight", 0)
|
||||
available_width = max(source["width"] - padding_left - padding_right, 1)
|
||||
visual_width = estimate_text_max_line_width(source)
|
||||
overflow_width = visual_width - source["width"]
|
||||
min_overflow = max(font_size * 1.5, source["width"] * 0.08)
|
||||
overflow_width = visual_width - available_width
|
||||
min_overflow = max(font_size * 1.5, available_width * 0.08)
|
||||
if overflow_width < min_overflow:
|
||||
return False
|
||||
|
||||
intrusion_width = source["x"] + visual_width - target["x"]
|
||||
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
|
||||
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
|
||||
if intrusion_width < min_intrusion:
|
||||
return False
|
||||
@@ -1056,8 +1324,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
|
||||
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
|
||||
source, target = sorted([left, right], key=lambda element: element["x"])
|
||||
padding_left = source.get("paddingLeft", 0)
|
||||
visual_width = estimate_text_max_line_width(source)
|
||||
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
width = intersection_width(source_visual_bbox, target)
|
||||
height = intersection_height(source_visual_bbox, target)
|
||||
return {
|
||||
@@ -1072,6 +1341,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
return False
|
||||
if is_text_element(right) and not has_text_content(right):
|
||||
return False
|
||||
if is_ghost_text(left) or is_ghost_text(right):
|
||||
return False
|
||||
if is_template_text_stack(left, right):
|
||||
return False
|
||||
if is_text_element(left) and is_text_element(right):
|
||||
@@ -1118,6 +1389,8 @@ def should_report_whiteboard_overlap(
|
||||
) -> dict[str, Any] | None:
|
||||
if other is whiteboard or not intersects(whiteboard, other):
|
||||
return None
|
||||
if is_ghost_text(other):
|
||||
return None
|
||||
if contains(whiteboard, other):
|
||||
return None
|
||||
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
|
||||
@@ -1194,6 +1467,8 @@ def detect_whiteboard_external_overlaps(
|
||||
|
||||
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
||||
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
||||
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
||||
return bbox
|
||||
rotation = element["rotation"]
|
||||
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
||||
rotation = 0
|
||||
@@ -1219,7 +1494,14 @@ def detect_elements_out_of_canvas(
|
||||
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
for element in (
|
||||
element
|
||||
for element in elements
|
||||
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),
|
||||
@@ -1976,8 +2258,6 @@ def normalize_issue(
|
||||
elements_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
normalized = dict(issue)
|
||||
if normalized.get("level") == "info":
|
||||
normalized["level"] = "warning"
|
||||
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
|
||||
normalized["schema_version"] = "2.0"
|
||||
normalized["element_ids"] = element_ids
|
||||
@@ -2039,8 +2319,10 @@ def build_result(
|
||||
) -> dict[str, Any]:
|
||||
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
|
||||
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
|
||||
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
|
||||
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
|
||||
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
|
||||
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
|
||||
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
|
||||
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
|
||||
status = slide_status(all_errors, all_warnings)
|
||||
@@ -2053,6 +2335,7 @@ def build_result(
|
||||
"slide_count": len(slides),
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
"status": status,
|
||||
"release_ready": error_count == 0,
|
||||
"screenshot_review_required": warning_count > 0,
|
||||
@@ -2060,6 +2343,7 @@ def build_result(
|
||||
"document": {
|
||||
"errors": document_errors,
|
||||
"warnings": document_warnings,
|
||||
"infos": document_infos,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -2150,6 +2434,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
]
|
||||
errors = [issue for issue in issues if issue["level"] == "error"]
|
||||
warnings = [issue for issue in issues if issue["level"] == "warning"]
|
||||
infos = [issue for issue in issues if issue["level"] == "info"]
|
||||
slides.append(
|
||||
{
|
||||
"slide_number": slide_number,
|
||||
@@ -2157,6 +2442,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
"element_count": len(elements_by_id),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"infos": infos,
|
||||
"issues": issues,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -343,6 +343,26 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<chart topLeftX="80" topLeftY="80" width="300" height="160">
|
||||
<chartData>
|
||||
<chartField>
|
||||
<chartParsedValues>Africa</chartParsedValues>
|
||||
</chartField>
|
||||
</chartData>
|
||||
</chart>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
@@ -637,9 +657,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
|
||||
|
||||
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
|
||||
@@ -660,8 +681,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(issue["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
|
||||
|
||||
@@ -671,7 +692,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -685,20 +706,29 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
|
||||
self.assertEqual(issues[0]["elements"], ["overflowing"])
|
||||
self.assertEqual(issues[0]["line_count"], 4)
|
||||
self.assertEqual(issues[0]["estimated_height"], 110)
|
||||
self.assertEqual(issues[0]["available_height"], 80)
|
||||
self.assertEqual(issues[0]["overflow"], 30)
|
||||
self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
|
||||
overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
overflow_ids = {issue["elements"][0] for issue in overflow_issues}
|
||||
self.assertIn("overflowing", overflow_ids)
|
||||
self.assertNotIn("auto-fit", overflow_ids)
|
||||
self.assertNotIn("shape-auto-fit", overflow_ids)
|
||||
self.assertNotIn("fitting", overflow_ids)
|
||||
overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
|
||||
self.assertEqual(overflowing_issue["line_count"], 4)
|
||||
self.assertEqual(overflowing_issue["estimated_height"], 110)
|
||||
self.assertEqual(overflowing_issue["available_height"], 80)
|
||||
self.assertEqual(overflowing_issue["overflow"], 30)
|
||||
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
|
||||
|
||||
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -706,7 +736,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
|
||||
<content fontSize="20" lineSpacing="fixed:20">
|
||||
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -716,17 +746,525 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
)
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(issue["level"], "warning")
|
||||
self.assertEqual(issue["line_height"], 20)
|
||||
self.assertEqual(issue["estimated_height"], 60)
|
||||
self.assertEqual(issue["overflow"], 10)
|
||||
|
||||
def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
|
||||
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
|
||||
<p>第一段</p><p>第二段</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
|
||||
<content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
|
||||
<content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
|
||||
</shape>
|
||||
<shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
|
||||
<content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_reports_plain_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="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
|
||||
<content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</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"], ["plain-age"])
|
||||
|
||||
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
|
||||
<content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
|
||||
<p>参数服务器 (Parameter Server)</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
|
||||
<content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
|
||||
<p>全球半导体市场规模持续高速增长,AI驱动新一轮景气周期</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
|
||||
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
|
||||
<p>总体目标:</p>
|
||||
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
|
||||
<p>具体目标:</p>
|
||||
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
|
||||
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
|
||||
<p>构建分布式训练策略,64 GPU 加速比 > 50x</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
overflow_issues = [
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
]
|
||||
self.assertEqual(overflow_issues, [])
|
||||
|
||||
def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
|
||||
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
|
||||
<p>总体目标:</p>
|
||||
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
|
||||
<p>具体目标:</p>
|
||||
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
|
||||
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
|
||||
<p>构建分布式训练策略,64 GPU 加速比 > 50x</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"], ["dense-body"])
|
||||
|
||||
def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
|
||||
<content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
|
||||
<p>RISKS & CHALLENGES</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"], ["caption"])
|
||||
|
||||
def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
|
||||
<content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
|
||||
<p>MARKET INSIGHT · 市场洞察</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"], ["micro-caption"])
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
|
||||
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p>
|
||||
</content>
|
||||
</shape>
|
||||
<shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
|
||||
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
|
||||
<p>第一段</p><p>第二段</p><p>第三段</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(issues["just-warning"]["level"], "warning")
|
||||
self.assertEqual(issues["just-warning"]["overflow"], 10)
|
||||
self.assertEqual(issues["error-overflow"]["level"], "error")
|
||||
self.assertEqual(issues["error-overflow"]["overflow"], 30)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
|
||||
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
|
||||
</shape>
|
||||
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
|
||||
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = {
|
||||
issue["elements"][0]: issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape"
|
||||
}
|
||||
self.assertEqual(issues["bg-deco"]["level"], "info")
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 1)
|
||||
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:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
|
||||
<content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
|
||||
</shape>
|
||||
<shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
|
||||
<content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
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.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_content_color_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
|
||||
<content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
|
||||
</shape>
|
||||
<shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
|
||||
<content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
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.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_faint_medium_ghost_text_out_of_canvas_and_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
|
||||
<content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
|
||||
</shape>
|
||||
<shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
|
||||
<content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
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.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
|
||||
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
|
||||
</shape>
|
||||
<img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
|
||||
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
|
||||
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertNotIn("image_covers_text", codes)
|
||||
self.assertNotIn("bbox_overlap", codes)
|
||||
|
||||
def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_slide(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
|
||||
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
|
||||
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
|
||||
</shape>
|
||||
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
|
||||
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
""",
|
||||
1,
|
||||
)
|
||||
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:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
|
||||
<content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
|
||||
<content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])
|
||||
|
||||
def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
|
||||
<content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
|
||||
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
|
||||
</shape>
|
||||
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
|
||||
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = next(
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
|
||||
)
|
||||
self.assertEqual(issue["level"], "error")
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
|
||||
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = next(
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
|
||||
)
|
||||
self.assertEqual(issue["level"], "error")
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
|
||||
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
|
||||
</shape>
|
||||
<shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
|
||||
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = next(
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
|
||||
)
|
||||
self.assertEqual(issue["level"], "error")
|
||||
|
||||
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
|
||||
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
|
||||
</shape>
|
||||
<shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
|
||||
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = next(
|
||||
issue
|
||||
for issue in result["slides"][0]["issues"]
|
||||
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
|
||||
)
|
||||
self.assertEqual(issue["level"], "error")
|
||||
|
||||
def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="35">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5">
|
||||
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
|
||||
<p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
|
||||
</content>
|
||||
</shape>
|
||||
@@ -745,7 +1283,36 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(issues[0]["line_count"], 2)
|
||||
self.assertEqual(issues[0]["line_height"], 10)
|
||||
self.assertEqual(issues[0]["estimated_height"], 40)
|
||||
self.assertEqual(issues[0]["overflow"], 5)
|
||||
self.assertEqual(issues[0]["overflow"], 10)
|
||||
|
||||
def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
|
||||
</shape>
|
||||
<shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
|
||||
</shape>
|
||||
<shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
|
||||
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
|
||||
self.assertNotIn("baseline", overflow_ids)
|
||||
self.assertIn("content-spaced", overflow_ids)
|
||||
self.assertIn("paragraph-spaced", overflow_ids)
|
||||
by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
|
||||
self.assertEqual(by_id["content-spaced"]["line_count"], 2)
|
||||
self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
|
||||
self.assertEqual(by_id["content-spaced"]["overflow"], 20)
|
||||
self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
|
||||
|
||||
def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -753,7 +1320,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"第一行\n第二行\n第三行",
|
||||
)
|
||||
|
||||
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
|
||||
def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -771,9 +1338,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
|
||||
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
|
||||
elements = xml_text_overlap_lint.extract_elements(
|
||||
@@ -807,7 +1373,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(elements[1]["fontSize"], 28)
|
||||
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
|
||||
|
||||
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
|
||||
def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -819,10 +1385,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
|
||||
def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -835,13 +1400,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[issue["code"] for issue in result["slides"][0]["errors"]],
|
||||
["img_out_of_canvas", "img_out_of_canvas"],
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
|
||||
def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -853,10 +1414,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
|
||||
def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -871,17 +1431,16 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["error_count"], 3)
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
|
||||
[
|
||||
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
|
||||
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
|
||||
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
|
||||
def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -895,11 +1454,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["errors"][0]
|
||||
self.assertEqual(issue["code"], "line_out_of_canvas")
|
||||
self.assertEqual(issue["element_ids"], ["connector"])
|
||||
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
|
||||
self.assertEqual(issue["related_objects"][0]["kind"], "line")
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -922,13 +1478,13 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
|
||||
|
||||
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
|
||||
def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
|
||||
<shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
|
||||
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
|
||||
</data>
|
||||
</slide>
|
||||
@@ -936,12 +1492,54 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
|
||||
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
|
||||
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
|
||||
self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
|
||||
self.assertNotIn("rotated-image", issues_by_element)
|
||||
|
||||
def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
|
||||
issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
|
||||
[
|
||||
{"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
|
||||
{"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
|
||||
{
|
||||
"id": "text",
|
||||
"kind": "shape",
|
||||
"type": "text",
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"rotation": 0,
|
||||
},
|
||||
{
|
||||
"id": "rect",
|
||||
"kind": "shape",
|
||||
"type": "rect",
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"rotation": 45,
|
||||
},
|
||||
{"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
|
||||
{
|
||||
"id": "ellipse",
|
||||
"kind": "shape",
|
||||
"type": "ellipse",
|
||||
"x": 95,
|
||||
"y": 0,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"rotation": 0,
|
||||
},
|
||||
],
|
||||
100,
|
||||
100,
|
||||
)
|
||||
|
||||
self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
|
||||
self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
|
||||
|
||||
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -1107,8 +1705,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
)
|
||||
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "info")
|
||||
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
|
||||
@@ -1201,7 +1800,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
}
|
||||
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for name, (table_xml, expected_warning_count) in cases.items():
|
||||
for name, (table_xml, expected_info_count) in cases.items():
|
||||
with self.subTest(case=name):
|
||||
input_path = Path(temp_dir) / f"{name}.xml"
|
||||
input_path.write_text(
|
||||
@@ -1221,9 +1820,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
result = json.loads(completed.stdout)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], expected_info_count)
|
||||
self.assertTrue(
|
||||
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
|
||||
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
|
||||
result["slides"][0]["issues"],
|
||||
)
|
||||
|
||||
@@ -1268,8 +1868,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
|
||||
self.assertEqual(issue["level"], "warning")
|
||||
self.assertEqual(issue["level"], "info")
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 1)
|
||||
|
||||
|
||||
class XmlTextOverlapLintDensityTest(unittest.TestCase):
|
||||
@@ -1357,7 +1958,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
|
||||
"id": "sparse_container_content",
|
||||
})
|
||||
self.assertEqual(issue["measurement"]["container_area"], 151700)
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
|
||||
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
|
||||
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
|
||||
self.assertEqual(
|
||||
@@ -1842,9 +2443,9 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
|
||||
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
|
||||
# from extract_elements), not the fontSize=96 max-descendant value that
|
||||
# extract_density_elements computes for the same "left" element id.
|
||||
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
|
||||
self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
|
||||
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
|
||||
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
|
||||
self.assertEqual(issue["measurement"]["intersection_area"], 742.56)
|
||||
|
||||
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
|
||||
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
|
||||
|
||||
@@ -17,10 +17,9 @@ import (
|
||||
// converts Markdown <-> ClientVars.
|
||||
const richTextMarkdown = "见 [设计文档](https://bytedance.feishu.cn/docx/abc) 和 **重点**"
|
||||
|
||||
// TestCalendar_CreateDescriptionRichDryRun verifies that +create forwards the
|
||||
// rich-text payload as-is under the description_rich body field, and that even
|
||||
// when the deprecated --description is also supplied, the CLI sends only
|
||||
// description_rich (rich wins) and omits the plain description field — the
|
||||
// TestCalendar_CreateDescriptionRichDryRun verifies that +create treats
|
||||
// --description as Markdown rich text and forwards it as-is under the
|
||||
// description_rich body field, omitting the plain description field — the
|
||||
// service treats the two as mutually exclusive.
|
||||
func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -38,8 +37,7 @@ func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
|
||||
"--summary", "rich dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description", "plain fallback",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--description", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -54,10 +52,10 @@ func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
|
||||
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "plain description must not be sent; stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that when only
|
||||
// --description-rich is provided (no --description), +create omits the
|
||||
// description body field entirely. Sending an empty description would suppress
|
||||
// the server's plain-preview backfill and break first-load rendering.
|
||||
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that +create forwards
|
||||
// --description under description_rich and omits the plain description body
|
||||
// field entirely. Sending an empty description would suppress the server's
|
||||
// plain-preview backfill and break first-load rendering.
|
||||
func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
@@ -74,7 +72,7 @@ func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
|
||||
"--summary", "rich only dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--description", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -103,7 +101,7 @@ func TestCalendar_UpdateDescriptionRichDryRun(t *testing.T) {
|
||||
"calendar", "+update",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--event-id", "evt_dry",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--description", richTextMarkdown,
|
||||
"--notify=false",
|
||||
"--dry-run",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user