mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
12 Commits
feat/sessi
...
v1.0.78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 |
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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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