"
+ dry.GET("/open-apis/wiki/v2/spaces/get_node").
+ Desc("Resolve wiki node to its docx document before writing local resources").
+ Params(map[string]interface{}{"token": ref.Token})
+ }
+ apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
+ dry.PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
- Set("document_id", ref.Token)
+ Set("document_id", documentID)
+ dry = appendRemoteDocImageDownloadsDryRun(dry, resources)
+ return appendLocalDocResourcesDryRun(dry, documentID, resources)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
- apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
- body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
+ body, resources, err := buildUpdateBodyWithPreparedInput(runtime)
if err != nil {
return err
}
+ documentID := ref.Token
+ if len(resources) > 0 {
+ documentID, err = resolveDocxDocumentID(runtime, runtime.Str("doc"))
+ if err != nil {
+ return err
+ }
+ }
+ apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
+ if docsAPIOperationFailed(data) {
+ return runtime.OutPartialFailure(data, nil)
+ }
+ if err := finalizeLocalDocResources(runtime, documentID, data, resources); err != nil {
+ return err
+ }
runtime.OutRaw(data, nil)
return nil
}
diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go
index 5c4ebfb3c1..789915af58 100644
--- a/shortcuts/doc/helpers.go
+++ b/shortcuts/doc/helpers.go
@@ -76,7 +76,18 @@ func extractDocumentFragment(raw string) string {
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
// surfaces for support escalations even when the body omits it.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
- return runtime.CallAPITyped(method, apiPath, nil, body)
+ data, err := runtime.CallAPITyped(method, apiPath, nil, body)
+ if err != nil {
+ return data, err
+ }
+ if data == nil {
+ return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "document API returned an empty data object")
+ }
+ return data, nil
+}
+
+func docsAPIOperationFailed(data map[string]interface{}) bool {
+ return strings.EqualFold(strings.TrimSpace(common.GetString(data, "result")), "failed")
}
func docsSceneFromContext(ctx context.Context) string {
diff --git a/shortcuts/doc/html5_block_resources.go b/shortcuts/doc/html5_block_resources.go
index 21fbc01ee6..4ac884b839 100644
--- a/shortcuts/doc/html5_block_resources.go
+++ b/shortcuts/doc/html5_block_resources.go
@@ -49,8 +49,9 @@ type html5BlockReferenceEntry struct {
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
- Content string
- ReferenceMap map[string]interface{}
+ Content string
+ ReferenceMap map[string]interface{}
+ LocalResources []localDocResource
}
type html5BlockAttr struct {
@@ -68,27 +69,35 @@ type whiteboardStartTag struct {
SelfClosing bool
}
-func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
+func buildCreateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
- return body, nil
+ return body, nil, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
- return nil, err
+ return nil, nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
- return body, nil
+ return body, input.LocalResources, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
+ body, _, err := buildUpdateBodyWithPreparedInput(runtime)
+ return body, err
+}
+
+func buildUpdateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
- return nil, err
+ return nil, nil, err
+ }
+ if err := validateLocalDocResourceUpdateCommand(runtime.Str("command"), input.LocalResources); err != nil {
+ return nil, nil, err
}
if input.Content != "" {
body["content"] = input.Content
@@ -96,7 +105,7 @@ func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[s
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
- return body, nil
+ return body, input.LocalResources, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
@@ -119,17 +128,25 @@ func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2Wri
}
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
+ return prepareDocsV2WriteInputForFormat(runtime, runtime.Str("doc-format"), input)
+}
+
+func prepareDocsV2WriteInputForFormat(runtime *common.RuntimeContext, format string, input docsV2WriteInput) (docsV2WriteInput, error) {
refMap := cloneReferenceMapObject(input.ReferenceMap)
html5RefMap, err := html5ReferenceMapFromObject(refMap)
if err != nil {
return docsV2WriteInput{}, err
}
- content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
+ content, localResources, err := prepareLocalDocResources(runtime, format, input.Content)
+ if err != nil {
+ return docsV2WriteInput{}, err
+ }
+ content, err = prepareWhiteboardWriteContent(runtime, format, content)
if err != nil {
return docsV2WriteInput{}, err
}
- content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
+ content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, format, content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -138,8 +155,9 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
- Content: content,
- ReferenceMap: refMap,
+ Content: content,
+ ReferenceMap: refMap,
+ LocalResources: localResources,
}, nil
}
diff --git a/shortcuts/doc/internal/docxparse/block_catalog.go b/shortcuts/doc/internal/docxparse/block_catalog.go
new file mode 100644
index 0000000000..5ceddd5d94
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/block_catalog.go
@@ -0,0 +1,116 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+type tagLayout string
+
+type tagDefinition struct {
+ layout tagLayout
+ presentation bool
+}
+
+const (
+ layoutBlock tagLayout = "block"
+ layoutInline tagLayout = "inline"
+ layoutDual tagLayout = "dual"
+ layoutStructural tagLayout = "structural"
+ layoutCommand tagLayout = "command"
+)
+
+// blockCatalog is a profiling catalog, not an XML schema. Unknown elements
+// remain valid XML containers and are intentionally absent from block counts.
+var blockCatalog = map[string]tagDefinition{}
+
+func init() {
+ registerTags(layoutBlock,
+ "title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
+ "div", "ul", "ol", "li", "blockquote", "column", "thead", "tbody", "tfoot",
+ "tr", "hr", "source", "base_refer", "synced_reference", "isv", "view",
+ "synced-source", "readonly-block", "checkbox", "okr-objective", "okr-key-result",
+ "okr-progress", "task", "append",
+ )
+ registerPresentationTags(layoutBlock,
+ "grid", "table", "pre", "img", "bitable", "sheet", "mindnote", "whiteboard",
+ "html5-block", "figure", "callout", "chat_card", "okr", "poll", "agenda",
+ "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
+ "chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
+ "bookmark", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab",
+ )
+ registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
+ registerTags(layoutDual, "latex", "code")
+ registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
+ registerTags(layoutCommand,
+ "comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
+ "block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
+ "identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
+ "replace_content", "action", "content", "parameter", "generation", "block_id",
+ )
+}
+
+func registerTags(layout tagLayout, tags ...string) {
+ for _, tag := range tags {
+ blockCatalog[tag] = tagDefinition{layout: layout}
+ }
+}
+
+func registerPresentationTags(layout tagLayout, tags ...string) {
+ for _, tag := range tags {
+ blockCatalog[tag] = tagDefinition{layout: layout, presentation: true}
+ }
+}
+
+func layoutOf(tag string) tagLayout {
+ return blockCatalog[tag].layout
+}
+
+func isKnownTag(tag string) bool { _, ok := blockCatalog[tag]; return ok }
+
+// IsPresentationBlockType reports whether tag is both counted by the profile
+// and suitable for a Presentation Decision block plan. The centralized
+// catalog keeps planning policy independent from individual component fields.
+func IsPresentationBlockType(tag string) bool {
+ definition, ok := blockCatalog[tag]
+ profiled := definition.layout == layoutBlock || definition.layout == layoutDual
+ return ok && profiled && definition.presentation
+}
+
+var voidTags = map[string]bool{
+ "br": true,
+ "col": true,
+ "hr": true,
+ "img": true,
+ "source": true,
+ "sub-page": true,
+}
+
+func isVoidTag(tag string) bool { return voidTags[tag] }
+
+var preserveSpaceTags = map[string]bool{
+ "title": true, "h1": true, "h2": true, "h3": true, "h4": true,
+ "h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
+ "p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
+ "code": true, "li": true, "a": true, "span": true,
+}
+
+var strictPhrasingTags = map[string]bool{
+ "title": true, "span": true, "b": true, "em": true, "i": true,
+ "u": true, "del": true, "a": true,
+}
+
+var autoCloseTags = map[string]map[string]bool{
+ "li": {"li": true},
+ "tr": {"tr": true},
+ "td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
+ "th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
+ "tbody": {"tbody": true, "tfoot": true},
+ "thead": {"tbody": true, "tfoot": true},
+ "column": {"column": true},
+}
+
+func shouldAutoClose(openTag, nextTag string) bool {
+ if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
+ return true
+ }
+ return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
+}
diff --git a/shortcuts/doc/internal/docxparse/block_catalog_test.go b/shortcuts/doc/internal/docxparse/block_catalog_test.go
new file mode 100644
index 0000000000..6b78c14fcb
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/block_catalog_test.go
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import "testing"
+
+func TestIsPresentationBlockType(t *testing.T) {
+ tests := []struct {
+ tag string
+ want bool
+ }{
+ {tag: "img", want: true},
+ {tag: "whiteboard", want: true},
+ {tag: "html5-block", want: true},
+ {tag: "table", want: true},
+ {tag: "pre", want: true},
+ {tag: "p", want: false},
+ {tag: "h1", want: false},
+ {tag: "li", want: false},
+ {tag: "code", want: false},
+ {tag: "future-widget", want: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.tag, func(t *testing.T) {
+ if got := IsPresentationBlockType(test.tag); got != test.want {
+ t.Fatalf("IsPresentationBlockType(%q) = %v, want %v", test.tag, got, test.want)
+ }
+ })
+ }
+}
diff --git a/shortcuts/doc/internal/docxparse/errors.go b/shortcuts/doc/internal/docxparse/errors.go
new file mode 100644
index 0000000000..4a84140bd6
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/errors.go
@@ -0,0 +1,18 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import "fmt"
+
+// parseError is an internal parser-domain error. Command boundaries attach the
+// relevant flag name and convert it to the public errs.* contract.
+type parseError struct {
+ message string
+}
+
+func (e *parseError) Error() string { return e.message }
+
+func newParseError(format string, args ...any) error {
+ return &parseError{message: fmt.Sprintf(format, args...)}
+}
diff --git a/shortcuts/doc/internal/docxparse/model.go b/shortcuts/doc/internal/docxparse/model.go
new file mode 100644
index 0000000000..3364ae8e5b
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/model.go
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package docxparse parses LarkOpenCLI DocxXML into a small DOM for the docs
+// +script shortcut.
+package docxparse
+
+// Format is an accepted source document format.
+type Format string
+
+const (
+ FormatXML Format = "xml"
+)
+
+// ParseResult is the complete result returned by Parse.
+type ParseResult struct {
+ Format Format `json:"format"`
+ XML string `json:"xml"`
+ Profile Profile `json:"profile"`
+}
+
+type nodeType uint8
+
+const (
+ nodeText nodeType = iota
+ nodeElement
+)
+
+// Node is the internal DocxXML DOM representation.
+type Node struct {
+ typ nodeType
+ tag string
+ attrs map[string]string
+ children []*Node
+ text string
+ parent *Node
+}
+
+func newText(text string) *Node {
+ return &Node{typ: nodeText, text: text}
+}
+
+func newElement(tag string, attrs map[string]string) *Node {
+ return &Node{typ: nodeElement, tag: tag, attrs: attrs}
+}
+
+func (n *Node) addChild(child *Node) {
+ if n == nil || child == nil {
+ return
+ }
+ child.parent = n
+ n.children = append(n.children, child)
+}
diff --git a/shortcuts/doc/internal/docxparse/parse_test.go b/shortcuts/doc/internal/docxparse/parse_test.go
new file mode 100644
index 0000000000..70af352220
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/parse_test.go
@@ -0,0 +1,503 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestParseXMLBuildsBlockDistribution(t *testing.T) {
+ result, err := Parse(`TP
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != `TP
` {
+ t.Fatalf("XML = %q", result.XML)
+ }
+ if result.Profile.BlockCount != 5 {
+ t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
+ }
+ shares := map[string]BlockShare{}
+ for _, share := range result.Profile.Blocks {
+ shares[share.Type] = share
+ }
+ if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
+ t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
+ }
+ for _, typ := range []string{"title", "p", "ul"} {
+ if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
+ t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
+ }
+ }
+}
+
+func TestParseXMLRejectsInvalidInput(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ }{
+ {name: "missing closing tag", source: `one`},
+ {name: "mismatched closing tag", source: `x`},
+ {name: "closing void tag", source: `
`},
+ {name: "malformed block id", source: ``},
+ {name: "unterminated cdata", source: ``},
+ {name: "tag spacing", source: `< p>text< / p>`},
+ {name: "self closing slash spacing", source: ``},
+ {name: "unquoted attribute", source: `
text
`},
+ {name: "invalid entity", source: `one &unknown;
`},
+ {name: "invalid attribute entity", source: `
`},
+ {name: "bare attribute ampersand", source: `
`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := Parse(tt.source, FormatXML); err == nil {
+ t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
+ }
+ })
+ }
+}
+
+func TestParseXMLChecksSyntaxWithoutBusinessSchema(t *testing.T) {
+ source := `known
` +
+ `x` +
+ `orphan |
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+ for tag, want := range map[string]int{
+ "p": 1, "table": 1, "tr": 1, "img": 1, "task": 1, "whiteboard": 1,
+ } {
+ if got := blockCountForTest(result.Profile.Blocks, tag); got != want {
+ t.Errorf("%s blocks = %d, want %d; profile=%+v", tag, got, want, result.Profile)
+ }
+ }
+ if result.Profile.BlockCount != 6 {
+ t.Fatalf("profile = %+v, want six known blocks", result.Profile)
+ }
+ for _, tag := range []string{"extension", "td"} {
+ if got := blockCountForTest(result.Profile.Blocks, tag); got != 0 {
+ t.Errorf("%s blocks = %d, want 0", tag, got)
+ }
+ }
+}
+
+func TestParseCompatibleXMLRepairsMalformedXMLForProfile(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ blocks map[string]int
+ total int
+ }{
+ {
+ name: "missing closes and final bracket",
+ source: `标题one
` +
+ `尾声
one< / p>
`,
+ blocks: map[string]int{"p": 1, "img": 1},
+ total: 2,
+ },
+ {
+ name: "block interrupts inline nesting",
+ source: `x`,
+ blocks: map[string]int{"table": 1, "tr": 1},
+ total: 2,
+ },
+ {
+ name: "truncated cdata keeps later blocks",
+ source: `after
`,
+ blocks: map[string]int{"code": 1, "p": 1},
+ total: 2,
+ },
+ {
+ name: "legacy block id does not hide following content",
+ source: `x
`,
+ blocks: map[string]int{"p": 1},
+ total: 1,
+ },
+ {
+ name: "orphan close is ignored",
+ source: `x
`,
+ blocks: map[string]int{"h1": 1},
+ total: 1,
+ },
+ {
+ name: "unterminated comment resumes at later block",
+ source: `one
?>`
+ if got := normalizeCompatibleXMLInput(source); got != source {
+ t.Fatalf("normalizeCompatibleXMLInput() = %q, want protected source unchanged %q", got, source)
+ }
+ profile, err := ParseCompatibleXML(source)
+ if err != nil {
+ t.Fatalf("ParseCompatibleXML() error = %v", err)
+ }
+ if profile.BlockCount != 1 || blockCountForTest(profile.Blocks, "p") != 1 {
+ t.Fatalf("profile = %+v, want one paragraph", profile)
+ }
+}
+
+func TestCompatibleXMLNormalizationDoesNotRewriteAttributeText(t *testing.T) {
+ source := `visible
`
+ if got := normalizeCompatibleXMLInput(source); got != source {
+ t.Fatalf("normalizeCompatibleXMLInput() = %q, want attribute source unchanged %q", got, source)
+ }
+}
+
+func TestCompatibleBlockIDPatternsOnlyInspectCurrentToken(t *testing.T) {
+ source := `x
`
+ for _, expression := range []*regexp.Regexp{
+ compatibleBlockIDSelfClosing,
+ compatibleBlockIDWithClosing,
+ compatibleBlockIDOpen,
+ } {
+ if match := expression.FindStringIndex(source); match != nil {
+ t.Fatalf("legacy block_id expression scanned past the current token: match=%v", match)
+ }
+ }
+}
+
+func TestParseCompatibleXMLAcceptsLocalImagePath(t *testing.T) {
+ profile, err := ParseCompatibleXML(`Local image
`)
+ if err != nil {
+ t.Fatalf("ParseCompatibleXML() error = %v", err)
+ }
+ if profile.BlockCount != 2 || blockCountForTest(profile.Blocks, "img") != 1 {
+ t.Fatalf("profile = %+v, want one title and one img block", profile)
+ }
+}
+
+func TestParseCompatibleXMLDoesNotSupportLegacyQAImage(t *testing.T) {
+ profile, err := ParseCompatibleXML(``)
+ if err != nil {
+ t.Fatalf("ParseCompatibleXML() error = %v", err)
+ }
+ if blockCountForTest(profile.Blocks, "img") != 0 {
+ t.Fatalf("profile = %+v, legacy qa_image must not be converted to img", profile)
+ }
+}
+
+func TestParseCompatibleXMLCompatibilityKeepsGlobalSafetyErrorsFatal(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ }{
+ {name: "unsafe declaration", source: `text
`},
+ {name: "invalid utf8", source: string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'})},
+ {name: "XML control character", source: "before\x0bafter
"},
+ {name: "XML noncharacter", source: "before\ufffeafter
"},
+ {name: "excessive nesting", source: strings.Repeat("", MaxNestingDepth+1)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if _, err := ParseCompatibleXML(tt.source); err == nil {
+ t.Fatalf("ParseCompatibleXML(%q) succeeded, want safety error", tt.source)
+ }
+ })
+ }
+}
+
+func TestParseRejectsXML10ForbiddenCharactersInEveryTextLocation(t *testing.T) {
+ for _, source := range []string{
+ "before\x01after
",
+ "text
",
+ "",
+ } {
+ if _, err := Parse(source, FormatXML); err == nil {
+ t.Errorf("Parse(%q) succeeded, want XML 1.0 character error", source)
+ }
+ }
+}
+
+func TestParseCompatibleXMLDoesNotCountRawWhiteboardTagsAsBlocks(t *testing.T) {
+ profile, err := ParseCompatibleXML(`visible
`)
+ if err != nil {
+ t.Fatalf("ParseCompatibleXML() error = %v", err)
+ }
+ if profile.BlockCount != 2 || blockCountForTest(profile.Blocks, "whiteboard") != 1 || blockCountForTest(profile.Blocks, "p") != 1 {
+ t.Fatalf("profile = %+v, want only whiteboard and p blocks", profile)
+ }
+ if blockCountForTest(profile.Blocks, "img") != 0 {
+ t.Fatalf("profile = %+v, raw whiteboard image must not be counted", profile)
+ }
+}
+
+func TestParseXMLDoesNotNormalizeTagAliases(t *testing.T) {
+ source := `onetwo
known
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+ if result.Profile.BlockCount != 2 ||
+ blockCountForTest(result.Profile.Blocks, "p") != 1 ||
+ blockCountForTest(result.Profile.Blocks, "img") != 1 {
+ t.Fatalf("profile = %+v, want only canonical p and img blocks", result.Profile)
+ }
+}
+
+func TestParseXMLAcceptsArbitraryAttributesWithoutChangingInput(t *testing.T) {
+ source := `x
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+ if result.Profile.BlockCount != 3 {
+ t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
+ }
+}
+
+func TestParseCompatibleXMLCompatibilityAcceptsBareAmpersandsInAttributes(t *testing.T) {
+ source := `-1
`
+ profile, err := ParseCompatibleXML(source)
+ if err != nil {
+ t.Fatalf("ParseCompatibleXML() error = %v", err)
+ }
+ if profile.BlockCount != 1 || blockCountForTest(profile.Blocks, "img") != 1 {
+ t.Fatalf("profile = %+v, want one img block", profile)
+ }
+}
+
+func TestParseXMLPreservesValidCDATA(t *testing.T) {
+ source := ` d]]>`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+}
+
+func TestParseXMLAllowsDeclarationTextInsideCDATAAndComments(t *testing.T) {
+ source := `]]>
ok
`
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original %q", result.XML, source)
+ }
+}
+
+func TestParseXMLPreservesWordBoundaryAcrossNewline(t *testing.T) {
+ result, err := Parse("Hello\nworld
", FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != "Hello\nworld
" {
+ t.Fatalf("XML = %q, want source unchanged", result.XML)
+ }
+ if result.Profile.WordCount != 2 || result.Profile.CharCount != 10 {
+ t.Fatalf("profile = %+v, want word_count=2 char_count=10", result.Profile)
+ }
+}
+
+func TestParseXMLPreservesUTF8BOM(t *testing.T) {
+ source := "\uFEFFtext
"
+ result, err := Parse(source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.XML != source {
+ t.Fatalf("XML = %q, want original input", result.XML)
+ }
+}
+
+func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
+ result, err := Parse(`标题一个苹果是 an apple。
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != 10 || profile.CharCount != 15 {
+ t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
+ }
+ if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+}
+
+func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ words int
+ chars int
+ blocks int
+ english int
+ numbers int
+ han int
+ listItems int
+ }{
+ {
+ name: "english number and punctuation",
+ source: `Hello world 123.45。
`,
+ words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
+ },
+ {
+ name: "list and checkbox markers",
+ source: `完成`,
+ words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := Parse(tt.source, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
+ t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
+ }
+ if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+ if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
+ t.Fatalf("li count = %d, want %d", got, tt.listItems)
+ }
+ })
+ }
+}
+
+func TestTextProfileCountsNumericCodeLexeme(t *testing.T) {
+ result, err := Parse(`123
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.Profile.WordCount != 1 || result.Profile.Breakdown.NumberWords != 1 || result.Profile.Breakdown.Digits != 3 {
+ t.Fatalf("profile = %+v, want one numeric code word", result.Profile)
+ }
+}
+
+func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
+ result, err := Parse(`
Click here
`, FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ profile := result.Profile
+ if profile.WordCount != 5 || profile.CharCount != 20 {
+ t.Fatalf("profile = %+v, want word_count=5 char_count=20", profile)
+ }
+ if profile.Breakdown.EnglishWords != 4 || profile.Breakdown.HanChars != 1 {
+ t.Fatalf("breakdown = %+v", profile.Breakdown)
+ }
+}
+
+func TestListMarkersUseMarkerSegments(t *testing.T) {
+ nodes, err := parseXML(`- two
`)
+ if err != nil {
+ t.Fatalf("parseXML() error = %v", err)
+ }
+ markers := map[string]segmentKind{}
+ for _, segment := range extractSegments(nodes) {
+ if segment.text == "•" || segment.text == "1." {
+ markers[segment.text] = segment.kind
+ }
+ }
+ for _, marker := range []string{"•", "1."} {
+ if markers[marker] != segmentMarker {
+ t.Fatalf("marker %q kind = %v, want segmentMarker", marker, markers[marker])
+ }
+ }
+}
+
+func TestTextProfileHandlesLongASCIIWord(t *testing.T) {
+ word := strings.Repeat("a", 100_000)
+ result, err := Parse(""+word+"
", FormatXML)
+ if err != nil {
+ t.Fatalf("Parse() error = %v", err)
+ }
+ if result.Profile.WordCount != 1 || result.Profile.CharCount != len(word) {
+ t.Fatalf("profile = %+v", result.Profile)
+ }
+}
+
+func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
+ _, err := Parse(`]>&x;
`, FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
+ t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
+ }
+}
+
+func TestParseRejectsInvalidUTF8(t *testing.T) {
+ _, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
+ t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
+ }
+}
+
+func TestParseRejectsExcessiveNesting(t *testing.T) {
+ source := strings.Repeat("", MaxNestingDepth+1)
+ _, err := Parse(source, FormatXML)
+ if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
+ t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
+ }
+}
+
+func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
+ if _, err := Parse(`<<<text
`, FormatXML); err == nil {
+ t.Fatal("Parse() succeeded, want invalid XML token error")
+ }
+}
+
+func blockCountForTest(blocks []BlockShare, typ string) int {
+ for _, block := range blocks {
+ if block.Type == typ {
+ return block.Count
+ }
+ }
+ return 0
+}
diff --git a/shortcuts/doc/internal/docxparse/profile.go b/shortcuts/doc/internal/docxparse/profile.go
new file mode 100644
index 0000000000..878ec5e147
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/profile.go
@@ -0,0 +1,328 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+)
+
+// Profile describes LarkOpenCLI document structure and visible text without
+// requiring callers to inspect the full XML.
+type Profile struct {
+ WordCount int `json:"word_count"`
+ CharCount int `json:"char_count"`
+ Breakdown TextBreakdown `json:"breakdown"`
+ BlockCount int `json:"block_count"`
+ Blocks []BlockShare `json:"blocks"`
+}
+
+// BlockShare reports one LarkOpenCLI block type's count and share. Structural
+// and inline-only tags are intentionally excluded.
+type BlockShare struct {
+ Type string `json:"type"`
+ Count int `json:"count"`
+ Ratio float64 `json:"ratio"`
+}
+
+// TextProfile is the internal result of the LarkOpenCLI semantic counter.
+type TextProfile struct {
+ WordCount int `json:"word_count"`
+ CharCount int `json:"char_count"`
+ Breakdown TextBreakdown `json:"breakdown"`
+}
+
+type TextBreakdown struct {
+ HanChars int `json:"han_chars"`
+ EnglishWords int `json:"english_words"`
+ NumberWords int `json:"number_words"`
+ ChinesePunctuations int `json:"chinese_punctuations"`
+ EnglishLetters int `json:"english_letters"`
+ Digits int `json:"digits"`
+ EnglishPunctuations int `json:"english_punctuations"`
+ SymbolWords int `json:"symbol_words"`
+ SymbolChars int `json:"symbol_chars"`
+}
+
+// Parse checks XML syntax, then builds its structure and visible-text profile.
+// XML business semantics are deliberately left to the document writer and
+// service.
+func Parse(source string, format Format) (ParseResult, error) {
+ if format != FormatXML {
+ return ParseResult{}, newParseError("unsupported input format %q", format)
+ }
+ nodes, err := parseXML(source)
+ if err != nil {
+ return ParseResult{}, err
+ }
+ if err := validateNestingDepth(nodes); err != nil {
+ return ParseResult{}, err
+ }
+ return ParseResult{
+ Format: format,
+ XML: source,
+ Profile: buildProfile(nodes),
+ }, nil
+}
+
+// ParseCompatibleXML builds a profile after deterministic recovery of common
+// malformed XML emitted while authoring a draft.
+func ParseCompatibleXML(source string) (Profile, error) {
+ if err := validateSource(source); err != nil {
+ return Profile{}, err
+ }
+ trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
+ if !strings.HasPrefix(trimmed, "<") {
+ return Profile{}, newParseError("XML input must begin with '<'")
+ }
+ nodes, err := parseXMLCompatible(source)
+ if err != nil {
+ return Profile{}, err
+ }
+ return buildProfile(nodes), nil
+}
+
+func validateNestingDepth(nodes []*Node) error {
+ type frame struct {
+ node *Node
+ exit bool
+ }
+ frames := make([]frame, 0, len(nodes))
+ for i := len(nodes) - 1; i >= 0; i-- {
+ frames = append(frames, frame{node: nodes[i]})
+ }
+ depth := 0
+ for len(frames) > 0 {
+ current := frames[len(frames)-1]
+ frames = frames[:len(frames)-1]
+ node := current.node
+ if node == nil || node.typ != nodeElement {
+ continue
+ }
+ if current.exit {
+ depth--
+ continue
+ }
+ if depth >= MaxNestingDepth {
+ return newParseError("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
+ }
+ depth++
+ frames = append(frames, frame{node: node, exit: true})
+ for i := len(node.children) - 1; i >= 0; i-- {
+ frames = append(frames, frame{node: node.children[i]})
+ }
+ }
+ return nil
+}
+
+func buildProfile(nodes []*Node) Profile {
+ counts := map[string]int{}
+ total := 0
+ var walk func(*Node)
+ walk = func(node *Node) {
+ if node == nil || node.typ != nodeElement {
+ return
+ }
+ layout := layoutOf(node.tag)
+ isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
+ if isBlock {
+ counts[node.tag]++
+ total++
+ }
+ for _, child := range node.children {
+ walk(child)
+ }
+ }
+ for _, node := range nodes {
+ walk(node)
+ }
+
+ distribution := make([]BlockShare, 0, len(counts))
+ for typ, count := range counts {
+ ratio := 0.0
+ if total > 0 {
+ ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
+ }
+ distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
+ }
+ sort.Slice(distribution, func(i, j int) bool {
+ if distribution[i].Count != distribution[j].Count {
+ return distribution[i].Count > distribution[j].Count
+ }
+ return distribution[i].Type < distribution[j].Type
+ })
+ segments := extractSegments(nodes)
+ stats := newTextCounter().countSegments(segments)
+ return Profile{
+ WordCount: stats.WordCount,
+ CharCount: stats.CharCount,
+ Breakdown: stats.Breakdown,
+ BlockCount: total,
+ Blocks: distribution,
+ }
+}
+
+type segmentKind uint8
+
+const (
+ segmentText segmentKind = iota
+ segmentMarker
+ segmentCode
+)
+
+type textSegment struct {
+ text string
+ kind segmentKind
+}
+
+var ignoredResourceTags = map[string]bool{
+ "whiteboard": true, "sheet": true, "source": true, "chat_card": true,
+ "base_refer": true, "bitable": true, "synced_reference": true,
+ "poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
+ "okr": true, "html5-block": true,
+}
+
+var ignoredInlineTags = map[string]bool{
+ "button": true, "cite": true, "latex": true, "bookmark": true,
+}
+
+func extractSegments(nodes []*Node) []textSegment {
+ var segments []textSegment
+ for _, node := range nodes {
+ extractNodeSegments(node, &segments)
+ }
+ return segments
+}
+
+func extractNodeSegments(node *Node, segments *[]textSegment) {
+ if node == nil {
+ return
+ }
+ if node.typ == nodeText {
+ if strings.TrimSpace(node.text) != "" {
+ *segments = append(*segments, textSegment{text: node.text})
+ }
+ return
+ }
+ if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
+ return
+ }
+ if node.tag == "task" {
+ return
+ }
+ if node.tag == "synced-source" && len(node.children) == 0 {
+ return
+ }
+
+ switch node.tag {
+ case "ul", "ol":
+ sequence := 1
+ for _, child := range node.children {
+ if child.typ == nodeElement && child.tag == "li" {
+ if node.tag == "ul" {
+ *segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
+ } else {
+ marker := sequence
+ if raw := child.attrs["seq"]; raw != "" {
+ if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
+ sequence = marker
+ }
+ }
+ *segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker), kind: segmentMarker})
+ sequence++
+ }
+ }
+ extractNodeSegments(child, segments)
+ }
+ return
+ case "checkbox":
+ marker := "☐"
+ if node.attrs["done"] == "true" {
+ marker = "☑"
+ }
+ *segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
+ }
+
+ kind := segmentText
+ if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
+ kind = segmentCode
+ }
+ text := visibleInlineText(node)
+ if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
+ if node.tag == "img" {
+ text = node.attrs["caption"]
+ } else {
+ text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
+ }
+ }
+ if strings.TrimSpace(text) != "" {
+ *segments = append(*segments, textSegment{text: text, kind: kind})
+ }
+
+ for _, child := range node.children {
+ if child.typ != nodeElement || isInlineForExtraction(child.tag) {
+ continue
+ }
+ extractNodeSegments(child, segments)
+ }
+}
+
+func visibleInlineText(node *Node) string {
+ var out strings.Builder
+ var walk func(*Node)
+ walk = func(current *Node) {
+ if current.typ == nodeText {
+ out.WriteString(current.text)
+ return
+ }
+ if current != node && !isInlineForExtraction(current.tag) {
+ return
+ }
+ if ignoredInlineTags[current.tag] {
+ return
+ }
+ if current.tag == "br" {
+ out.WriteByte('\n')
+ return
+ }
+ before := out.Len()
+ for _, child := range current.children {
+ walk(child)
+ }
+ if current != node && out.Len() == before {
+ if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
+ out.WriteString(display)
+ }
+ }
+ }
+ for _, child := range node.children {
+ walk(child)
+ }
+ return out.String()
+}
+
+func hasBlockChildren(node *Node) bool {
+ for _, child := range node.children {
+ if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
+ return true
+ }
+ }
+ return false
+}
+
+func isInlineForExtraction(tag string) bool {
+ layout := layoutOf(tag)
+ return layout == layoutInline || layout == layoutDual
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/shortcuts/doc/internal/docxparse/wordcount.go b/shortcuts/doc/internal/docxparse/wordcount.go
new file mode 100644
index 0000000000..5494f3537e
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/wordcount.go
@@ -0,0 +1,352 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+// This file implements the LarkOpenCLI document text-counting contract.
+
+import (
+ "regexp"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "golang.org/x/text/width"
+)
+
+const chinesePunctuation = ",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥"
+const englishPunctuation = `!"#$%&'()*+,-./:;<=>?@[\]^_` + "`" + `{|}~`
+
+var (
+ urlToken = regexp.MustCompile(`^https?://[!-~]+`)
+ asciiCompoundToken = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+`)
+)
+
+type lexemeKind uint8
+
+const (
+ lexemeNone lexemeKind = iota
+ lexemeEnglish
+ lexemeNumber
+)
+
+type textCounter struct {
+ stats TextProfile
+ lexeme lexemeKind
+ lexemeHasDigit bool
+ symbolRunLength int
+ atBoundary bool
+}
+
+func newTextCounter() *textCounter {
+ return &textCounter{atBoundary: true}
+}
+
+func (c *textCounter) countSegments(segments []textSegment) TextProfile {
+ for _, segment := range segments {
+ c.endUnit()
+ c.atBoundary = true
+ switch segment.kind {
+ case segmentMarker:
+ c.writeMarker(segment.text)
+ case segmentCode:
+ c.writeCode(segment.text)
+ default:
+ c.write(segment.text)
+ }
+ c.endUnit()
+ c.atBoundary = true
+ }
+ c.endUnit()
+ return c.stats
+}
+
+func (c *textCounter) write(value string) {
+ for offset := 0; offset < len(value); {
+ if c.lexeme == lexemeNone && isASCIIAlphaNumericByte(value[offset]) {
+ if token := matchASCIICompound(value[offset:]); token != "" {
+ c.writeASCIICompound(token)
+ offset += len(token)
+ continue
+ }
+ }
+ r, size := utf8.DecodeRuneInString(value[offset:])
+ if r == '/' && isVisibleHanSeparator(value, offset, size) {
+ c.endUnit()
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.Breakdown.SymbolWords++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ offset += size
+ continue
+ }
+ c.writeRune(r)
+ offset += size
+ }
+}
+
+func isASCIIAlphaNumericByte(ch byte) bool {
+ return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9'
+}
+
+func (c *textCounter) writeMarker(value string) {
+ for _, r := range value {
+ if unicode.IsSpace(r) {
+ continue
+ }
+ c.endUnit()
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ }
+}
+
+func (c *textCounter) writeCode(value string) {
+ for _, r := range value {
+ c.writeCodeRune(r)
+ }
+}
+
+func (c *textCounter) writeCodeRune(r rune) {
+ if unicode.IsSpace(r) {
+ c.endUnit()
+ c.atBoundary = true
+ return
+ }
+ if unicode.Is(unicode.Han, r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.HanChars++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isASCIILetterRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
+ c.lexeme = lexemeEnglish
+ }
+ c.atBoundary = false
+ return
+ }
+ if isASCIIDigitRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ c.lexemeHasDigit = true
+ if c.lexeme == lexemeNone {
+ c.lexeme = lexemeNumber
+ }
+ c.atBoundary = false
+ return
+ }
+ if isChinesePunctuation(r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.ChinesePunctuations++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isEnglishPunctuation(r) {
+ keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-')
+ if !keepsLexeme {
+ hadLexeme := c.lexeme != lexemeNone
+ c.endLexeme()
+ if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
+ c.symbolRunLength++
+ }
+ }
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ if keepsLexeme {
+ c.atBoundary = false
+ }
+ return
+ }
+ if unicode.Is(unicode.Symbol, r) {
+ c.writeSymbol(r)
+ return
+ }
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.atBoundary = false
+}
+
+func (c *textCounter) writeRune(r rune) {
+ if unicode.IsSpace(r) {
+ c.endUnit()
+ c.atBoundary = true
+ return
+ }
+ if unicode.Is(unicode.Han, r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.HanChars++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isASCIILetterRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
+ c.lexeme = lexemeEnglish
+ }
+ c.atBoundary = false
+ return
+ }
+ if isASCIIDigitRune(r) {
+ c.endSymbolRun(false)
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ c.lexemeHasDigit = true
+ if c.lexeme == lexemeNone {
+ c.lexeme = lexemeNumber
+ }
+ c.atBoundary = false
+ return
+ }
+ if isChinesePunctuation(r) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.stats.Breakdown.ChinesePunctuations++
+ c.stats.WordCount++
+ c.stats.CharCount++
+ c.atBoundary = false
+ return
+ }
+ if isEnglishPunctuation(r) {
+ keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-' || c.lexemeHasDigit && r == '.') ||
+ c.lexeme == lexemeNumber && (r == '.' || r == ',' || r == '-')
+ if !keepsLexeme {
+ hadLexeme := c.lexeme != lexemeNone
+ c.endLexeme()
+ if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
+ c.symbolRunLength++
+ }
+ }
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ if keepsLexeme {
+ c.atBoundary = false
+ }
+ return
+ }
+ if unicode.Is(unicode.Symbol, r) {
+ c.writeSymbol(r)
+ return
+ }
+ c.endLexeme()
+ c.endSymbolRun(false)
+ c.atBoundary = false
+}
+
+func matchASCIICompound(value string) string {
+ if match := urlToken.FindString(value); match != "" {
+ return match
+ }
+ match := asciiCompoundToken.FindString(value)
+ if match == "" || !strings.ContainsAny(match, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
+ return ""
+ }
+ return match
+}
+
+func (c *textCounter) writeASCIICompound(token string) {
+ c.endUnit()
+ c.stats.Breakdown.EnglishWords++
+ c.stats.WordCount++
+ for _, r := range token {
+ switch {
+ case isASCIILetterRune(r):
+ c.stats.Breakdown.EnglishLetters++
+ c.stats.CharCount++
+ case isASCIIDigitRune(r):
+ c.stats.Breakdown.Digits++
+ c.stats.CharCount++
+ case isEnglishPunctuation(r):
+ c.stats.Breakdown.EnglishPunctuations++
+ c.stats.CharCount++
+ }
+ }
+ c.atBoundary = false
+}
+
+func (c *textCounter) writeSymbol(r rune) {
+ c.endLexeme()
+ c.endSymbolRun(false)
+ units := utf16Units(r)
+ c.stats.Breakdown.SymbolWords++
+ c.stats.Breakdown.SymbolChars += units
+ c.stats.WordCount++
+ c.stats.CharCount += units
+ c.atBoundary = false
+}
+
+func (c *textCounter) endUnit() {
+ c.endLexeme()
+ c.endSymbolRun(true)
+}
+
+func (c *textCounter) endLexeme() {
+ switch c.lexeme {
+ case lexemeEnglish:
+ c.stats.Breakdown.EnglishWords++
+ c.stats.WordCount++
+ case lexemeNumber:
+ c.stats.Breakdown.NumberWords++
+ c.stats.WordCount++
+ }
+ c.lexeme = lexemeNone
+ c.lexemeHasDigit = false
+}
+
+func (c *textCounter) endSymbolRun(countWord bool) {
+ if c.symbolRunLength > 0 && countWord {
+ c.stats.Breakdown.SymbolWords++
+ c.stats.WordCount++
+ }
+ if c.symbolRunLength > 0 {
+ c.atBoundary = false
+ }
+ c.symbolRunLength = 0
+}
+
+func isVisibleHanSeparator(value string, offset, size int) bool {
+ if offset == 0 || offset+size >= len(value) {
+ return false
+ }
+ previous, _ := utf8.DecodeLastRuneInString(value[:offset])
+ next, _ := utf8.DecodeRuneInString(value[offset+size:])
+ return unicode.Is(unicode.Han, previous) && unicode.Is(unicode.Han, next)
+}
+
+func isASCIILetterRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' }
+func isASCIIDigitRune(r rune) bool { return r >= '0' && r <= '9' }
+
+func isChinesePunctuation(r rune) bool {
+ if strings.ContainsRune(chinesePunctuation, r) {
+ return true
+ }
+ kind := width.LookupRune(r).Kind()
+ return unicode.Is(unicode.Punct, r) && (kind == width.EastAsianWide || kind == width.EastAsianFullwidth)
+}
+
+func isEnglishPunctuation(r rune) bool {
+ return r < utf8.RuneSelf && strings.ContainsRune(englishPunctuation, r)
+}
+
+func utf16Units(r rune) int {
+ if r > 0xffff {
+ return 2
+ }
+ return 1
+}
diff --git a/shortcuts/doc/internal/docxparse/xml.go b/shortcuts/doc/internal/docxparse/xml.go
new file mode 100644
index 0000000000..4adb7d33b5
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/xml.go
@@ -0,0 +1,797 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "html"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ MaxInputBytes = 20_000_000
+ MaxNestingDepth = 1024
+)
+
+func validateSource(source string) error {
+ if len(source) > MaxInputBytes {
+ return newParseError("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes)
+ }
+ if !utf8.ValidString(source) {
+ return newParseError("input must be valid UTF-8")
+ }
+ for offset, r := range source {
+ if !isXML10Character(r) {
+ return newParseError("input contains an XML 1.0 forbidden character U+%04X at byte %d", r, offset)
+ }
+ }
+ if containsForbiddenXMLDeclaration(source) {
+ return newParseError("XML input must not contain DOCTYPE or ENTITY declarations")
+ }
+ return nil
+}
+
+func isXML10Character(r rune) bool {
+ return r == '\t' || r == '\n' || r == '\r' ||
+ r >= 0x20 && r <= 0xD7FF ||
+ r >= 0xE000 && r <= 0xFFFD ||
+ r >= 0x10000 && r <= 0x10FFFF
+}
+
+func containsForbiddenXMLDeclaration(source string) bool {
+ for offset := 0; offset < len(source); {
+ relative := strings.IndexByte(source[offset:], '<')
+ if relative < 0 {
+ return false
+ }
+ start := offset + relative
+ switch {
+ case strings.HasPrefix(source[start:], ""); end >= 0 {
+ offset = start + 4 + end + len("-->")
+ continue
+ }
+ return false
+ case strings.HasPrefix(source[start:], ""); end >= 0 {
+ offset = start + len("")
+ continue
+ }
+ return false
+ case strings.HasPrefix(source[start:], ""):
+ if end := strings.Index(source[start+2:], "?>"); end >= 0 {
+ offset = start + 2 + end + len("?>")
+ continue
+ }
+ return false
+ }
+
+ position := start + 1
+ if position < len(source) && source[position] == '!' {
+ position++
+ for position < len(source) && isXMLSpace(source[position]) {
+ position++
+ }
+ for _, declaration := range []string{"DOCTYPE", "ENTITY"} {
+ end := position + len(declaration)
+ if end <= len(source) && strings.EqualFold(source[position:end], declaration) &&
+ (end == len(source) || !isTagNamePart(source[end])) {
+ return true
+ }
+ }
+ }
+ offset = start + 1
+ }
+ return false
+}
+
+func parseXML(source string) ([]*Node, error) {
+ return parseXMLWithCompatibility(source, false)
+}
+
+func parseXMLCompatible(source string) ([]*Node, error) {
+ return parseXMLWithCompatibility(source, true)
+}
+
+func parseXMLWithCompatibility(source string, compatible bool) ([]*Node, error) {
+ if err := validateSource(source); err != nil {
+ return nil, err
+ }
+ if compatible {
+ source = normalizeCompatibleXMLInput(source)
+ }
+ source = strings.TrimPrefix(source, "\uFEFF")
+
+ root := newElement("__fragment__", nil)
+ stack := []*Node{root}
+parseLoop:
+ for i := 0; i < len(source); {
+ lt := strings.IndexByte(source[i:], '<')
+ if lt < 0 {
+ if !compatible {
+ if err := validateXMLText(source[i:], i); err != nil {
+ return nil, err
+ }
+ }
+ appendText(stack[len(stack)-1], source[i:])
+ break
+ }
+ lt += i
+ if !compatible {
+ if err := validateXMLText(source[i:lt], i); err != nil {
+ return nil, err
+ }
+ }
+ appendText(stack[len(stack)-1], source[i:lt])
+
+ token, end, state := scanXMLToken(source, lt)
+ switch state {
+ case tokenComment, tokenProcessingInstruction:
+ i = end
+ continue
+ case tokenCDATA:
+ appendTextValue(stack[len(stack)-1], token.text)
+ i = end
+ continue
+ case tokenInvalid:
+ if !compatible {
+ return nil, newParseError("invalid XML token at byte %d", lt)
+ }
+ if strings.HasPrefix(source[lt:], ""); closeAt >= 0 {
+ i = lt + 4 + closeAt + len("-->")
+ continue
+ }
+ if nextRelative := strings.IndexByte(source[lt+4:], '<'); nextRelative >= 0 {
+ i = lt + 4 + nextRelative
+ continue
+ }
+ break parseLoop
+ }
+ if token.name == "" {
+ next := compatibleTokenBoundary(source, lt, end)
+ if closingTag, ok := scanCompatibleIncompleteClosingTag(source[lt:next]); ok {
+ stack = closeCompatibleStack(stack, closingTag)
+ i = next
+ continue
+ }
+ if openingTag, ok := scanCompatibleIncompleteOpeningTag(source[lt:next]); ok {
+ token = openingTag
+ end = next
+ break
+ }
+ if end > lt+1 {
+ appendTextValue(stack[len(stack)-1], source[lt:end])
+ i = end
+ } else {
+ appendTextValue(stack[len(stack)-1], "<")
+ i = lt + 1
+ }
+ continue
+ }
+ case tokenIncomplete:
+ if !compatible {
+ return nil, newParseError("unterminated XML tag at byte %d", lt)
+ }
+ switch {
+ case strings.HasPrefix(source[lt:], "= 0 {
+ i = lt + prefixLength + nextRelative
+ continue
+ }
+ break parseLoop
+ }
+ next := compatibleTokenBoundary(source, lt, end)
+ if closingTag, ok := scanCompatibleIncompleteClosingTag(source[lt:next]); ok {
+ stack = closeCompatibleStack(stack, closingTag)
+ i = next
+ continue
+ }
+ if openingTag, ok := scanCompatibleIncompleteOpeningTag(source[lt:next]); ok {
+ token = openingTag
+ end = next
+ break
+ }
+ appendTextValue(stack[len(stack)-1], source[lt:next])
+ i = next
+ continue
+ }
+
+ tag := token.name
+ if compatible && !isKnownTag(tag) {
+ i = end
+ continue
+ }
+ if token.spacingNormalized && !compatible {
+ return nil, newParseError("invalid whitespace in XML tag <%s> at byte %d", token.name, lt)
+ }
+ if compatible && stack[len(stack)-1].tag == "whiteboard" &&
+ !(token.closing && tag == "whiteboard") &&
+ !(!token.closing && tag == "br") {
+ i = end
+ continue
+ }
+
+ if token.closing {
+ if isVoidTag(tag) {
+ if compatible {
+ i = end
+ continue
+ }
+ return nil, newParseError("void tag <%s/> must not have a closing tag", tag)
+ }
+ if len(stack) == 1 {
+ if compatible {
+ i = end
+ continue
+ }
+ return nil, newParseError("unexpected closing tag %s> at byte %d", tag, lt)
+ }
+ open := stack[len(stack)-1].tag
+ if open != tag {
+ if compatible {
+ stack = closeCompatibleStack(stack, tag)
+ i = end
+ continue
+ }
+ return nil, newParseError("mismatched closing tag %s> at byte %d; expected %s>", tag, lt, open)
+ }
+ stack = stack[:len(stack)-1]
+ i = end
+ continue
+ }
+
+ if compatible && len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, tag) {
+ for len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, tag) {
+ stack = stack[:len(stack)-1]
+ }
+ }
+ node := newElement(tag, token.attrs)
+ stack[len(stack)-1].addChild(node)
+ if !token.selfClosing && !isVoidTag(tag) {
+ if len(stack) > MaxNestingDepth {
+ return nil, newParseError("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt)
+ }
+ stack = append(stack, node)
+ }
+ i = end
+ }
+
+ if len(stack) > 1 && !compatible {
+ return nil, newParseError("missing closing tag %s> at end of input", stack[len(stack)-1].tag)
+ }
+ normalizeParsedLineBreaks(root.children, false, false)
+ for _, child := range root.children {
+ child.parent = nil
+ }
+ return root.children, nil
+}
+
+func compatibleTokenBoundary(source string, start, scannedEnd int) int {
+ if nextRelative := strings.IndexByte(source[start+1:], '<'); nextRelative >= 0 {
+ return start + 1 + nextRelative
+ }
+ if scannedEnd > start {
+ return scannedEnd
+ }
+ return len(source)
+}
+
+func scanCompatibleIncompleteClosingTag(fragment string) (string, bool) {
+ position := 0
+ if position >= len(fragment) || fragment[position] != '<' {
+ return "", false
+ }
+ position++
+ for position < len(fragment) && isXMLSpace(fragment[position]) {
+ position++
+ }
+ if position >= len(fragment) || fragment[position] != '/' {
+ return "", false
+ }
+ position++
+ for position < len(fragment) && isXMLSpace(fragment[position]) {
+ position++
+ }
+ if position >= len(fragment) || !isTagNameStart(fragment[position]) {
+ return "", false
+ }
+ nameStart := position
+ position++
+ for position < len(fragment) && isTagNamePart(fragment[position]) {
+ position++
+ }
+ if strings.TrimSpace(fragment[position:]) != "" {
+ return "", false
+ }
+ tag := fragment[nameStart:position]
+ if !isKnownTag(tag) || isVoidTag(tag) {
+ return "", false
+ }
+ return tag, true
+}
+
+func scanCompatibleIncompleteOpeningTag(fragment string) (xmlToken, bool) {
+ fragment = strings.TrimRightFunc(fragment, unicode.IsSpace)
+ if fragment == "" || strings.HasSuffix(fragment, ">") {
+ return xmlToken{}, false
+ }
+ synthetic := fragment + ">"
+ token, end, state := scanXMLToken(synthetic, 0)
+ if end != len(synthetic) || token.name == "" || token.closing ||
+ state != tokenOK && state != tokenInvalid {
+ return xmlToken{}, false
+ }
+ return token, true
+}
+
+func closeCompatibleStack(stack []*Node, tag string) []*Node {
+ for i := len(stack) - 1; i > 0; i-- {
+ if stack[i].tag == tag {
+ return stack[:i]
+ }
+ }
+ return stack
+}
+
+// normalizeParsedLineBreaks removes formatting newlines from ordinary XML,
+// while source-bearing code/whiteboard blocks keep semantic
+// line breaks as explicit
nodes. str_replace pattern/replacement payloads
+// retain raw newlines because their string matching semantics depend on them.
+func normalizeParsedLineBreaks(nodes []*Node, sourceBlock, stringMutation bool) {
+ for _, node := range nodes {
+ if node == nil || node.typ != nodeElement {
+ continue
+ }
+ nextSourceBlock := sourceBlock || node.tag == "code" || node.tag == "whiteboard"
+ nextStringMutation := stringMutation || node.tag == "str_replace"
+ preserveRaw := nextStringMutation && (node.tag == "pattern" || node.tag == "replacement")
+ if node.tag == "code" || node.tag == "whiteboard" {
+ trimSourceBlockBoundaryNewlines(node.children)
+ }
+ children := make([]*Node, 0, len(node.children))
+ for _, child := range node.children {
+ if child.typ != nodeText || !strings.ContainsAny(child.text, "\r\n") {
+ children = append(children, child)
+ continue
+ }
+ switch {
+ case preserveRaw:
+ children = append(children, child)
+ case nextSourceBlock:
+ for _, replacement := range rawTextWithBreakNodes(child.text) {
+ replacement.parent = node
+ children = append(children, replacement)
+ }
+ default:
+ child.text = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(child.text)
+ if child.text != "" {
+ children = append(children, child)
+ }
+ }
+ }
+ node.children = children
+ normalizeParsedLineBreaks(node.children, nextSourceBlock, nextStringMutation)
+ }
+}
+
+func trimSourceBlockBoundaryNewlines(children []*Node) {
+ for _, child := range children {
+ if child.typ == nodeText {
+ child.text = strings.TrimLeft(child.text, "\r\n")
+ break
+ }
+ if child.typ == nodeElement {
+ break
+ }
+ }
+ for i := len(children) - 1; i >= 0; i-- {
+ child := children[i]
+ if child.typ == nodeText {
+ child.text = strings.TrimRight(child.text, "\r\n")
+ break
+ }
+ if child.typ == nodeElement {
+ break
+ }
+ }
+}
+
+func rawTextWithBreakNodes(content string) []*Node {
+ if content == "" {
+ return nil
+ }
+ var nodes []*Node
+ start := 0
+ for i := 0; i < len(content); i++ {
+ if content[i] != '\n' && content[i] != '\r' {
+ continue
+ }
+ if i > start {
+ nodes = append(nodes, newText(content[start:i]))
+ }
+ if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
+ i++
+ }
+ nodes = append(nodes, newElement("br", nil))
+ start = i + 1
+ }
+ if start < len(content) {
+ nodes = append(nodes, newText(content[start:]))
+ }
+ return nodes
+}
+
+type tokenState uint8
+
+const (
+ tokenOK tokenState = iota
+ tokenInvalid
+ tokenIncomplete
+ tokenComment
+ tokenProcessingInstruction
+ tokenCDATA
+)
+
+type xmlToken struct {
+ name string
+ attrs map[string]string
+ text string
+ closing bool
+ selfClosing bool
+ spacingNormalized bool
+}
+
+func scanXMLToken(source string, start int) (xmlToken, int, tokenState) {
+ if strings.HasPrefix(source[start:], ""); closeAt >= 0 {
+ contentEnd := contentStart + closeAt
+ return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+ if strings.HasPrefix(source[start:], ""); closeAt >= 0 {
+ if strings.Contains(source[start+4:start+4+closeAt], "--") {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ return xmlToken{}, start + 4 + closeAt + 3, tokenComment
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+ if strings.HasPrefix(source[start:], "") {
+ if closeAt := strings.Index(source[start+2:], "?>"); closeAt >= 0 {
+ return xmlToken{}, start + 2 + closeAt + 2, tokenProcessingInstruction
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+
+ quote := byte(0)
+ end := -1
+ for i := start + 1; i < len(source); i++ {
+ switch source[i] {
+ case '\'', '"':
+ if quote == 0 {
+ quote = source[i]
+ } else if quote == source[i] {
+ quote = 0
+ }
+ case '>':
+ if quote == 0 {
+ end = i + 1
+ i = len(source)
+ }
+ case '<':
+ // A second unquoted '<' cannot belong to the current XML tag.
+ // Stop here so a long sequence of invalid tag starts is scanned
+ // once instead of repeatedly searching to a distant '>'.
+ if quote == 0 {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ }
+ }
+ if end < 0 {
+ candidate := strings.TrimSpace(source[start+1:])
+ if candidate == "" || !isTagNameStart(candidate[0]) && candidate[0] != '/' {
+ return xmlToken{}, start + 1, tokenInvalid
+ }
+ return xmlToken{}, len(source), tokenIncomplete
+ }
+
+ body := source[start+1 : end-1]
+ if body == "" {
+ return xmlToken{}, end, tokenInvalid
+ }
+ token := xmlToken{}
+ position := 0
+ for position < len(body) && isXMLSpace(body[position]) {
+ position++
+ }
+ if position > 0 {
+ token.spacingNormalized = true
+ }
+ if position >= len(body) || body[position] == '!' {
+ return xmlToken{}, end, tokenInvalid
+ }
+ if body[position] == '/' {
+ token.closing = true
+ position++
+ spaceStart := position
+ for position < len(body) && isXMLSpace(body[position]) {
+ position++
+ }
+ if position > spaceStart {
+ token.spacingNormalized = true
+ }
+ }
+ if position >= len(body) || !isTagNameStart(body[position]) {
+ return xmlToken{}, end, tokenInvalid
+ }
+ nameStart := position
+ position++
+ for position < len(body) && isTagNamePart(body[position]) {
+ position++
+ }
+ token.name = body[nameStart:position]
+ rawRemainder := body[position:]
+ remainder := strings.TrimRightFunc(rawRemainder, unicode.IsSpace)
+ if token.closing {
+ if strings.TrimSpace(remainder) != "" {
+ return token, end, tokenInvalid
+ }
+ return token, end, tokenOK
+ }
+ if strings.HasSuffix(remainder, "/") {
+ token.selfClosing = true
+ if len(remainder) != len(rawRemainder) {
+ return token, end, tokenInvalid
+ }
+ remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace)
+ }
+ trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace)
+ if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) {
+ token.attrs = parseAttributes(remainder)
+ return token, end, tokenInvalid
+ }
+ var ok bool
+ token.attrs, ok = parseStrictAttributes(remainder)
+ if !ok {
+ token.attrs = parseAttributes(remainder)
+ return token, end, tokenInvalid
+ }
+ return token, end, tokenOK
+}
+
+func isXMLSpace(ch byte) bool {
+ return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
+}
+
+func isTagNameStart(ch byte) bool {
+ return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
+}
+
+func isTagNamePart(ch byte) bool {
+ return isTagNameStart(ch) || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.' || ch == ':'
+}
+
+func isAttributeNameStart(ch byte) bool {
+ return isTagNameStart(ch) || ch == '_' || ch == ':'
+}
+
+func parseAttributes(source string) map[string]string {
+ attrs := map[string]string{}
+ for i := 0; i < len(source); {
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ if i >= len(source) {
+ break
+ }
+ start := i
+ for i < len(source) && isAttributeNameByte(source[i]) {
+ i++
+ }
+ if start == i {
+ i++
+ continue
+ }
+ name := source[start:i]
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ value := ""
+ if i < len(source) && source[i] == '=' {
+ i++
+ for i < len(source) && unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ if i < len(source) && (source[i] == '\'' || source[i] == '"') {
+ quote := source[i]
+ i++
+ start = i
+ for i < len(source) && source[i] != quote {
+ i++
+ }
+ value = source[start:i]
+ if i < len(source) {
+ i++
+ }
+ } else {
+ start = i
+ for i < len(source) && !unicode.IsSpace(rune(source[i])) {
+ i++
+ }
+ value = source[start:i]
+ }
+ }
+ attrs[name] = html.UnescapeString(value)
+ }
+ if len(attrs) == 0 {
+ return nil
+ }
+ return attrs
+}
+
+// parseStrictAttributes implements the quoted attribute grammar accepted by
+// XML. parseAttributes remains intentionally permissive for compatibility
+// recovery of malformed authoring output.
+func parseStrictAttributes(source string) (map[string]string, bool) {
+ attrs := map[string]string{}
+ for i := 0; i < len(source); {
+ spaceStart := i
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) {
+ break
+ }
+ if i == spaceStart || !isAttributeNameStart(source[i]) {
+ return nil, false
+ }
+
+ nameStart := i
+ i++
+ for i < len(source) && isTagNamePart(source[i]) {
+ i++
+ }
+ name := source[nameStart:i]
+ if _, exists := attrs[name]; exists {
+ return nil, false
+ }
+
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) || source[i] != '=' {
+ return nil, false
+ }
+ i++
+ for i < len(source) && isXMLSpace(source[i]) {
+ i++
+ }
+ if i >= len(source) || (source[i] != '\'' && source[i] != '"') {
+ return nil, false
+ }
+
+ quote := source[i]
+ i++
+ valueStart := i
+ for i < len(source) && source[i] != quote {
+ if source[i] == '<' {
+ return nil, false
+ }
+ i++
+ }
+ if i >= len(source) {
+ return nil, false
+ }
+ rawValue := source[valueStart:i]
+ if invalidXMLEntityAt(rawValue) >= 0 {
+ return nil, false
+ }
+ attrs[name] = html.UnescapeString(rawValue)
+ i++
+ }
+ if len(attrs) == 0 {
+ return nil, true
+ }
+ return attrs, true
+}
+
+func isAttributeNameByte(ch byte) bool {
+ return ch > ' ' && ch != '=' && ch != '/' && ch != '>'
+}
+
+func appendText(parent *Node, raw string) {
+ if parent == nil || raw == "" {
+ return
+ }
+ appendTextValue(parent, html.UnescapeString(raw))
+}
+
+func appendTextValue(parent *Node, text string) {
+ if parent == nil || text == "" {
+ return
+ }
+ if strings.TrimSpace(text) == "" && !preserveSpaceTags[parent.tag] && parent.tag != "whiteboard" {
+ return
+ }
+ if count := len(parent.children); count > 0 && parent.children[count-1].typ == nodeText {
+ parent.children[count-1].text += text
+ return
+ }
+ parent.addChild(newText(text))
+}
+
+func validateXMLText(value string, absoluteOffset int) error {
+ if offset := strings.Index(value, "]]>"); offset >= 0 {
+ return newParseError("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset)
+ }
+ if offset := invalidXMLEntityAt(value); offset >= 0 {
+ return newParseError("invalid XML entity at byte %d", absoluteOffset+offset)
+ }
+ return nil
+}
+
+func invalidXMLEntityAt(value string) int {
+ for cursor := 0; cursor < len(value); {
+ relative := strings.IndexByte(value[cursor:], '&')
+ if relative < 0 {
+ return -1
+ }
+ start := cursor + relative
+ endRelative := strings.IndexByte(value[start+1:], ';')
+ if endRelative < 0 {
+ return start
+ }
+ end := start + 1 + endRelative
+ if !isValidXMLEntity(value[start+1 : end]) {
+ return start
+ }
+ cursor = end + 1
+ }
+ return -1
+}
+
+func isValidXMLEntity(entity string) bool {
+ switch entity {
+ case "amp", "lt", "gt", "quot", "apos":
+ return true
+ }
+
+ base := 10
+ digits := ""
+ switch {
+ case strings.HasPrefix(entity, "#x"):
+ base = 16
+ digits = entity[2:]
+ case strings.HasPrefix(entity, "#"):
+ digits = entity[1:]
+ default:
+ return false
+ }
+ if digits == "" {
+ return false
+ }
+ value, err := strconv.ParseUint(digits, base, 32)
+ if err != nil {
+ return false
+ }
+ r := rune(value)
+ return r == '\t' || r == '\n' || r == '\r' ||
+ r >= 0x20 && r <= 0xD7FF ||
+ r >= 0xE000 && r <= 0xFFFD ||
+ r >= 0x10000 && r <= utf8.MaxRune
+}
diff --git a/shortcuts/doc/internal/docxparse/xml_compat.go b/shortcuts/doc/internal/docxparse/xml_compat.go
new file mode 100644
index 0000000000..349f4ee8af
--- /dev/null
+++ b/shortcuts/doc/internal/docxparse/xml_compat.go
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package docxparse
+
+import (
+ "regexp"
+ "strings"
+)
+
+var (
+ compatibleBlockIDSelfClosing = regexp.MustCompile(`^`)
+ compatibleBlockIDWithClosing = regexp.MustCompile(`^\s*`)
+ compatibleBlockIDOpen = regexp.MustCompile(`^`)
+)
+
+// normalizeCompatibleXMLInput applies deterministic legacy-shape rewrites
+// before the tolerant parser builds its in-memory tree. The repaired XML is
+// intentionally not exposed or written back by docs +script parse.
+func normalizeCompatibleXMLInput(source string) string {
+ var out strings.Builder
+ out.Grow(len(source))
+ for offset := 0; offset < len(source); {
+ relative := strings.IndexByte(source[offset:], '<')
+ if relative < 0 {
+ out.WriteString(source[offset:])
+ break
+ }
+ start := offset + relative
+ out.WriteString(source[offset:start])
+
+ _, end, state := scanXMLToken(source, start)
+ if state == tokenComment || state == tokenCDATA || state == tokenProcessingInstruction {
+ out.WriteString(source[start:end])
+ offset = end
+ continue
+ }
+
+ if replacement, consumed, ok := rewriteCompatibleBlockID(source[start:]); ok {
+ out.WriteString(replacement)
+ offset = start + consumed
+ continue
+ }
+ if end > start+1 && (state == tokenOK || state == tokenInvalid) {
+ out.WriteString(source[start:end])
+ offset = end
+ continue
+ }
+
+ out.WriteByte(source[start])
+ offset = start + 1
+ }
+ return out.String()
+}
+
+func rewriteCompatibleBlockID(source string) (string, int, bool) {
+ for _, expression := range []*regexp.Regexp{
+ compatibleBlockIDSelfClosing,
+ compatibleBlockIDWithClosing,
+ compatibleBlockIDOpen,
+ } {
+ match := expression.FindStringSubmatchIndex(source)
+ if len(match) < 4 || match[0] != 0 {
+ continue
+ }
+ return `` + source[match[2]:match[3]] + ``, match[1], true
+ }
+ return "", 0, false
+}
diff --git a/shortcuts/doc/local_doc_resources.go b/shortcuts/doc/local_doc_resources.go
new file mode 100644
index 0000000000..da0ab5a04e
--- /dev/null
+++ b/shortcuts/doc/local_doc_resources.go
@@ -0,0 +1,2829 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "image"
+ "io"
+ "math"
+ "math/big"
+ "mime"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/validate"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const (
+ localDocResourceBindBatchSize = 20
+ localDocResourceCleanupBatchSize = 200
+ localDocResourceBindAttempts = 3
+ localDocResourceUploadAttempts = 3
+ remoteDocImageDownloadAttempts = 3
+ remoteDocImageUploadConcurrency = 10
+ localDocResourceUploadInterval = 220 * time.Millisecond
+ localDocResourceBindInterval = 350 * time.Millisecond
+ localDocResourceVerifyInterval = 220 * time.Millisecond
+ localDocResourceUploadConflictCode = 1061045
+ localDocResourceUploadRateLimitCode = 99991400
+ localDocImageMaxDisplayWidthPx = 1020
+ localDocImageScalePrecision = 1000000
+ remoteDocImageMaxBytes = int64(20 * 1024 * 1024)
+)
+
+var waitLocalDocResourceRequest = func(ctx context.Context, delay time.Duration) error {
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func localDocResourceRetryDelay(base time.Duration, attempt int) time.Duration {
+ delay := base * time.Duration(1<= fenceLen && markdownFenceCloses(line, char, run) {
+ fenceChar, fenceLen = 0, 0
+ }
+ continue
+ }
+ if indentedCode {
+ flush()
+ out.WriteString(line)
+ continue
+ }
+ segment.WriteString(line)
+ }
+ flush()
+ if rewriteErr != nil {
+ return "", nil, rewriteErr
+ }
+ result := out.String()
+ for _, inert := range inertSpans {
+ result = strings.ReplaceAll(result, inert.Protected, inert.Original)
+ }
+ return result, resources, nil
+}
+
+type protectedLocalDocResourceSpan struct {
+ Protected string
+ Original string
+}
+
+func protectMarkdownLocalDocResourceMarkup(content string) (string, []protectedLocalDocResourceSpan) {
+ var out strings.Builder
+ spans := make([]protectedLocalDocResourceSpan, 0)
+ nextToken := 0
+ for i := 0; i < len(content); {
+ end := -1
+ switch {
+ case strings.HasPrefix(content[i:], ""); offset >= 0 {
+ end = i + 4 + offset + 3
+ } else {
+ end = len(content)
+ }
+ case strings.HasPrefix(content[i:], ""); offset >= 0 {
+ end = i + 9 + offset + 3
+ } else {
+ end = len(content)
+ }
+ default:
+ if rawEnd, ok := findMarkdownRawHTMLInertEnd(content, i); ok {
+ end = rawEnd
+ }
+ }
+ if end < 0 {
+ out.WriteByte(content[i])
+ i++
+ continue
+ }
+
+ original := content[i:end]
+ protected := ""
+ for {
+ protected = fmt.Sprintf("\ue000lark_cli_inert_%d\ue001", nextToken)
+ nextToken++
+ if !strings.Contains(content, protected) {
+ break
+ }
+ }
+ protected += strings.Repeat("\n", strings.Count(original, "\n"))
+ out.WriteString(protected)
+ spans = append(spans, protectedLocalDocResourceSpan{Protected: protected, Original: original})
+ i = end
+ }
+ return out.String(), spans
+}
+
+func rewriteLocalDocResourceSegment(runtime *common.RuntimeContext, segment string, markdownMode bool, localRefs map[string]struct{}, resources *[]localDocResource) (string, error) {
+ var out strings.Builder
+ for i := 0; i < len(segment); {
+ if markdownMode {
+ if end, ok := findMarkdownRawHTMLInertEnd(segment, i); ok {
+ out.WriteString(segment[i:end])
+ i = end
+ continue
+ }
+ }
+ if strings.HasPrefix(segment[i:], "")
+ if end < 0 {
+ out.WriteString(segment[i:])
+ break
+ }
+ end += i + 7
+ out.WriteString(segment[i:end])
+ i = end
+ continue
+ }
+ if strings.HasPrefix(segment[i:], "")
+ if end < 0 {
+ out.WriteString(segment[i:])
+ break
+ }
+ end += i + 12
+ out.WriteString(segment[i:end])
+ i = end
+ continue
+ }
+ if markdownMode && segment[i] == '`' {
+ run := countByteRun(segment, i, '`')
+ end := findMatchingBacktickRun(segment, i+run, run)
+ if end < 0 {
+ out.WriteString(segment[i : i+run])
+ i += run
+ continue
+ }
+ out.WriteString(segment[i:end])
+ i = end
+ continue
+ }
+
+ if segment[i] == '<' && !(markdownMode && isEscapedMarkdownByte(segment, i)) {
+ name := localResourceTagNameAt(segment, i)
+ if name != "" {
+ end := findXMLStartTagEnd(segment, i)
+ if end < 0 {
+ return "", common.ValidationErrorf("invalid <%s> local resource tag", name).WithParam(name)
+ }
+ raw := segment[i:end]
+ rewritten, resource, changed, err := rewriteRawLocalResourceTag(runtime, raw, name, len(*resources)+1)
+ if err != nil {
+ return "", err
+ }
+ out.WriteString(rewritten)
+ if changed {
+ *resources = append(*resources, resource)
+ }
+ i = end
+ continue
+ }
+ }
+
+ if segment[i] == '!' && i+1 < len(segment) && segment[i+1] == '[' && !isEscapedMarkdownByte(segment, i) {
+ image, ok := parseMarkdownImageAt(segment, i)
+ if ok {
+ if image.ReferenceLabel != "" {
+ if _, local := localRefs[normalizeMarkdownReferenceLabel(image.ReferenceLabel)]; local {
+ return "", common.ValidationErrorf("local Markdown reference-style images are not supported; use  instead").WithParam("content")
+ }
+ out.WriteString(segment[i:image.End])
+ i = image.End
+ continue
+ }
+ if strings.HasPrefix(image.Destination, "@") {
+ resource, err := newLocalDocResource(runtime, localDocResourceImage, image.Destination, len(*resources)+1)
+ if err != nil {
+ return "", err
+ }
+ out.WriteString(`
")
+ *resources = append(*resources, resource)
+ i = image.End
+ continue
+ }
+ out.WriteString(segment[i:image.End])
+ i = image.End
+ continue
+ }
+ }
+
+ out.WriteByte(segment[i])
+ i++
+ }
+ return out.String(), nil
+}
+
+func rewriteRawLocalResourceTag(runtime *common.RuntimeContext, raw, name string, occurrence int) (string, localDocResource, bool, error) {
+ tag, err := parseLocalDocResourceTag(raw, name)
+ if err != nil {
+ return "", localDocResource{}, false, common.ValidationErrorf("invalid <%s> local resource tag: %v", name, err).WithParam(name).WithCause(err)
+ }
+ pathValue, hasPath := tag.attr("path")
+ if !hasPath {
+ if name != "img" {
+ return raw, localDocResource{}, false, nil
+ }
+ href, hasHref := tag.attr("href")
+ if !hasHref {
+ return raw, localDocResource{}, false, nil
+ }
+ conflicts := []string{"src", "token", "img_key", "img-key", "url"}
+ for _, conflict := range conflicts {
+ if tag.hasAttr(conflict) {
+ return "", localDocResource{}, false, common.ValidationErrorf("
href cannot be combined with %s", conflict).WithParam("img")
+ }
+ }
+ resource, err := newRemoteDocImageResource(href, occurrence)
+ if err != nil {
+ return "", localDocResource{}, false, err
+ }
+ tag.renameAttr("href", "path")
+ tag.setAttr("path", resource.Marker)
+ if _, hasCaption := tag.attr("caption"); !hasCaption {
+ tag.renameAttr("alt", "caption")
+ }
+ resource.RequestedImagePresentation = captureLocalDocImagePresentation(tag)
+ resource.captureImagePresentation(tag)
+ for _, name := range []string{"width", "height", "align", "scale"} {
+ tag.deleteAttr(name)
+ }
+ return tag.render(), resource, true, nil
+ }
+
+ conflicts := []string{"src", "href", "token", "img_key", "img-key", "url"}
+ for _, conflict := range conflicts {
+ if tag.hasAttr(conflict) {
+ return "", localDocResource{}, false, common.ValidationErrorf("<%s> local path cannot be combined with %s", name, conflict).WithParam(name)
+ }
+ }
+
+ kind := localDocResourceImage
+ if name == "source" {
+ kind = localDocResourceFile
+ }
+ resource, err := newLocalDocResource(runtime, kind, pathValue, occurrence)
+ if err != nil {
+ return "", localDocResource{}, false, err
+ }
+ tag.setAttr("path", resource.Marker)
+ if kind == localDocResourceImage {
+ if _, hasCaption := tag.attr("caption"); !hasCaption {
+ tag.renameAttr("alt", "caption")
+ }
+ if err := normalizeLocalDocImagePresentation(runtime, &tag, &resource); err != nil {
+ return "", localDocResource{}, false, localResourceValidationErrorWithCause(kind, occurrence, "file is not a supported BMP, GIF, JPEG, PNG, TIFF, or WebP image", err)
+ }
+ resource.captureImagePresentation(tag)
+ } else if rawName, hasName := tag.attr("name"); hasName {
+ fileName := strings.TrimSpace(rawName)
+ if fileName == "" || fileName == "." || fileName == ".." || strings.ContainsAny(fileName, `/\\`) {
+ return "", localDocResource{}, false, common.ValidationErrorf(" name must be a non-empty file name without path separators").WithParam("source")
+ }
+ resource.FileName = fileName
+ tag.setAttr("name", fileName)
+ }
+ return tag.render(), resource, true, nil
+}
+
+func newRemoteDocImageResource(rawURL string, occurrence int) (localDocResource, error) {
+ rawURL = strings.TrimSpace(rawURL)
+ u, err := url.Parse(rawURL)
+ if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || strings.TrimSpace(u.Hostname()) == "" || u.User != nil {
+ return localDocResource{}, errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "remote image #%d href must be an absolute HTTP(S) URL without userinfo",
+ occurrence,
+ ).WithParam("href")
+ }
+ marker, err := newLocalDocResourceMarker(localDocResourceImage)
+ if err != nil {
+ return localDocResource{}, errs.NewInternalError(errs.SubtypeUnknown, "failed to generate remote image marker").WithCause(err)
+ }
+ return localDocResource{
+ Occurrence: occurrence,
+ Kind: localDocResourceImage,
+ Marker: marker,
+ RemoteURL: u.String(),
+ }, nil
+}
+
+type remoteDocImageDownload struct {
+ Content []byte
+ FileName string
+ Width int
+ Height int
+}
+
+var (
+ downloadRemoteDocImage = downloadRemoteDocImageContent
+ doRemoteDocImageRequest = func(client remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return client.Do(req)
+ }
+)
+
+type remoteDocImageHTTPDoer interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+func downloadRemoteDocImageContent(runtime *common.RuntimeContext, rawURL string, occurrence int) (remoteDocImageDownload, error) {
+ if err := validateRemoteDocImageSource(runtime.Ctx(), rawURL, occurrence); err != nil {
+ return remoteDocImageDownload{}, err
+ }
+ baseClient, err := runtime.Factory.ExternalHTTPClient()
+ if err != nil {
+ return remoteDocImageDownload{}, errs.NewInternalError(errs.SubtypeSDKError, "initialize remote image HTTP client: %v", err).WithCause(err)
+ }
+ client := validate.NewDownloadHTTPClient(baseClient, validate.DownloadHTTPClientOptions{AllowHTTP: true, MaxRedirects: 5})
+ req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, rawURL, nil) //nolint:forbidigo // guarded download of user-provided image URL
+ if err != nil {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid remote image #%d href: %v", occurrence, err).WithParam("href").WithCause(err)
+ }
+ resp, err := doRemoteDocImageRequest(client, req)
+ if err != nil {
+ return remoteDocImageDownload{}, remoteDocImageNetworkError(err, occurrence, "request failed")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ subtype := errs.SubtypeNetworkTransport
+ retryable := false
+ switch {
+ case resp.StatusCode == http.StatusTooManyRequests:
+ subtype = errs.SubtypeRateLimit
+ retryable = true
+ case resp.StatusCode >= 500:
+ subtype = errs.SubtypeNetworkServer
+ retryable = true
+ }
+ httpErr := errs.NewNetworkError(subtype, "download remote image #%d failed: HTTP %d", occurrence, resp.StatusCode).WithCode(resp.StatusCode)
+ if retryable {
+ httpErr.WithRetryable()
+ }
+ return remoteDocImageDownload{}, httpErr
+ }
+ mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
+ if err != nil {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response has an invalid Content-Type", occurrence).WithParam("href").WithCause(err)
+ }
+ ext, ok := docCoverAllowedContentTypes[strings.ToLower(mediaType)]
+ if !ok {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response Content-Type %q is not supported", occurrence, mediaType).WithParam("href")
+ }
+ if resp.ContentLength > remoteDocImageMaxBytes {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href")
+ }
+
+ body := &remoteDocImageResponseReader{reader: resp.Body}
+ content, err := io.ReadAll(io.LimitReader(body, remoteDocImageMaxBytes+1))
+ if err != nil {
+ if body.err != nil {
+ return remoteDocImageDownload{}, remoteDocImageNetworkError(body.err, occurrence, "response body failed")
+ }
+ if contextErr := runtime.Ctx().Err(); contextErr != nil {
+ return remoteDocImageDownload{}, remoteDocImageNetworkError(contextErr, occurrence, "was interrupted")
+ }
+ return remoteDocImageDownload{}, remoteDocImageNetworkError(err, occurrence, "response body failed")
+ }
+ if len(content) == 0 {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response is empty", occurrence).WithParam("href")
+ }
+ if int64(len(content)) > remoteDocImageMaxBytes {
+ return remoteDocImageDownload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href")
+ }
+ config, detectedFormat, err := image.DecodeConfig(bytes.NewReader(content))
+ if err != nil {
+ return remoteDocImageDownload{}, errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "remote image #%d response body is not a valid %s image",
+ occurrence,
+ mediaType,
+ ).WithParam("href").WithCause(err)
+ }
+ expectedFormat := strings.TrimPrefix(strings.ToLower(mediaType), "image/")
+ if expectedFormat == "jpeg" && detectedFormat == "jpg" {
+ detectedFormat = "jpeg"
+ }
+ if detectedFormat != expectedFormat {
+ return remoteDocImageDownload{}, errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "remote image #%d response declared %s but contains %s image data",
+ occurrence,
+ mediaType,
+ detectedFormat,
+ ).WithParam("href")
+ }
+ return remoteDocImageDownload{
+ Content: content,
+ FileName: "image" + ext,
+ Width: config.Width,
+ Height: config.Height,
+ }, nil
+}
+
+type remoteDocImageResponseReader struct {
+ reader io.Reader
+ err error
+}
+
+func (r *remoteDocImageResponseReader) Read(p []byte) (int, error) {
+ n, err := r.reader.Read(p)
+ if err != nil && !errors.Is(err, io.EOF) {
+ r.err = err
+ }
+ return n, err
+}
+
+func remoteDocImageNetworkError(err error, occurrence int, action string) error {
+ if _, ok := errs.ProblemOf(err); ok {
+ return err
+ }
+ subtype := errs.SubtypeNetworkTransport
+ if errors.Is(err, context.DeadlineExceeded) {
+ subtype = errs.SubtypeNetworkTimeout
+ }
+ networkErr := errs.NewNetworkError(subtype, "download remote image #%d %s", occurrence, action).WithCause(err)
+ if !errors.Is(err, context.Canceled) {
+ networkErr.WithRetryable()
+ }
+ return networkErr
+}
+
+func validateRemoteDocImageSource(ctx context.Context, rawURL string, occurrence int) error {
+ if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument,
+ "remote image #%d href is not allowed: %v", occurrence, err).
+ WithParam("href").
+ WithHint("use a public HTTP(S) image URL, or save the image in the draft workspace and reference it with
").
+ WithCause(err)
+ }
+ return nil
+}
+
+func probeRemoteDocImageDownload(runtime *common.RuntimeContext, rawURL string, occurrence int) error {
+ if err := validateRemoteDocImageSource(runtime.Ctx(), rawURL, occurrence); err != nil {
+ return err
+ }
+ baseClient, err := runtime.Factory.ExternalHTTPClient()
+ if err != nil {
+ return errs.NewInternalError(errs.SubtypeSDKError, "initialize remote image HTTP client: %v", err).WithCause(err)
+ }
+ client := validate.NewDownloadHTTPClient(baseClient, validate.DownloadHTTPClientOptions{AllowHTTP: true, MaxRedirects: 5})
+ return probeRemoteDocImageRequest(runtime, client, rawURL, occurrence)
+}
+
+func probeRemoteDocImageRequest(runtime *common.RuntimeContext, client remoteDocImageHTTPDoer, rawURL string, occurrence int) error {
+ req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, rawURL, nil) //nolint:forbidigo // guarded probe of user-provided image URL
+ if err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid remote image #%d href: %v", occurrence, err).WithParam("href").WithCause(err)
+ }
+ req.Header.Set("Range", "bytes=0-0")
+ resp, err := doRemoteDocImageRequest(client, req)
+ if err != nil {
+ return remoteDocImageNetworkError(err, occurrence, "availability probe failed")
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return remoteDocImageHTTPStatusError(occurrence, resp.StatusCode, "probe")
+ }
+ if resp.ContentLength > remoteDocImageMaxBytes {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d exceeds 20MiB limit", occurrence).WithParam("href")
+ }
+ contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
+ if contentType == "" {
+ return nil
+ }
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response has an invalid Content-Type", occurrence).WithParam("href").WithCause(err)
+ }
+ if _, ok := docCoverAllowedContentTypes[strings.ToLower(mediaType)]; !ok {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "remote image #%d response Content-Type %q is not supported", occurrence, mediaType).WithParam("href")
+ }
+ return nil
+}
+
+func remoteDocImageHTTPStatusError(occurrence, statusCode int, action string) error {
+ subtype := errs.SubtypeNetworkTransport
+ retryable := false
+ switch {
+ case statusCode == http.StatusTooManyRequests:
+ subtype = errs.SubtypeRateLimit
+ retryable = true
+ case statusCode >= 500:
+ subtype = errs.SubtypeNetworkServer
+ retryable = true
+ }
+ httpErr := errs.NewNetworkError(subtype, "%s remote image #%d failed: HTTP %d", action, occurrence, statusCode).WithCode(statusCode)
+ if retryable {
+ httpErr.WithRetryable()
+ }
+ return httpErr
+}
+
+func applyRemoteDocImageDownload(resource *localDocResource, download remoteDocImageDownload) error {
+ if len(download.Content) == 0 || download.Width <= 0 || download.Height <= 0 {
+ return errs.NewInternalError(errs.SubtypeInvalidResponse, "remote image download is missing content or dimensions")
+ }
+ resource.FileName = download.FileName
+ resource.Size = int64(len(download.Content))
+ tag := resource.RequestedImagePresentation.tag()
+ normalizeDocImagePresentation(&tag, resource, download.Width, download.Height)
+ resource.ImageWidth = 0
+ resource.ImageHeight = 0
+ resource.ImageAlign = ""
+ resource.ImageScale = 0
+ resource.HasScale = false
+ resource.captureImagePresentation(tag)
+ return nil
+}
+
+func captureLocalDocImagePresentation(tag localDocResourceTag) localDocImagePresentation {
+ presentation := localDocImagePresentation{}
+ presentation.Width, _ = tag.attr("width")
+ presentation.Height, _ = tag.attr("height")
+ presentation.Align, _ = tag.attr("align")
+ presentation.Scale, _ = tag.attr("scale")
+ return presentation
+}
+
+func (p localDocImagePresentation) tag() localDocResourceTag {
+ tag := localDocResourceTag{Name: "img", SelfClosing: true}
+ for _, attr := range []struct {
+ name string
+ value string
+ }{
+ {name: "width", value: p.Width},
+ {name: "height", value: p.Height},
+ {name: "align", value: p.Align},
+ {name: "scale", value: p.Scale},
+ } {
+ if attr.value != "" {
+ tag.setAttr(attr.name, attr.value)
+ }
+ }
+ return tag
+}
+
+func (r *localDocResource) captureImagePresentation(tag localDocResourceTag) {
+ if raw, ok := tag.attr("width"); ok {
+ if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && value > 0 {
+ r.ImageWidth = value
+ }
+ }
+ if raw, ok := tag.attr("height"); ok {
+ if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && value > 0 {
+ r.ImageHeight = value
+ }
+ }
+ if raw, ok := tag.attr("align"); ok {
+ align := strings.ToLower(strings.TrimSpace(raw))
+ if _, supported := alignMap[align]; supported {
+ r.ImageAlign = align
+ }
+ }
+ if raw, ok := tag.attr("scale"); ok {
+ if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil && value > 0 {
+ r.ImageScale = value
+ r.HasScale = true
+ }
+ }
+}
+
+// normalizeLocalDocImagePresentation mirrors the SDK's network-image
+// normalization before the local path is replaced by an opaque marker. The
+// stored width/height are always the source image's intrinsic pixel
+// dimensions; model-provided display dimensions are converted to scale.
+func normalizeLocalDocImagePresentation(runtime *common.RuntimeContext, tag *localDocResourceTag, resource *localDocResource) error {
+ nativeWidth, nativeHeight, err := detectImageDimensionsFromPath(runtime.FileIO(), resource.Path)
+ if err != nil || nativeWidth <= 0 || nativeHeight <= 0 {
+ if err != nil {
+ return err
+ }
+ return invalidLocalDocImageDimensionsError()
+ }
+ normalizeDocImagePresentation(tag, resource, nativeWidth, nativeHeight)
+ return nil
+}
+
+func normalizeDocImagePresentation(tag *localDocResourceTag, resource *localDocResource, nativeWidth, nativeHeight int) {
+ modelScale, hasModelScale := positiveLocalDocImageFloatAttr(*tag, "scale")
+ modelWidth, hasModelWidth := positiveLocalDocImageDisplaySizeAttr(*tag, "width", nativeWidth)
+ modelHeight, hasModelHeight := positiveLocalDocImageDisplaySizeAttr(*tag, "height", nativeHeight)
+
+ tag.setAttr("width", strconv.Itoa(nativeWidth))
+ tag.setAttr("height", strconv.Itoa(nativeHeight))
+
+ scale, hasScale := resolveLocalDocImageScale(
+ nativeWidth,
+ nativeHeight,
+ modelScale,
+ hasModelScale,
+ modelWidth,
+ hasModelWidth,
+ modelHeight,
+ hasModelHeight,
+ )
+ if !hasScale {
+ tag.deleteAttr("scale")
+ return
+ }
+ tag.setAttr("scale", strconv.FormatFloat(scale, 'f', 6, 64))
+}
+
+func resolveLocalDocImageScale(nativeWidth, nativeHeight int, modelScale float64, hasModelScale bool, modelWidth float64, hasModelWidth bool, modelHeight float64, hasModelHeight bool) (float64, bool) {
+ var scale float64
+ switch {
+ case hasModelScale:
+ scale = modelScale
+ case hasModelWidth:
+ scale = modelWidth / float64(nativeWidth)
+ case hasModelHeight:
+ scale = modelHeight / float64(nativeHeight)
+ case nativeWidth >= localDocImageMaxDisplayWidthPx:
+ scale = 1
+ default:
+ return 0, false
+ }
+ scale = floorLocalDocImageScalePrecision(scale)
+ return capLocalDocImageScaleBelowPageWidth(nativeWidth, scale), true
+}
+
+func positiveLocalDocImageFloatAttr(tag localDocResourceTag, name string) (float64, bool) {
+ value, ok := tag.attr(name)
+ if !ok {
+ return 0, false
+ }
+ return positiveLocalDocImageFloat(strings.TrimSpace(value))
+}
+
+func positiveLocalDocImageDisplaySizeAttr(tag localDocResourceTag, name string, nativeSize int) (float64, bool) {
+ value, ok := tag.attr(name)
+ if !ok {
+ return 0, false
+ }
+ value = strings.TrimSpace(value)
+ if strings.HasSuffix(value, "%") {
+ percentage, valid := positiveLocalDocImageFloat(strings.TrimSpace(strings.TrimSuffix(value, "%")))
+ if !valid {
+ return 0, false
+ }
+ return float64(nativeSize) * percentage / 100, true
+ }
+ return positiveLocalDocImageFloat(value)
+}
+
+func positiveLocalDocImageFloat(value string) (float64, bool) {
+ if value == "" {
+ return 0, false
+ }
+ parsed, err := strconv.ParseFloat(value, 64)
+ if err != nil || parsed <= 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
+ return 0, false
+ }
+ return parsed, true
+}
+
+func floorLocalDocImageScalePrecision(scale float64) float64 {
+ floored := math.Floor(scale*localDocImageScalePrecision) / localDocImageScalePrecision
+ if floored <= 0 {
+ return scale
+ }
+ return floored
+}
+
+func capLocalDocImageScaleBelowPageWidth(nativeWidth int, scale float64) float64 {
+ maxScale := float64(localDocImageMaxDisplayWidthPx) / float64(nativeWidth)
+ if scale < maxScale {
+ return scale
+ }
+ capped := math.Floor(maxScale*localDocImageScalePrecision) / localDocImageScalePrecision
+ if capped >= maxScale {
+ capped -= 1.0 / localDocImageScalePrecision
+ }
+ if capped <= 0 {
+ return math.Nextafter(maxScale, 0)
+ }
+ return capped
+}
+
+func newLocalDocResource(runtime *common.RuntimeContext, kind localDocResourceKind, pathValue string, occurrence int) (localDocResource, error) {
+ pathValue = strings.TrimSpace(pathValue)
+ if !strings.HasPrefix(pathValue, "@") {
+ return localDocResource{}, localResourceValidationError(kind, occurrence, "path must start with @")
+ }
+ if isReservedLocalDocResourceMarker(pathValue) {
+ return localDocResource{}, localResourceValidationError(kind, occurrence, "path uses a reserved lark-cli marker")
+ }
+ relPath := strings.TrimSpace(strings.TrimPrefix(pathValue, "@"))
+ clean := filepath.Clean(relPath)
+ if relPath == "" || filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
+ return localDocResource{}, localResourceValidationError(kind, occurrence, "path must be a relative file inside the current working directory")
+ }
+
+ info, err := runtime.FileIO().Stat(clean)
+ if err != nil {
+ return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file does not exist or its path is unsafe", err)
+ }
+ if !info.Mode().IsRegular() {
+ return localDocResource{}, localResourceValidationError(kind, occurrence, "path must point to a regular file")
+ }
+ if info.Size() <= 0 {
+ return localDocResource{}, localResourceValidationError(kind, occurrence, "file must not be empty")
+ }
+ file, err := runtime.FileIO().Open(clean)
+ if err != nil {
+ return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file is not readable", err)
+ }
+ if err := file.Close(); err != nil {
+ return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file could not be closed after validation", err)
+ }
+ var imageWidth, imageHeight int
+ if kind == localDocResourceImage {
+ imageWidth, imageHeight, _, err = detectImageConfigFromPath(runtime.FileIO(), clean)
+ if err != nil || imageWidth <= 0 || imageHeight <= 0 {
+ if err == nil {
+ err = invalidLocalDocImageDimensionsError()
+ }
+ return localDocResource{}, localResourceValidationErrorWithCause(kind, occurrence, "file is not a supported BMP, GIF, JPEG, PNG, TIFF, or WebP image", err)
+ }
+ }
+
+ marker, err := newLocalDocResourceMarker(kind)
+ if err != nil {
+ return localDocResource{}, errs.NewInternalError(errs.SubtypeUnknown, "failed to generate local resource marker").WithCause(err)
+ }
+ return localDocResource{
+ Occurrence: occurrence,
+ Kind: kind,
+ Marker: marker,
+ Path: clean,
+ FileName: filepath.Base(clean),
+ Size: info.Size(),
+ ImageWidth: imageWidth,
+ ImageHeight: imageHeight,
+ }, nil
+}
+
+func invalidLocalDocImageDimensionsError() error {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "decoded image has invalid dimensions")
+}
+
+func newLocalDocResourceMarker(kind localDocResourceKind) (string, error) {
+ raw := make([]byte, 16)
+ if _, err := io.ReadFull(rand.Reader, raw); err != nil {
+ return "", err
+ }
+ prefix := "@lcli_img_"
+ if kind == localDocResourceFile {
+ prefix = "@lcli_file_"
+ }
+ return prefix + hex.EncodeToString(raw), nil
+}
+
+func localResourceValidationError(kind localDocResourceKind, occurrence int, reason string) error {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "local %s #%d: %s", kind, occurrence, reason).WithParam("path")
+}
+
+func localResourceValidationErrorWithCause(kind localDocResourceKind, occurrence int, reason string, cause error) error {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "local %s #%d: %s", kind, occurrence, reason).WithParam("path").WithCause(cause)
+}
+
+func validateLocalDocResourceUpdateCommand(command string, resources []localDocResource) error {
+ if len(resources) == 0 || command == "append" || command == "block_insert_after" || command == "block_replace" || command == "overwrite" {
+ return nil
+ }
+ return errs.NewValidationError(
+ errs.SubtypeInvalidArgument,
+ "local images and files are only supported with --command append, block_insert_after, block_replace, or overwrite",
+ ).WithParams(
+ errs.InvalidParam{Name: "--command", Reason: "use append, block_insert_after, block_replace, or overwrite for local resources"},
+ errs.InvalidParam{Name: "--content", Reason: "contains local image or file input"},
+ )
+}
+
+func finalizeLocalDocResources(runtime *common.RuntimeContext, documentKey string, data map[string]interface{}, resources []localDocResource) error {
+ if len(resources) == 0 {
+ return nil
+ }
+ if data == nil {
+ data = map[string]interface{}{}
+ }
+ outcomes := correlateLocalDocResources(data, resources)
+ if strings.TrimSpace(documentKey) == "" {
+ for _, outcome := range outcomes {
+ outcome.Status = "correlation_failed"
+ outcome.CleanupStatus = "skipped"
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "document response is missing document_id")
+ }
+ appendLocalDocResourceFailures(data, outcomes)
+ scrubLocalDocResourceResponseMarkers(data)
+ return runtime.OutPartialFailure(data, nil)
+ }
+
+ uploadLocalDocResources(runtime, documentKey, outcomes)
+ lastRevision := localDocResourceRevisionFromDocsAI(data)
+ revisionKnown := lastRevision != nil
+ bindRevision, bindRevisionKnown := bindLocalDocResources(runtime, documentKey, outcomes)
+ if bindRevision != nil {
+ lastRevision = bindRevision
+ revisionKnown = true
+ } else if !bindRevisionKnown {
+ lastRevision = nil
+ revisionKnown = false
+ }
+ cleanupRevision, cleanupRevisionKnown := cleanupLocalDocResourcePlaceholders(runtime, documentKey, outcomes, lastRevision)
+ if cleanupRevision != nil {
+ lastRevision = cleanupRevision
+ revisionKnown = true
+ } else if !cleanupRevisionKnown {
+ lastRevision = nil
+ revisionKnown = false
+ }
+ if revisionKnown && lastRevision != nil {
+ setLocalDocResourceRevision(data, lastRevision)
+ } else if !revisionKnown {
+ clearLocalDocResourceRevision(data)
+ }
+
+ failed := false
+ for _, outcome := range outcomes {
+ if outcome.Status == "bound" {
+ if outcome.Block != nil {
+ outcome.Block["block_token"] = outcome.FileToken
+ }
+ continue
+ }
+ failed = true
+ if outcome.Block != nil {
+ delete(outcome.Block, "block_token")
+ }
+ }
+ scrubLocalDocResourceResponseMarkers(data)
+ if !failed {
+ return nil
+ }
+ appendLocalDocResourceFailures(data, outcomes)
+ return runtime.OutPartialFailure(data, nil)
+}
+
+func appendLocalDocResourceFailures(data map[string]interface{}, outcomes []*localDocResourceOutcome) {
+ failures := make([]interface{}, 0)
+ for _, outcome := range outcomes {
+ if outcome == nil || outcome.Status == "bound" {
+ continue
+ }
+ failure := map[string]interface{}{
+ "occurrence": outcome.Resource.Occurrence,
+ "kind": outcome.Resource.Kind,
+ "status": outcome.Status,
+ "cleanup_status": outcome.CleanupStatus,
+ }
+ if problem, ok := errs.ProblemOf(outcome.Err); ok {
+ detail := map[string]interface{}{
+ "type": problem.Category,
+ "subtype": problem.Subtype,
+ }
+ if problem.Code != 0 {
+ detail["code"] = problem.Code
+ }
+ if problem.Retryable {
+ detail["retryable"] = true
+ }
+ failure["error"] = detail
+ }
+ if len(outcome.ServerWarnings) > 0 {
+ failure["server_warnings"] = outcome.ServerWarnings
+ }
+ failures = append(failures, failure)
+ }
+ if len(failures) > 0 {
+ data["local_resource_failures"] = failures
+ }
+}
+
+func scrubLocalDocResourceResponseMarkers(data map[string]interface{}) {
+ doc, _ := data["document"].(map[string]interface{})
+ for _, raw := range common.GetSlice(doc, "new_blocks") {
+ block, _ := raw.(map[string]interface{})
+ if block == nil {
+ continue
+ }
+ marker := strings.TrimSpace(common.GetString(block, "block_token"))
+ if isReservedLocalDocResourceMarker(marker) {
+ delete(block, "block_token")
+ }
+ }
+}
+
+func correlateLocalDocResources(data map[string]interface{}, resources []localDocResource) []*localDocResourceOutcome {
+ outcomes := make([]*localDocResourceOutcome, len(resources))
+ byMarker := make(map[string][]map[string]interface{}, len(resources))
+ expectedMarkers := make(map[string]struct{}, len(resources))
+ for _, resource := range resources {
+ expectedMarkers[resource.Marker] = struct{}{}
+ }
+ unknownBlocks := make([]map[string]interface{}, 0)
+ doc, _ := data["document"].(map[string]interface{})
+ for _, raw := range common.GetSlice(doc, "new_blocks") {
+ block, _ := raw.(map[string]interface{})
+ if block == nil {
+ continue
+ }
+ marker := strings.TrimSpace(common.GetString(block, "block_token"))
+ if marker == "" {
+ continue
+ }
+ if _, expected := expectedMarkers[marker]; expected {
+ byMarker[marker] = append(byMarker[marker], block)
+ } else if isReservedLocalDocResourceMarker(marker) {
+ unknownBlocks = append(unknownBlocks, block)
+ }
+ }
+
+ for i, resource := range resources {
+ outcome := &localDocResourceOutcome{
+ Resource: resource,
+ Status: "pending",
+ CleanupStatus: "not_needed",
+ }
+ matches := byMarker[resource.Marker]
+ if len(matches) != 1 {
+ outcome.Status = "correlation_failed"
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned %d blocks for local %s #%d; expected exactly one", len(matches), resource.Kind, resource.Occurrence)
+ allBlocksMatchKind := true
+ for _, block := range matches {
+ if !localDocResourceBlockMatchesKind(block, resource.Kind) {
+ allBlocksMatchKind = false
+ }
+ if id := strings.TrimSpace(common.GetString(block, "block_id")); id != "" {
+ outcome.CleanupBlockIDs = append(outcome.CleanupBlockIDs, id)
+ }
+ }
+ outcome.SafeToCleanup = len(outcome.CleanupBlockIDs) > 0 && allBlocksMatchKind
+ if !allBlocksMatchKind {
+ outcome.CleanupStatus = "skipped_ambiguous"
+ }
+ outcomes[i] = outcome
+ continue
+ }
+
+ block := matches[0]
+ blockID := strings.TrimSpace(common.GetString(block, "block_id"))
+ blockType := strings.TrimSpace(common.GetString(block, "block_type"))
+ outcome.Block = block
+ outcome.BlockID = blockID
+ blockMatchesKind := localDocResourceBlockMatchesKind(block, resource.Kind)
+ if blockID != "" && blockMatchesKind {
+ outcome.CleanupBlockIDs = []string{blockID}
+ outcome.SafeToCleanup = true
+ }
+ if blockID == "" || !blockMatchesKind {
+ outcome.Status = "correlation_failed"
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned an invalid block for local %s #%d", resource.Kind, resource.Occurrence)
+ if blockID != "" && blockType != string(resource.Kind) {
+ outcome.CleanupStatus = "skipped_ambiguous"
+ }
+ }
+ outcomes[i] = outcome
+ }
+ if len(unknownBlocks) > 0 {
+ for _, outcome := range outcomes {
+ outcome.Status = "correlation_failed"
+ outcome.CleanupStatus = "skipped_ambiguous"
+ outcome.SafeToCleanup = false
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK returned unrecognized local resource markers; no local uploads were started")
+ }
+ return outcomes
+ }
+ byBlockID := make(map[string][]*localDocResourceOutcome, len(outcomes))
+ for _, outcome := range outcomes {
+ if outcome.BlockID != "" {
+ byBlockID[outcome.BlockID] = append(byBlockID[outcome.BlockID], outcome)
+ }
+ }
+ for blockID, conflicts := range byBlockID {
+ if len(conflicts) < 2 {
+ continue
+ }
+ blockIDSafeToCleanup := true
+ for _, outcome := range conflicts {
+ if !outcome.SafeToCleanup {
+ blockIDSafeToCleanup = false
+ break
+ }
+ }
+ for i, outcome := range conflicts {
+ outcome.Status = "correlation_failed"
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "SDK correlated %d local resources to the same block_id", len(conflicts))
+ if i == 0 && blockIDSafeToCleanup {
+ found := false
+ for _, cleanupBlockID := range outcome.CleanupBlockIDs {
+ if cleanupBlockID == blockID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ outcome.CleanupBlockIDs = append(outcome.CleanupBlockIDs, blockID)
+ }
+ continue
+ }
+ outcome.CleanupBlockIDs = nil
+ outcome.SafeToCleanup = false
+ if blockIDSafeToCleanup {
+ outcome.CleanupStatus = "skipped_duplicate"
+ } else {
+ outcome.CleanupStatus = "skipped_ambiguous"
+ }
+ }
+ }
+ return outcomes
+}
+
+func uploadLocalDocResources(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) {
+ remoteOutcomes := make([]*localDocResourceOutcome, 0)
+ localOutcomes := make([]*localDocResourceOutcome, 0, len(outcomes))
+ for _, outcome := range outcomes {
+ if outcome.Status != "pending" {
+ continue
+ }
+ if outcome.Resource.RemoteURL != "" {
+ remoteOutcomes = append(remoteOutcomes, outcome)
+ continue
+ }
+ localOutcomes = append(localOutcomes, outcome)
+ }
+ uploadRemoteDocImages(runtime, documentKey, remoteOutcomes)
+ uploadLocalDocResourcesSerially(runtime, documentKey, localOutcomes)
+}
+
+func uploadRemoteDocImages(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) {
+ if len(outcomes) == 0 {
+ return
+ }
+ // Resolve credentials before fan-out so lazy credential-source selection
+ // completes on the caller goroutine. Upload requests still resolve the
+ // current token through the standard API path.
+ if _, err := runtime.AccessToken(); err != nil {
+ for _, outcome := range outcomes {
+ markLocalDocResourceUploadFailed(outcome, err)
+ }
+ return
+ }
+ workerCount := min(remoteDocImageUploadConcurrency, len(outcomes))
+ jobs := make(chan *localDocResourceOutcome)
+ done := make(chan struct{}, workerCount)
+ for range workerCount {
+ go func() {
+ defer func() { done <- struct{}{} }()
+ for outcome := range jobs {
+ uploadLocalDocResource(runtime, documentKey, outcome)
+ }
+ }()
+ }
+ for _, outcome := range outcomes {
+ jobs <- outcome
+ }
+ close(jobs)
+ for range workerCount {
+ <-done
+ }
+}
+
+func uploadLocalDocResourcesSerially(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) {
+ started := false
+ for _, outcome := range outcomes {
+ if started {
+ if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceUploadInterval); err != nil {
+ markLocalDocResourceUploadFailed(outcome, err)
+ continue
+ }
+ }
+ started = true
+ uploadLocalDocResource(runtime, documentKey, outcome)
+ }
+}
+
+func uploadLocalDocResource(runtime *common.RuntimeContext, documentKey string, outcome *localDocResourceOutcome) {
+ var content []byte
+ if outcome.Resource.RemoteURL != "" {
+ outcome.Resource.Content = nil
+ download, err := downloadRemoteDocImageWithRetry(runtime, outcome.Resource.RemoteURL, outcome.Resource.Occurrence)
+ if err != nil {
+ markLocalDocResourceUploadFailed(outcome, err)
+ return
+ }
+ if err := applyRemoteDocImageDownload(&outcome.Resource, download); err != nil {
+ markLocalDocResourceUploadFailed(outcome, err)
+ return
+ }
+ content = download.Content
+ outcome.Resource.Content = nil
+ defer func() {
+ content = nil
+ outcome.Resource.Content = nil
+ }()
+ } else if len(outcome.Resource.Content) > 0 {
+ content = outcome.Resource.Content
+ }
+
+ var uploadErr error
+ for attempt := 0; attempt < localDocResourceUploadAttempts; attempt++ {
+ upload := UploadDocMediaFileConfig{
+ FilePath: outcome.Resource.Path,
+ FileName: outcome.Resource.FileName,
+ FileSize: outcome.Resource.Size,
+ ParentType: parentTypeForMediaType(string(outcome.Resource.Kind)),
+ ParentNode: outcome.BlockID,
+ DocID: documentKey,
+ }
+ if len(content) > 0 {
+ upload.Reader = bytes.NewReader(content)
+ }
+ token, err := uploadDocMediaFile(runtime, upload)
+ if err == nil {
+ outcome.FileToken = token
+ outcome.Status = "uploaded"
+ return
+ }
+ uploadErr = err
+ if !isRetryableLocalDocResourceUpload(err) || attempt+1 >= localDocResourceUploadAttempts {
+ break
+ }
+ delay := localDocResourceRetryDelay(localDocResourceUploadInterval, attempt)
+ if waitErr := waitLocalDocResourceRequest(runtime.Ctx(), delay); waitErr != nil {
+ uploadErr = errors.Join(uploadErr, waitErr)
+ break
+ }
+ }
+ markLocalDocResourceUploadFailed(outcome, uploadErr)
+}
+
+func downloadRemoteDocImageWithRetry(runtime *common.RuntimeContext, rawURL string, occurrence int) (remoteDocImageDownload, error) {
+ var downloadErr error
+ for attempt := 0; attempt < remoteDocImageDownloadAttempts; attempt++ {
+ download, err := downloadRemoteDocImage(runtime, rawURL, occurrence)
+ if err == nil {
+ return download, nil
+ }
+ downloadErr = err
+ if !isRetryableLocalDocResourceNetwork(err) || attempt+1 >= remoteDocImageDownloadAttempts {
+ break
+ }
+ delay := localDocResourceRetryDelay(localDocResourceUploadInterval, attempt)
+ if waitErr := waitLocalDocResourceRequest(runtime.Ctx(), delay); waitErr != nil {
+ downloadErr = errors.Join(downloadErr, waitErr)
+ break
+ }
+ }
+ return remoteDocImageDownload{}, downloadErr
+}
+
+func markLocalDocResourceUploadFailed(outcome *localDocResourceOutcome, err error) {
+ outcome.Status = "upload_failed"
+ outcome.CleanupStatus = "pending"
+ outcome.Err = err
+}
+
+func isRetryableLocalDocResourceUpload(err error) bool {
+ if isRetryableLocalDocResourceNetwork(err) {
+ return true
+ }
+ problem, ok := errs.ProblemOf(err)
+ return ok && (problem.Code == localDocResourceUploadConflictCode || problem.Code == localDocResourceUploadRateLimitCode)
+}
+
+func isRetryableLocalDocResourceNetwork(err error) bool {
+ if errors.Is(err, context.Canceled) {
+ return false
+ }
+ if errs.IsRetryable(err) {
+ return true
+ }
+ problem, ok := errs.ProblemOf(err)
+ if !ok {
+ return false
+ }
+ if problem.Category != errs.CategoryNetwork {
+ return false
+ }
+ return problem.Subtype == errs.SubtypeNetworkTransport ||
+ problem.Subtype == errs.SubtypeNetworkTimeout ||
+ problem.Subtype == errs.SubtypeNetworkServer ||
+ problem.Subtype == errs.SubtypeRateLimit ||
+ problem.Code == http.StatusTooManyRequests ||
+ problem.Code >= http.StatusInternalServerError
+}
+
+func bindLocalDocResources(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome) (interface{}, bool) {
+ ready := make([]*localDocResourceOutcome, 0, len(outcomes))
+ for _, outcome := range outcomes {
+ if outcome.Status == "uploaded" {
+ ready = append(ready, outcome)
+ }
+ }
+ var lastRevision interface{}
+ revisionKnown := true
+ for start := 0; start < len(ready); start += localDocResourceBindBatchSize {
+ end := min(start+localDocResourceBindBatchSize, len(ready))
+ chunk := ready[start:end]
+ if start > 0 {
+ if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceBindInterval); err != nil {
+ markLocalDocResourceBindFailed(ready[start:], err)
+ break
+ }
+ }
+ revision, chunkRevisionKnown := bindLocalDocResourceChunk(runtime, documentKey, chunk)
+ if revision != nil {
+ lastRevision = revision
+ revisionKnown = true
+ } else if !chunkRevisionKnown {
+ lastRevision = nil
+ revisionKnown = false
+ }
+ }
+ return lastRevision, revisionKnown
+}
+
+func bindLocalDocResourceChunk(runtime *common.RuntimeContext, documentKey string, chunk []*localDocResourceOutcome) (interface{}, bool) {
+ clientToken := uuid.NewString()
+ body := buildLocalDocResourceBatchUpdate(chunk)
+ url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentKey))
+ var lastErr error
+ for attempt := 0; attempt < localDocResourceBindAttempts; attempt++ {
+ data, err := runtime.CallAPITyped("PATCH", url, map[string]interface{}{"client_token": clientToken}, body)
+ if err == nil {
+ for _, outcome := range chunk {
+ outcome.Status = "bound"
+ outcome.CleanupStatus = "not_needed"
+ outcome.SafeToCleanup = false
+ }
+ revision := localDocResourceRevisionFromBatch(data)
+ return revision, revision != nil
+ }
+ lastErr = err
+ allBound, hasConflict, hasUnknown := verifyLocalDocResourceChunk(runtime, documentKey, chunk, err)
+ if allBound {
+ return nil, false
+ }
+ if hasConflict || hasUnknown || !errs.IsRetryable(err) {
+ break
+ }
+ if attempt+1 < localDocResourceBindAttempts {
+ delay := localDocResourceBindInterval * time.Duration(1< 0 {
+ if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceVerifyInterval); err != nil {
+ outcome.Status = "bind_ambiguous"
+ outcome.CleanupStatus = "skipped_ambiguous"
+ outcome.SafeToCleanup = false
+ outcome.Err = errors.Join(bindErr, err)
+ allBound = false
+ hasUnknown = true
+ continue
+ }
+ }
+ data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(outcome.BlockID)), nil, nil)
+ if err != nil {
+ outcome.Status = "bind_ambiguous"
+ outcome.CleanupStatus = "skipped_ambiguous"
+ outcome.SafeToCleanup = false
+ outcome.Err = errors.Join(bindErr, err)
+ allBound = false
+ hasUnknown = true
+ continue
+ }
+ actualToken := localDocResourceBlockToken(common.GetMap(data, "block"), outcome.Resource.Kind)
+ switch actualToken {
+ case outcome.FileToken:
+ outcome.Status = "bound"
+ outcome.CleanupStatus = "not_needed"
+ outcome.SafeToCleanup = false
+ case "":
+ outcome.Status = "uploaded"
+ outcome.SafeToCleanup = true
+ allBound = false
+ default:
+ outcome.Status = "bind_conflict"
+ outcome.CleanupStatus = "skipped_conflict"
+ outcome.SafeToCleanup = false
+ outcome.Err = errs.NewInternalError(errs.SubtypeInvalidResponse, "local %s #%d block token changed unexpectedly; placeholder was preserved", outcome.Resource.Kind, outcome.Resource.Occurrence)
+ allBound = false
+ hasConflict = true
+ }
+ }
+ return allBound, hasConflict, hasUnknown
+}
+
+func buildLocalDocResourceBatchUpdate(chunk []*localDocResourceOutcome) map[string]interface{} {
+ requests := make([]interface{}, 0, len(chunk))
+ for _, outcome := range chunk {
+ request := map[string]interface{}{"block_id": outcome.BlockID}
+ if outcome.Resource.Kind == localDocResourceFile {
+ request["replace_file"] = map[string]interface{}{"token": outcome.FileToken}
+ } else {
+ replaceImage := map[string]interface{}{"token": outcome.FileToken}
+ if outcome.Resource.ImageWidth > 0 {
+ replaceImage["width"] = outcome.Resource.ImageWidth
+ }
+ if outcome.Resource.ImageHeight > 0 {
+ replaceImage["height"] = outcome.Resource.ImageHeight
+ }
+ if align, ok := alignMap[outcome.Resource.ImageAlign]; ok {
+ replaceImage["align"] = align
+ }
+ if outcome.Resource.HasScale {
+ replaceImage["scale"] = outcome.Resource.ImageScale
+ }
+ request["replace_image"] = replaceImage
+ }
+ requests = append(requests, request)
+ }
+ return map[string]interface{}{"requests": requests}
+}
+
+func cleanupLocalDocResourcePlaceholders(runtime *common.RuntimeContext, documentKey string, outcomes []*localDocResourceOutcome, baseRevision interface{}) (interface{}, bool) {
+ type cleanupTarget struct {
+ BlockID string
+ Owner *localDocResourceOutcome
+ }
+ targets := make([]cleanupTarget, 0)
+ seenBlockIDs := make(map[string]struct{})
+ for _, outcome := range outcomes {
+ if outcome.Status == "bound" || !outcome.SafeToCleanup {
+ continue
+ }
+ for _, blockID := range outcome.CleanupBlockIDs {
+ blockID = strings.TrimSpace(blockID)
+ if blockID == "" {
+ continue
+ }
+ if _, duplicate := seenBlockIDs[blockID]; duplicate {
+ continue
+ }
+ seenBlockIDs[blockID] = struct{}{}
+ targets = append(targets, cleanupTarget{BlockID: blockID, Owner: outcome})
+ }
+ if len(outcome.CleanupBlockIDs) == 0 && outcome.CleanupStatus == "not_needed" {
+ outcome.CleanupStatus = "skipped"
+ }
+ }
+ if len(targets) == 0 {
+ return nil, true
+ }
+ baseRevision = normalizeLocalDocResourceRevision(baseRevision)
+ if baseRevision == nil {
+ err := errs.NewInternalError(errs.SubtypeInvalidResponse, "document response is missing a revision; local resource placeholders were preserved")
+ for _, target := range targets {
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, err)
+ }
+ return nil, false
+ }
+
+ verifiedTargets := make([]cleanupTarget, 0, len(targets))
+ blockedOwners := make(map[*localDocResourceOutcome]struct{})
+ for i, target := range targets {
+ if i > 0 {
+ if err := waitLocalDocResourceRequest(runtime.Ctx(), localDocResourceVerifyInterval); err != nil {
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ blockedOwners[target.Owner] = struct{}{}
+ appendLocalDocResourceOutcomeError(target.Owner, err)
+ continue
+ }
+ }
+ data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(target.BlockID)), nil, nil)
+ if err != nil {
+ target.Owner.Status = "bind_ambiguous"
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ blockedOwners[target.Owner] = struct{}{}
+ appendLocalDocResourceOutcomeError(target.Owner, err)
+ continue
+ }
+ block := common.GetMap(data, "block")
+ if !localDocResourceBlockMatchesKind(block, target.Owner.Resource.Kind) {
+ blockedOwners[target.Owner] = struct{}{}
+ target.Owner.Status = "bind_ambiguous"
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "local %s #%d block type could not be verified before cleanup; placeholder was preserved",
+ target.Owner.Resource.Kind,
+ target.Owner.Resource.Occurrence,
+ ))
+ continue
+ }
+
+ actualTokens := localDocResourceBlockTokens(block)
+ if len(actualTokens) == 0 {
+ if target.Owner.Resource.Kind == localDocResourceFile {
+ // DocX represents a file as a source child inside a figure
+ // block. Deleting the source ID is accepted by docs_ai but
+ // leaves an empty behind, so remove
+ // the owning figure after the tokenless child is verified.
+ parentID := strings.TrimSpace(common.GetString(block, "parent_id"))
+ if parentID == "" || parentID == target.BlockID || parentID == documentKey {
+ blockedOwners[target.Owner] = struct{}{}
+ target.Owner.Status = "bind_ambiguous"
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "local file #%d figure parent could not be verified before cleanup; placeholder was preserved",
+ target.Owner.Resource.Occurrence,
+ ))
+ continue
+ }
+ parentData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentKey), validate.EncodePathSegment(parentID)), nil, nil)
+ if err != nil {
+ blockedOwners[target.Owner] = struct{}{}
+ target.Owner.Status = "bind_ambiguous"
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, err)
+ continue
+ }
+ parentBlock := common.GetMap(parentData, "block")
+ if !localDocResourceIsSoleFileFigure(parentBlock, parentID, target.BlockID) {
+ blockedOwners[target.Owner] = struct{}{}
+ target.Owner.Status = "bind_ambiguous"
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "local file #%d parent is not a sole-source figure; placeholder was preserved",
+ target.Owner.Resource.Occurrence,
+ ))
+ continue
+ }
+ target.BlockID = parentID
+ }
+ verifiedTargets = append(verifiedTargets, target)
+ continue
+ }
+
+ blockedOwners[target.Owner] = struct{}{}
+ target.Owner.SafeToCleanup = false
+ if target.Owner.FileToken != "" && len(actualTokens) == 1 && actualTokens[0] == target.Owner.FileToken {
+ target.Owner.Status = "bound"
+ target.Owner.CleanupStatus = "not_needed"
+ if target.Owner.Block != nil {
+ target.Owner.Block["block_token"] = target.Owner.FileToken
+ }
+ continue
+ }
+ target.Owner.Status = "bind_conflict"
+ target.Owner.CleanupStatus = "skipped_conflict"
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "local %s #%d block token changed before cleanup; placeholder was preserved",
+ target.Owner.Resource.Kind,
+ target.Owner.Resource.Occurrence,
+ ))
+ }
+ verifiedByBlockID := make(map[string]cleanupTarget, len(verifiedTargets))
+ for _, target := range verifiedTargets {
+ if previous, duplicate := verifiedByBlockID[target.BlockID]; duplicate {
+ blockedOwners[previous.Owner] = struct{}{}
+ blockedOwners[target.Owner] = struct{}{}
+ for _, owner := range []*localDocResourceOutcome{previous.Owner, target.Owner} {
+ owner.Status = "bind_ambiguous"
+ owner.CleanupStatus = "skipped_ambiguous"
+ owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(owner, errs.NewInternalError(
+ errs.SubtypeInvalidResponse,
+ "multiple local resources resolved to cleanup block %s; placeholders were preserved",
+ target.BlockID,
+ ))
+ }
+ continue
+ }
+ verifiedByBlockID[target.BlockID] = target
+ }
+ deleteTargets := verifiedTargets[:0]
+ for _, target := range verifiedTargets {
+ if _, blocked := blockedOwners[target.Owner]; !blocked {
+ deleteTargets = append(deleteTargets, target)
+ }
+ }
+ if len(deleteTargets) == 0 {
+ return nil, false
+ }
+
+ var lastRevision interface{}
+ currentRevision := baseRevision
+ for start := 0; start < len(deleteTargets); start += localDocResourceCleanupBatchSize {
+ end := min(start+localDocResourceCleanupBatchSize, len(deleteTargets))
+ chunk := deleteTargets[start:end]
+ ids := make([]string, 0, len(chunk))
+ for _, target := range chunk {
+ ids = append(ids, target.BlockID)
+ }
+ body := map[string]interface{}{
+ "format": "xml",
+ "command": "block_delete",
+ "block_id": strings.Join(ids, ","),
+ "revision_id": currentRevision,
+ }
+ injectDocsScene(runtime, body)
+ data, err := doDocAPI(runtime, "PUT", fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentKey)), body)
+ if err != nil {
+ for _, target := range chunk {
+ target.Owner.CleanupStatus = "failed"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, err)
+ }
+ for _, target := range deleteTargets[end:] {
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup revision could not be confirmed; placeholder was preserved"))
+ }
+ return nil, false
+ }
+ if docsAPIOperationFailed(data) {
+ serviceErr := errs.NewAPIError(errs.SubtypeUnknown, "local resource placeholder cleanup returned result=failed")
+ warnings := common.GetSlice(data, "warnings")
+ for _, target := range chunk {
+ target.Owner.CleanupStatus = "failed"
+ target.Owner.SafeToCleanup = false
+ target.Owner.ServerWarnings = append(target.Owner.ServerWarnings, warnings...)
+ appendLocalDocResourceOutcomeError(target.Owner, serviceErr)
+ }
+ for _, target := range deleteTargets[end:] {
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup revision could not be confirmed; placeholder was preserved"))
+ }
+ return nil, false
+ }
+ for _, target := range chunk {
+ target.Owner.CleanupStatus = "succeeded"
+ target.Owner.SafeToCleanup = false
+ }
+ revision := localDocResourceRevisionFromDocsAI(data)
+ if revision == nil {
+ for _, target := range deleteTargets[end:] {
+ target.Owner.CleanupStatus = "skipped_ambiguous"
+ target.Owner.SafeToCleanup = false
+ appendLocalDocResourceOutcomeError(target.Owner, errs.NewInternalError(errs.SubtypeInvalidResponse, "cleanup response is missing a revision; placeholder was preserved"))
+ }
+ return nil, false
+ }
+ lastRevision = revision
+ currentRevision = revision
+ }
+ return lastRevision, true
+}
+
+func appendLocalDocResourceOutcomeError(outcome *localDocResourceOutcome, err error) {
+ if err == nil {
+ return
+ }
+ if outcome.Err == nil {
+ outcome.Err = err
+ return
+ }
+ outcome.Err = errors.Join(outcome.Err, err)
+}
+
+func localDocResourceBlockToken(block map[string]interface{}, kind localDocResourceKind) string {
+ if block == nil {
+ return ""
+ }
+ if token := strings.TrimSpace(common.GetString(block, "token")); token != "" {
+ return token
+ }
+ return strings.TrimSpace(common.GetString(common.GetMap(block, string(kind)), "token"))
+}
+
+func localDocResourceBlockMatchesKind(block map[string]interface{}, kind localDocResourceKind) bool {
+ if block == nil {
+ return false
+ }
+ rawType, ok := block["block_type"]
+ if !ok {
+ return false
+ }
+ if blockType, ok := rawType.(string); ok {
+ return strings.TrimSpace(blockType) == string(kind)
+ }
+ blockType, ok := normalizeLocalDocResourceRevision(rawType).(int64)
+ return ok && blockType == int64(blockTypeForMediaType(string(kind)))
+}
+
+func localDocResourceIsSoleFileFigure(block map[string]interface{}, expectedBlockID, childBlockID string) bool {
+ if block == nil || strings.TrimSpace(common.GetString(block, "block_id")) != expectedBlockID {
+ return false
+ }
+ rawType, ok := block["block_type"]
+ if !ok {
+ return false
+ }
+ figureType := false
+ if blockType, ok := rawType.(string); ok {
+ switch strings.ToLower(strings.TrimSpace(blockType)) {
+ case "figure", "view":
+ figureType = true
+ }
+ } else if blockType, ok := normalizeLocalDocResourceRevision(rawType).(int64); ok {
+ figureType = blockType == 33
+ }
+ if !figureType {
+ return false
+ }
+ children := common.GetSlice(block, "children")
+ if len(children) != 1 {
+ return false
+ }
+ child, ok := children[0].(string)
+ return ok && strings.TrimSpace(child) == childBlockID
+}
+
+func localDocResourceBlockTokens(block map[string]interface{}) []string {
+ if block == nil {
+ return nil
+ }
+ tokens := make([]string, 0, 3)
+ seen := make(map[string]struct{}, 3)
+ for _, token := range []string{
+ common.GetString(block, "token"),
+ common.GetString(common.GetMap(block, string(localDocResourceImage)), "token"),
+ common.GetString(common.GetMap(block, string(localDocResourceFile)), "token"),
+ } {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ continue
+ }
+ if _, duplicate := seen[token]; duplicate {
+ continue
+ }
+ seen[token] = struct{}{}
+ tokens = append(tokens, token)
+ }
+ return tokens
+}
+
+func localDocResourceRevisionFromBatch(data map[string]interface{}) interface{} {
+ if data == nil {
+ return nil
+ }
+ return normalizeLocalDocResourceRevision(data["document_revision_id"])
+}
+
+func localDocResourceRevisionFromDocsAI(data map[string]interface{}) interface{} {
+ doc, _ := data["document"].(map[string]interface{})
+ if doc == nil {
+ return nil
+ }
+ return normalizeLocalDocResourceRevision(doc["revision_id"])
+}
+
+func normalizeLocalDocResourceRevision(value interface{}) interface{} {
+ var revision int64
+ switch number := value.(type) {
+ case int:
+ revision = int64(number)
+ case int8:
+ revision = int64(number)
+ case int16:
+ revision = int64(number)
+ case int32:
+ revision = int64(number)
+ case int64:
+ revision = number
+ case uint:
+ if uint64(number) > math.MaxInt64 {
+ return nil
+ }
+ revision = int64(number)
+ case uint8:
+ revision = int64(number)
+ case uint16:
+ revision = int64(number)
+ case uint32:
+ revision = int64(number)
+ case uint64:
+ if number > math.MaxInt64 {
+ return nil
+ }
+ revision = int64(number)
+ case float32:
+ value := float64(number)
+ if value > math.MaxInt64 || value < 0 || math.Trunc(value) != value {
+ return nil
+ }
+ revision = int64(value)
+ case float64:
+ if number > math.MaxInt64 || number < 0 || math.Trunc(number) != number {
+ return nil
+ }
+ revision = int64(number)
+ case json.Number:
+ parsed, err := strconv.ParseInt(number.String(), 10, 64)
+ if err != nil {
+ return nil
+ }
+ revision = parsed
+ case string:
+ parsed, err := strconv.ParseInt(strings.TrimSpace(number), 10, 64)
+ if err != nil {
+ return nil
+ }
+ revision = parsed
+ default:
+ return nil
+ }
+ if revision < 0 {
+ return nil
+ }
+ return revision
+}
+
+func setLocalDocResourceRevision(data map[string]interface{}, revision interface{}) {
+ doc, _ := data["document"].(map[string]interface{})
+ if doc != nil {
+ if revision = normalizeLocalDocResourceRevision(revision); revision != nil {
+ doc["revision_id"] = revision
+ }
+ }
+}
+
+func clearLocalDocResourceRevision(data map[string]interface{}) {
+ doc, _ := data["document"].(map[string]interface{})
+ if doc != nil {
+ delete(doc, "revision_id")
+ }
+}
+
+func appendLocalDocResourcesDryRun(dry *common.DryRunAPI, documentKey string, resources []localDocResource) *common.DryRunAPI {
+ if len(resources) == 0 {
+ return dry
+ }
+ encodedDocumentKey := validate.EncodePathSegment(documentKey)
+ routeExtra, _ := buildDriveRouteExtra(documentKey)
+ for _, resource := range resources {
+ parentType := parentTypeForMediaType(string(resource.Kind))
+ body := map[string]interface{}{
+ "file_name": fmt.Sprintf("", resource.Kind, resource.Occurrence),
+ "parent_type": parentType,
+ "parent_node": fmt.Sprintf("", resource.Kind, resource.Occurrence),
+ "size": resource.Size,
+ "extra": routeExtra,
+ }
+ if resource.Size > common.MaxDriveMediaUploadSinglePartSize {
+ dry.POST("/open-apis/drive/v1/medias/upload_prepare").
+ Desc(fmt.Sprintf("Upload local %s #%d: initialize multipart upload", resource.Kind, resource.Occurrence)).
+ Body(body).
+ POST("/open-apis/drive/v1/medias/upload_part").
+ Desc(fmt.Sprintf("Upload local %s #%d: upload parts (repeated)", resource.Kind, resource.Occurrence)).
+ Body(map[string]interface{}{"upload_id": "", "seq": "", "size": "", "file": ""}).
+ POST("/open-apis/drive/v1/medias/upload_finish").
+ Desc(fmt.Sprintf("Upload local %s #%d: finish multipart upload", resource.Kind, resource.Occurrence)).
+ Body(map[string]interface{}{"upload_id": "", "block_num": ""})
+ } else {
+ body["file"] = ""
+ dry.POST("/open-apis/drive/v1/medias/upload_all").
+ Desc(fmt.Sprintf("Upload local %s #%d", resource.Kind, resource.Occurrence)).
+ Body(body)
+ }
+ }
+ for start := 0; start < len(resources); start += localDocResourceBindBatchSize {
+ end := min(start+localDocResourceBindBatchSize, len(resources))
+ requests := make([]interface{}, 0, end-start)
+ for _, resource := range resources[start:end] {
+ request := map[string]interface{}{"block_id": fmt.Sprintf("", resource.Kind, resource.Occurrence)}
+ if resource.Kind == localDocResourceFile {
+ request["replace_file"] = map[string]interface{}{"token": fmt.Sprintf("", resource.Occurrence)}
+ } else {
+ replaceImage := map[string]interface{}{"token": fmt.Sprintf("", resource.Occurrence)}
+ if resource.ImageWidth > 0 {
+ replaceImage["width"] = resource.ImageWidth
+ }
+ if resource.ImageHeight > 0 {
+ replaceImage["height"] = resource.ImageHeight
+ }
+ if align, ok := alignMap[resource.ImageAlign]; ok {
+ replaceImage["align"] = align
+ }
+ if resource.HasScale {
+ replaceImage["scale"] = resource.ImageScale
+ }
+ request["replace_image"] = replaceImage
+ }
+ requests = append(requests, request)
+ }
+ dry.PATCH(fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", encodedDocumentKey)).
+ Desc(fmt.Sprintf("Bind uploaded local resources (batch %d, max %d requests)", start/localDocResourceBindBatchSize+1, localDocResourceBindBatchSize)).
+ Params(map[string]interface{}{"client_token": fmt.Sprintf("", start/localDocResourceBindBatchSize+1)}).
+ Body(map[string]interface{}{"requests": requests})
+ }
+ dry.GET(fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", encodedDocumentKey, validate.EncodePathSegment(""))).
+ Desc("Conditional: verify block token after an ambiguous bind response or immediately before cleanup")
+ dry.PUT(fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", encodedDocumentKey)).
+ Desc("Conditional: delete placeholders whose upload or bind failed; successful resources are preserved").
+ Body(map[string]interface{}{"format": "xml", "command": "block_delete", "block_id": "", "revision_id": ""})
+ return dry
+}
+
+func appendRemoteDocImageDownloadsDryRun(dry *common.DryRunAPI, resources []localDocResource) *common.DryRunAPI {
+ for _, resource := range resources {
+ if resource.RemoteURL == "" {
+ continue
+ }
+ dry.GET(redactRemoteDocImageURL(resource.RemoteURL)).
+ Desc(fmt.Sprintf("Download remote image #%d in a bounded concurrent upload worker after the document write succeeds (userinfo, query, and fragment are redacted)", resource.Occurrence))
+ }
+ return dry
+}
+
+func redactRemoteDocImageURL(rawURL string) string {
+ parsed, err := url.Parse(rawURL)
+ if err != nil || parsed == nil {
+ return ""
+ }
+ parsed.User = nil
+ parsed.RawQuery = ""
+ parsed.ForceQuery = false
+ parsed.Fragment = ""
+ return parsed.String()
+}
+
+func (t localDocResourceTag) attr(name string) (string, bool) {
+ for _, attr := range t.Attrs {
+ if attr.Name == name {
+ return attr.Value, true
+ }
+ }
+ return "", false
+}
+
+func (t localDocResourceTag) hasAttr(name string) bool {
+ _, ok := t.attr(name)
+ return ok
+}
+
+func (t *localDocResourceTag) setAttr(name, value string) {
+ for i := range t.Attrs {
+ if t.Attrs[i].Name == name {
+ t.Attrs[i].Value = value
+ return
+ }
+ }
+ t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
+}
+
+func (t *localDocResourceTag) deleteAttr(name string) {
+ attrs := t.Attrs[:0]
+ for _, attr := range t.Attrs {
+ if attr.Name != name {
+ attrs = append(attrs, attr)
+ }
+ }
+ t.Attrs = attrs
+}
+
+func (t *localDocResourceTag) renameAttr(oldName, newName string) bool {
+ if t.hasAttr(newName) {
+ return false
+ }
+ for i := range t.Attrs {
+ if t.Attrs[i].Name == oldName {
+ t.Attrs[i].Name = newName
+ return true
+ }
+ }
+ return false
+}
+
+type markdownIndentContext struct {
+ quoteDepth int
+ listContentIndents []int
+}
+
+func (c *markdownIndentContext) isIndentedCodeLine(line string) bool {
+ line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
+ content, quoteDepth := stripMarkdownBlockQuotePrefixes(line)
+ if quoteDepth != c.quoteDepth {
+ c.quoteDepth = quoteDepth
+ c.listContentIndents = nil
+ }
+ if strings.TrimSpace(content) == "" {
+ return false
+ }
+
+ indent, offset := markdownLeadingIndent(content)
+ for len(c.listContentIndents) > 0 && indent < c.listContentIndents[len(c.listContentIndents)-1] {
+ c.listContentIndents = c.listContentIndents[:len(c.listContentIndents)-1]
+ }
+ if indent <= 3 && isMarkdownThematicBreak(content[offset:]) {
+ return false
+ }
+ if markerIndent, ok := markdownListItemContentIndent(content[offset:], indent); ok && c.enterListItem(indent, markerIndent) {
+ return false
+ }
+
+ containerIndent := 0
+ if len(c.listContentIndents) > 0 {
+ containerIndent = c.listContentIndents[len(c.listContentIndents)-1]
+ }
+ return indent >= containerIndent+4
+}
+
+func (c *markdownIndentContext) enterListItem(indent, contentIndent int) bool {
+ for len(c.listContentIndents) > 0 {
+ parentIndent := c.listContentIndents[len(c.listContentIndents)-1]
+ if indent >= parentIndent && indent <= parentIndent+3 {
+ c.listContentIndents = append(c.listContentIndents, contentIndent)
+ return true
+ }
+ if indent < parentIndent {
+ c.listContentIndents = c.listContentIndents[:len(c.listContentIndents)-1]
+ continue
+ }
+ return false
+ }
+ if indent > 3 {
+ return false
+ }
+ c.listContentIndents = append(c.listContentIndents[:0], contentIndent)
+ return true
+}
+
+func stripMarkdownBlockQuotePrefixes(line string) (string, int) {
+ depth := 0
+ for {
+ indent := 0
+ for indent < len(line) && indent < 3 && line[indent] == ' ' {
+ indent++
+ }
+ if indent >= len(line) || line[indent] != '>' {
+ return line, depth
+ }
+ line = line[indent+1:]
+ if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
+ line = line[1:]
+ }
+ depth++
+ }
+}
+
+func markdownLeadingIndent(line string) (columns, offset int) {
+ for offset < len(line) {
+ switch line[offset] {
+ case ' ':
+ columns++
+ case '\t':
+ columns += 4 - columns%4
+ default:
+ return columns, offset
+ }
+ offset++
+ }
+ return columns, offset
+}
+
+func markdownListItemContentIndent(line string, markerColumn int) (int, bool) {
+ markerWidth := 0
+ if len(line) > 0 && (line[0] == '-' || line[0] == '+' || line[0] == '*') {
+ markerWidth = 1
+ } else {
+ for markerWidth < len(line) && markerWidth < 9 && line[markerWidth] >= '0' && line[markerWidth] <= '9' {
+ markerWidth++
+ }
+ if markerWidth == 0 || markerWidth >= len(line) || (line[markerWidth] != '.' && line[markerWidth] != ')') {
+ return 0, false
+ }
+ markerWidth++
+ }
+ if markerWidth == len(line) {
+ return markerColumn + markerWidth + 1, true
+ }
+ if line[markerWidth] != ' ' && line[markerWidth] != '\t' {
+ return 0, false
+ }
+
+ paddingColumns := 0
+ column := markerColumn + markerWidth
+ for i := markerWidth; i < len(line); i++ {
+ switch line[i] {
+ case ' ':
+ column++
+ paddingColumns++
+ case '\t':
+ width := 4 - column%4
+ column += width
+ paddingColumns += width
+ default:
+ if paddingColumns > 4 {
+ paddingColumns = 1
+ }
+ return markerColumn + markerWidth + paddingColumns, true
+ }
+ }
+ if paddingColumns > 4 {
+ paddingColumns = 1
+ }
+ return markerColumn + markerWidth + paddingColumns, true
+}
+
+func isMarkdownThematicBreak(line string) bool {
+ marker := byte(0)
+ count := 0
+ for i := 0; i < len(line); i++ {
+ switch line[i] {
+ case ' ', '\t':
+ continue
+ case '*', '-', '_':
+ if marker == 0 {
+ marker = line[i]
+ } else if line[i] != marker {
+ return false
+ }
+ count++
+ default:
+ return false
+ }
+ }
+ return count >= 3
+}
+
+func findMarkdownRawHTMLInertEnd(content string, start int) (int, bool) {
+ if start < 0 || start >= len(content) || content[start] != '<' || start+2 >= len(content) {
+ return 0, false
+ }
+ for _, name := range []string{"pre", "script", "style", "textarea"} {
+ nameEnd := start + 1 + len(name)
+ if nameEnd >= len(content) || !strings.EqualFold(content[start+1:nameEnd], name) || !isLocalDocResourceHTMLTagBoundary(content[nameEnd]) {
+ continue
+ }
+ startTagEnd := findXMLStartTagEnd(content, start)
+ if startTagEnd < 0 {
+ return len(content), true
+ }
+ if strings.HasSuffix(strings.TrimSpace(content[start:startTagEnd]), "/>") {
+ return startTagEnd, true
+ }
+
+ searchFrom := startTagEnd
+ lowerRest := strings.ToLower(content[searchFrom:])
+ needle := "" + name
+ for {
+ offset := strings.Index(lowerRest, needle)
+ if offset < 0 {
+ return len(content), true
+ }
+ closeStart := searchFrom + offset
+ boundary := closeStart + len(needle)
+ if boundary < len(content) && isLocalDocResourceHTMLTagBoundary(content[boundary]) {
+ closeEnd := findXMLStartTagEnd(content, closeStart)
+ if closeEnd < 0 {
+ return len(content), true
+ }
+ return closeEnd, true
+ }
+ searchFrom = boundary
+ lowerRest = strings.ToLower(content[searchFrom:])
+ }
+ }
+ return 0, false
+}
+
+func isLocalDocResourceHTMLTagBoundary(value byte) bool {
+ return value == '>' || value == '/' || value == ' ' || value == '\t' || value == '\r' || value == '\n'
+}
+
+func (t localDocResourceTag) render() string {
+ var out strings.Builder
+ out.WriteByte('<')
+ out.WriteString(t.Name)
+ for _, attr := range t.Attrs {
+ out.WriteByte(' ')
+ out.WriteString(attr.Name)
+ out.WriteString(`="`)
+ out.WriteString(escapeXMLAttr(attr.Value))
+ out.WriteByte('"')
+ }
+ if t.SelfClosing {
+ out.WriteString("/>")
+ } else {
+ out.WriteByte('>')
+ }
+ return out.String()
+}
+
+func parseLocalDocResourceTag(raw, expected string) (localDocResourceTag, error) {
+ if expected == "img" {
+ raw = escapeBareXMLAmpersandsInTagAttr(raw, "href")
+ }
+ decoder := xml.NewDecoder(strings.NewReader(raw))
+ for {
+ token, err := decoder.Token()
+ if err != nil {
+ return localDocResourceTag{}, err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ if start.Name.Local != expected {
+ return localDocResourceTag{}, fmt.Errorf("expected <%s>, got <%s>", expected, start.Name.Local) //nolint:forbidigo // caller wraps with typed validation error.
+ }
+ attrs := make([]html5BlockAttr, 0, len(start.Attr))
+ seenAttrs := make(map[string]struct{}, len(start.Attr))
+ for _, attr := range start.Attr {
+ attrName := strings.ToLower(strings.TrimSpace(attr.Name.Local))
+ if _, exists := seenAttrs[attrName]; exists {
+ return localDocResourceTag{}, fmt.Errorf("duplicate attribute %q", attrName) //nolint:forbidigo // caller wraps with typed validation error.
+ }
+ seenAttrs[attrName] = struct{}{}
+ attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
+ }
+ return localDocResourceTag{Name: expected, Attrs: attrs, SelfClosing: strings.HasSuffix(strings.TrimSpace(raw), "/>")}, nil
+ }
+}
+
+func escapeBareXMLAmpersandsInTagAttr(raw, targetAttr string) string {
+ for i := 1; i < len(raw); {
+ for i < len(raw) && isXMLSpace(raw[i]) {
+ i++
+ }
+ nameStart := i
+ for i < len(raw) && isLocalDocResourceAttrNameByte(raw[i]) {
+ i++
+ }
+ if nameStart == i {
+ i++
+ continue
+ }
+ name := raw[nameStart:i]
+ for i < len(raw) && isXMLSpace(raw[i]) {
+ i++
+ }
+ if i >= len(raw) || raw[i] != '=' {
+ continue
+ }
+ i++
+ for i < len(raw) && isXMLSpace(raw[i]) {
+ i++
+ }
+ if i >= len(raw) || (raw[i] != '"' && raw[i] != '\'') {
+ continue
+ }
+ quote := raw[i]
+ valueStart := i + 1
+ valueEnd := strings.IndexByte(raw[valueStart:], quote)
+ if valueEnd < 0 {
+ return raw
+ }
+ valueEnd += valueStart
+ if strings.EqualFold(name, targetAttr) {
+ value := escapeBareXMLAmpersands(raw[valueStart:valueEnd])
+ return raw[:valueStart] + value + raw[valueEnd:]
+ }
+ i = valueEnd + 1
+ }
+ return raw
+}
+
+func isXMLSpace(value byte) bool {
+ return value == ' ' || value == '\t' || value == '\r' || value == '\n'
+}
+
+func isLocalDocResourceAttrNameByte(value byte) bool {
+ return value > ' ' && value != '=' && value != '/' && value != '>'
+}
+
+func escapeBareXMLAmpersands(value string) string {
+ var out strings.Builder
+ for i := 0; i < len(value); {
+ if value[i] != '&' {
+ out.WriteByte(value[i])
+ i++
+ continue
+ }
+ if entityLen := validXMLCharacterEntityLength(value[i:]); entityLen > 0 {
+ out.WriteString(value[i : i+entityLen])
+ i += entityLen
+ continue
+ }
+ out.WriteString("&")
+ i++
+ }
+ return out.String()
+}
+
+func validXMLCharacterEntityLength(value string) int {
+ for _, entity := range []string{"&", "<", ">", """, "'"} {
+ if strings.HasPrefix(value, entity) {
+ return len(entity)
+ }
+ }
+ if !strings.HasPrefix(value, "") {
+ return 0
+ }
+ i := 2
+ isDigit := func(value byte) bool { return value >= '0' && value <= '9' }
+ if i < len(value) && (value[i] == 'x' || value[i] == 'X') {
+ i++
+ isDigit = func(value byte) bool {
+ return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F')
+ }
+ }
+ digitsStart := i
+ for i < len(value) && isDigit(value[i]) {
+ i++
+ }
+ if i == digitsStart || i >= len(value) || value[i] != ';' {
+ return 0
+ }
+ return i + 1
+}
+
+func localResourceTagNameAt(content string, index int) string {
+ for _, name := range []string{"img", "source"} {
+ prefix := "<" + name
+ if !strings.HasPrefix(content[index:], prefix) {
+ continue
+ }
+ next := index + len(prefix)
+ if next >= len(content) || content[next] == '>' || content[next] == '/' || content[next] == ' ' || content[next] == '\t' || content[next] == '\r' || content[next] == '\n' {
+ return name
+ }
+ }
+ return ""
+}
+
+func findXMLStartTagEnd(content string, start int) int {
+ var quote byte
+ for i := start + 1; i < len(content); i++ {
+ if quote != 0 {
+ if content[i] == quote {
+ quote = 0
+ }
+ continue
+ }
+ switch content[i] {
+ case '\'', '"':
+ quote = content[i]
+ case '>':
+ return i + 1
+ }
+ }
+ return -1
+}
+
+type parsedMarkdownImage struct {
+ Alt string
+ Destination string
+ Title string
+ ReferenceLabel string
+ End int
+}
+
+func parseMarkdownImageAt(content string, start int) (parsedMarkdownImage, bool) {
+ altEnd := findMarkdownClosingBracket(content, start+2)
+ if altEnd < 0 {
+ return parsedMarkdownImage{}, false
+ }
+ alt := content[start+2 : altEnd]
+ next := altEnd + 1
+ if next < len(content) && content[next] == '(' {
+ destination, title, end, ok := parseMarkdownImageDestination(content, next)
+ if !ok {
+ return parsedMarkdownImage{}, false
+ }
+ return parsedMarkdownImage{Alt: alt, Destination: destination, Title: title, End: end}, true
+ }
+ if next < len(content) && content[next] == '[' {
+ labelEnd := findMarkdownClosingBracket(content, next+1)
+ if labelEnd < 0 {
+ return parsedMarkdownImage{}, false
+ }
+ label := content[next+1 : labelEnd]
+ if label == "" {
+ label = alt
+ }
+ return parsedMarkdownImage{Alt: alt, ReferenceLabel: label, End: labelEnd + 1}, true
+ }
+ return parsedMarkdownImage{Alt: alt, ReferenceLabel: alt, End: altEnd + 1}, true
+}
+
+func parseMarkdownImageDestination(content string, open int) (string, string, int, bool) {
+ i := open + 1
+ for i < len(content) && (content[i] == ' ' || content[i] == '\t') {
+ i++
+ }
+ if i >= len(content) {
+ return "", "", 0, false
+ }
+ var destination string
+ if content[i] == '<' {
+ start := i + 1
+ i++
+ for i < len(content) {
+ if content[i] == '>' && !isEscapedMarkdownByte(content, i) {
+ destination = content[start:i]
+ i++
+ break
+ }
+ i++
+ }
+ if destination == "" {
+ return "", "", 0, false
+ }
+ } else {
+ start := i
+ depth := 0
+ for i < len(content) {
+ if isEscapedMarkdownByte(content, i) {
+ i++
+ continue
+ }
+ switch content[i] {
+ case '(':
+ depth++
+ case ')':
+ if depth == 0 {
+ destination = content[start:i]
+ return unescapeMarkdownText(strings.TrimSpace(destination)), "", i + 1, destination != ""
+ }
+ depth--
+ case ' ', '\t', '\r', '\n':
+ if depth == 0 {
+ destination = content[start:i]
+ goto findClose
+ }
+ }
+ i++
+ }
+ return "", "", 0, false
+ }
+
+findClose:
+ for i < len(content) && isMarkdownSpace(content[i]) {
+ i++
+ }
+ if i >= len(content) {
+ return "", "", 0, false
+ }
+ if content[i] == ')' {
+ return unescapeMarkdownText(strings.TrimSpace(destination)), "", i + 1, destination != ""
+ }
+
+ opener := content[i]
+ closer := opener
+ if opener == '(' {
+ closer = ')'
+ } else if opener != '\'' && opener != '"' {
+ return "", "", 0, false
+ }
+ titleStart := i + 1
+ i = titleStart
+ for i < len(content) {
+ if content[i] == closer && !isEscapedMarkdownByte(content, i) {
+ title := unescapeMarkdownText(content[titleStart:i])
+ i++
+ for i < len(content) && isMarkdownSpace(content[i]) {
+ i++
+ }
+ if i >= len(content) || content[i] != ')' {
+ return "", "", 0, false
+ }
+ return unescapeMarkdownText(strings.TrimSpace(destination)), title, i + 1, destination != ""
+ }
+ i++
+ }
+ return "", "", 0, false
+}
+
+func isMarkdownSpace(char byte) bool {
+ return char == ' ' || char == '\t' || char == '\r' || char == '\n'
+}
+
+func collectLocalMarkdownImageReferences(content string) map[string]struct{} {
+ refs := map[string]struct{}{}
+ var fenceChar byte
+ var fenceLen int
+ var indentCtx markdownIndentContext
+ for _, line := range strings.Split(maskLocalDocResourceMarkupInertContexts(content), "\n") {
+ indentedCode := false
+ if fenceChar == 0 {
+ indentedCode = indentCtx.isIndentedCodeLine(line)
+ }
+ char, run, isFence := markdownFence(line)
+ if fenceChar == 0 && isFence {
+ fenceChar, fenceLen = char, run
+ continue
+ }
+ if fenceChar != 0 {
+ if isFence && char == fenceChar && run >= fenceLen && markdownFenceCloses(line, char, run) {
+ fenceChar, fenceLen = 0, 0
+ }
+ continue
+ }
+ if indentedCode {
+ continue
+ }
+ label, destination, ok := parseMarkdownReferenceDefinition(line)
+ if ok && strings.HasPrefix(destination, "@") {
+ refs[normalizeMarkdownReferenceLabel(label)] = struct{}{}
+ }
+ }
+ return refs
+}
+
+func maskLocalDocResourceMarkupInertContexts(content string) string {
+ var out strings.Builder
+ for i := 0; i < len(content); {
+ if end, ok := findMarkdownRawHTMLInertEnd(content, i); ok {
+ writeLocalDocResourceMaskedSpan(&out, content[i:end])
+ i = end
+ continue
+ }
+ terminator := ""
+ prefixLen := 0
+ switch {
+ case strings.HasPrefix(content[i:], ""
+ prefixLen = 4
+ case strings.HasPrefix(content[i:], ""
+ prefixLen = 9
+ }
+ if terminator == "" {
+ out.WriteByte(content[i])
+ i++
+ continue
+ }
+ end := strings.Index(content[i+prefixLen:], terminator)
+ if end < 0 {
+ end = len(content)
+ } else {
+ end += i + prefixLen + len(terminator)
+ }
+ writeLocalDocResourceMaskedSpan(&out, content[i:end])
+ i = end
+ }
+ return out.String()
+}
+
+func writeLocalDocResourceMaskedSpan(out *strings.Builder, content string) {
+ for _, char := range content {
+ if char == '\n' || char == '\r' {
+ out.WriteRune(char)
+ } else {
+ out.WriteByte(' ')
+ }
+ }
+}
+
+func parseMarkdownReferenceDefinition(line string) (string, string, bool) {
+ trimmed := strings.TrimLeft(line, " \t")
+ if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "[") {
+ return "", "", false
+ }
+ end := findMarkdownClosingBracket(trimmed, 1)
+ if end < 0 || end+1 >= len(trimmed) || trimmed[end+1] != ':' {
+ return "", "", false
+ }
+ rest := strings.TrimSpace(trimmed[end+2:])
+ if strings.HasPrefix(rest, "<") {
+ close := strings.Index(rest, ">")
+ if close < 0 {
+ return "", "", false
+ }
+ return trimmed[1:end], rest[1:close], true
+ }
+ if field := strings.Fields(rest); len(field) > 0 {
+ return trimmed[1:end], unescapeMarkdownText(field[0]), true
+ }
+ return "", "", false
+}
+
+func normalizeMarkdownReferenceLabel(label string) string {
+ return strings.ToLower(strings.Join(strings.Fields(unescapeMarkdownText(label)), " "))
+}
+
+func unescapeMarkdownText(value string) string {
+ var out strings.Builder
+ for i := 0; i < len(value); i++ {
+ if value[i] == '\\' && i+1 < len(value) && isMarkdownEscapablePunctuation(value[i+1]) {
+ i++
+ }
+ out.WriteByte(value[i])
+ }
+ return out.String()
+}
+
+func isMarkdownEscapablePunctuation(value byte) bool {
+ return (value >= '!' && value <= '/') || (value >= ':' && value <= '@') ||
+ (value >= '[' && value <= '`') || (value >= '{' && value <= '~')
+}
+
+func findMarkdownClosingBracket(content string, start int) int {
+ depth := 0
+ for i := start; i < len(content); i++ {
+ if isEscapedMarkdownByte(content, i) {
+ continue
+ }
+ switch content[i] {
+ case '[':
+ depth++
+ case ']':
+ if depth == 0 {
+ return i
+ }
+ depth--
+ }
+ }
+ return -1
+}
+
+func isEscapedMarkdownByte(content string, index int) bool {
+ backslashes := 0
+ for i := index - 1; i >= 0 && content[i] == '\\'; i-- {
+ backslashes++
+ }
+ return backslashes%2 == 1
+}
+
+func countByteRun(content string, start int, value byte) int {
+ i := start
+ for i < len(content) && content[i] == value {
+ i++
+ }
+ return i - start
+}
+
+func findMatchingBacktickRun(content string, start, run int) int {
+ for i := start; i < len(content); {
+ if content[i] != '`' {
+ i++
+ continue
+ }
+ got := countByteRun(content, i, '`')
+ if got == run {
+ return i + got
+ }
+ i += got
+ }
+ return -1
+}
+
+func markdownFence(line string) (byte, int, bool) {
+ trimmed, ok := markdownFenceCandidate(line)
+ if !ok || len(trimmed) < 3 {
+ return 0, 0, false
+ }
+ char := trimmed[0]
+ if char != '`' && char != '~' {
+ return 0, 0, false
+ }
+ run := countByteRun(trimmed, 0, char)
+ return char, run, run >= 3
+}
+
+func markdownFenceCloses(line string, char byte, run int) bool {
+ trimmed, ok := markdownFenceCandidate(line)
+ return ok && len(trimmed) >= run && strings.TrimSpace(trimmed[run:]) == "" && trimmed[0] == char
+}
+
+func markdownFenceCandidate(line string) (string, bool) {
+ rest := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
+ for {
+ indent := 0
+ for indent < len(rest) && rest[indent] == ' ' {
+ indent++
+ }
+ if indent > 3 || (indent < len(rest) && rest[indent] == '\t') {
+ return "", false
+ }
+ rest = rest[indent:]
+ if strings.HasPrefix(rest, ">") {
+ rest = rest[1:]
+ if strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, "\t") {
+ rest = rest[1:]
+ }
+ continue
+ }
+ if prefixLen := markdownListMarkerPrefixLen(rest); prefixLen > 0 {
+ rest = rest[prefixLen:]
+ continue
+ }
+ return rest, true
+ }
+}
+
+func markdownListMarkerPrefixLen(line string) int {
+ if len(line) >= 2 && (line[0] == '-' || line[0] == '+' || line[0] == '*') && (line[1] == ' ' || line[1] == '\t') {
+ return 2
+ }
+ digits := 0
+ for digits < len(line) && digits < 9 && line[digits] >= '0' && line[digits] <= '9' {
+ digits++
+ }
+ if digits == 0 || digits+1 >= len(line) || (line[digits] != '.' && line[digits] != ')') || (line[digits+1] != ' ' && line[digits+1] != '\t') {
+ return 0
+ }
+ return digits + 2
+}
diff --git a/shortcuts/doc/local_doc_resources_test.go b/shortcuts/doc/local_doc_resources_test.go
new file mode 100644
index 0000000000..84dccc9d78
--- /dev/null
+++ b/shortcuts/doc/local_doc_resources_test.go
@@ -0,0 +1,2029 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ "image/png"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/larksuite/cli/errs"
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/credential"
+ "github.com/larksuite/cli/internal/httpmock"
+ internaltransport "github.com/larksuite/cli/internal/transport"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+func TestPrepareLocalDocResourcesXMLImageAndSource(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{
+ "diagram.png": localDocResourcePNG(t, 100, 80),
+ "report.pdf": "pdf-data",
+ })
+ content := `before
`
+
+ got, resources, err := prepareLocalDocResources(runtime, "xml", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 2 {
+ t.Fatalf("resources len = %d, want 2: %#v", len(resources), resources)
+ }
+ if resources[0].Kind != localDocResourceImage || resources[0].Path != "diagram.png" {
+ t.Fatalf("image resource = %#v", resources[0])
+ }
+ if resources[1].Kind != localDocResourceFile || resources[1].Path != "report.pdf" {
+ t.Fatalf("file resource = %#v", resources[1])
+ }
+ if strings.Contains(got, "@diagram.png") || strings.Contains(got, "@report.pdf") {
+ t.Fatalf("rewritten content leaks local path: %s", got)
+ }
+ for _, want := range []string{resources[0].Marker, resources[1].Marker, `width="100"`, `height="80"`, `align="right"`, `scale="0.500000"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("rewritten content missing %q: %s", want, got)
+ }
+ }
+}
+
+func TestPrepareLocalDocResourcesRejectsUndecodableLocalImage(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"fake.png": "not an image"})
+ _, _, err := prepareLocalDocResources(runtime, "xml", `
`)
+ problem, ok := errs.ProblemOf(err)
+ var validationErr *errs.ValidationError
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
+ !errors.As(err, &validationErr) || validationErr.Param != "path" {
+ t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr)
+ }
+}
+
+func TestPrepareLocalDocResourcesXMLRemoteImage(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ got, resources, err := prepareLocalDocResources(runtime, "xml", `before
`)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources = %d, want 1", len(resources))
+ }
+ resource := resources[0]
+ if resource.RemoteURL != "http://93.184.216.34/photo.png" {
+ t.Fatalf("RemoteURL = %q", resource.RemoteURL)
+ }
+ if resource.Path != "" || resource.Size != 0 {
+ t.Fatalf("remote resource materialized during preparation: %#v", resource)
+ }
+ if strings.Contains(got, `href=`) || !strings.Contains(got, `path="`+resource.Marker+`"`) || !strings.Contains(got, `caption="remote"`) {
+ t.Fatalf("prepared content = %q", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesXMLRemoteImageAcceptsBareAmpersandsInHref(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ content := `
`
+ got, resources, err := prepareLocalDocResources(runtime, "xml", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources = %d, want 1", len(resources))
+ }
+ if want := "https://93.184.216.34/photo.png?x=1&image_size=large&token=a%26b"; resources[0].RemoteURL != want {
+ t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want)
+ }
+ if !strings.Contains(got, `path="`+resources[0].Marker+`"`) || strings.Contains(got, `href=`) {
+ t.Fatalf("prepared content = %q", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesXMLRemoteImagePreservesValidEntities(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ got, resources, err := prepareLocalDocResources(
+ runtime,
+ "xml",
+ `
`,
+ )
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources = %d, want 1", len(resources))
+ }
+ if want := "https://93.184.216.34/photo.png?x=1&y=2&z=3"; resources[0].RemoteURL != want {
+ t.Fatalf("RemoteURL = %q, want %q", resources[0].RemoteURL, want)
+ }
+ if !strings.Contains(got, `caption="A & B"`) {
+ t.Fatalf("prepared content = %q", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesXMLRemoteImageDoesNotRelaxOtherAttributes(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ _, _, err := prepareLocalDocResources(runtime, "xml", `
`)
+ if err == nil {
+ t.Fatal("prepareLocalDocResources() error = nil")
+ }
+ if !strings.Contains(err.Error(), "invalid character entity") {
+ t.Fatalf("prepareLocalDocResources() error = %v", err)
+ }
+}
+
+func TestPrepareLocalDocResourcesRemoteImageRejectsConflicts(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ for _, content := range []string{
+ `
`,
+ `
`,
+ `
`,
+ } {
+ if _, _, err := prepareLocalDocResources(runtime, "xml", content); err == nil {
+ t.Fatalf("prepareLocalDocResources(%q) error = nil", content)
+ }
+ }
+}
+
+func TestUploadRemoteDocImagesRecordsIndividualDownloadFailure(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-partial-download"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-partial-download"), factory, core.AsUser)
+ _, resources, err := prepareLocalDocResources(
+ runtime,
+ "xml",
+ `![]()
`,
+ )
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+
+ originalDownload := downloadRemoteDocImage
+ t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
+ var downloadMu sync.Mutex
+ downloadCalls := make(map[int]int)
+ downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, occurrence int) (remoteDocImageDownload, error) {
+ downloadMu.Lock()
+ downloadCalls[occurrence]++
+ downloadMu.Unlock()
+ if occurrence == 2 {
+ return remoteDocImageDownload{}, errors.New("second download failed")
+ }
+ payload := []byte(localDocResourcePNG(t, 20, 10))
+ return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
+ }
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_one"}},
+ })
+ outcomes := []*localDocResourceOutcome{
+ {Resource: resources[0], BlockID: "blk_one", Status: "pending"},
+ {Resource: resources[1], BlockID: "blk_two", Status: "pending"},
+ }
+ uploadLocalDocResources(runtime, "doxcn_partial_download", outcomes)
+ if outcomes[0].Status != "uploaded" || outcomes[0].FileToken != "file_one" {
+ t.Fatalf("first outcome = %#v", outcomes[0])
+ }
+ if outcomes[1].Status != "upload_failed" || outcomes[1].Err == nil || !strings.Contains(outcomes[1].Err.Error(), "second download failed") {
+ t.Fatalf("second outcome = %#v", outcomes[1])
+ }
+ downloadMu.Lock()
+ secondDownloadCalls := downloadCalls[2]
+ downloadMu.Unlock()
+ if secondDownloadCalls != 1 {
+ t.Fatalf("non-retryable second download calls = %d, want 1", secondDownloadCalls)
+ }
+}
+
+func TestUploadRemoteDocImageRetriesRetryableDownload(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-download-retry"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-download-retry"), factory, core.AsUser)
+ payload := []byte(localDocResourcePNG(t, 20, 10))
+ originalDownload := downloadRemoteDocImage
+ downloadCalls := 0
+ downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
+ downloadCalls++
+ if downloadCalls < remoteDocImageDownloadAttempts {
+ return remoteDocImageDownload{}, errs.NewNetworkError(errs.SubtypeNetworkTransport, "temporary download failure").WithRetryable()
+ }
+ return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
+ }
+ t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_download_retry"}},
+ })
+ var waits []time.Duration
+ originalWait := waitLocalDocResourceRequest
+ waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
+ waits = append(waits, delay)
+ return nil
+ }
+ t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
+
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"},
+ BlockID: "blk_download_retry",
+ Status: "pending",
+ }
+ uploadLocalDocResources(runtime, "doxcn_download_retry", []*localDocResourceOutcome{outcome})
+ if outcome.Status != "uploaded" || outcome.FileToken != "file_after_download_retry" {
+ t.Fatalf("outcome = %#v", outcome)
+ }
+ if downloadCalls != remoteDocImageDownloadAttempts {
+ t.Fatalf("download calls = %d, want %d", downloadCalls, remoteDocImageDownloadAttempts)
+ }
+ assertLocalDocResourceRetryWaits(t, waits, 2)
+}
+
+func TestUploadRemoteDocImageRetriesRetryableUploadWithoutRedownloading(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("remote-image-upload-retry"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("remote-image-upload-retry"), factory, core.AsUser)
+ payload := []byte(localDocResourcePNG(t, 20, 10))
+ originalDownload := downloadRemoteDocImage
+ downloadCalls := 0
+ downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
+ downloadCalls++
+ return remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 20, Height: 10}, nil
+ }
+ t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
+
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Status: http.StatusServiceUnavailable,
+ Body: map[string]interface{}{"code": 0, "msg": "temporary upstream failure"},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_after_upload_retry"}},
+ })
+ var waits []time.Duration
+ originalWait := waitLocalDocResourceRequest
+ waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
+ waits = append(waits, delay)
+ return nil
+ }
+ t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
+
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, RemoteURL: "https://93.184.216.34/retry.png"},
+ BlockID: "blk_upload_retry",
+ Status: "pending",
+ }
+ uploadLocalDocResources(runtime, "doxcn_upload_retry", []*localDocResourceOutcome{outcome})
+ if outcome.Status != "uploaded" || outcome.FileToken != "file_after_upload_retry" {
+ t.Fatalf("outcome = %#v", outcome)
+ }
+ if downloadCalls != 1 {
+ t.Fatalf("download calls = %d, want 1 when only upload is retried", downloadCalls)
+ }
+ assertLocalDocResourceRetryWaits(t, waits, 1)
+}
+
+func TestApplyRemoteDocImageDownloadNormalizesPresentationWithoutRetainingContent(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ content, resources, err := prepareLocalDocResources(runtime, "xml", `
`)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+ payload := []byte(localDocResourcePNG(t, 200, 100))
+ resource := resources[0]
+ if err := applyRemoteDocImageDownload(&resource, remoteDocImageDownload{Content: payload, FileName: "image.png", Width: 200, Height: 100}); err != nil {
+ t.Fatalf("applyRemoteDocImageDownload: %v", err)
+ }
+ if resource.ImageWidth != 200 || resource.ImageHeight != 100 || !resource.HasScale || resource.ImageScale != 0.25 {
+ t.Fatalf("downloaded resource = %#v", resource)
+ }
+ if resource.FileName != "image.png" || resource.Size != int64(len(payload)) || len(resource.Content) != 0 {
+ t.Fatalf("downloaded resource metadata = %#v", resource)
+ }
+ if !strings.Contains(content, resource.Marker) || strings.Contains(content, `width=`) || strings.Contains(content, `align=`) {
+ t.Fatalf("placeholder content = %q, want presentation deferred until upload", content)
+ }
+}
+
+func TestDownloadRemoteDocImageContentBuffersSupportedImage(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ payload := []byte(localDocResourcePNG(t, 30, 20))
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: io.NopCloser(bytes.NewReader(payload)),
+ ContentLength: int64(len(payload)),
+ Request: req,
+ }, nil
+ }
+
+ download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/photo.png", 1)
+ if err != nil {
+ t.Fatalf("downloadRemoteDocImageContent: %v", err)
+ }
+ if download.FileName != "image.png" || download.Width != 30 || download.Height != 20 || !bytes.Equal(download.Content, payload) {
+ t.Fatalf("download result = %#v", download)
+ }
+}
+
+type remoteDocImageProbeBody struct {
+ reads int
+ closed bool
+}
+
+func (b *remoteDocImageProbeBody) Read([]byte) (int, error) {
+ b.reads++
+ return 0, io.EOF
+}
+
+func (b *remoteDocImageProbeBody) Close() error {
+ b.closed = true
+ return nil
+}
+
+func TestProbeRemoteDocImageDownloadDoesNotReadImageBody(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ body := &remoteDocImageProbeBody{}
+ method := ""
+ requestRange := ""
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ method = req.Method
+ requestRange = req.Header.Get("Range")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ ContentLength: 1024,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: body,
+ }, nil
+ }
+
+ if err := probeRemoteDocImageDownload(runtime, "https://93.184.216.34/image.png", 1); err != nil {
+ t.Fatalf("probeRemoteDocImageDownload: %v", err)
+ }
+ if method != http.MethodGet || requestRange != "bytes=0-0" || body.reads != 0 || !body.closed {
+ t.Fatalf("probe method=%q range=%q reads=%d closed=%v, want ranged GET with closed unread body", method, requestRange, body.reads, body.closed)
+ }
+}
+
+func TestDownloadRemoteDocImageContentRejectsNonImage(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ Body: io.NopCloser(strings.NewReader("not an image")),
+ Request: req,
+ }, nil
+ }
+
+ _, err := downloadRemoteDocImageContent(runtime, "http://93.184.216.34/not-image", 1)
+ if err == nil {
+ t.Fatal("downloadRemoteDocImageContent() error = nil")
+ }
+ problem, ok := errs.ProblemOf(err)
+ var validationErr *errs.ValidationError
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
+ !errors.As(err, &validationErr) || validationErr.Param != "href" {
+ t.Fatalf("problem = %#v, validation = %#v, %v", problem, validationErr, ok)
+ }
+}
+
+func TestDownloadRemoteDocImageContentRejectsDeclaredOversize(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: io.NopCloser(strings.NewReader("")),
+ ContentLength: remoteDocImageMaxBytes + 1,
+ Request: req,
+ }, nil
+ }
+
+ _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/oversize.png", 1)
+ if err == nil || !strings.Contains(err.Error(), "20MiB") {
+ t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
+ }
+ problem, ok := errs.ProblemOf(err)
+ var validationErr *errs.ValidationError
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
+ !errors.As(err, &validationErr) || validationErr.Param != "href" {
+ t.Fatalf("problem = %#v, validation = %#v, ok = %v", problem, validationErr, ok)
+ }
+}
+
+func TestDownloadRemoteDocImageHTTPStatusRetryMetadata(t *testing.T) {
+ tests := []struct {
+ status int
+ wantSubtype errs.Subtype
+ wantRetryable bool
+ }{
+ {status: http.StatusTooManyRequests, wantSubtype: errs.SubtypeRateLimit, wantRetryable: true},
+ {status: http.StatusServiceUnavailable, wantSubtype: errs.SubtypeNetworkServer, wantRetryable: true},
+ {status: http.StatusNotFound, wantSubtype: errs.SubtypeNetworkTransport},
+ }
+ for _, test := range tests {
+ t.Run(http.StatusText(test.status), func(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: test.status,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("temporary")),
+ Request: req,
+ }, nil
+ }
+
+ _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != test.wantSubtype ||
+ problem.Code != test.status || problem.Retryable != test.wantRetryable {
+ t.Fatalf("error=%T %v problem=%#v", err, err, problem)
+ }
+ })
+ }
+}
+
+type cannedRemoteImageTransport struct {
+ base http.RoundTripper
+ payload []byte
+}
+
+func (t *cannedRemoteImageTransport) BaseRoundTripper() http.RoundTripper {
+ return t.base
+}
+
+func (t *cannedRemoteImageTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
+ return &cannedRemoteImageTransport{base: base, payload: t.payload}
+}
+
+func (t *cannedRemoteImageTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: io.NopCloser(bytes.NewReader(t.payload)),
+ ContentLength: int64(len(t.payload)),
+ Request: req,
+ }, nil
+}
+
+func TestDownloadRemoteDocImageContentUsesExternalPolicyBranch(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+ runtime.Factory.HttpClient = func() (*http.Client, error) {
+ external := &cannedRemoteImageTransport{base: http.DefaultTransport, payload: []byte(localDocResourcePNG(t, 2, 1))}
+ return &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(http.DefaultTransport, external)}, nil
+ }
+ download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
+ if err != nil {
+ t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
+ }
+ if download.FileName != "image.png" || len(download.Content) <= 3 {
+ t.Fatalf("download result = %#v", download)
+ }
+}
+
+func TestDownloadRemoteDocImageContentRejectsInvalidImageBody(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: io.NopCloser(strings.NewReader("not an image")),
+ ContentLength: 25,
+ Request: req,
+ }, nil
+ }
+
+ _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/not-really.png", 1)
+ problem, ok := errs.ProblemOf(err)
+ var validationErr *errs.ValidationError
+ if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
+ !errors.As(err, &validationErr) || validationErr.Param != "href" {
+ t.Fatalf("error = %T %v, problem=%#v validation=%#v", err, err, problem, validationErr)
+ }
+}
+
+func TestDownloadRemoteDocImageContentSupportsWebPDimensions(t *testing.T) {
+ const encodedWebP = "UklGRrIBAABXRUJQVlA4TKUBAAAvSsAYAA8w//M///MfeJAkbXvaSG7m8Q3GfYSBJekwQztm/IcZlgwnmWImn2BK7aFmBtnVir6q//8VOkFE/xm4baTIu8c48ArEo6+B3zFKYln3pqClSCKX0begFTAXFOLXHSyF8cCNcZEG4OywuA4KVVfJCiArU7GAgJI8+lJP/OKMT/fBAjevg1cYB7YVkFuWga2lyPi5I0HFy5YTpWIHg0RZpkniRVW9odHAKOwosWuOGdxIyn2OvaCDvhg/we6TwadPBPbqBV58MsLmMJ8yZnOWk8SRz4N+QoyPL+MnamzMvcE1rHNEr91F9GKZPVUcS9w7PhhH36suB9qPeYb/oLk6cuTiJ0wOK3m5h1cKjW6EVZCYMK7dxcKCBdgP9HkKr9gkAO2P8GKZGWVdIAatQa+1IDpt6qyorVwdy01xdW8Jkfk6xjEXmVQQ+HQdFr6OKhIN34dXWq0+0qr6EJSCeeVLH9+gvGTLyqM65PQ44ihzlTXxQKjKbAvshXgir7Lil9w4L2bvMycmjQcqXaMCO6BlY28i+FOLzbfI1vEqxAhotocAAA=="
+ payload, err := base64.StdEncoding.DecodeString(encodedWebP)
+ if err != nil {
+ t.Fatalf("decode WebP fixture: %v", err)
+ }
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/webp"}},
+ Body: io.NopCloser(bytes.NewReader(payload)),
+ ContentLength: int64(len(payload)),
+ Request: req,
+ }, nil
+ }
+
+ download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.webp", 1)
+ if err != nil {
+ t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
+ }
+ if download.Width <= 0 || download.Height <= 0 || download.FileName != "image.webp" || !bytes.Equal(download.Content, payload) {
+ t.Fatalf("WebP download = %#v", download)
+ }
+}
+
+func TestDownloadRemoteDocImageContentSupportsBMPAndTIFFDimensions(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+
+ tests := []struct {
+ name string
+ contentType string
+ payload []byte
+ width int
+ height int
+ ext string
+ }{
+ {name: "BMP", contentType: "image/bmp", payload: testBMPImage(2, 3), width: 2, height: 3, ext: ".bmp"},
+ {name: "TIFF", contentType: "image/tiff", payload: testTIFFImage(4, 5), width: 4, height: 5, ext: ".tiff"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{tt.contentType}},
+ Body: io.NopCloser(bytes.NewReader(tt.payload)),
+ ContentLength: int64(len(tt.payload)),
+ Request: req,
+ }, nil
+ }
+
+ download, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image"+tt.ext, 1)
+ if err != nil {
+ t.Fatalf("downloadRemoteDocImageContent() error = %v", err)
+ }
+ if download.Width != tt.width || download.Height != tt.height || download.FileName != "image"+tt.ext || !bytes.Equal(download.Content, tt.payload) {
+ t.Fatalf("download = %#v", download)
+ }
+ })
+ }
+}
+
+type remoteImageErrorReader struct{ err error }
+
+func (r remoteImageErrorReader) Read([]byte) (int, error) { return 0, r.err }
+
+func TestDownloadRemoteDocImageContentClassifiesResponseReadFailureAsNetwork(t *testing.T) {
+ readErr := errors.New("connection reset while streaming")
+ runtime := newLocalDocResourceTestRuntime(t, nil)
+
+ originalDo := doRemoteDocImageRequest
+ t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
+ doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"image/png"}},
+ Body: io.NopCloser(remoteImageErrorReader{err: readErr}),
+ ContentLength: -1,
+ Request: req,
+ }, nil
+ }
+
+ _, err := downloadRemoteDocImageContent(runtime, "https://93.184.216.34/image.png", 1)
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport || !problem.Retryable || !errors.Is(err, readErr) {
+ t.Fatalf("error = %T %v, problem=%#v", err, err, problem)
+ }
+ var validationErr *errs.ValidationError
+ if errors.As(err, &validationErr) {
+ t.Fatalf("response read failure was classified as validation: %#v", validationErr)
+ }
+}
+
+func TestRemoteDocImageNetworkErrorDoesNotRetryCanceledContext(t *testing.T) {
+ err := remoteDocImageNetworkError(context.Canceled, 1, "was interrupted")
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Category != errs.CategoryNetwork || problem.Retryable || !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %T %v, problem=%#v", err, err, problem)
+ }
+}
+
+func TestRemoteImageErrorsAndDryRunRedactURLCredentials(t *testing.T) {
+ const rawURL = "https://alice-sensitive:password@93.184.216.34/image.png?token=secret-value#frag-sensitive"
+ requestErr := &url.Error{Op: "Get", URL: rawURL, Err: errors.New("dial failed")}
+ got := remoteDocImageNetworkError(requestErr, 1, "request failed")
+ for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} {
+ if strings.Contains(got.Error(), secret) {
+ t.Errorf("network error leaks %q: %v", secret, got)
+ }
+ }
+
+ dry := appendRemoteDocImageDownloadsDryRun(common.NewDryRunAPI(), []localDocResource{{Occurrence: 1, RemoteURL: rawURL}})
+ raw, err := json.Marshal(dry)
+ if err != nil {
+ t.Fatalf("marshal dry run: %v", err)
+ }
+ for _, secret := range []string{"alice-sensitive", "password", "secret-value", "frag-sensitive"} {
+ if strings.Contains(string(raw), secret) {
+ t.Errorf("dry run leaks %q: %s", secret, raw)
+ }
+ }
+ if !strings.Contains(string(raw), `https://93.184.216.34/image.png`) {
+ t.Fatalf("dry run lost redacted endpoint identity: %s", raw)
+ }
+}
+
+func TestPrepareLocalDocResourcesNormalizesImageDimensionsAndScale(t *testing.T) {
+ tests := []struct {
+ name string
+ nativeWidth int
+ nativeHeight int
+ attrs string
+ wantWidth int
+ wantHeight int
+ wantScale float64
+ wantHasScale bool
+ wantScaleText string
+ }{
+ {
+ name: "intrinsic dimensions without model display size",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ wantWidth: 100,
+ wantHeight: 80,
+ },
+ {
+ name: "model width becomes scale",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` width="50"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ wantScale: 0.5,
+ wantHasScale: true,
+ wantScaleText: `scale="0.500000"`,
+ },
+ {
+ name: "model height becomes scale",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` height="20"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ wantScale: 0.25,
+ wantHasScale: true,
+ wantScaleText: `scale="0.250000"`,
+ },
+ {
+ name: "model width wins when both dimensions exist",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` width="25" height="70"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ wantScale: 0.25,
+ wantHasScale: true,
+ wantScaleText: `scale="0.250000"`,
+ },
+ {
+ name: "explicit scale wins over model dimensions",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` width="50" scale="0.75"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ wantScale: 0.75,
+ wantHasScale: true,
+ wantScaleText: `scale="0.750000"`,
+ },
+ {
+ name: "percentage width becomes scale",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` width="80%"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ wantScale: 0.8,
+ wantHasScale: true,
+ wantScaleText: `scale="0.800000"`,
+ },
+ {
+ name: "invalid model dimensions are ignored",
+ nativeWidth: 100,
+ nativeHeight: 80,
+ attrs: ` width="invalid" height="0" scale="-1"`,
+ wantWidth: 100,
+ wantHeight: 80,
+ },
+ {
+ name: "wide image is capped below page width",
+ nativeWidth: 1200,
+ nativeHeight: 800,
+ wantWidth: 1200,
+ wantHeight: 800,
+ wantScale: 0.849999,
+ wantHasScale: true,
+ wantScaleText: `scale="0.849999"`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{
+ "diagram.png": localDocResourcePNG(t, tt.nativeWidth, tt.nativeHeight),
+ })
+ content := `
`
+
+ got, resources, err := prepareLocalDocResources(runtime, "xml", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources len = %d, want 1: %#v", len(resources), resources)
+ }
+ resource := resources[0]
+ if resource.ImageWidth != tt.wantWidth || resource.ImageHeight != tt.wantHeight {
+ t.Fatalf("intrinsic dimensions = %dx%d, want %dx%d; content=%s", resource.ImageWidth, resource.ImageHeight, tt.wantWidth, tt.wantHeight, got)
+ }
+ if resource.HasScale != tt.wantHasScale || resource.ImageScale != tt.wantScale {
+ t.Fatalf("scale = %v (present=%v), want %v (present=%v); content=%s", resource.ImageScale, resource.HasScale, tt.wantScale, tt.wantHasScale, got)
+ }
+ for _, want := range []string{
+ fmt.Sprintf(`width="%d"`, tt.wantWidth),
+ fmt.Sprintf(`height="%d"`, tt.wantHeight),
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("rewritten content missing %q: %s", want, got)
+ }
+ }
+ if tt.wantHasScale {
+ if !strings.Contains(got, tt.wantScaleText) {
+ t.Fatalf("rewritten content missing %q: %s", tt.wantScaleText, got)
+ }
+ } else if strings.Contains(got, ` scale=`) {
+ t.Fatalf("rewritten content unexpectedly contains scale: %s", got)
+ }
+ })
+ }
+}
+
+// BUG_MAP #1: Markdown alt is persisted by docx_engine through caption, then
+// exported back as Markdown alt. Sending only the SDK-only alt attribute loses
+// the text when the placeholder reaches the engine.
+func TestPrepareLocalDocResourcesMarkdownAltUsesCaption(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", ``)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources len = %d, want 1", len(resources))
+ }
+ if !strings.Contains(got, `caption="architecture diagram"`) {
+ t.Fatalf("Markdown alt must be mapped to engine caption, got: %s", got)
+ }
+ if strings.Contains(got, ` alt=`) {
+ t.Fatalf("rewritten Markdown image must not rely on non-persisted alt: %s", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesMarkdownTitleConsumesQuotedClosingParen(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := `before ") after`
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 {
+ t.Fatalf("resources len = %d, want 1", len(resources))
+ }
+ want := `before
after`
+ if got != want {
+ t.Fatalf("rewritten Markdown image = %q, want %q", got, want)
+ }
+}
+
+func TestParseMarkdownImageDestinationRejectsUnclosedTitle(t *testing.T) {
+ content := `) trailing`
+ if image, ok := parseMarkdownImageAt(content, 0); ok {
+ t.Fatalf("parseMarkdownImageAt() = %#v, true; want invalid unclosed title", image)
+ }
+}
+
+// BUG_MAP #2: image-looking text in inert Markdown contexts must remain text;
+// otherwise the CLI plans an upload for a block the Markdown parser never
+// creates and reports a partial failure after the document write succeeded.
+func TestPrepareLocalDocResourcesMarkdownIgnoresInertContexts(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := "\n \n"
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if got != content {
+ t.Fatalf("inert Markdown was rewritten:\n got: %q\nwant: %q", got, content)
+ }
+ if len(resources) != 0 {
+ t.Fatalf("inert Markdown planned %d resources, want 0: %#v", len(resources), resources)
+ }
+}
+
+func TestPrepareLocalDocResourcesMarkdownListIndentIsRelativeToList(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := "- report:\n\n \n"
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) {
+ t.Fatalf("four-space list content was not rewritten: got=%q resources=%#v", got, resources)
+ }
+ if strings.Contains(got, "@diagram.png") {
+ t.Fatalf("rewritten list content leaked local path: %q", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesMarkdownListIndentedCodeRemainsInert(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := "- report:\n\n \n"
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if got != content || len(resources) != 0 {
+ t.Fatalf("six-space list code was rewritten: got=%q resources=%#v", got, resources)
+ }
+}
+
+func TestPrepareLocalDocResourcesMarkdownThematicBreakDoesNotOpenList(t *testing.T) {
+ for _, thematicBreak := range []string{"* * *", "- - -"} {
+ t.Run(thematicBreak, func(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := thematicBreak + "\n\n \n"
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if got != content || len(resources) != 0 {
+ t.Fatalf("indented code after thematic break was rewritten: got=%q resources=%#v", got, resources)
+ }
+ })
+ }
+}
+
+// BUG_MAP #3: internal correlation markers are an implementation detail and
+// must never escape in a partial-failure result, even if no document ID was
+// returned and cleanup therefore cannot run.
+func TestFinalizeLocalDocResourcesScrubsMarkerWhenDocumentIDMissing(t *testing.T) {
+ marker := "@lcli_img_0123456789abcdef0123456789abcdef"
+ factory, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-marker-scrub"))
+ runtime := common.TestNewRuntimeContextForAPI(
+ context.Background(),
+ &cobra.Command{Use: "docs +create"},
+ docsTestConfigWithAppID("local-resource-marker-scrub"),
+ factory,
+ core.AsUser,
+ )
+ block := map[string]interface{}{
+ "block_id": "blk_placeholder",
+ "block_type": "image",
+ "block_token": marker,
+ }
+ data := map[string]interface{}{
+ "document": map[string]interface{}{
+ "new_blocks": []interface{}{block},
+ },
+ }
+
+ err := finalizeLocalDocResources(runtime, "", data, []localDocResource{{
+ Occurrence: 1,
+ Kind: localDocResourceImage,
+ Marker: marker,
+ }})
+ if err == nil {
+ t.Fatal("finalizeLocalDocResources() error = nil, want partial failure")
+ }
+ if token := common.GetString(block, "block_token"); token != "" {
+ t.Fatalf("partial-failure response leaked marker %q", token)
+ }
+ var envelope struct {
+ OK bool `json:"ok"`
+ Data map[string]interface{} `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("unmarshal partial-failure response: %v\nstdout: %s", err, stdout.String())
+ }
+ if envelope.OK {
+ t.Fatalf("partial failure reported ok:true: %s", stdout.String())
+ }
+ for _, internalField := range []string{"summary", "items"} {
+ if _, exposed := envelope.Data[internalField]; exposed {
+ t.Fatalf("partial-failure response exposed internal field %q: %s", internalField, stdout.String())
+ }
+ }
+}
+
+func TestNewLocalDocResourceRejectsTraversalAndSymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ work := filepath.Join(root, "work")
+ outside := filepath.Join(root, "outside")
+ if err := os.MkdirAll(work, 0o700); err != nil {
+ t.Fatalf("MkdirAll(work): %v", err)
+ }
+ if err := os.MkdirAll(outside, 0o700); err != nil {
+ t.Fatalf("MkdirAll(outside): %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0o600); err != nil {
+ t.Fatalf("WriteFile(secret): %v", err)
+ }
+ if err := os.Symlink(filepath.Join(outside, "secret.png"), filepath.Join(work, "escape.png")); err != nil {
+ t.Fatalf("Symlink: %v", err)
+ }
+ cmdutil.TestChdir(t, work)
+ factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-path"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-resource-path"), factory, core.AsUser)
+
+ for _, path := range []string{"@../outside/secret.png", "@escape.png"} {
+ if _, err := newLocalDocResource(runtime, localDocResourceImage, path, 1); err == nil {
+ t.Fatalf("newLocalDocResource(%q) error = nil, want unsafe path rejection", path)
+ }
+ }
+}
+
+func TestCorrelateLocalDocResourcesMatchesTypeAndBlockID(t *testing.T) {
+ marker := "@lcli_file_0123456789abcdef0123456789abcdef"
+ block := map[string]interface{}{
+ "block_id": "blk_file",
+ "block_type": "file",
+ "block_token": marker,
+ }
+ data := map[string]interface{}{
+ "document": map[string]interface{}{"new_blocks": []interface{}{block}},
+ }
+
+ outcomes := correlateLocalDocResources(data, []localDocResource{{Occurrence: 1, Kind: localDocResourceFile, Marker: marker}})
+ if len(outcomes) != 1 {
+ t.Fatalf("outcomes len = %d, want 1", len(outcomes))
+ }
+ if outcomes[0].Status != "pending" || outcomes[0].BlockID != "blk_file" || !outcomes[0].SafeToCleanup {
+ t.Fatalf("outcome = %#v", outcomes[0])
+ }
+}
+
+func TestCorrelateLocalDocResourcesTypeMismatchIsNotSafeToCleanup(t *testing.T) {
+ marker := "@lcli_img_0123456789abcdef0123456789abcdef"
+ outcomes := correlateLocalDocResources(map[string]interface{}{
+ "document": map[string]interface{}{"new_blocks": []interface{}{
+ map[string]interface{}{"block_id": "blk_mismatch", "block_type": "file", "block_token": marker},
+ }},
+ }, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: marker}})
+
+ if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup {
+ t.Fatalf("mismatched block outcome = %#v", outcomes)
+ }
+ if len(outcomes[0].CleanupBlockIDs) != 0 || outcomes[0].CleanupStatus != "skipped_ambiguous" {
+ t.Fatalf("mismatched block was scheduled for cleanup: %#v", outcomes[0])
+ }
+}
+
+func TestCorrelateLocalDocResourcesUnknownMarkerDisablesCleanup(t *testing.T) {
+ expectedMarker := "@lcli_img_0123456789abcdef0123456789abcdef"
+ unknownMarker := "@lcli_file_fedcba9876543210fedcba9876543210"
+ outcomes := correlateLocalDocResources(map[string]interface{}{
+ "document": map[string]interface{}{"new_blocks": []interface{}{
+ map[string]interface{}{"block_id": "blk_expected", "block_type": "image", "block_token": expectedMarker},
+ map[string]interface{}{"block_id": "blk_unknown", "block_type": "file", "block_token": unknownMarker},
+ }},
+ }, []localDocResource{{Occurrence: 1, Kind: localDocResourceImage, Marker: expectedMarker}})
+
+ if len(outcomes) != 1 || outcomes[0].Status != "correlation_failed" || outcomes[0].SafeToCleanup {
+ t.Fatalf("unknown marker outcome = %#v", outcomes)
+ }
+ if outcomes[0].CleanupStatus != "skipped_ambiguous" {
+ t.Fatalf("unknown marker cleanup status = %q, want skipped_ambiguous", outcomes[0].CleanupStatus)
+ }
+ for _, blockID := range outcomes[0].CleanupBlockIDs {
+ if blockID == "blk_unknown" {
+ t.Fatalf("unknown marker block was scheduled for cleanup: %#v", outcomes[0])
+ }
+ }
+}
+
+func TestBuildLocalDocResourceBatchUpdatePreservesImagePresentation(t *testing.T) {
+ image := &localDocResourceOutcome{
+ Resource: localDocResource{
+ Kind: localDocResourceImage,
+ ImageWidth: 640,
+ ImageHeight: 480,
+ ImageAlign: "right",
+ ImageScale: 0.5,
+ HasScale: true,
+ },
+ BlockID: "blk_image",
+ FileToken: "file_image",
+ }
+ file := &localDocResourceOutcome{
+ Resource: localDocResource{Kind: localDocResourceFile},
+ BlockID: "blk_file",
+ FileToken: "file_attachment",
+ }
+
+ body := buildLocalDocResourceBatchUpdate([]*localDocResourceOutcome{image, file})
+ requests, _ := body["requests"].([]interface{})
+ if len(requests) != 2 {
+ t.Fatalf("requests len = %d, want 2: %#v", len(requests), body)
+ }
+ imageReq, _ := requests[0].(map[string]interface{})
+ replaceImage, _ := imageReq["replace_image"].(map[string]interface{})
+ for key, want := range map[string]interface{}{
+ "token": "file_image",
+ "width": 640,
+ "height": 480,
+ "align": alignMap["right"],
+ "scale": 0.5,
+ } {
+ if got := replaceImage[key]; got != want {
+ t.Fatalf("replace_image[%s] = %#v, want %#v; body=%#v", key, got, want, body)
+ }
+ }
+ fileReq, _ := requests[1].(map[string]interface{})
+ replaceFile, _ := fileReq["replace_file"].(map[string]interface{})
+ if got := replaceFile["token"]; got != "file_attachment" {
+ t.Fatalf("replace_file token = %#v, want file_attachment", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesSourceUsesExplicitUploadName(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"})
+
+ got, resources, err := prepareLocalDocResources(runtime, "xml", ``)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources() error: %v", err)
+ }
+ if len(resources) != 1 || resources[0].FileName != "自定义报告.pdf" {
+ t.Fatalf("resources = %#v, want trimmed explicit file name", resources)
+ }
+ if !strings.Contains(got, `name="自定义报告.pdf"`) {
+ t.Fatalf("rewritten source did not preserve trimmed name: %s", got)
+ }
+}
+
+func TestPrepareLocalDocResourcesRejectsInvalidSourceName(t *testing.T) {
+ for _, name := range []string{" ", "../secret.pdf", `folder\secret.pdf`} {
+ t.Run(strings.ReplaceAll(name, "/", "_"), func(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"report.pdf": "pdf-data"})
+ _, _, err := prepareLocalDocResources(runtime, "xml", ``)
+ if err == nil {
+ t.Fatalf("source name %q was accepted", name)
+ }
+ })
+ }
+}
+
+func TestUploadLocalDocResourceUsesExplicitSourceName(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("report.pdf", []byte("pdf-data"), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-source-name"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-source-name"), factory, core.AsUser)
+ _, resources, err := prepareLocalDocResources(runtime, "xml", ``)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+ upload := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"file_token": "file_custom_name"},
+ },
+ }
+ reg.Register(upload)
+ outcome := &localDocResourceOutcome{Resource: resources[0], BlockID: "blk_file", Status: "pending"}
+ uploadLocalDocResources(runtime, "doxcn_source_name", []*localDocResourceOutcome{outcome})
+ if outcome.Status != "uploaded" || outcome.FileToken != "file_custom_name" {
+ t.Fatalf("outcome = %#v", outcome)
+ }
+ body := string(upload.CapturedBody)
+ if !strings.Contains(body, "自定义报告.pdf") || strings.Contains(body, "\r\n\r\nreport.pdf\r\n") {
+ t.Fatalf("upload body did not use explicit source name: %s", body)
+ }
+}
+
+func TestUploadRemoteDocImagesUsesBoundedConcurrency(t *testing.T) {
+ config := docsTestConfigWithAppID("remote-image-concurrent-upload")
+ factory, _, _, reg := cmdutil.TestFactory(t, config)
+ var credentialReady atomic.Bool
+ factory.Credential = credential.NewCredentialProvider(
+ nil,
+ &remoteImageTestAccountResolver{config: config},
+ &remoteImageTestTokenResolver{resolved: &credentialReady},
+ nil,
+ )
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, config, factory, core.AsUser)
+ payload := []byte(localDocResourcePNG(t, 20, 10))
+ originalDownload := downloadRemoteDocImage
+ t.Cleanup(func() { downloadRemoteDocImage = originalDownload })
+ var downloadMu sync.Mutex
+ downloads := 0
+ var downloadBeforeCredential atomic.Bool
+ downloadRemoteDocImage = func(_ *common.RuntimeContext, _ string, _ int) (remoteDocImageDownload, error) {
+ if !credentialReady.Load() {
+ downloadBeforeCredential.Store(true)
+ }
+ downloadMu.Lock()
+ downloads++
+ downloadMu.Unlock()
+ return remoteDocImageDownload{Content: payload, FileName: "remote.png", Width: 20, Height: 10}, nil
+ }
+
+ releaseUploads := make(chan struct{})
+ reachedConcurrentUpload := make(chan struct{})
+ var concurrentOnce sync.Once
+ var uploadMu sync.Mutex
+ activeUploads := 0
+ maxActiveUploads := 0
+ upload := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/drive/v1/medias/upload_all",
+ Reusable: true,
+ OnMatch: func(*http.Request) {
+ uploadMu.Lock()
+ activeUploads++
+ if activeUploads > maxActiveUploads {
+ maxActiveUploads = activeUploads
+ }
+ if activeUploads >= 2 {
+ concurrentOnce.Do(func() { close(reachedConcurrentUpload) })
+ }
+ uploadMu.Unlock()
+ <-releaseUploads
+ uploadMu.Lock()
+ activeUploads--
+ uploadMu.Unlock()
+ },
+ Body: map[string]interface{}{
+ "code": 0,
+ "data": map[string]interface{}{"file_token": "file_remote_image"},
+ },
+ }
+ reg.Register(upload)
+ outcomes := make([]*localDocResourceOutcome, remoteDocImageUploadConcurrency+2)
+ for i := range outcomes {
+ occurrence := i + 1
+ outcomes[i] = &localDocResourceOutcome{
+ Resource: localDocResource{
+ Occurrence: occurrence,
+ Kind: localDocResourceImage,
+ RemoteURL: fmt.Sprintf("https://93.184.216.34/%d.png", occurrence),
+ Content: payload,
+ },
+ BlockID: fmt.Sprintf("blk_remote_image_%d", occurrence),
+ Status: "pending",
+ }
+ }
+
+ done := make(chan struct{})
+ go func() {
+ uploadLocalDocResources(runtime, "doxcn_remote_image", outcomes)
+ close(done)
+ }()
+ select {
+ case <-reachedConcurrentUpload:
+ close(releaseUploads)
+ case <-time.After(2 * time.Second):
+ close(releaseUploads)
+ <-done
+ t.Fatalf("remote image uploads did not overlap; max active uploads = %d", maxActiveUploads)
+ }
+ <-done
+ downloadMu.Lock()
+ gotDownloads := downloads
+ downloadMu.Unlock()
+ if gotDownloads != len(outcomes) {
+ t.Fatalf("remote image downloads = %d, want %d", gotDownloads, len(outcomes))
+ }
+ if downloadBeforeCredential.Load() {
+ t.Fatal("remote image worker started before credentials were resolved")
+ }
+ if maxActiveUploads < 2 {
+ t.Fatalf("max active remote image uploads = %d, want at least 2", maxActiveUploads)
+ }
+ if maxActiveUploads > remoteDocImageUploadConcurrency {
+ t.Fatalf("max active remote image uploads = %d, concurrency limit = %d", maxActiveUploads, remoteDocImageUploadConcurrency)
+ }
+ for _, outcome := range outcomes {
+ if outcome.Status != "uploaded" || outcome.FileToken != "file_remote_image" {
+ t.Fatalf("outcome = %#v", outcome)
+ }
+ if len(outcome.Resource.Content) != 0 {
+ t.Fatalf("remote image #%d retained %d buffered bytes after upload", outcome.Resource.Occurrence, len(outcome.Resource.Content))
+ }
+ }
+}
+
+type remoteImageTestAccountResolver struct {
+ config *core.CliConfig
+}
+
+func (r *remoteImageTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
+ return credential.AccountFromCliConfig(r.config), nil
+}
+
+type remoteImageTestTokenResolver struct {
+ resolved *atomic.Bool
+}
+
+func (r *remoteImageTestTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) {
+ r.resolved.Store(true)
+ return &credential.TokenResult{Token: "test-token"}, nil
+}
+
+func TestUploadLocalDocResourcesRetriesConflictAndSerializes(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ for _, name := range []string{"first.png", "second.png"} {
+ if err := os.WriteFile(name, []byte(name), 0o600); err != nil {
+ t.Fatalf("WriteFile(%s): %v", name, err)
+ }
+ }
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-upload-serial"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-upload-serial"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": localDocResourceUploadConflictCode, "msg": "material transaction conflict"}})
+ reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_first"}}})
+ reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/drive/v1/medias/upload_all", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "file_second"}}})
+ outcomes := []*localDocResourceOutcome{
+ {Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage, Path: "first.png", FileName: "first.png", Size: int64(len("first.png"))}, BlockID: "blk_first", Status: "pending"},
+ {Resource: localDocResource{Occurrence: 2, Kind: localDocResourceImage, Path: "second.png", FileName: "second.png", Size: int64(len("second.png"))}, BlockID: "blk_second", Status: "pending"},
+ }
+ var waits []time.Duration
+ originalWait := waitLocalDocResourceRequest
+ waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
+ waits = append(waits, delay)
+ return nil
+ }
+ t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
+ uploadLocalDocResources(runtime, "doxcn_upload_serial", outcomes)
+ if outcomes[0].FileToken != "file_first" || outcomes[1].FileToken != "file_second" {
+ t.Fatalf("outcomes = %#v", outcomes)
+ }
+ if len(waits) != 2 {
+ t.Fatalf("upload pacing waits = %#v", waits)
+ }
+ retryWaits := waits[:1]
+ assertLocalDocResourceRetryWaits(t, retryWaits, 1)
+ if waits[1] != localDocResourceUploadInterval {
+ t.Fatalf("serial upload pacing wait = %v, want %v", waits[1], localDocResourceUploadInterval)
+ }
+}
+
+func assertLocalDocResourceRetryWaits(t *testing.T, waits []time.Duration, want int) {
+ t.Helper()
+ if len(waits) != want {
+ t.Fatalf("retry waits = %#v, want %d", waits, want)
+ }
+ for attempt, got := range waits {
+ base := localDocResourceUploadInterval * time.Duration(1< max {
+ t.Fatalf("retry wait[%d] = %v, want in [%v, %v]", attempt, got, base, max)
+ }
+ }
+}
+
+func TestPrepareLocalDocResourcesRejectsDuplicateAttributes(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{
+ "diagram.png": localDocResourcePNG(t, 2, 1),
+ "secret.png": localDocResourcePNG(t, 3, 1),
+ })
+ _, resources, err := prepareLocalDocResources(runtime, "xml", `
`)
+ if err == nil {
+ t.Fatal("duplicate path attributes were accepted")
+ }
+ if len(resources) != 0 {
+ t.Fatalf("duplicate attributes planned resources: %#v", resources)
+ }
+ if strings.Contains(err.Error(), "secret.png") {
+ t.Fatalf("duplicate-attribute error leaked the second local path: %v", err)
+ }
+}
+
+func TestCorrelateLocalDocResourcesRejectsDuplicateBlockID(t *testing.T) {
+ markers := []string{
+ "@lcli_img_0123456789abcdef0123456789abcdef",
+ "@lcli_img_fedcba9876543210fedcba9876543210",
+ }
+ blocks := []interface{}{
+ map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[0]},
+ map[string]interface{}{"block_id": "blk_shared", "block_type": "image", "block_token": markers[1]},
+ }
+ outcomes := correlateLocalDocResources(map[string]interface{}{
+ "document": map[string]interface{}{"new_blocks": blocks},
+ }, []localDocResource{
+ {Occurrence: 1, Kind: localDocResourceImage, Marker: markers[0]},
+ {Occurrence: 2, Kind: localDocResourceImage, Marker: markers[1]},
+ })
+ cleanupCount := 0
+ for _, outcome := range outcomes {
+ if outcome.Status != "correlation_failed" {
+ t.Fatalf("duplicate block outcome = %#v", outcome)
+ }
+ cleanupCount += len(outcome.CleanupBlockIDs)
+ }
+ if cleanupCount != 1 {
+ t.Fatalf("cleanup block IDs = %d, want one deduplicated delete", cleanupCount)
+ }
+}
+
+func TestPrepareLocalDocResourcesMarkdownIgnoresNestedFencesAndRawHTML(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ content := strings.Join([]string{
+ "> ```xml",
+ ">
",
+ "> ```",
+ "- ```markdown",
+ " ",
+ " ```",
+ "",
+ "```",
+ "
",
+ "```",
+ "
",
+ "",
+ "",
+ "",
+ `\
`,
+ }, "\n")
+
+ got, resources, err := prepareLocalDocResources(runtime, "markdown", content)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+ if got != content || len(resources) != 0 {
+ t.Fatalf("inert Markdown changed: resources=%#v\n got=%q\nwant=%q", resources, got, content)
+ }
+}
+
+func TestPrepareLocalDocResourcesXMLBackticksAreNotInert(t *testing.T) {
+ runtime := newLocalDocResourceTestRuntime(t, map[string]string{"diagram.png": localDocResourcePNG(t, 2, 1)})
+ got, resources, err := prepareLocalDocResources(runtime, "xml", "`
`")
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+ if len(resources) != 1 || !strings.Contains(got, resources[0].Marker) {
+ t.Fatalf("XML backticks incorrectly hid resource: got=%q resources=%#v", got, resources)
+ }
+}
+
+func TestMarkdownUnescapePreservesNonPunctuationBackslashes(t *testing.T) {
+ if got, want := unescapeMarkdownText(`C:\temp\photo \*draft\* \\`), `C:\temp\photo *draft* \`; got != want {
+ t.Fatalf("unescapeMarkdownText() = %q, want %q", got, want)
+ }
+ image, ok := parseMarkdownImageAt(``, 0)
+ if !ok || image.Destination != `@images\photo.png` {
+ t.Fatalf("parsed destination = %q, ok=%v", image.Destination, ok)
+ }
+}
+
+func TestBindLocalDocResourcesBatchesTwentyAndPaces(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-batch"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-batch"), factory, core.AsUser)
+ first := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 1}}}
+ second := &httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_batch/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document_revision_id": 2}}}
+ reg.Register(first)
+ reg.Register(second)
+ outcomes := make([]*localDocResourceOutcome, 21)
+ for i := range outcomes {
+ outcomes[i] = &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: i + 1, Kind: localDocResourceImage},
+ BlockID: fmt.Sprintf("blk_%d", i),
+ FileToken: fmt.Sprintf("file_%d", i),
+ Status: "uploaded",
+ }
+ }
+ var waits []time.Duration
+ originalWait := waitLocalDocResourceRequest
+ waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
+ waits = append(waits, delay)
+ return nil
+ }
+ t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
+
+ revision, revisionKnown := bindLocalDocResources(runtime, "doxcn_batch", outcomes)
+ if !revisionKnown || revision != int64(2) {
+ t.Fatalf("revision = %#v, want 2", revision)
+ }
+ if got := requestCountFromLocalDocBatchBody(t, first.CapturedBody); got != 20 {
+ t.Fatalf("first batch size = %d, want 20", got)
+ }
+ if got := requestCountFromLocalDocBatchBody(t, second.CapturedBody); got != 1 {
+ t.Fatalf("second batch size = %d, want 1", got)
+ }
+ if len(waits) != 1 || waits[0] != localDocResourceBindInterval {
+ t.Fatalf("bind pacing waits = %#v", waits)
+ }
+}
+
+func TestNormalizeLocalDocResourceRevision(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ value interface{}
+ want interface{}
+ }{
+ {name: "integer", value: 7, want: int64(7)},
+ {name: "float integer", value: float64(8), want: int64(8)},
+ {name: "json number", value: json.Number("9"), want: int64(9)},
+ {name: "numeric string", value: "10", want: int64(10)},
+ {name: "negative sentinel", value: -1},
+ {name: "fraction", value: 1.5},
+ {name: "invalid string", value: "latest"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := normalizeLocalDocResourceRevision(tt.value); got != tt.want {
+ t.Fatalf("normalizeLocalDocResourceRevision(%#v) = %#v, want %#v", tt.value, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestBindLocalDocResourceRetryUsesStableTokenAndBackoff(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-retry"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-retry"), factory, core.AsUser)
+ var clientTokens []string
+ for _, stub := range []*httpmock.Stub{
+ {Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }},
+ {Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/blk_retry", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": ""}}}}},
+ {Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_retry/blocks/batch_update", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, OnMatch: func(req *http.Request) { clientTokens = append(clientTokens, req.URL.Query().Get("client_token")) }},
+ } {
+ reg.Register(stub)
+ }
+ var waits []time.Duration
+ originalWait := waitLocalDocResourceRequest
+ waitLocalDocResourceRequest = func(_ context.Context, delay time.Duration) error {
+ waits = append(waits, delay)
+ return nil
+ }
+ t.Cleanup(func() { waitLocalDocResourceRequest = originalWait })
+ outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_retry", FileToken: "file_retry", Status: "uploaded"}
+ _, _ = bindLocalDocResourceChunk(runtime, "doxcn_retry", []*localDocResourceOutcome{outcome})
+ if outcome.Status != "bound" || len(clientTokens) != 2 || clientTokens[0] == "" || clientTokens[0] != clientTokens[1] {
+ t.Fatalf("outcome=%#v client_tokens=%#v", outcome, clientTokens)
+ }
+ if len(waits) != 1 || waits[0] != localDocResourceBindInterval {
+ t.Fatalf("retry waits = %#v", waits)
+ }
+}
+
+func TestBindLocalDocResourceAmbiguousSuccessDropsStaleRevision(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-bind-ambiguous-success"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-bind-ambiguous-success"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{Method: "PATCH", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/batch_update", Body: map[string]interface{}{"code": localDocResourceUploadRateLimitCode, "msg": "rate limited"}})
+ reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_ambiguous/blocks/blk_ambiguous", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"image": map[string]interface{}{"token": "file_ambiguous"}}}}})
+ outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, BlockID: "blk_ambiguous", FileToken: "file_ambiguous", Status: "uploaded"}
+ revision, revisionKnown := bindLocalDocResourceChunk(runtime, "doxcn_ambiguous", []*localDocResourceOutcome{outcome})
+ if revision != nil || revisionKnown || outcome.Status != "bound" {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+}
+
+func TestCleanupLocalDocResourcePlaceholdersRevalidatesToken(t *testing.T) {
+ tests := []struct {
+ name string
+ getStatus int
+ block map[string]interface{}
+ wantDelete bool
+ wantStatus string
+ wantCleanup string
+ wantKnown bool
+ wantRevision interface{}
+ }{
+ {name: "empty", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}, wantDelete: true, wantStatus: "bind_failed", wantCleanup: "succeeded", wantKnown: true, wantRevision: int64(8)},
+ {name: "ours", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_ours"}}, wantStatus: "bound", wantCleanup: "not_needed"},
+ {name: "other", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": "file_other"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"},
+ {name: "other_kind_token", block: map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}, "file": map[string]interface{}{"token": "file_hidden"}}, wantStatus: "bind_conflict", wantCleanup: "skipped_conflict"},
+ {name: "type_mismatch", block: map[string]interface{}{"block_type": 23, "file": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
+ {name: "type_unknown", block: map[string]interface{}{"block_type": 999, "image": map[string]interface{}{"token": ""}}, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
+ {name: "get_fail", getStatus: 503, wantStatus: "bind_ambiguous", wantCleanup: "skipped_ambiguous"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-"+tt.name))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-"+tt.name), factory, core.AsUser)
+ getStub := &httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup"}
+ if tt.getStatus != 0 {
+ getStub.Status = tt.getStatus
+ getStub.RawBody = []byte("temporary")
+ } else {
+ getStub.Body = map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": tt.block}}
+ }
+ reg.Register(getStub)
+ deleteCalls := 0
+ var deleteStub *httpmock.Stub
+ if tt.wantDelete {
+ deleteStub = &httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}}, OnMatch: func(*http.Request) { deleteCalls++ }}
+ reg.Register(deleteStub)
+ }
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage},
+ BlockID: "blk_cleanup",
+ CleanupBlockIDs: []string{"blk_cleanup"},
+ FileToken: "file_ours",
+ Status: "bind_failed",
+ CleanupStatus: "pending",
+ SafeToCleanup: true,
+ }
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
+ if got := deleteCalls > 0; got != tt.wantDelete {
+ t.Fatalf("delete called=%v, want %v", got, tt.wantDelete)
+ }
+ if revisionKnown != tt.wantKnown || revision != tt.wantRevision {
+ t.Fatalf("revision=%#v known=%v, want revision=%#v known=%v", revision, revisionKnown, tt.wantRevision, tt.wantKnown)
+ }
+ if outcome.Status != tt.wantStatus || outcome.CleanupStatus != tt.wantCleanup || outcome.SafeToCleanup {
+ t.Fatalf("outcome=%#v, want status=%s cleanup=%s safe=false", outcome, tt.wantStatus, tt.wantCleanup)
+ }
+ if tt.wantDelete {
+ var body map[string]interface{}
+ if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil {
+ t.Fatalf("decode cleanup body: %v", err)
+ }
+ if body["revision_id"] != float64(7) {
+ t.Fatalf("cleanup revision_id=%#v, want 7", body["revision_id"])
+ }
+ }
+ })
+ }
+}
+
+func TestCleanupLocalDocResourcePlaceholdersRequiresKnownRevision(t *testing.T) {
+ factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-no-revision"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-no-revision"), factory, core.AsUser)
+ outcome := &localDocResourceOutcome{Resource: localDocResource{Kind: localDocResourceImage}, CleanupBlockIDs: []string{"blk_cleanup"}, Status: "upload_failed", CleanupStatus: "pending", SafeToCleanup: true}
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, nil)
+ if revision != nil || revisionKnown || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+}
+
+func TestCleanupLocalDocResourcePlaceholdersPreservesServiceFailure(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-service-failure"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-service-failure"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_cleanup",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_type": 27,
+ "image": map[string]interface{}{"token": ""},
+ }}},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "PUT",
+ URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
+ "result": "failed",
+ "warnings": []interface{}{"target block cannot be deleted"},
+ }},
+ })
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceImage},
+ BlockID: "blk_cleanup",
+ CleanupBlockIDs: []string{"blk_cleanup"},
+ Status: "upload_failed",
+ CleanupStatus: "pending",
+ SafeToCleanup: true,
+ }
+
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
+
+ if revision != nil || revisionKnown || outcome.CleanupStatus != "failed" || outcome.SafeToCleanup {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+ if len(outcome.ServerWarnings) != 1 || outcome.ServerWarnings[0] != "target block cannot be deleted" {
+ t.Fatalf("server warnings = %#v", outcome.ServerWarnings)
+ }
+ problem, ok := errs.ProblemOf(outcome.Err)
+ if !ok || problem.Category != errs.CategoryAPI {
+ t.Fatalf("cleanup error = %T %v, problem=%#v", outcome.Err, outcome.Err, problem)
+ }
+}
+
+func TestCleanupLocalDocResourceFileDeletesFigureParent(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-parent"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-parent"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_id": "blk_file",
+ "block_type": 23,
+ "parent_id": "blk_figure",
+ "file": map[string]interface{}{"token": ""},
+ }}},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_figure",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_id": "blk_figure",
+ "block_type": 33,
+ "children": []interface{}{"blk_file"},
+ "view": map[string]interface{}{"view_type": 2},
+ }}},
+ })
+ deleteStub := &httpmock.Stub{
+ Method: "PUT",
+ URL: "/open-apis/docs_ai/v1/documents/doxcn_cleanup",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 8}}},
+ }
+ reg.Register(deleteStub)
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
+ BlockID: "blk_file",
+ CleanupBlockIDs: []string{"blk_file"},
+ Status: "upload_failed",
+ CleanupStatus: "pending",
+ SafeToCleanup: true,
+ }
+
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
+
+ if !revisionKnown || revision != int64(8) || outcome.CleanupStatus != "succeeded" {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(deleteStub.CapturedBody, &body); err != nil {
+ t.Fatalf("decode cleanup body: %v", err)
+ }
+ if body["block_id"] != "blk_figure" {
+ t.Fatalf("cleanup block_id=%#v, want figure parent", body["block_id"])
+ }
+}
+
+func TestCleanupLocalDocResourceFileDoesNotDeleteInlineParent(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-inline-file"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-inline-file"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_id": "blk_file",
+ "block_type": 23,
+ "parent_id": "blk_paragraph",
+ "file": map[string]interface{}{"token": ""},
+ }}},
+ })
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_paragraph",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_id": "blk_paragraph",
+ "block_type": 2,
+ "children": []interface{}{"blk_file"},
+ "text": map[string]interface{}{"elements": []interface{}{map[string]interface{}{"text_run": map[string]interface{}{"content": "keep me"}}}},
+ }}},
+ })
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
+ BlockID: "blk_file",
+ CleanupBlockIDs: []string{"blk_file"},
+ Status: "upload_failed",
+ CleanupStatus: "pending",
+ SafeToCleanup: true,
+ }
+
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
+
+ if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+ if outcome.Err == nil || !strings.Contains(outcome.Err.Error(), "sole-source figure") {
+ t.Fatalf("outcome error = %v, want parent verification failure", outcome.Err)
+ }
+}
+
+func TestCleanupLocalDocResourceFileWithoutFigureParentIsPreserved(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-cleanup-file-no-parent"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-cleanup-file-no-parent"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{
+ Method: "GET",
+ URL: "/open-apis/docx/v1/documents/doxcn_cleanup/blocks/blk_file",
+ Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{
+ "block_id": "blk_file",
+ "block_type": 23,
+ "file": map[string]interface{}{"token": ""},
+ }}},
+ })
+ outcome := &localDocResourceOutcome{
+ Resource: localDocResource{Occurrence: 1, Kind: localDocResourceFile},
+ BlockID: "blk_file",
+ CleanupBlockIDs: []string{"blk_file"},
+ Status: "upload_failed",
+ CleanupStatus: "pending",
+ SafeToCleanup: true,
+ }
+
+ revision, revisionKnown := cleanupLocalDocResourcePlaceholders(runtime, "doxcn_cleanup", []*localDocResourceOutcome{outcome}, float64(7))
+
+ if revision != nil || revisionKnown || outcome.Status != "bind_ambiguous" || outcome.CleanupStatus != "skipped_ambiguous" || outcome.SafeToCleanup {
+ t.Fatalf("revision=%#v known=%v outcome=%#v", revision, revisionKnown, outcome)
+ }
+}
+
+func TestDocAPINullDataIsTypedInvalidResponse(t *testing.T) {
+ factory, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-null-data"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-null-data"), factory, core.AsUser)
+ reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_null", Body: map[string]interface{}{"code": 0, "data": nil}})
+ _, err := doDocAPI(runtime, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_null", map[string]interface{}{})
+ problem, ok := errs.ProblemOf(err)
+ if !ok || problem.Subtype != errs.SubtypeInvalidResponse {
+ t.Fatalf("error = %T %v, want typed invalid_response", err, err)
+ }
+}
+
+func TestFinalizeLocalDocResourcesTOCTOUErrorDoesNotLeakCWD(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("vanished.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ factory, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-toctou"))
+ runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, docsTestConfigWithAppID("local-toctou"), factory, core.AsUser)
+ _, resources, err := prepareLocalDocResources(runtime, "xml", `
`)
+ if err != nil {
+ t.Fatalf("prepareLocalDocResources: %v", err)
+ }
+ if err := os.Remove("vanished.png"); err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+ block := map[string]interface{}{"block_id": "blk_vanished", "block_type": "image", "block_token": resources[0].Marker}
+ data := map[string]interface{}{"document": map[string]interface{}{"revision_id": 1, "new_blocks": []interface{}{block}}}
+ reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/docx/v1/documents/doxcn_toctou/blocks/blk_vanished", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block": map[string]interface{}{"block_type": 27, "image": map[string]interface{}{"token": ""}}}}})
+ reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/docs_ai/v1/documents/doxcn_toctou", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"document": map[string]interface{}{"revision_id": 2}}}})
+ if err := finalizeLocalDocResources(runtime, "doxcn_toctou", data, resources); err == nil {
+ t.Fatal("finalizeLocalDocResources error = nil, want partial failure")
+ }
+ public, _ := json.Marshal(data)
+ public = append(public, stdout.Bytes()...)
+ for _, secret := range []string{dir, "vanished.png", resources[0].Marker} {
+ if strings.Contains(string(public), secret) {
+ t.Fatalf("partial response leaked %q: %s", secret, public)
+ }
+ }
+ var envelope struct {
+ Data struct {
+ Failures []struct {
+ Occurrence int `json:"occurrence"`
+ Kind string `json:"kind"`
+ Status string `json:"status"`
+ Cleanup string `json:"cleanup_status"`
+ Error struct {
+ Type string `json:"type"`
+ Subtype string `json:"subtype"`
+ } `json:"error"`
+ } `json:"local_resource_failures"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode partial failure: %v", err)
+ }
+ if len(envelope.Data.Failures) != 1 || envelope.Data.Failures[0].Occurrence != 1 ||
+ envelope.Data.Failures[0].Kind != "image" || envelope.Data.Failures[0].Status != "upload_failed" ||
+ envelope.Data.Failures[0].Error.Type == "" || envelope.Data.Failures[0].Error.Subtype == "" {
+ t.Fatalf("failure details = %#v", envelope.Data.Failures)
+ }
+}
+
+func TestLocalDocResourceDryRunIncludesRouteExtraPartSizeAndTwentyBatch(t *testing.T) {
+ resources := make([]localDocResource, 21)
+ for i := range resources {
+ resources[i] = localDocResource{Occurrence: i + 1, Kind: localDocResourceImage, Size: common.MaxDriveMediaUploadSinglePartSize + 1}
+ }
+ dry := appendLocalDocResourcesDryRun(common.NewDryRunAPI(), "doc/with space", resources)
+ decoded := decodeDocDryRun(t, dry)
+ patches := 0
+ for _, api := range decoded.API {
+ switch {
+ case api.URL == "/open-apis/drive/v1/medias/upload_prepare":
+ if !strings.Contains(fmt.Sprint(api.Body["extra"]), "drive_route_token") || api.Body["size"] == nil {
+ t.Fatalf("upload_prepare body = %#v", api.Body)
+ }
+ case api.URL == "/open-apis/drive/v1/medias/upload_part":
+ if api.Body["size"] == nil {
+ t.Fatalf("upload_part body missing size: %#v", api.Body)
+ }
+ case strings.Contains(api.URL, "/blocks/batch_update"):
+ patches++
+ if !strings.Contains(api.URL, "doc%2Fwith%20space") {
+ t.Fatalf("batch URL is not encoded: %s", api.URL)
+ }
+ }
+ }
+ if patches != 2 {
+ t.Fatalf("batch PATCH count = %d, want 2", patches)
+ }
+}
+
+func TestDocsUpdateLocalResourceWikiDryRunResolvesDocxFirst(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "doc": "https://example.larksuite.com/wiki/wikcn_local",
+ "command": "append",
+ "content": `
`,
+ })
+ dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
+ if len(dry.API) < 2 || dry.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
+ t.Fatalf("dry-run must resolve wiki first: %#v", dry.API)
+ }
+ if got := dry.API[1].URL; got != "/open-apis/docs_ai/v1/documents/%3Cresolved_docx_token%3E" {
+ t.Fatalf("docs_ai URL = %q", got)
+ }
+ for _, api := range dry.API[2:] {
+ if strings.Contains(api.URL, "wikcn_local") {
+ t.Fatalf("post-resolve API still uses wiki node token: %s", api.URL)
+ }
+ }
+}
+
+func TestDocsUpdateLocalResourceRejectsLegacyDocBeforeWrite(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("diagram.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "doc": "https://example.larksuite.com/doc/docc_legacy",
+ "command": "append",
+ "content": `
`,
+ })
+ err := validateUpdateV2(context.Background(), runtime)
+ problem, ok := errs.ProblemOf(err)
+ var validationErr *errs.ValidationError
+ if !ok || problem.Subtype != errs.SubtypeInvalidArgument ||
+ !errors.As(err, &validationErr) || validationErr.Param != "--doc" {
+ t.Fatalf("error=%T %v problem=%#v validation=%#v", err, err, problem, validationErr)
+ }
+}
+
+func TestDocsUpdateRemoteImageDryRunDownloadsAfterDocumentWrite(t *testing.T) {
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "doc": "doxcn_remote_image",
+ "command": "append",
+ "content": `
`,
+ })
+ dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
+ if len(dry.API) != 6 {
+ t.Fatalf("dry-run API calls = %d, want 6: %#v", len(dry.API), dry.API)
+ }
+ if got := dry.API[0].URL; got != "/open-apis/docs_ai/v1/documents/doxcn_remote_image" {
+ t.Fatalf("document update URL = %q", got)
+ }
+ if got := dry.API[1].URL; got != "https://93.184.216.34/photo.png" {
+ t.Fatalf("download URL = %q", got)
+ }
+ if got := dry.API[2].URL; got != "/open-apis/drive/v1/medias/upload_all" {
+ t.Fatalf("upload URL = %q", got)
+ }
+ preparedContent := fmt.Sprint(dry.API[0].Body["content"])
+ if strings.Contains(preparedContent, `href=`) || !strings.Contains(preparedContent, "@lcli_img_") {
+ t.Fatalf("prepared content = %q", preparedContent)
+ }
+}
+
+func TestLocalDocResourceUpdateCommands(t *testing.T) {
+ resources := []localDocResource{{Kind: localDocResourceImage}}
+ for _, command := range []string{"str_replace"} {
+ if err := validateLocalDocResourceUpdateCommand(command, resources); err == nil {
+ t.Fatalf("command %s accepted local resources", command)
+ }
+ }
+ for _, command := range []string{"append", "block_insert_after", "block_replace", "overwrite"} {
+ if err := validateLocalDocResourceUpdateCommand(command, resources); err != nil {
+ t.Fatalf("command %s rejected: %v", command, err)
+ }
+ }
+}
+
+func TestDocsUpdateLocalResourceBlockReplaceDryRun(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("replacement.png", []byte(localDocResourcePNG(t, 2, 1)), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "doc": "doxcn_block_replace",
+ "command": "block_replace",
+ "block-id": "blk_target",
+ "content": `
`,
+ })
+
+ dry := decodeDocDryRun(t, dryRunUpdateV2(context.Background(), runtime))
+ if len(dry.API) != 5 {
+ t.Fatalf("dry-run API calls = %d, want 5: %#v", len(dry.API), dry.API)
+ }
+ update := dry.API[0]
+ if got := update.Body["command"]; got != "block_replace" {
+ t.Fatalf("update command = %#v, want block_replace", got)
+ }
+ if got := update.Body["block_id"]; got != "blk_target" {
+ t.Fatalf("update block_id = %#v, want blk_target", got)
+ }
+ preparedContent := fmt.Sprint(update.Body["content"])
+ if strings.Contains(preparedContent, "@replacement.png") || !strings.Contains(preparedContent, "@lcli_img_") {
+ t.Fatalf("prepared content = %q, want private marker without local path", preparedContent)
+ }
+ if got := dry.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
+ t.Fatalf("upload URL = %q", got)
+ }
+ if got := dry.API[2].URL; got != "/open-apis/docx/v1/documents/doxcn_block_replace/blocks/batch_update" {
+ t.Fatalf("bind URL = %q", got)
+ }
+}
+
+func requestCountFromLocalDocBatchBody(t *testing.T, raw []byte) int {
+ t.Helper()
+ var body struct {
+ Requests []interface{} `json:"requests"`
+ }
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Fatalf("decode batch body: %v; %s", err, raw)
+ }
+ return len(body.Requests)
+}
+
+func newLocalDocResourceTestRuntime(t *testing.T, files map[string]string) *common.RuntimeContext {
+ t.Helper()
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ for name, content := range files {
+ if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
+ t.Fatalf("WriteFile(%s): %v", name, err)
+ }
+ }
+ factory, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("local-resource-test"))
+ return common.TestNewRuntimeContextForAPI(
+ context.Background(),
+ &cobra.Command{Use: "docs local-resource-test"},
+ docsTestConfigWithAppID("local-resource-test"),
+ factory,
+ core.AsUser,
+ )
+}
+
+func localDocResourcePNG(t *testing.T, width, height int) string {
+ t.Helper()
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil {
+ t.Fatalf("encode %dx%d PNG: %v", width, height, err)
+ }
+ return buf.String()
+}
diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go
index 85d28062f9..6b5eced0de 100644
--- a/shortcuts/doc/shortcuts.go
+++ b/shortcuts/doc/shortcuts.go
@@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
+ DocsScript,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md
index dba530dc3d..33d7ceadb0 100644
--- a/skills/lark-doc/SKILL.md
+++ b/skills/lark-doc/SKILL.md
@@ -1,7 +1,6 @@
---
name: lark-doc
-version: 2.0.0
-description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
+description: "飞书云文档(Docx / Wiki)内容操作:读取、创建、编辑文档,插入或下载图片附件,以及操作思维笔记。用户提供文档 URL/token(包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。文档内嵌资源按读取参考中的统一规则分流。文档评论走 lark-drive;表格或 Base 内部数据操作不在本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -11,75 +10,40 @@ metadata:
# docs
-**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
+## 场景与 Shortcut 路由
-```bash
-# 常用示例
-lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
-lark-cli docs +create --content '标题内容
'
-lark-cli docs +update --doc "文档URL或token" --command append --content '内容
'
-```
+**CRITICAL:先判断场景,再读取该场景的参考文件;不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。**
-## 前置条件 — 执行操作前必读
+**身份:文档操作推荐显式指定 `--as user`。**
-**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
-1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
-2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构)
-3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
+**所有表示本地文件的 `@path` 均使用 `@./xxx` 形式的相对路径,并以运行 `lark-cli` 时的当前工作目录(CWD)为基准。**
-**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
+### 文档内容
-> **格式选择规则(全局):**
-> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
-> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
+- **读取 / 摘要 — [`+fetch`](references/lark-doc-fetch.md)**:先读参考再获取文档。
+- **从零创作 — [`创建工作流`](references/lark-doc-create-workflow.md)**:先完整执行创建工作流,**简单任务不是跳过的理由**;
+- **导入 / 空文档 — [`+create`](references/lark-doc-create.md)**:仅创建空文档或原样导入用户提供的完整内容时,跳过创建工作流。
+- **编辑 / block 直达链接 — [`+update`](references/lark-doc-update.md)**:语义改写、润色、重组、补写或排版均按 update 参考完成。
-## 快速决策
-- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
-- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
-- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
-- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
-- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
-- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
-- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
-- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
-- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
-- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
-- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
-- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
-- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
-- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
-- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
-- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
-- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
-- 文档内容中出现嵌入的 ``、`` 或 `` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
+### 辅助能力
-| 标签 / 属性 | 提取字段 | 切到技能 |
-|-|-|-|
-| `` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
-| `` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
-| `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) |
-| `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) |
-| `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` |
-| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block |
+- **草稿初始化、解析与统计 — [`+script`](references/lark-doc-script.md)**:支持解析文档 URL / token 与本地 XML / Markdown,统计字数并返回字符诊断。
+- **历史版本 — [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md)**:查询、回滚文档历史版本或检查回滚任务状态。
-## Shortcuts(推荐优先使用)
+### 资源、画板与思维笔记
-Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`)。有 Shortcut 的操作优先使用。
+- **插入本地素材 — [`+media-insert`](references/lark-doc-media-insert.md)**:在文末插入本地图片或文件。
+- **预览素材 — [`+media-preview`](references/lark-doc-media-preview.md)**:预览文档中的图片、附件或素材。
+- **下载素材 — [`+media-download`](references/lark-doc-media-download.md)**:下载文档中的图片、附件、素材或画板缩略图。
+- **Docx 封面 — [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md)**:下载、更新或删除 Docx 封面。
+- **画板 — [`画板工作流`](references/lark-doc-whiteboard.md)**:创建或更新画板时先读取工作流;更新已有画板必须复用现有 token,禁止新建空白画板;使用 [`whiteboard +update`](../lark-whiteboard/references/lark-whiteboard-update.md) 写入。
+- **思维笔记 — `mindnotes`**:已有思维笔记走 [`思维笔记链路`](references/lark-doc-mindnote.md);新建思维笔记走 [`lark-doc-whiteboard`](references/lark-doc-whiteboard.md)。
-| Shortcut | 说明 |
-|----------|------|
-| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
-| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
-| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
-| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
-| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
-| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
-| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
-| [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
-| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
+### 认证与 Scope
+
+执行 Shortcut 时,不预读 [`lark-shared`](../lark-shared/SKILL.md) 或预跑 `auth status --verify`;仅遇到未认证、token / 身份或 scope 错误时读取该 Skill,修复后重试。认证、身份或 scope 管理请求则直接使用该 Skill。
## 不在本 Skill 范围
-- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
-- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
-- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)
+- **Drive 文件级操作**:找文档、导入导出、云空间文件上传 / 下载 / 权限管理 → [`lark-drive`](../lark-drive/SKILL.md)。复制文档、创建副本或另存为副本时,按其指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文。
+- **文档评论**:添加、查看、回复评论或增删 reaction → [`lark-drive`](../lark-drive/SKILL.md)。
diff --git a/skills/lark-doc/references/genres/business-analysis.md b/skills/lark-doc/references/genres/business-analysis.md
new file mode 100644
index 0000000000..ca92a9b9f1
--- /dev/null
+++ b/skills/lark-doc/references/genres/business-analysis.md
@@ -0,0 +1,30 @@
+# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 |
+| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 |
+| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 |
+| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 ROI / BCR / 评分替代平衡判断;套 SWOT;估算冒充事实;忽略全周期成本、依赖或分配影响;未获批写成已承诺;建议不回链证据 |
+
+## 适用与消歧
+
+比较投资、资源、市场、产品、经营或供应选项并支持判断,但正文不要求具名决策者作出选择 / 批准,也不形成授权、资源拨付或执行承诺入口。`商业`、`市场分析`、`SWOT`单独只用于召回;回答研究问题走 [`research-report.md`](research-report.md),纯指标解读走 [`data-report.md`](data-report.md),命中上述 ask / 授权入口时走 Workplace Proposal,接口、不变量和实现取舍为主走 Technical RFC。
+
+## 子类型
+
+投资 / 资源配置;build-buy-partner 或 vendor;市场进入 / 扩张;产品 / 组合优先级;经营模式 / 流程;定价 / 商业模式;高不确定性的试点或阶段门。分析深度随金额、复杂度、不可逆性、影响范围和风险提高。
+
+## 证据与方法
+
+- 定义问题、目标、成功标准、范围、约束、决策 owner / 时点和现状 / 不行动基准;记录选项生成与排除理由。
+- 对每个可行选项用相同维度比较收益、全生命周期成本、时间、能力 / 依赖、风险、受影响方、不可货币化影响和可逆性。
+- 现状数据与预测分开;按需说明币种、价格时点、折现和估算方法。不得从官网标价推断销量、收入或份额。
+- 对可能翻转结论的假设做范围、情景或敏感性分析,并给 switching value、决策门或验证信号;评分模型须解释权重和证据,不能只报总分。
+- 缺目标、成功标准、基准或可行选项时只产出 decision frame / options discovery;关键估算用 `[成本区间待核]` 和验证计划,可能翻转结论且无法界定时标记 `blocked`。
+
+## 结构与高质量写法
+
+推荐与条件 → case for change / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。
diff --git a/skills/lark-doc/references/genres/data-report.md b/skills/lark-doc/references/genres/data-report.md
new file mode 100644
index 0000000000..f0d7a86fa1
--- /dev/null
+++ b/skills/lark-doc/references/genres/data-report.md
@@ -0,0 +1,32 @@
+# Genre Contract: Data Report / 数据报告 (`report.data_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 |
+| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 |
+| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 |
+| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 |
+
+## 适用与消歧
+
+解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据`、`有数字`、`分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。
+
+## 子类型
+
+- KPI / 经营表现与趋势;分群、cohort 与分布;漏斗 / 路径与监控异常。
+- A/B 或实验 readout;设计和推断不足时只能报告观察值,不宣布因果胜出。
+- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。
+
+## 证据与方法
+
+- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。
+- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。
+- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。
+- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。
+- 缺定义、分母、时间或来源时使用 `[指标定义待核]`、`[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`。
+
+## 结构与高质量写法
+
+关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。
diff --git a/skills/lark-doc/references/genres/email.md b/skills/lark-doc/references/genres/email.md
new file mode 100644
index 0000000000..1fb8b0c99d
--- /dev/null
+++ b/skills/lark-doc/references/genres/email.md
@@ -0,0 +1,38 @@
+# Genre Contract: Email / 邮件 (`platform.email`)
+
+## 核心定位(硬约束)
+
+- 交付物是可复制到邮件客户端的邮件成稿,不代表已发送,也不执行收件人查询、邮件发送、草稿箱或邮箱管理;实际邮件操作切到 `lark-mail`。
+- 视觉策略默认使用 `formal`;仅在用户要求且不违反组织规范或所选 content contract 时调整。全文禁止 emoji、高亮块和装饰性组件。
+- 默认只使用短段落、列表和普通链接等基础结构。只有已由用户说明、平台文档或可信配置确认目标平台完整支持飞书富文本时,才允许使用 `rich` 或 rich block;不得根据“邮件”“HTML 邮件”、飞书文档承载或平台名称自行推断支持。
+- 一封邮件只承担一个主要沟通任务;主题、首段、正文和行动请求围绕同一目的。不得编造发件人身份、收件人关系、事实、权限、承诺、截止时间、附件或已完成动作;缺失但必需的信息使用清楚的占位符。
+- 不使用封面或目录。即使已确认平台能力,表格、图片、`callout`、画板及其他 rich block 也只能在信息确有需要时使用,并确保复制、投递和接收后的语义完整。
+
+## 适用与消歧
+
+用户明确要“写邮件、邮件成稿、邮件草稿、邮件措辞、email、e-mail”,或要求起草回复、跟进、通知、邀约、外联邮件时使用,内容保存在哪里不影响本合同生效。
+
+查看、搜索、发送、回复或管理邮箱中的真实邮件属于 `lark-mail` 操作;邮件系统说明、邮件数据分析、营销方案或把邮件作为信息来源时不触发本合同。若任务既要成稿又要实际发送,先按本合同形成并确认内容,再切到 `lark-mail` 执行发送。
+
+## 邮件主任务
+
+| 主任务 | 内容脊柱 |
+|-|-|
+| 请求 / 决策 | 目的或结论 → 必要背景 → 明确请求 / 选项 → 期望时间或下一步 |
+| 通知 / 同步 | 关键变化 → 影响范围 → 接收方需知 / 需做 → 时间点与联系入口 |
+| 回复 / 跟进 | 对应的前情 → 新信息或直接答复 → 未决事项 → 下一步 |
+| 邀约 / 外联 | 联系缘由 → 与收件人的相关性 → 具体提议 → 低成本回应方式 |
+| 致歉 / 问题沟通 | 承认影响 → 已确认事实 → 补救动作 → 后续安排与边界 |
+
+## 成稿要求
+
+- 成稿先给主题,再给正文;只有用户要求或材料明确时才列出收件人、抄送人等信封字段。主题准确表达对象、事项或所需行动,不使用标题党、空泛寒暄或无信息量的“重要通知”。
+- 称呼依据已知关系和语境选择;关系不明时使用稳妥中性的称呼或显式占位符,不擅自套用亲密、职级或性别称谓。
+- 首段尽快说明来意、结论或与既有线程的关系。背景只保留收件人理解、判断或行动所需的信息,不把完整报告、会议纪要或思考过程原样搬入邮件。
+- 行动请求写清需要谁在何时以何种方式完成什么;材料没有给出负责人或时间时,不自行补造。多个并列事项使用列表,优先让收件人能直接逐项回应。
+- 提及链接、附件或引用材料时说明其用途;未实际提供或上传的材料写成待补占位符,不声称“见附件”。回复和跟进邮件只补充新信息,不机械复述整个线程。
+- 结尾与邮件目的匹配:请求类明确回应方式,通知类说明无需动作或下一节点,外联类保留易于拒绝或调整的空间。署名仅使用已知身份;身份不明时使用占位符,不虚构姓名、团队或联系方式。
+
+## 交付前检查
+
+确认收件人能从主题和首段判断“为什么收到、需要知道或做什么”,事实、责任人、时间和附件状态均有依据,正文没有无关铺垫或重复,语气符合关系与风险,全文无 emoji;若使用 rich block,已有目标平台支持飞书富文本的确认依据;复制到邮件客户端后仍清晰可读,且未把“成稿”误写成“已发送”。
diff --git a/skills/lark-doc/references/genres/execution-plan.md b/skills/lark-doc/references/genres/execution-plan.md
new file mode 100644
index 0000000000..e84872fd39
--- /dev/null
+++ b/skills/lark-doc/references/genres/execution-plan.md
@@ -0,0 +1,27 @@
+# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 |
+| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 |
+| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项;时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容;未批准方向不得写成承诺,关键资源或安全前提未知时收窄计划或 `blocked` |
+| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线,任一出现即失败 |
+
+## 适用与消歧
+
+用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`。
+
+“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。
+
+## 可执行性与证据
+
+- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。
+- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。
+- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
+- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
+
+## 高质量写法
+
+让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。
diff --git a/skills/lark-doc/references/genres/formal-doc.md b/skills/lark-doc/references/genres/formal-doc.md
new file mode 100644
index 0000000000..2de096db13
--- /dev/null
+++ b/skills/lark-doc/references/genres/formal-doc.md
@@ -0,0 +1,37 @@
+# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话或机械层级 |
+| 视觉策略 | 固定 `formal`;格式中立的内容稿完成后再应用,禁止高亮块、emoji 和装饰性组件 |
+| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
+| 限用 block | `ul`、`ol`容器及`li`子块仅承载真实并列或顺序;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅承载多对象同字段信息;`img`、`figure`仅承载有必要证据作用、来源说明和文字等价信息的材料 |
+| 禁止 block | 禁止未列入允许 / 限用清单的类型,包括`callout`、`checkbox`、`grid`容器及`column`子块、`whiteboard`、`blockquote`、`pre`、根级`code`和`hr`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 |
+| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 |
+| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口未关闭时不得创建 |
+| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 |
+
+## 适用与收口
+
+用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。
+
+待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。
+
+## 按读者任务选择唯一内容路径
+
+| 读者任务 | 内容主线 |
+|-|-|
+| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 |
+| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 |
+| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 |
+| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 |
+
+## 证据与高质量写法
+
+- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义,强度不明时标`[规范强度待确认]`。
+- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
+- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
+- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
+- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。
diff --git a/skills/lark-doc/references/genres/meeting-minutes.md b/skills/lark-doc/references/genres/meeting-minutes.md
new file mode 100644
index 0000000000..f74bef3047
--- /dev/null
+++ b/skills/lark-doc/references/genres/meeting-minutes.md
@@ -0,0 +1,24 @@
+# Genre Contract: Meeting Minutes / 会议纪要 (`workplace.meeting_minutes`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 中性、精确、按议题和决定组织,用稳定标签区分决定、建议、未决和待确认,不重放发言顺序 |
+| 内容逻辑 | 先说明会议身份和记录状态,再按议题写实际材料 / 必要讨论摘要 → 决定及理由 / 异议 → 未决项 → 行动 → 审阅材料;深度与治理风险相称 |
+| 事实 / 边界 | 出席、法定人数、冲突、动议、表决、决定、owner、期限和批准状态均须来自会议材料或确认;只保留治理所需个人信息,草稿不得冒充批准版;历史状态固定为文字快照,不得由`checkbox`、`task`等可变交互块改写 |
+| 错误 | 摘要冒充逐字稿、讨论流水账、建议写成决定、行动不可跟踪、法定人数不明却宣称决定有效、草稿冒充批准、静默改历史或泄露无关个人信息,任一出现即失败 |
+
+## 适用与消歧
+
+用于某次已发生会议的可引用治理记录,使缺席者、执行者和审核者确认决定、未决与行动。逐字 / 逐发言人 / 可回放内容只是 transcript 源材料;会前准备走 `memo-brief.md`;非会议状态走 `weekly-report.md`;党政机关法定“纪要”走 `official-redhead.md`。
+
+## 子类型与治理证据
+
+普通工作会可精简为会议身份、决定、未决和行动;项目决策会补必要理由与审阅材料;董事会、委员会、表决或法定会议按章程 / 适用规则记录出席、法定人数、利益冲突、动议、票决、精确决议及认证。
+
+证据可来自 agenda、出席记录、实际审阅材料、动议 / 投票和录音 / 逐字稿,但正文只链接关键来源,不复制附件淹没决定。来源冲突并列保留并交主持人 / 参会者确认。
+
+## 结构与高质量写法
+
+标明名称 / 类型、日期时间、地点 / 方式、主持 / 记录和草稿 / 已批准状态;每个议题围绕结果而非发言顺序。行动项写交付物 / 动作、责任人 / 单位、时间要求和状态。缺失信息用`[决议原文待确认]`、`[owner 待确认]`等具体占位;法定人数或批准不明时不得宣称有效,保持草稿并进入确认流程。
diff --git a/skills/lark-doc/references/genres/memo-brief.md b/skills/lark-doc/references/genres/memo-brief.md
new file mode 100644
index 0000000000..53b1b5fa4e
--- /dev/null
+++ b/skills/lark-doc/references/genres/memo-brief.md
@@ -0,0 +1,25 @@
+# Genre Contract: Memo / Brief (`workplace.memo_brief`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 直接、克制、按具名读者控制信息密度,首屏给结论、状态或 ask,不设固定篇幅 |
+| 内容逻辑 | 先选信息、决策或会前三种模式之一,再按“核心事项 / ask → 必要事实 → 影响 / 取舍 → 风险 / 未知 → 动作”推进;只有真实选择才写选项 |
+| 事实 / 边界 | 事实、数字、立场、审批状态和时点均须可核验;未知与假设就近标记;Memo 只可作完整 Proposal 的决策封面,不替代其论证 |
+| 错误 | 首屏无结论或 ask、把完整 Proposal 压成摘要、编造审批 / 立场、用固定篇幅删证据、细节不解释影响,任一出现即失败 |
+
+## 适用与消歧
+
+用于让具名内部读者快速知悉、判断或完成会前准备。请求批准完整方向、预算、资源或执行承诺走 `proposal.md`;按周期判断相对目标的位置走 `weekly-report.md`;“摘要 / 简报”单词本身不触发本体裁。
+
+## 子类型与证据
+
+- 信息 Brief:变化 → 影响 → 当前状态 / 风险 → 下一步;无须行动时明确“仅供知悉”。
+- 决策 Memo:决定事项 / 时点 → 现状 → 真实选项及同口径影响 → 推荐与证据 → 明确决策入口。
+- 会前 Brief:会议目标 → 已核验的参与方立场 / 利益 → 要点与禁区 → 期望结果;未知立场不得补造。
+- 按需标读者、作者 / 责任团队、日期和信息截至时间。持续更新时说明相对上版的变化及下次更新点。
+
+## 结构与高质量写法
+
+按重要性而非材料顺序组织,一个段落一个观点;关键判断不藏在附件。建议写清谁做什么、为什么以及怎样判断完成,并呈现足以改变判断的风险、反例和不确定性。缺关键事实时用`[关键结论待确认]`、`[数据口径待核]`等具体占位,或收窄为待核问题清单;仍要求据此批准时必须 `blocked`。
diff --git a/skills/lark-doc/references/genres/official-redhead.md b/skills/lark-doc/references/genres/official-redhead.md
new file mode 100644
index 0000000000..589ad684fa
--- /dev/null
+++ b/skills/lark-doc/references/genres/official-redhead.md
@@ -0,0 +1,73 @@
+# Genre Contract: Official Document / 公文内容稿 (`workplace.official_redhead`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 庄重、准确、简洁、直接;禁网感、营销话术、情绪化评价、空话和机械编号 |
+| 视觉策略 | 固定 `formal`;禁止高亮块、emoji 和装饰性组件 |
+| 允许 block | `title`(完整文稿最多 1 个)、`p`、`h1`、`h2`、`h3`、`h4`;标题层级连续且不超过四级 |
+| 少用 block | `ul`、`ol`容器及`li`子块仅用于真实并列项,不替代公文层级序号;`table`容器及`thead`、`tbody`、`tfoot`、`tr`子块仅用于非表格难以清楚表达的多对象同字段信息 |
+| 禁止 block | 禁止未列入允许 / 少用清单的类型,包括`callout`、`grid`容器及`column`子块、`checkbox`、`whiteboard`、`blockquote`、`pre`、根级`code`、`hr`、`img`、`figure`;禁装饰色、伪红头和伪印章 |
+| 内容逻辑 | 按行文目的、机关关系和受众确定唯一文种,再按“必要依据 / 缘由 → 核心事项 / 决定 → 可执行要求 → 必要结语”推进 |
+| 事实 / 边界 | 只写已给定或已核验的事实、依据、权限和决定;未知项具体占位,关键缺口未关闭时不得创建;飞书只交付内容审校稿,不宣称已签发或生效 |
+| 错误 | 禁止文种或行文关系错误、报告夹请示、请示一文多事 / 多头主送、批复无对应请示、引用 / 文号 / 序号 / 附件不规范,以及编造事实、依据、权限或制发要素 |
+
+## 适用
+
+仅在明确要求公文、红头 / 套红、正式发文,或法定文种与机关行文关系、发文字号、签发人、主送机关等制发要素共同出现时使用。“红头文件”是制发信号,不是文种;普通公司通知、制度、检查 / 整改材料走 `formal-doc.md`,普通会议记录走 `meeting-minutes.md`。
+
+## 文种选择
+
+按“行文目的 → 发文与受文机关关系 → 受众范围”判断,不按单个关键词判断。
+
+| 文种 | 适用意图 |
+|-|-|
+| 决议 | 会议讨论通过重大决策 |
+| 决定 | 对重要事项作出决策部署、奖惩或变更 / 撤销决定 |
+| 命令(令) | 公布法规规章、施行重大强制措施、授予衔级或嘉奖 |
+| 公报 | 权威公布重要决定或重大事项 |
+| 公告 | 向国内外宣布重要或法定事项 |
+| 通告 | 在一定范围公布应遵守或周知的事项 |
+| 意见 | 对重要问题提出见解和处理办法 |
+| 通知 | 要求下级 / 有关单位执行或周知,批转、转发公文 |
+| 通报 | 表彰、批评、传达重要精神或告知重要情况 |
+| 报告 | 向上级汇报工作、反映情况或答复询问,不请求决定 |
+| 请示 | 向上级请求指示或批准;一文一事,原则上只主送一个上级机关 |
+| 批复 | 答复下级机关请示,必须有对应来文 |
+| 议案 | 政府依法向同级人大或其常委会提请审议 |
+| 函 | 不相隶属机关间商洽、询答、请求批准或答复审批 |
+| 纪要 | 记载正式会议主要情况和议定事项,不写逐字过程 |
+
+优先消歧:汇报且不求决定用报告,求上级决定用请示,不相隶属机关商洽用函;面向明确单位执行用通知,面向一定范围不特定对象遵守用通告,向国内外宣布重要 / 法定事项用公告,传达情况或评价用通报。
+
+## 行文与事实
+
+- 按隶属关系、职权和授权行文;一般不越级,特殊越级时同时抄送被越过机关。
+- 上行文原则上主送一个上级机关,不抄送下级;报告不得夹带请示。除直接交办外,不主送上级负责人个人。
+- 一份主文保持一个行文方向和授权状态;同一事项若既需向上请求批准又需向下要求执行,应拆分文稿或待批准后另行制发,附件不得偷渡尚未授权的执行要求。
+- 下行要求不得超出发文机关权限;涉及其他地区 / 部门职权时先协商。联合行文仅限必要且主体关系适当的情形。
+- 只把已确认的决定写成指令。措施按需写明主体、动作、对象、期限、标准和反馈去向;对不相隶属机关使用`商请`、`请予`、`函复`等匹配关系的措辞。
+- 缺少授权、关键依据、核心事实、适用范围或审批决定时不得发布;不得猜测文号、签发人、密级或紧急程度。
+
+## 内容结构
+
+- 标题一般使用“发文机关 + 事由 + 文种”,内含法规、规章或被印发文件名称时使用书名号。
+- 主送机关使用全称、规范简称或同类机关统称。附件说明与附件顺序、名称逐字一致;多个附件用阿拉伯数字编号,名称末尾不加标点。
+
+| 文种 | 常用结构 |
+|-|-|
+| 通知 | 缘由 / 依据 → 事项 → 对象 / 时间 → 已确认要求 |
+| 请示 | 缘由 / 依据 → 单一请示事项与倾向意见 → `妥否,请批示` |
+| 批复 | 准确引用来文 → 明确意见 → 执行要求 → `此复` |
+| 函 | 事项 / 依据 → 商请或答复 → `请予函复` / `特此函复` |
+| 报告 | 情况 → 事实 / 成效 → 问题 → 后续安排 → `特此报告` |
+| 纪要 | 会议基本信息 → 主要情况 → 议定事项 / 责任 / 时限 |
+
+## 文号、引用与序号
+
+- 普通发文字号采用“机关代字 + 完整年份 + 顺序号”,如 `×政发〔2026〕8号`;年份用六角括号,顺序号不加“第”、不编虚位。命令(令)的令号可用 `第×号`。
+- 首次引用其他公文时写完整标题和文号:`《××机关关于印发〈××办法〉的通知》(×发〔2026〕8号)`。不只写文号,不用论文式参考文献编号。
+- 文件、法律法规名称使用书名号;直接引文使用中文双引号,内层用单引号。引文须核对原文、效力、制定机关和适用范围;无法核实则标记 `[引文待核]`。
+- 正文层级依次使用 `一、`、`(一)`、`1.`、`(1)`,不得写成 `1、`、`(一)、`,不得跳级;超过四级时重组内容。
+- 成文日期写为 `2026年7月13日`,月日不补零。标点和数字按 GB/T 15834、GB/T 15835 使用;全称及规范简称前后一致。
diff --git a/skills/lark-doc/references/genres/prd.md b/skills/lark-doc/references/genres/prd.md
new file mode 100644
index 0000000000..b1e3c5bf7e
--- /dev/null
+++ b/skills/lark-doc/references/genres/prd.md
@@ -0,0 +1,26 @@
+# Genre Contract: PRD / 产品需求 (`workplace.prd`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 具体、行为化、术语和状态一致;不用固定大模板制造完整感 |
+| 视觉约束 | 在有明确内容作用时用场景、状态流、表格、图示等组件降低理解与验收成本,但不得替代需求、证据或验收 |
+| 内容逻辑 | 方向已定后按“用户问题 / 证据 → 目标 / 结果 → 范围 / 非目标 → 场景 → 行为需求 / 验收 → 异常 / 边界 → 适用质量约束 → 依赖 / 开放问题”推进 |
+| 事实 / 边界 | 用户需要、指标、研究、阈值、可行性、owner、状态和排期须可追溯;需求描述可观察结果,acceptance criteria 验结果;安全、隐私、无障碍等仅按实际风险和标准纳入 |
+| 错误 | 功能清单无用户问题、Proposal 论证吞没需求、范围 / 非目标缺失、需求暗藏实现、验收不可测、正常路径无异常、伪造研究 / 阈值 / 批准或机械填质量模板,任一出现即失败 |
+
+## 适用与消歧
+
+用于方向与投入原则已定后,让产品、设计、研发和测试就用户问题、范围、可观察行为与完成标准形成共识。是否立项 / 选择方向 / 批资源走 `proposal.md`;架构、接口和实现取舍走 `technical-doc.md`;已批准重复操作走 `sop-tutorial.md`。“需求 / 功能”单词本身不触发。
+
+## 证据与需求写法
+
+- 明确目标用户、任务情境、问题及研究 / 行为 / 支持证据;内部偏好和预设功能不冒充用户需要。
+- 产品目标连接可观测结果,指标标口径、来源和时间窗。未知目标值用`[目标值待产品 / 数据确认]`并给确认 owner / 时点,不编使用量或阈值。
+- 关键需求写成 actor + trigger / precondition + observable outcome + failure / edge;术语和状态一致。用户故事格式只是工具,不是章节配额。
+- 每个质量约束给可验证门槛或明确待确认项;不适用时不填模板。需求、验收 / 测试和来源保持追踪。
+
+## 结构与高质量写法
+
+先定范围、非目标、优先级、依赖、假设和开放问题,防止 scope creep;再按关键场景写正常、异常和边界行为。把大而不可测的需求拆到可验收粒度,不用“体验更好 / 性能高”等形容词。没有用户证据时收窄为假设和研究计划;关键合规 / 安全门缺失时 `blocked`,开放问题不得藏在脚注。
diff --git a/skills/lark-doc/references/genres/proposal.md b/skills/lark-doc/references/genres/proposal.md
new file mode 100644
index 0000000000..64e454a67f
--- /dev/null
+++ b/skills/lark-doc/references/genres/proposal.md
@@ -0,0 +1,24 @@
+# Genre Contract: Proposal / 方案提案 (`workplace.proposal`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 结论前置、具体、可审议,主动呈现代价、反例与不确定性,不用宏大背景或伪精确制造可批准感 |
+| 内容逻辑 | 明确 decision / 决策者 / 时点,再按“改变理由与不行动基准 → 目标 → 真实选项同口径比较 → 推荐 → 资源 / 交付 → 风险 / 未知 → 决策入口”推进 |
+| 事实 / 边界 | 区分事实、估算、假设和未知;收益、成本、资源、用户证据、审批和排期须可追溯;进入执行决策才写治理 / 退出条件,未批准不得写成既有承诺 |
+| 错误 | 无决策者 / ask、无不行动基准、预设单一答案、选项口径不同、成本风险后置、未批先承诺、编造收益 / 审批或与 PRD 混写,任一出现即失败 |
+
+## 适用与消歧
+
+用于请求具名决策者批准、驳回或选择方向、预算、资源、试点或执行承诺。方向已定并定义产品行为 / 验收走 `prd.md`;短决策封面走 `memo-brief.md`;已批准安排的发布走 `formal-doc.md`。“方案”单词本身不触发本体裁。
+
+## 子类型与证据
+
+可用于概念 / 方向、投资 / 预算、资源申请、变更、试点 / 实验和执行承诺提案;深度随阶段、金额、风险和不可逆性裁剪。必须给 case for change、目标 / 成功标准、不行动或最小变化基准,以及足以判断的成本、收益、依赖、风险和敏感因素。
+
+存在真实选择时纳入可行替代并以相同范围、时间和评价标准比较;没有真实替代时说明约束如何收敛,不能造假选项。不可量化影响可定性,但须说明原因及其决策影响。
+
+## 结构与高质量写法
+
+先把选择题写对,再论证推荐;显式记录被放弃选项和推荐代价。数字不足时使用范围、依据和验证计划,不补精确点估。进入执行决策时按需补 owner、里程碑、治理、衡量、退出 / 复盘;缺决策权、关键成本或安全合规依据时收窄为探索稿,仍要求批准则 `blocked`。
diff --git a/skills/lark-doc/references/genres/research-report.md b/skills/lark-doc/references/genres/research-report.md
new file mode 100644
index 0000000000..02e98f216d
--- /dev/null
+++ b/skills/lark-doc/references/genres/research-report.md
@@ -0,0 +1,32 @@
+# Genre Contract: Research Report / 调研报告 (`report.research_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 证据驱动、校准、术语一致;摘要独立可读,语气强度不得超过证据强度 |
+| 内容逻辑 | 先确定研究问题与研究类型,再交付当前答案、证据强度和可推广边界;按问题或主题组织发现,解释、建议和验证计划必须回链发现 |
+| 事实 / 边界 | 区分原始事实或参与者陈述、分析推断、假设和建议;方法披露足以评估偏差;适用时确认委托、利益、同意、匿名或保密、敏感数据用途;研究材料须确认使用权、去标识、来源与说明,复杂视觉附文字等价信息;未知不补造 |
+| 错误 | 无明确问题;方法黑箱;资料摘要冒充发现;样本外推;醒目个案冒充模式;事实、解释和建议混写;相关写成因果;合规状态、授权或行业共识靠猜 |
+
+## 适用与消歧
+
+以明确研究问题、研究设计或材料、发现和限制为主要交付。`调研`、`研究过`、`访谈`、`问卷`单独只用于召回;只解读既定指标走 [`data-report.md`](data-report.md),比较特定战略或资源选项走 [`business-analysis.md`](business-analysis.md),方法并非判断重点的问题框架综合走 [`white-paper.md`](white-paper.md)。
+
+## 子类型
+
+- **定量 / 定性 / 混合研究**:根据问题选择总体、抽样或招募、工具、采集和分析方法;不用一种方法的规范冒充全部研究标准。
+- **用户研究 / 项目或政策评估**:说明场景、参与者、干预或对象、成功标准、观察窗口和用途。
+- **证据综合**:只有检索范围、纳排和综合方法明确时才作为研究发现;普通资料汇总不得升级为系统结论。
+
+## 证据与方法
+
+- 明确对象、用途、非目标和适用情境;按需披露委托 / 执行方、总体与纳排、抽样 / 招募、样本量、工具 / 题项、采集方式 / 语言 / 时点、响应 / 脱落、加权、编码 / 分析和质量控制。
+- 写明偏差、缺失、反例、负结果、替代解释及其可能方向;透明报告不等于设计无偏,也不证明结论可复现。
+- 人员或敏感研究在适用规则下确认知情同意、撤回与伤害风险、匿名 / 保密、访问和数据用途;未获授权不公开可识别材料、原始数据或代码。
+- 引文只说明有出处的体验或机制,不把单个引文写成频率;结论只推广到设计和样本支持的人群、时间与环境。
+- 无原始材料只能产出研究范围或计划,不能生成 findings;方法或样本缺失时用 `[抽样方法待核]`、`[采集时点待核]` 并收窄为探索性观察。关键伦理、授权或方法缺口会改变结论时标记 `blocked`。
+
+## 结构与高质量写法
+
+独立答案、证据强度与关键限制 → 问题 / 范围 / 既有知识 → 方法 / 样本 → 按问题或主题组织的发现 → 解释、反例与替代解释 → 有边界的建议 / 验证 → 局限、来源与必要附录。摘要覆盖目的、方法、发现、含义和限制;正文以“主张 → 证据 → 限定”推进,不按作业时间线罗列过程,不用组件数量代替研究质量。
diff --git a/skills/lark-doc/references/genres/retrospective.md b/skills/lark-doc/references/genres/retrospective.md
new file mode 100644
index 0000000000..25926de72b
--- /dev/null
+++ b/skills/lark-doc/references/genres/retrospective.md
@@ -0,0 +1,25 @@
+# Genre Contract: Retrospective / 复盘 (`workplace.retrospective`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 坦诚、无责备、因果克制,围绕证据和下一轮改变,不用“加强沟通 / 持续关注”代替可验证实验 |
+| 内容逻辑 | 界定已结束周期 / 事件,再按“目标 / 证据 → 预期与实际 → 聚类观察 → 洞见 / 待验证因果 → 保留项 → 少量改进实验 → 复查”推进 |
+| 事实 / 边界 | 事实 / 观察、解释 / 假设、洞见和行动分层;结论回链事件、指标或交付物;根因仅在证据充分时声明,否则写可证伪假设;保护必要隐私 |
+| 错误 | 周报换标题、成绩陈列 / 情绪宣泄、个人归罪、单一根因臆测、行动无 owner / 验证 / 跟踪、不回看上轮或把模板便签当结论,任一出现即失败 |
+
+## 适用与消歧
+
+用于回看明确迭代、阶段、项目或事件,形成可复用学习并改变下一轮做法。当前状态与升级需求走 `weekly-report.md`;仍在未知中止损、取证、恢复或调查生产事故走 `technical-doc.md`。生产事故可由 Technical 主文承载影响 / 时间线 / 根因 / 恢复,再附本体裁的团队学习层。
+
+## 证据与因果
+
+- 开头界定范围、时间、目标 / 原计划、参与视角和已知证据;不得补造指标、时间线、共识、原因或行动。
+- 同时识别应保留与应改变的条件,按影响聚类;以系统、流程、工具、接口和当时条件为对象,不把惩罚叙事冒充根因。
+- 个人工作心得 / 成长反思以一个真实事件或转折为证据,呈现“当时判断 → 反证 / 后果 → 新认识 → 下一次可观察行为”;不代写材料没有提供的情绪、动机、心路或成长。
+- 证据不足时写“促成条件 / 假设 + 验证方式”,不能用确定语气。缺基线用`[基线待补]`,涉及安全 / 法务而证据不足时转 Technical 并 `blocked`。
+
+## 结构与高质量写法
+
+便签、4Ls、Start / Stop / Continue 只是收集手段,成稿须综合为主题和判断。改进实验写动作、owner、目标时间、验证条件和跟踪位置,优先改变系统而不是要求人“更小心”;按需补上轮行动效果和下轮复查点。项目收尾可增加成本、范围、相关方和知识移交,但不机械扩章。
diff --git a/skills/lark-doc/references/genres/route-consumer.md b/skills/lark-doc/references/genres/route-consumer.md
new file mode 100644
index 0000000000..48c334340d
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-consumer.md
@@ -0,0 +1,37 @@
+# Genre Contract: Consumer / 消费决策内容 (`router.consumer`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 具体、可信、可亲近,体验感服务于选择,不用热情语气替代测试、价格和适用条件 |
+| 内容逻辑 | 围绕具体消费场景,用“需求 / 使用条件 → 评价标准 → 体验或测试证据 → 权衡 → 适合谁 / 不适合谁”推进;合集和比较须共享标准,不按品牌逐段堆卖点 |
+| 事实 / 边界 | 只声称真实体验或有方法支撑的测试;披露赠品、佣金、赞助和其他重要关系;标明版本、时间、价格口径及限制;用户 / 专家引语、图片和前后对比须有授权、来源、语境与真实性依据,非文字证据须有文字等价信息;遵守目标法域和平台当期消费者、广告与高风险品类规则,关键利益关系、核心功效、安全条件或报价条款缺失时 blocked |
+| 错误 | 编造使用经历、未披露商业关系、无方法的评分 / 排名、把主观偏好写成客观最佳、隐藏不适用人群或总成本、用极端个案概括功效、过期信息仍当现状,任一出现即失败 |
+
+## 适用与消歧
+
+用于帮助读者购买、比较、避坑或判断某种生活方式是否适合自己。仅出现小红书、微信等平台名不触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,消费选择任务作为该 leaf contract 的硬约束,不再并读 Consumer。以公共事件核实为主走 Media,以价值判断为主走 Opinion,以品牌拥有的转化内容走 Marketing。
+
+“测评”必须继续区分独立比较、真实个人体验和品牌演示:前两者可走本合同,品牌控制结论或行动入口时走 Marketing,并保留显著披露。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 单品体验 / 好物分享 | 判断某物在真实场景是否值得;使用背景 → 观察 → 优缺点 → 适用人群 |
+| 对比测评 / 排名 | 在同一任务下选择;方法与样本 → 共同标准 → 结果 → 权衡与不确定性 |
+| 合集 / 清单 | 快速缩小候选范围;选择门槛 → 分组理由 → 各项差异 → 最终选择路径 |
+| 探店 / 服务体验 | 判断是否到访或购买服务;时间地点 → 实际流程 / 价格 → 体验证据 → 限制 |
+| 生活方式内容 | 判断实践成本与可复制性;目标 → 做法 → 真实投入 / 结果 → 适用边界 |
+
+## 证据、披露与合规
+
+- 第一人称体验交代使用时长、频率、版本和条件;未亲测就明确资料来源,不伪装成亲历。比较结论说明样本、标准、测量方法及未覆盖变量。
+- 把“真实 / 有效”“适合当前读者”“值得当前价格”分开判断。强参数品只保留会改变选择的指标,并解释版本口径和决策影响;使用评分 / 排名时公开标准、权重、主观边界和反转条件,安全或资格等一票否决项不得被平均分稀释。
+- 商业关系和激励在读者接触推荐时清楚出现,不能藏在模糊标签或文末。披露、重大限制和安全警示须就近可见,不能只藏在链接或视觉装饰中。
+- 健康、安全、金融、未成年人等高风险内容只写证据支持且适用法域允许的范围;不能核实的功效或个体化建议删除。规则冲突时按交付地区、渠道和发布时间核验,不把单一国家指南写成全球义务。
+
+## 结构与高质量写法
+
+先告诉读者评判基准,再给结论,才能让“推荐”可复核。优点与代价写在同一决策语境内,价格同时说明时间、地区、规格和附加成本。结尾给条件化选择,而不是人人适用的口号;关键参数待补时用具体占位并暂停对应结论,不能靠语气填空。
diff --git a/skills/lark-doc/references/genres/route-creative.md b/skills/lark-doc/references/genres/route-creative.md
new file mode 100644
index 0000000000..d1ecaf17ba
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-creative.md
@@ -0,0 +1,36 @@
+# Genre Contract: Creative / 叙事创作 (`router.creative`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 语言、节奏和视角服务指定叙事体验,表达自由不替代人物动机、因果和场景可读性 |
+| 内容逻辑 | 本合同只覆盖叙事创作;先确认体验、篇幅、视角和约束,用“人物欲望 → 阻力 → 选择 → 代价 → 变化”形成场景因果;剧本和互动叙事分别服从可演行动与有后果分支 |
+| 事实 / 边界 | 用户授权的虚构可创造,但真实背景、引用、既有作品正史和人物身份不得伪造;图片、题记、原作片段和创作参考须遵守来源与使用权限,非文字参考及互动分支图须附文字等价的关系、路径和状态说明;区分明确设定、合理创作补白和待确认约束;核心世界观 / 权利边界冲突且无法安全收窄时 blocked |
+| 错误 | 只堆设定不发生选择、人物为推进情节突然失去动机、冲突靠偶然或外力无代价解决、视角 / 时态无意漂移、剧本写成解释性小说、互动分支无状态差异、把诗歌静默纳入交付,任一出现即失败 |
+
+## 适用与消歧
+
+仅用于网文、短篇故事、同人叙事、互动小说、剧本和故事大纲等以事件、人物选择和变化为核心的创作。诗歌、歌词、纯抒情散文不在本合同范围;收到这类请求时先确认目标或使用相应专用规则,不因“Creative”一级名称而静默扩写。
+
+以论点和证据表达判断走 Opinion;以真实个人经历建立专业信誉走 Personal Brand。世界观说明若目标只是知识解释,不因带角色名就成为故事。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 短篇 / 网文 | 获得连续叙事体验;触发变化 → 升级阻力 → 关键选择 → 后果 / 回响 |
+| 同人叙事 | 在约定正史与角色核心上体验新情境;明确时间点 / 偏离点 → 角色选择 → 新后果 |
+| 剧本 / 短剧 / 叙事短视频 | 看见可拍、可演的行动与冲突;媒介 / 时长 / 制作约束 → 场景目标 → 视觉、行动、声音 / 对话 → 转折 → 场景状态变化 |
+| 互动小说 | 作出有信息依据且有后果的选择;状态 → 选择 → 反馈 → 状态改变 → 后续分支 |
+| 故事大纲 | 判断故事能否成立并继续创作;前提 → 人物弧 → 节点因果 → 高潮选择 → 结局变化 |
+
+## 设定与真实性
+
+- 先锁定用户给定的人物、关系、禁区、正史时间点和期望体验;未指定的创作空间可以补白,但不得覆盖明确约束。关键歧义有多个会显著改变成品的方向时先询问。
+- 使用真实地点、历史、科学或文化材料时核验会影响情节的事实;有意架空应让读者能辨认其虚构约定。同人创作不把自设冒充正史,也不虚构原作引语。
+- 真实人物、未公开经历、受保护素材和委托作品按授权边界处理;不能确认可用性时改为原创替代或保持 blocked。
+
+## 结构与高质量写法
+
+每个场景都让人物为目标采取行动,并在离场时改变信息、关系、资源或风险。细节同时承担感官、人物或伏笔功能,背景通过当前冲突释放,不集中讲解。对话要改变局面而非重复旁白;剧本动作、声音和调度须在声明的演员、场地、道具与媒介条件下可实现,不用固定“前三秒 / 每分钟一反转”公式代替因果。结局兑现前文建立的选择与代价。大纲可显式呈现结构,成稿则把结构转化为可体验的场景。
diff --git a/skills/lark-doc/references/genres/route-knowledge.md b/skills/lark-doc/references/genres/route-knowledge.md
new file mode 100644
index 0000000000..57cee81d5b
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-knowledge.md
@@ -0,0 +1,39 @@
+# Genre Contract: Knowledge / 知识与教程 (`router.knowledge`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|--|
+| 写作风格 | 具体、可操作、按读者水平解释;科普可生动、有好奇心,但不得牺牲准确性或编造戏剧性 |
+| 内容逻辑 | 先选择唯一主模式和读者起点,再承诺一个理解、学习、一次操作、检索或选择结果;第一屏给适用对象、目标和关键前置,概念、步骤、练习 / 验证、反馈与例外按需渐进展开 |
+| 事实 / 边界 | 事实、版本、命令、UI 路径和链接须核验;示例与规则分开;截图 / 案例不得泄露敏感信息;已知自助路径可写,组织受控重复作业走 SOP,设计、精确技术契约或未知诊断走 Technical |
+| 错误 | 不声明读者起点;教程变理论课;学习计划无基线 / 完成标准 / 调整规则;只有原则无步骤 / 例子;步骤无结果或验证;版本、权限、环境缺失;编造命令、UI 或链接;FAQ 脱离真实问题;资源合集无标准 / 注释 / 维护;视觉成为唯一信息;把未知排障写成确定答案 |
+
+## 适用与消歧
+
+适用自主理解、学习 / 备考规划、一次已知任务或检索复用。`科普`、`教程`、`指南`、`攻略`、`学习计划`、`FAQ`、`知识库`、`资源合集`只用于召回;“知识库”是容器或渠道,不决定文章体裁。组织要求多人按批准版本重复执行并留痕走 [`sop-tutorial.md`](sop-tutorial.md);未来设计、API 精确契约、生产状态变更或未知根因走 [`technical-doc.md`](technical-doc.md);研究或数据形成新洞察走 Report。
+
+## 主模式
+
+| 模式 / 读者任务 | 结构推进 |
+|-|-|
+| Explanation / 科普:建立正确心智模型 | 现象 / 误区 → 概念模型 → 机制与证据 → 例子 → 争议、限制与适用边界 |
+| Tutorial:通过受引导练习获得技能 | 学习目标 → 起点 / 环境 → 安全练习 → checkpoint → 复盘与下一步 |
+| Learning plan:在现实约束下持续提高 | 基线诊断 → 可观察的阶段目标 → 练习 / 资料 / 时间 → 完成标准与反馈 → 调整规则 |
+| How-to:完成一个已知目标 | 目标 → 前置 → 最短有效步骤与可观察结果 → 变体 / 已知错误 → 完成验证 |
+| FAQ / known troubleshooting:快速找到已验证答案 | 按真实问题或症状分组 → 直接答案 → 必要条件 / 操作 → 相关内容;需要新假设或根因调查时转 Technical |
+| Resource guide:按标准选择资源 | 使用场景 / 筛选标准 → 分类 → 每项适配、代价与访问条件 → 维护信息 |
+| Reference / KB article:检索并复用事实或解法 | 上下文 / 适用版本 → 事实或 issue-resolution → 限制 / 相关项 → 时效性强时标 owner / last verified |
+
+## 事实、步骤与维护
+
+- 明确受众的已有知识、范围 / 非范围、版本、环境、权限与风险;术语在首次需要时解释,不先灌输完整理论。
+- 学习计划按阶段 / 能力、可用时间、既有任务和可得资料控制强度;目标拆成可观察表现,每阶段合写练习、完成标准、反馈和调整条件。会显著改变安排的缺口先问或条件化,不补造基础 / 时间。
+- 顺序任务一项写一个清楚动作,紧邻给可观察结果;命令、输入、输出和成功验证须能在声明环境中复现,危险或不可逆警告必须在动作前。
+- FAQ 只收真实用户问题或检索需求;否则按用户任务重组。资源指南先写选择标准,再给有描述的精选链接,不用外链代替核心上下文。
+- 时效性内容标适用版本 / 时间并说明维护边界;复杂视觉须有可传达同等信息的正文,图片、案例和代码不得成为无解释的唯一依据。
+- 版本或权限不明时用 `[适用版本待核]`、`[所需权限待确认]` 并只写不受影响部分;未验证命令或链接不进入发布稿。缺口可能造成损失、安全风险或关键分叉时标记 `blocked`。
+
+## 高质量写法
+
+第一屏让读者知道能理解、学会、完成或找到什么;用读者语言、具体动词和可验证结果推进,每节只增加必要的新理解或动作。先给最短可行路径,再在需要处补原理、变体和进一步阅读;示例只服务迁移,不扩张为用户未要求的全套内容,也不用丰富组件掩盖解释不足。
diff --git a/skills/lark-doc/references/genres/route-marketing.md b/skills/lark-doc/references/genres/route-marketing.md
new file mode 100644
index 0000000000..b991317fff
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-marketing.md
@@ -0,0 +1,40 @@
+# Genre Contract: Marketing / 营销与公关 (`router.marketing`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 清楚、有吸引力且可行动,表达强度不得超过承诺、证据和授权,紧迫感不得制造误导 |
+| 内容逻辑 | 先锁定受众、漏斗阶段和唯一主要读者结果;转化内容用“场景 / 问题 → 有边界的价值主张 → 证据 → 关键条件 / 异议 → 一个 CTA”推进,公关稿按已授权事实、相关方影响、组织回应和后续更新推进 |
+| 事实 / 边界 | 所有客观、比较、功效和稀缺性主张发布前有相称证据;价格、资格、期限和限制就近可见;广告身份与商业关系按目标法域 / 平台规则披露;评价、案例、引语、图片和活动素材须真实、可核且获授权,非文字证据须有文字等价信息;核心主张证据、适用法域、发布授权、关键交易条件或任务要求的行动入口缺失时 blocked |
+| 错误 | 无证据的“最佳 / 保证 / 第一”、隐藏限制或自动续费、伪造倒计时 / 库存 / 评价、把广告伪装成独立报道、未经授权承诺赔付或责任、转化内容多个 CTA 争抢、用复杂 block 掩盖价值缺口,任一出现即失败 |
+
+## 适用与消歧
+
+用于组织拥有或授权、目标是认知、转化、留存或公共关系管理的内容。由新闻机构独立选题、核实和报道的内容走 Media;组织自有新闻稿、媒体通稿、品牌声明和回应口径走 Marketing,即使采用新闻结构也不变成独立报道。
+
+个人真实体验用于帮助消费选择时走 Consumer;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,营销目标、商业关系和交易条件作为该 leaf contract 的硬约束,不再并读 Marketing。出现“新闻稿、软文、活动文案”只作召回信号,仍须确认发布主体、受众、行动和商业关系。
+
+内部营销策划、增长方案或活动执行计划不因“营销”进入本合同:比较打法走 Business Analysis,请求预算 / 资源 / 战役批准走 Proposal,已定打法的协同落地走 Execution Plan;只有最终面向受众的传播、招募或转化成稿走 Marketing。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 广告 / 短文案 | 迅速判断是否值得行动;受众场景 → 单一利益 → 可信理由 → 条件 → CTA |
+| 详情页 / 落地页 | 完成比较与转化;价值主张 → 关键能力 → 证据 → 方案 / 条款 → 异议 → CTA |
+| 活动 / 私域话术 | 判断是否参与并知道下一步;对象 → 收益 → 时间地点 / 门槛 → 风险限制 → 行动 |
+| 新闻稿 / 媒体通稿 | 获取组织已授权消息;可发布事实 → 为什么重要 → 引语 / 背景 → 联系与更新安排 |
+| 声明 / 危机回应 | 理解已知事实和组织行动;事件范围 → 已确认影响 → 当前措施 → 未知项 → 下次更新时间 |
+
+## 证据、授权与合规
+
+- 建立“主张—证据”对应:定量效果说明口径、样本和时间,比较主张保证对象与标准可比;图片、引语、评价和案例保留来源、必要语境及授权记录。
+- 披露和限制应让普通受众在作决定前看见并理解,不能由链接、模糊缩写或弱提示代替。规则随法域、媒介、品类和时间变化,交付前核验当期法律、监管与平台要求。
+- 公关内容只写已获授权的事实和承诺;事故原因、责任、补偿、调查结论未核定时明确 unknown。关键批准或法律审阅未完成,不生成可直接外发版本。
+
+## 结构与高质量写法
+
+价值主张具体到受众、场景和结果,证据紧跟对应主张。文案须锚定品牌独有资产、产品细节或品类语境;换成竞品名仍成立就返工。多版本应改变受众状态、主张、证据或场景并说明选择条件,不做同义改写。
+
+删除不改变理解或行动的品牌空话,不把真实痛点升级为羞耻、身份不足或恐惧操控。有转化目标时,次级入口均服务同一主要行动;优惠资格和截止时间采用可比较字段,待补价格、库存或链接用语义化占位,并让受影响结论保持 blocked。
diff --git a/skills/lark-doc/references/genres/route-media.md b/skills/lark-doc/references/genres/route-media.md
new file mode 100644
index 0000000000..6ff646499d
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-media.md
@@ -0,0 +1,36 @@
+# Genre Contract: Media / 资讯媒体 (`router.media`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 准确、中立、紧凑,信息密度服从读者快速理解,不用戏剧化措辞替代事实强度 |
+| 内容逻辑 | 先确定快讯 / 报道、解释、人物特写或访谈的读者任务;关键信息优先,随后给证据、必要背景、相关方视角和仍未知事项,段落按重要性、因果或时间关系推进 |
+| 事实 / 边界 | 区分已核事实、来源说法、推断和 unknown;准确优先于抢发,关键主张可追溯,负面涉及方获得合理回应机会;引语须忠实可核,图片和原始材料须有使用权限、来源、语境说明及文字等价信息,更正、披露和关键缺口须直接可见;核心事实、来源真实性、发布权限缺失,或严重负面指控尚未提供回应机会时保持草稿并 blocked |
+| 错误 | 把组织自有通稿伪装成独立报道、标题超出证据、单一匿名来源承载重大指控、引语失真、事实与评论混写、遗漏重大反方或不确定性、图片无权利 / 来源 / 文字等价信息,任一出现即失败 |
+
+## 适用与消歧
+
+本合同用于以独立采集、核实和公共理解为职责的新闻内容。请求出现“新闻稿、媒体稿、报道”只作召回信号:编辑方能独立核实、选择角度并承担报道判断时走 Media;由组织拥有、批准并面向媒体或公众发布的新闻稿、品牌声明和公关口径走 Marketing。
+
+以立场说服为主走 Opinion;以购买决策和亲身体验为主走 Consumer;内部事实简报不因写得像新闻而改变读者任务。渠道名、标题风格或“像媒体一样写”均不能单独触发;明确要求最终交付小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,资讯核实边界作为该 leaf contract 的硬约束。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 快讯 / 硬新闻 | 尽快知道发生了什么及其可信程度;核心事实 → 来源与范围 → 必要背景 → 下一确认点 |
+| 解释报道 | 理解为什么发生、如何运作及争议在哪里;问题 → 机制 / 时间线 → 多方证据 → 已知边界 |
+| 人物 / 特写 | 通过可核场景和经历理解人物或议题;场景 → 关键变化 → 证据与他者视角 → 公共意义 |
+| 访谈 / 问答 | 准确获取受访者观点及上下文;交代身份与场景,忠实编辑问答,不补造连接语或立场 |
+
+## 证据与真实性
+
+- 为可能引发争议的事实保留可追溯材料,记录来源身份、接近事实的方式、核实状态和使用限制;匿名只在有公共价值且无法安全具名时采用,并说明读者判断所需的来源范围。
+- 引语逐字可核;压缩、翻译和转述不得改变含义。无法确认的数字、时间、身份或因果就近标明 unknown,不用“据悉”“有消息称”遮蔽来源质量。
+- 开盒、网暴、羞辱、未成年人或其他可能放大伤害的事件只保留理解事实、责任和传播机制所需的最少信息;不为证明热点而复刻身份线索、攻击性内容或未核传言。
+- 更正要说明改了什么;新证据改变核心判断时更新标题和结论。发布前无法核实的核心主张不得靠占位符放行。
+
+## 结构与高质量写法
+
+标题和导语只承诺正文已证明的内容。每段承担一个信息动作,并在首次出现时交代人物、机构、时间和口径;背景只保留改变理解的部分。多方说法按证据权重而非形式上的各打一板排列,不把可验证事实写成“双方观点”,也不把尚无结论写成确定因果。
diff --git a/skills/lark-doc/references/genres/route-opinion.md b/skills/lark-doc/references/genres/route-opinion.md
new file mode 100644
index 0000000000..4e87f0571d
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-opinion.md
@@ -0,0 +1,38 @@
+# Genre Contract: Opinion / 观点评论 (`router.opinion`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 立场鲜明但措辞精确、公平,论证密度高于情绪密度,锋利不等于侮辱或夸张 |
+| 内容逻辑 | 明确可争辩的中心判断及其重要性,用理由和证据推进;对有实质争议的主张呈现最强相关反论并回应,结论说明判断边界或行动含义 |
+| 事实 / 边界 | 区分事实、推断、价值判断、预测和个人经验;事实可追溯,证据强度匹配主张强度,不把相关性写成因果或把个案外推为普遍规律;引语、图片和作品片段须有可核来源、必要语境与使用权限,非文字证据须有文字等价信息,重要利益关系须显著披露;关键事实缺失时收窄主张,无法成立则 blocked |
+| 错误 | 只有态度没有论点、稻草人反驳、选择性证据、人格攻击、标题先定罪、把经验冒充统计、隐藏重大反例或利益关系、结论超出论证,任一出现即失败 |
+
+## 适用与消歧
+
+用于帮助读者评估一个判断、立场或解释框架。事件复述和独立核实走 Media;围绕购买选择的测评走 Consumer;组织为行动或转化发声走 Marketing。出现“评论、专栏、观点”只是召回词,正文必须有可辨认的判断和论证任务。
+
+文化评论关注作品、现象的意义和判断;若主要提供剧情复述或故事体验,不走本合同。个人经历可以作为观察入口,但若目标是展示经历与能力,走 Personal Brand。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 时评 / 公共议题评论 | 判断事件意味着什么;争点 → 判断 → 证据与机制 → 反论 → 后果 / 建议 |
+| 商业 / 行业评论 | 评估策略、趋势或制度;基线 → 驱动因素 → 证据 → 替代解释 → 适用条件 |
+| 文化评论 | 理解作品或现象的价值;分析对象 → 解释框架 → 细读证据 → 限度 → 判断 |
+| 专栏 / 随笔 | 从观察或经验形成可迁移洞见;具体场景 → 反思 → 关联 → 有边界的结论 |
+
+## 证据与论证
+
+- 开头尽早写出“我主张什么”和“为什么现在值得讨论”,避免用大段背景延迟论点。每个理由回答一个潜在质疑,并由事实、例子、机制或可靠来源支持。
+- 反论选择真正能动摇中心判断的版本,不挑最弱说法;回应可以承认条件、修改范围或解释为何仍不改变结论。观点平衡不是机械分配篇幅。
+- 公共争议先拆清事实真伪、规则 / 权利、价值取舍、责任归属和 unknown,再分别判断;行动建议须对应具体主体、权限 / 义务、可用杠杆与代价,不用“多方协同”抹平责任边界。
+- 预测写明前提和时间范围;价值判断说明采用的标准。涉及他人动机、违法或伤害的判断不得凭语气升级为事实。
+
+## 结构与高质量写法
+
+段落之间形成“主张 → 理由 → 证据 → 推论”的可追链条,过渡词只标真实关系。文化评论选择一个能统摄正文的主分析轴,把情节、语言、镜头、声音、表演或结构写成“形式选择 → 产生效果 → 支持何种解释 / 评价”的证据链;比较或综述可有多个对象,但不能退化成剧情复述或维度清单,也不把效果直接冒充创作者意图。
+
+结尾不复述全文,而是给出经反论校准后的判断、仍然未知的部分,或读者下一步应重新考虑什么。随笔可弱化显式论证标记,但不能牺牲观察与结论之间的可理解联系。
diff --git a/skills/lark-doc/references/genres/route-personal-brand.md b/skills/lark-doc/references/genres/route-personal-brand.md
new file mode 100644
index 0000000000..de735b2962
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-personal-brand.md
@@ -0,0 +1,36 @@
+# Genre Contract: Personal Brand / 个人品牌 (`router.personal_brand`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 可信、具体、有辨识度,声音服从目标读者和真实经历,不用自我评价替代成果证据 |
+| 内容逻辑 | 从目标读者和目标机会出发,用“身份 / 价值定位 → 相关经历 → 可验证贡献 → 做事方式 → 下一步意图”组织;每项经历说明情境、本人动作、结果及与目标的关系 |
+| 事实 / 边界 | 职位、时间、职责、学历、技能、作品和指标须真实可核,个人贡献与团队成果分开;尊重保密、个人信息、雇主和作品权利,作品、图片和推荐语须确认归属、使用权限与必要语境,非文字证据须有文字等价信息;关键身份、时间、归属或公开权限缺失时用具体占位,无法安全表述则 blocked |
+| 错误 | 夸大头衔 / 技能 / 指标、把团队成果全归个人、关键词堆砌、伪造推荐语或客户、泄露敏感信息、作品无归属 / 权限、同一经历前后矛盾、渠道语气改变事实,任一出现即失败 |
+
+## 适用与消歧
+
+用于让招聘方、合作方、客户或专业社群判断“这个人是谁、做过什么、能带来什么”。仅出现平台名称不触发;明确要求最终交付 Email、小红书笔记或微信公众号文章时走 `route_platform`,再选择对应 leaf,个人身份、经历和信誉目标作为该 leaf contract 的硬约束,不再并读 Personal Brand。
+
+以项目经验得失来改进下一轮走 Retrospective;以购买体验帮助他人选择走 Consumer;以组织身份转化客户走 Marketing。出现“介绍、主页、复盘”不能单独触发,须确认目标是个人能力和信誉呈现。
+
+## 子类型
+
+| 子类型 | 读者任务与推进 |
+|-|-|
+| 简历 / CV | 快速判断岗位匹配;摘要 → 相关经历与成果 → 技能 / 教育 → 必要补充 |
+| 求职信 / 自我介绍 / 简介 | 理解动机与差异化价值;目标 → 相关证据 → 工作方式 → 明确下一步 |
+| 个人主页 | 建立清晰定位并找到入口;一句定位 → 代表证据 → 领域 / 服务 → 联系或作品 |
+| 作品集 / 案例集 | 判断能力如何形成结果;问题 → 约束与本人角色 → 过程决策 → 结果与反思 |
+| 个人成长回顾 | 理解身份与能力变化;起点 → 关键选择 → 证据 → 学到什么 → 下一方向 |
+
+## 证据与真实性
+
+- 成果优先写可核结果及其口径,不能量化时写可观察变化、交付物或他人采用情况,不编造数字。明确“负责、协作、支持、批准”等角色差异。
+- 时间线、组织名、客户名、作品链接和推荐语在公开前确认准确与授权;需匿名时保留问题、本人动作和结果的判断价值,不留下可反推的敏感细节。
+- 技能由近期作品、职责范围或实际使用场景支撑;自我定位可以有主张,但不能使用未获认可的资质、奖项或身份。
+
+## 结构与高质量写法
+
+先筛选与目标读者最相关的经历,不把完整人生经历当作专业证明。经历条目以动作和影响开头,背景只写理解贡献所需的约束;案例说明权衡和本人判断,比工具清单更能证明能力。CTA 具体到希望发生的下一步,并只提供获授权的联系方式。
diff --git a/skills/lark-doc/references/genres/route-platform.md b/skills/lark-doc/references/genres/route-platform.md
new file mode 100644
index 0000000000..8c28bdd073
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-platform.md
@@ -0,0 +1,9 @@
+# Genre Router: Platform / 平台发布稿 (`route_platform`)
+
+仅当最终交付物是小红书笔记、微信公众号文章或邮件成稿时进入本 router;按目标平台选择且只读取一个 leaf。仅把平台作为研究对象、信息来源或业务渠道时不触发;多平台成稿分别路由和生成。
+
+| 关键词 | Leaf |
+|-----------------|------------------------------------|
+| XHS、小红书 | [`xiaohongshu.md`](xiaohongshu.md) |
+| 微信、wechat | [`wechat.md`](wechat.md) |
+| 邮件、email、e-mail | [`email.md`](email.md) |
diff --git a/skills/lark-doc/references/genres/route-report.md b/skills/lark-doc/references/genres/route-report.md
new file mode 100644
index 0000000000..3e88d40b04
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-report.md
@@ -0,0 +1,10 @@
+# Genre Router: Report (`router.report`)
+
+用数据、样本或研究形成洞察走本类;按读者任务选择且只读一个 leaf,关键词仅用于召回,`报告 / 分析 / 研究 / 数据 / 白皮书`单独不决定路由。组织执行 / 批准走 Workplace,自主学习走 Knowledge。
+
+| 读者任务 / 关键词、强信号与排除 | Leaf |
+|-|-|
+| 回答明确研究问题,方法、样本和可推广边界决定可信度;调研报告、访谈 / 问卷 / 用户研究。仅解读既定指标时排除 | [`research-report.md`](research-report.md) |
+| 解读已定义指标、趋势、分布、漏斗或实验观察值;数据报告、经营数据、指标复盘。需重新设计样本回答问题时排除 | [`data-report.md`](data-report.md) |
+| 让专业读者系统理解并评估问题、框架或方案;白皮书、行业框架、技术 / 政策议题。获客、产品卖点或 CTA 为主时排除 | [`white-paper.md`](white-paper.md) |
+| 比较战略、投资、市场、产品或资源选项及成本、收益、风险;商业分析、可行性、进入 / 自建或采购判断。正文要求具名决策者选择 / 批准,或形成授权、资源拨付、执行承诺入口时排除 | [`business-analysis.md`](business-analysis.md) |
diff --git a/skills/lark-doc/references/genres/route-workplace.md b/skills/lark-doc/references/genres/route-workplace.md
new file mode 100644
index 0000000000..ae17c76e8b
--- /dev/null
+++ b/skills/lark-doc/references/genres/route-workplace.md
@@ -0,0 +1,17 @@
+# Genre Router: Workplace (`router.workplace`)
+
+组织内决策、执行、留档走本类;先按读者任务与生命周期选择且只读一个 leaf,关键词仅用于召回,排除信号优先于同名词。
+
+| 读者任务 / 关键词、强信号与排除 | Leaf |
+|-|-|
+| 快速知悉、短判断或会前准备;备忘录、决策摘要、会前材料。完整批准论证走 Proposal,周期状态走 Weekly | [`memo-brief.md`](memo-brief.md) |
+| 按周期判断相对承诺的状态、偏差、风险和下一步;周报、日报、月报、项目状态。原因学习走 Retrospective,完整分析走 Report | [`weekly-report.md`](weekly-report.md) |
+| 请具名决策者批准方向、预算、资源或执行承诺;提案、立项、资源申请。已定产品行为走 PRD | [`proposal.md`](proposal.md) |
+| 将方向已定的一次性项目、变更、专项行动或营销战役转成可协同推进的交付、依赖、里程碑与验收;项目计划、执行方案、实施计划。仍在比较方向或请求批准走 Proposal / Report,重复稳定路径走 SOP | [`execution-plan.md`](execution-plan.md) |
+| 将已授权的内部规则 / 安排、可复核的检查整改记录或已核定组织立场写成正式载体;制度、公司通知、整改记录、讲话底稿。待批准方向走 Proposal,复杂执行走 Execution Plan,法定公文走 Official;`正式`单独不触发 | [`formal-doc.md`](formal-doc.md) |
+| 党政机关法定公文拟制、审校或制发;明确要求公文 / 红头 / 套红 / 正式发文,或法定文种与机关行文关系、文号、主送等制发要素共同出现。`通知 / 报告 / 公告 / 纪要 / 正式 / 官方`单独不触发 | [`official-redhead.md`](official-redhead.md) |
+| 记录已发生会议的决定、异议、行动和批准状态;会议记录、行动项。逐字稿不走本 leaf,法定公文纪要走 Official | [`meeting-minutes.md`](meeting-minutes.md) |
+| 从已结束周期 / 事件提炼证据化学习并改变下一轮;复盘、回顾、经验教训。当前状态走 Weekly,活跃未知事故走 Technical | [`retrospective.md`](retrospective.md) |
+| 方向已定,定义用户问题、范围、产品行为与验收;PRD、用户故事、验收标准。是否投入走 Proposal,实现取舍走 Technical | [`prd.md`](prd.md) |
+| 评审未来技术设计、查询精确契约或调查未知故障;RFC、API、架构、事故调查。产品行为走 PRD,已定重复路径走 SOP | [`technical-doc.md`](technical-doc.md) |
+| 按已批准、可验证路径重复达到终态,或为已知事件类别预置响应路径;SOP、runbook、值班 / 操作手册、BCP / 处置预案。应急预案若主要发布权威职责走 Formal,法定制发走 Official,活跃未知事故走 Technical,一次学习教程走 Knowledge | [`sop-tutorial.md`](sop-tutorial.md) |
diff --git a/skills/lark-doc/references/genres/sop-tutorial.md b/skills/lark-doc/references/genres/sop-tutorial.md
new file mode 100644
index 0000000000..65b3a5fa3f
--- /dev/null
+++ b/skills/lark-doc/references/genres/sop-tutorial.md
@@ -0,0 +1,41 @@
+# Genre Contract: SOP / Runbook (`workplace.sop_tutorial`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 命令式、具体、顺序稳定,一步一动作并紧邻可观察判据,不写无条件的“适当 / 必要时” |
+| 内容逻辑 | 先定 routine / controlled / high-risk,并识别是否为响应预案,再按“版本 → 触发 / 范围 / 终态 → 角色 / 前置 → 动作 / 判据 / 证据 → 异常 / 停止 / 恢复 → 完成记录 / 复审”推进 |
+| 事实 / 边界 | owner、版本、环境、资格、权限、工具、命令、阈值、预期结果和恢复路径均须已验证;警告在动作前;命令成功不等于业务终态;流程图 / 示意不能替代可执行步骤、判据与异常路径,须有文字等价;关键未知使可发布稿 `blocked` |
+| 错误 | 教程冒充 SOP、未分风险、缺 owner / 版本 / 前置、一条多动作、编造入口 / 阈值 / 权限 / 命令、停止后状态未知、只写“必要时回滚”或未验证终态;响应预案无分级触发、替补指挥、降级路径或解除条件,任一出现即失败 |
+
+## 适用与风险分类
+
+用于组织规定的重复作业、沿已批准路线取得确定终态的 runbook,或针对已知事件类别预置并授权的响应 / 业务连续性路径。一次性自助 how-to / 学习走 Knowledge;未来设计取舍、活跃未知故障或临场根因调查走 `technical-doc.md`;只建立组织权威、职责或发布要求而不提供现场步骤走 `formal-doc.md`。“教程 / 操作 / 手册 / 应急预案”单词本身不触发。
+
+| 分类 | 增量证明义务 |
+|-|-|
+| `routine` | 阶段或终态验证、常见异常和升级 |
+| `controlled` | 再含审批、接受 / 拒绝、偏差记录、变更复审和代表性试跑 |
+| `high-risk` | 再含 precheck、hold point、go / no-go、停止条件,以及可执行 rollback / fallback / roll-forward 和恢复验证 |
+
+## 响应预案增量
+
+- 涉及人身安全或法定直报时,其优先级高于业务与财产;按已核风险设置进入、升级、降级和解除条件,明确指挥 / 决策权限、替补角色、首轮动作、信息报送与对外口径边界。联络序列、等待时长和重试次数须预先批准;未知时保留占位,仅放行无需等待授权的安全动作。
+- 预设负责人失联、断网断电、主资源不可用等降级场景及可达的安全终态;恢复须验证真实业务终态。发布前按风险做桌面推演或代表性演练,高风险场景包含故障注入并记录缺口、owner 和复验。
+
+## 文控与证据
+
+写明触发、目标终态、范围、owner / 资格、当前版本 / 环境、前置、权限、工具和输入。命令、参数、阈值、预期输出、备份 / 恢复资产和试跑结果须来自真实环境;流程变更后更新、复审并标 superseded 状态。
+
+关键缺口就近使用`[待环境 owner 验证]`等具体占位。命令、权限、阈值、停止或恢复判据未知时只保留安全只读 precheck,不得创建可执行稿。
+
+## 步骤、异常与恢复
+
+- 每个关键步骤只写一个动作,紧邻可观察结果、阈值与证据;验证需要操作时另列一步。未知偏差停止于已知安全状态,记录证据并升级。
+- high-risk 在不可逆动作前设置 hold point:列 go / no-go 信号、决策人和信号缺失时的安全终态。rollback 写触发条件、适用范围、步骤、阈值、停止点和恢复后业务验证,不能只写命令回执。
+- 有状态迁移另列不可逆点、写入归属、checkpoint / 幂等,以及完整、无重复、有序或等价验证;关闭 fallback 前必须证明新终态稳定。
+
+## 高质量写法
+
+让具备规定基础资格但不熟流程的人可独立复现;选择条件写在动作前,稳定原理链接出去,不混入原理课或临场诊断。按风险裁剪篇幅但不删证明义务;按适用治理要求由代表性执行者试跑,未经任何实际验证不得发布。
diff --git a/skills/lark-doc/references/genres/technical-doc.md b/skills/lark-doc/references/genres/technical-doc.md
new file mode 100644
index 0000000000..82246cb4a1
--- /dev/null
+++ b/skills/lark-doc/references/genres/technical-doc.md
@@ -0,0 +1,39 @@
+# Genre Contract: Technical Document / 技术文档 (`workplace.technical_doc`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 精确、可证伪、术语与版本稳定;规范词仅用于明确采用的互操作、安全或验收语义 |
+| 视觉约束 | 在有明确内容作用时用代码、表格、架构 / 状态 / 时序图等组件降低实现与诊断成本,但不得替代契约、证据或操作说明 |
+| 内容逻辑 | 必须且只能选 design_rfc、api_reference、incident_diagnostic 一种主模式;分别按“证据 → 取舍 / 设计 → 验收”“契约 → 错误 / 兼容”“影响 → 假设 / 检查 → 验证 / 升级”推进 |
+| 事实 / 边界 | 标对象、环境、版本、时间、范围和证据窗;事实、推断、决定、未知分开;示例 / 图不替代契约;任何改状态动作须有授权、影响、停止、还原和恢复验证,关键缺口按 reader impact 处理 |
+| 错误 | 按关键词路由、三模式混写、设计无取舍 / 验收、reference 漏权限 / 错误 / 生命周期 / 兼容、未知故障直接定根因、改状态无授权 / 停止 / 还原或图作唯一证据,任一出现即失败 |
+
+## 先选唯一主模式
+
+| 主模式 | 读者任务 | 排除 |
+|-|-|-|
+| `design_rfc` | 评审者能批准并实现未来技术状态,理解替代、后果和验收 | 产品可观察行为走 PRD;既定路径走 SOP |
+| `api_reference` | 调用者无需猜版本、权限、输入、行为、副作用、错误与生命周期 | 仍在讨论接口取舍时走 design_rfc |
+| `incident_diagnostic` | 响应者以安全、有区分度的动作缩小未知、止损、恢复或升级 | 单纯团队学习走 Retrospective;已知重复处置走 SOP |
+
+## 共同证据边界
+
+标明对象、环境、版本、时间、范围 / 前置和证据位置 / 窗口;结论回链仓库、IDL / schema、日志、metrics、traces、变更记录或验证实验。缺口就近使用具体占位、收窄或 `blocked`;数据分级、访问、保留、重放、owner、时限和升级只在适用时形成门禁。
+
+代码与命令示例须实际验证并标环境 / 版本;架构、状态或时序图必须附文字等价,不能成为唯一证据或唯一操作说明。
+
+## Design RFC
+
+按问题证据 → 目标 / 非目标 → 约束 / 不变量 → 真实备选与同口径取舍 → 接口 / 数据 / 状态设计 → 失败、安全、兼容与迁移 → 上线 / rollback → 可观测性、测试 / 验收 → 未决决定推进。每项关键决定写 why、被否方案及后果;不得隐藏低置信度或版本偏差。
+
+## API Reference
+
+写清版本 / 环境 / 权限 / 签名、输入约束、行为 / 副作用 / 幂等、输出、已知错误及可操作恢复、限流 / 分页 / 重试、兼容 / 弃用。事件、异步、CLI、SDK、流式按需补 channel / message、交付 / 顺序、生命周期 / 耗尽、I/O、取消与背压;未知语义明确 unspecified,不从示例推断承诺。
+
+## Incident Diagnostic
+
+按影响与 expected / actual → 当前状态与证据链 → 可证伪假设 → 信息增益高且副作用低的检查 → 止损 / 恢复验证 → 升级与后续 RCA 推进。每项检查写预期观察及其支持 / 排除的假设。
+
+修改状态前必须确认授权、目标范围、潜在副作用、停止条件、还原路径和恢复判据;分开止损、根因与永久修复。缺证据、授权、owner、还原或升级路径时只给安全只读检查并 `blocked`;涉及安全 / 法务时先保全证据和升级。
diff --git a/skills/lark-doc/references/genres/wechat.md b/skills/lark-doc/references/genres/wechat.md
new file mode 100644
index 0000000000..618b16f124
--- /dev/null
+++ b/skills/lark-doc/references/genres/wechat.md
@@ -0,0 +1,39 @@
+# Genre Contract: WeChat Official Account / 微信公众号文章 (`platform.wechat`)
+
+## 核心定位(硬约束)
+
+- 交付物是飞书文档中的“微信公众号风格”内容稿,不代表实际发布,也不执行微信平台审核、流量、商业或发布规则。
+- 视觉策略默认使用 `rich`,主动寻找图文结合的表达机会,但每个组件必须服务主线。
+- 写作风格可信、有观点、有叙事或论证推进,在专业感与亲近感之间保持平衡。公众号不是加长版小红书,也不是公文或报告换皮。
+- 一篇只服务一个读者任务和一个可兑现承诺;标题、封面、摘要、导语、正文与结尾围绕同一主线。不编造亲历、身份、数据、引语、案例或效果;无来源时不用“多数、普遍、研究表明”等统计口吻,材料不足时明确收窄表达。
+- 飞书源稿禁止使用 `callout`;生成后通过 Draft Profile Check 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
+
+## 适用与消歧
+
+用户明确要“微信公众号文章、公众号推文、微信长文、微信爆文、公众号风格”时使用,内容保存在哪里不影响本合同生效。
+
+普通微信聊天消息、群公告、朋友圈文案、视频号口播、小程序页面和服务通知不走本合同。仅把微信作为研究对象、信息来源或业务渠道时也不触发;若同时要公众号稿和正式体裁,分别生成,不混写。
+
+## 内容模式
+
+| 模式 | 内容脊柱 |
+|-|-|
+| 知识 / 方法 | 读者处境 → 核心原理 / 结论 → 方法与验证 → 成本、例外和适用边界 → 可执行认识 |
+| 观点 / 解释 | 现象或争点 → 中心判断 → 理由、证据与机制 → 相关反论 / 边界 → 校准后的结论 |
+| 资讯 / 热点 | 已确认事实 → 为什么重要 → 必要背景与多方信息 → 争议 / 未知 → 当前结论或更新点 |
+| 案例 / 故事 | 具体场景 → 选择与行动 → 可观察结果 → 代价 / 失误 → 可迁移洞见 |
+| 品牌 / 行动 | 读者场景 → 有边界的价值 → 证据 / 体验 → 条件与取舍 → 清楚结论 |
+
+## 成稿要求
+
+- 先钉住具体读者、核心问题与中心判断;内部比较信息清晰型、问题 / 冲突型、观点浓缩型标题,成稿只输出既有张力又不透支正文的一个。
+- 标题负责建立准确预期;摘要按需补充关键背景、判断或阅读收益,不复述标题。摘要、导语和首节必须各有信息增量。封面只保留一个视觉中心,图片文案不制造第二个主题。
+- 导语在首屏内用具体场景、问题、变化或判断说明“为什么值得读”,随后尽快进入主线,不用宏大背景、客套话或悬念拖延核心信息。
+- 正文沿一条逻辑线展开,小标题概括本节增量。段落各有一个主要意思,但长短随内容变化:重点句可独立成段,证据、故事和推理要保留完整上下文,避免短句过多造成逻辑断裂。
+- 使用自然、可交流的书面语;用具体细节、例子、转折和取舍形成作者声音,不靠网络热词、排比口号或统一句式制造“爆文感”,避免连续复用同一反转句式。
+- 完整稿至少给出一个封面或正文视觉方案;已有图片时就近用于提供证据、解释信息、建立场景或调节长文节奏。图片不设固定数量,也不为“图文并茂”强塞装饰图,正文仍须独立可读。
+- 结尾回扣开头问题或中心判断,留下结论、影响或自然的下一步;互动句、emoji 和话题标签均按需使用,不要求固定收尾动作。
+
+## 交付前检查
+
+确认标题没有透支正文,摘要与导语没有重复,文章主线连续,每节都在推进事实、故事、论证或方法,手机上容易扫读但不过度碎片化,图片确实帮助理解,且没有空洞口号、标题党、模板腔或虚构事实。
diff --git a/skills/lark-doc/references/genres/weekly-report.md b/skills/lark-doc/references/genres/weekly-report.md
new file mode 100644
index 0000000000..6b453baff9
--- /dev/null
+++ b/skills/lark-doc/references/genres/weekly-report.md
@@ -0,0 +1,24 @@
+# Genre Contract: Weekly / Status Report (`workplace.weekly_report`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 具体、短、面向判断,稳定使用最小字段与状态语义,不用“持续推进”代替产出 |
+| 内容逻辑 | 围绕报告对象和周期,按“总体状态 / 最大变化 → 对照基线的产出 → 偏差 / 风险 / 依赖 → 下一里程碑 → ask”推进,只写影响判断的变化 |
+| 事实 / 边界 | 状态须回链范围、时间、质量、成本、资源或阻塞证据;事实、当前状态和下期计划分开;无基线或数据不足时写 unknown,不猜完成率、原因、owner 或日期 |
+| 错误 | 活动流水账、无周期 / 基线、健康色无判据、风险被埋、猜测根因冒充事实、下一步无里程碑、ask 不可执行或自动汇总不可追,任一出现即失败 |
+
+## 适用与消歧
+
+用于按固定或约定周期判断当前相对目标 / 计划 / 承诺的位置。解释已结束周期为何如此并改变下一轮走 `retrospective.md`;完整指标洞察走数据报告;一次性高层知会走 `memo-brief.md`。“报告 / 进展”单词本身不触发本体裁。
+
+## 状态与证据
+
+- 标明报告对象、周期 / 截至时间;进展使用已验收产出、里程碑或有口径指标,会议数、沟通和投入时长本身不等于进展。
+- On track / 红黄绿等状态须有预先定义或就近说明的判据。无基线时明确“无法判断是否按计划”,而不是默认绿色。
+- 风险、问题、依赖和阻塞按已知程度写影响、当前缓解、责任方与升级需求;冲突数据并列保留并标`[口径待核]`。
+
+## 结构与高质量写法
+
+个人短更新可收缩,项目群 / 月报可按需增加趋势、成本或预算,但不复制无用栏目。优先写相对上期和相对承诺的 delta;稳定低风险项可链接原记录。ask 写明对象、事项和需要时间,关键数据延迟时说明最近可用时间点及其判断影响。
diff --git a/skills/lark-doc/references/genres/white-paper.md b/skills/lark-doc/references/genres/white-paper.md
new file mode 100644
index 0000000000..85336336b6
--- /dev/null
+++ b/skills/lark-doc/references/genres/white-paper.md
@@ -0,0 +1,32 @@
+# Genre Contract: White Paper / 白皮书 (`report.white_paper`)
+
+## 体裁规则表(硬约束)
+
+| 规则项 | 规则 |
+|-|-|
+| 写作风格 | 系统、清楚、克制;权威来自真实主体、证据与归属,不来自篇幅、正式腔或视觉复杂度 |
+| 内容逻辑 | 先确认白皮书类型、发布主体、专业读者和期望判断,再用证据建立问题、评价标准或框架、论证、反例及应用边界;框架必须实际解释或比较 |
+| 事实 / 边界 | 客观主张连接真实来源、时点、范围和限制;事实、解释、价值判断、提议与品牌立场可区分;政策身份、发布状态、利益、资助和案例选择不得虚构或隐匿;证据图与材料须确认使用权、来源和说明,复杂视觉附文字等价信息 |
+| 错误 | 政策与品牌身份混写;标题或版式伪造权威;宏大背景填篇幅;自创框架仅作装饰;来源不可追;单一案例冒充共识;忽略反证 / 利益冲突;CTA 吞没证据;复杂组件代替论证 |
+
+## 适用与消歧
+
+让专业读者系统理解并评估问题、框架或解决路径。先区分有权主体的政府政策白皮书与专业、技术或品牌资助白皮书;`白皮书`、`正式`、`权威`单独不产生政府或标准身份。明确研究问题和方法为核心走 [`research-report.md`](research-report.md),特定组织选项决策走 [`business-analysis.md`](business-analysis.md),设计 / RFC / 接口契约走 Technical,产品卖点、获客或 CTA 为主走 Marketing。
+
+## 子类型
+
+- **政府政策白皮书**:只有真实有权主体可使用;准确标政策、咨询、立法与发布状态,不模拟批准或法律效力。
+- **政策 / 专业问题白皮书**:围绕问题、证据、评价标准、方案和影响形成可审查论证。
+- **技术 / 行业 landscape 白皮书**:解释技术、标准或系统框架;一旦主要任务是批准实现设计或查询精确契约,改走 Technical。
+- **品牌资助白皮书**:证据评估仍须是主体任务;披露资助、产品利益和案例选择,转化内容与论证分层。
+
+## 证据与边界
+
+- 开头明确作者 / 发布主体、读者、使用场景、范围、文档状态、核心立场和期望判断。
+- 主张强度匹配证据层级;有限测试、相关观察、厂商数据或单一案例不得扩写成绝对承诺或行业共识。
+- 框架的每一层都须增加解释、比较或选择价值;问题原因、评价标准与方案逻辑相连,并处理重要反证、替代解释和可行性限制。
+- 主体或授权不明时用 `[发布主体待确认]`,不得写成政府、官方或标准;核心证据不足时收窄为 concept note / outline。利益关系或关键政策状态无法确认的发布稿标记 `blocked`。
+
+## 结构与高质量写法
+
+独立摘要(主体、论点、证据边界) → 问题与现有证据 → 评价标准或核心框架 → 逐层论证、方案与反例 → 应用 / 政策含义及条件 → 限制、利益关系与来源。摘要让忙碌读者复述主张和保留条件;长篇才增加目录或附录,不以背景、封面、缩写或组件制造权威感。
diff --git a/skills/lark-doc/references/genres/xiaohongshu.md b/skills/lark-doc/references/genres/xiaohongshu.md
new file mode 100644
index 0000000000..584ec6298b
--- /dev/null
+++ b/skills/lark-doc/references/genres/xiaohongshu.md
@@ -0,0 +1,38 @@
+# Genre Contract: Xiaohongshu Note / 小红书笔记 (`platform.xiaohongshu`)
+
+## 核心定位(硬约束)
+
+- 交付物是飞书文档中的“小红书风格”内容稿,不代表实际发布,也不执行小红书平台审核、禁词、流量或商业规则。
+- 视觉策略默认使用 `rich`,偏爱图文并茂和清晰轻松的阅读体验,但装饰不能代替内容。
+- 写作风格鲜活、有节奏、有画面感,可使用符合语境的 emoji。
+- 一篇只解决一个主要问题;标题、封面、首屏和正文围绕同一获得感并真正兑现。不编造亲历、身份、数字、效果或用户反馈,材料不足时用第二人称、场景化讲解或中性叙述。
+- 飞书源稿禁止使用 `callout`;生成后通过 Draft Profile Check 的 `profile.blocks` 检查,其他 block 按真实信息关系选择。
+
+## 适用与消歧
+
+用户明确要“小红书笔记、小红书写法、小红书 style、红书感、XHS 风格”时使用,内容保存在哪里不影响本合同生效。
+
+仅把小红书作为研究对象、数据源或业务渠道时不触发:小红书运营方案走 Workplace,平台数据或竞品分析走 Report,规则说明走 Knowledge。若同时要小红书风格稿和正式体裁,分别生成,不混写。
+
+## 笔记主任务
+
+| 主任务 | 内容脊柱 |
+|-|-|
+| 教程 / 攻略 / 知识 | 痛点场景 → 核心判断 → 分步做法 → 易错点 / 限制 → 马上可做的一步 |
+| 体验 / 测评 / 探店 | 使用场景 → 具体观察 → 亮点与槽点 → 适合谁 / 不适合谁 → 选择建议 |
+| 观点 / 热点 | 争议或反差 → 核心判断 → 理由与例子 → 另一面 / 边界 → 留给读者的问题 |
+| 个人经历 / 成长 | 真实困扰 → 转折瞬间 → 做过什么 → 可观察变化 → 可迁移认识 |
+| 推荐 / 种草 / 活动 | 目标人群与场景 → 核心价值 → 具体理由 / 体验 → 使用条件与取舍 |
+
+## 成稿要求
+
+- 先钉住具体读者、场景与获得感;内部比较搜索清晰型、痛点共鸣型、反差好奇型 3 个标题,成稿只输出正文能兑现的最强一个。
+- 首屏用 1—3 个短段落完成“具体场景 / 冲突 → 核心判断 → 内容预告”,不从宏大背景或自我介绍讲起。
+- 正文用短段落和有意义的小标题按信息增量推进;每节新增动作、观察、例子、判断或限制。“活人感”来自具体细节、选择和取舍,不靠强塞网感词。
+- emoji 可比正式体裁用得更积极,用于导航、语气和停顿,但不连续堆叠。围绕一个视觉中心设计封面,图片 / 截图 / 示意图就近服务对应内容;无可用图片时给出简短配图建议,正文仍须独立可读。
+- 核心主题词自然出现在标题或首屏,相关表达按需进入小标题和正文;话题标签少而相关,不为覆盖关键词而复读。
+- 结尾用一句记忆点收束;互动问题可选且至多一个,不要求固定收尾动作。
+
+## 交付前检查
+
+确认读者能一眼判断“这和我有关”,标题承诺已兑现,每节都有实质信息,手机上容易扫读,emoji 与图片确实帮助理解。出现公文腔、长铺垫、文字墙、题文错配、空情绪或虚构事实时返工。
diff --git a/skills/lark-doc/references/lark-doc-create-workflow.md b/skills/lark-doc/references/lark-doc-create-workflow.md
new file mode 100644
index 0000000000..aedd0d73e0
--- /dev/null
+++ b/skills/lark-doc/references/lark-doc-create-workflow.md
@@ -0,0 +1,121 @@
+# Lark Doc Authoring
+
+## Philosophy
+
+以下原则是每个内容、结构和视觉决策的判定依据;写作和复查时逐条套用,冲突时按「约束栈」排序。
+
+- **读者本位**:落地前先回答:读者是谁、为什么要读、带着什么任务来。按读者的任务组织内容,不按功能或作者视角罗列。
+- **结构先行**:结论先行,先整体后局部;按逻辑分组与递进,依据关系选择列表、步骤或表格,使内容便于扫读。(特殊体裁除外)
+- **视觉服从语义**:先确定全篇主线和每节的中心任务或命题,再让视觉层级复现内容优先级。文档脱离讲解仍须完整、连续、可独立阅读。
+- **最低理解成本**:选择最能降低读者理解、执行和出错成本的表达形式,而不是机械选择字符最少或制作成本最低的形式;删冗余,用短句、动词和数据,并按真实信息关系使用图、表格或交互组件。
+- **克制且连贯**:每个视觉元素必须承担导航、比较、解释、证据、行动,或体裁所需的氛围与品牌功能;相关文字与视觉相邻,同类关系复用同类组件和样式。去掉后不影响读者任务或预期语气的装饰应删除。
+- **约束栈**:事实 > 用户硬约束 > 读者任务 > 内容 > 组件样式;后项不得牺牲或放宽前项,格式与组件不得反向改变内容判断。
+- **表达一致**:同一对象、动作和状态全文同名;标题层级与编号采用统一体系,如下;用户提供样例时,在不违反更高优先级规则的前提下延续其有效结构、语气、术语和编号。
+ - **自动编号模式**:每一个正文标题都写 `seq="auto"`,标题文本不手写任何前置序号。
+ - **中文手写模式**:适用于公文或正式场景,在标题文本中手写 `一、→(一)→ 1.→(1)`;最忌中文层级配阿拉伯小数,绝不出现 `一、` 下接 `1.1`。
+
+## Step Plan
+
+**CRITICAL:从零创作文档时按下述步骤依次执行,不可跳步。**
+
+### Step 1:理解读者任务、文档格式要求、硬约束和禁区。
+
+### Step 2:选择 genre content contract。
+
+下表文件均位于当前 Skill 的 `references/genres/` 目录。
+
+- 路由表仅用于选择候选,不代替 contract。高置信命中后必须读取对应 Profile / Adapter,并按其中的路由与消歧规则复核;未读取不得确定该值或进入 Step 3。确认后记录固定短名,最多各读取一个;未命中时,`genre_contract` 和 `adapter` 均可使用 `"none"` 或 `null`。
+- contract 决定内容任务、证据和体裁边界;adapter 只调整与所选 contract 兼容的平台结构、写作风格和组件约束。
+
+ | Content Profile | 独特专业任务 |
+ |-|-|
+ | [`route-workplace.md`](genres/route-workplace.md) | 组织决策、执行、留档 |
+ | [`route-report.md`](genres/route-report.md) | 数据、研究和证据形成洞察 |
+ | [`route-knowledge.md`](genres/route-knowledge.md) | 理解、自学、一次已知操作或检索 |
+ | [`route-media.md`](genres/route-media.md) | 独立采集、核实和公共理解 |
+ | [`route-opinion.md`](genres/route-opinion.md) | 形成并论证判断 |
+ | [`route-consumer.md`](genres/route-consumer.md) | 以真实体验或测试辅助消费选择 |
+ | [`route-marketing.md`](genres/route-marketing.md) | 组织授权的认知、转化或公关内容 |
+ | [`route-personal-brand.md`](genres/route-personal-brand.md) | 本人经历、能力和作品的可信呈现 |
+ | [`route-creative.md`](genres/route-creative.md) | 角色、冲突、情节与分支叙事 |
+
+ | Adapter | 渠道 |
+ |-|-|
+ | [`route-platform.md`](genres/route-platform.md) | Email、微信公众号、小红书 |
+
+### Step 3:收集资料并扫描表达机会。
+
+1. 强制扫描事实、数据、案例、引用和图片等资源缺口;内容需要而现有材料不足时必须检索或生成,判断需要图片且用户未提供素材时必须搜索图片。
+2. 根据用户要求、contract / adapter 限制和内容需要确定 `presentation_mode`,再识别真实信息关系并选择候选表达;不因命中关系就机械使用组件。
+
+ | 信息关系 | 候选表达 |
+ |-|-|
+ | 同组字段的精确比较或映射 | `table` |
+ | 流程、依赖、分支、时序、层级、因果、空间或拓扑关系 | `whiteboard` |
+ | 对象、场景、界面、外观、氛围、示例或视觉证据 | `img` |
+ | 复杂交互、动态状态、可探索数据或应用式布局 | `html5-block` |
+ | 两组简短、等权且适合横向阅读的信息 | `grid` |
+ | 单个关键提醒或限制 | `callout` |
+ | 简单并列、步骤或连续论述 | 列表或段落 |
+
+3. 按全篇、章节、block 三个尺度构图:相关内容相邻,同类关系保持相同顺序与对齐;正文可以是主表达,不要求每节都有 presentation block。
+4. 在写正文前确定计划使用的 block 和具体 `purpose`。Presentation Decision 的 `visual_plan.blocks` 只记录确需最低数量约束的 `whiteboard`、`img`、`html5-block`。三类均无硬性数量要求时写 `"blocks": []`。
+
+`presentation_mode` 只表示模型采用的视觉策略;只有用户要求、contract / adapter 限制互相冲突时才询问用户:
+
+- `formal`:视觉正式、克制;不使用高亮块、emoji 或装饰性组件,只保留正式体裁确有必要的结构。
+- `normal`:按内容需要使用组件;只有能降低理解、执行或出错成本时才扩展视觉表达。
+- `rich`:主动利用图片、画板、HTML 和其他飞书组件;每个组件须有明确目的,不设全局数量配额。
+
+### Step 4:提交 Presentation Decision,并初始化草稿。
+
+生成完整 JSON;字段值必须来自 Step 1–3,不得照抄示例。`word_count` 仅在用户明确提出字数要求时加入,使用 `min` / `max`;单边无限制写 `null`,“约 N 字”按 ±10%,无要求时省略整个字段:
+
+```json
+{
+ "audience": "项目负责人",
+ "reader_task": "判断偏差并决定下一轮动作",
+ "genre_contract": null,
+ "adapter": null,
+ "presentation_mode": "rich",
+ "visual_plan": {
+ "reason": "需要用因果图解释偏差来源与后续行动依赖",
+ "blocks": [
+ {"type": "whiteboard", "min_count": 1, "purpose": "展示偏差成因与行动依赖"}
+ ]
+ }
+}
+```
+
+不预建临时目录、草稿或决策文件。将上述 JSON 原样替换命令中的占位符并实际执行:
+
+```bash
+lark-cli docs +script --command init-draft --presentation-decision '<上方完整 JSON>' --format json
+```
+
+成功后:
+
+- 保持当前工作目录不变;将 `data.workspace` 原样记为 `work_dir`,将 `data.draft_path` 原样记为 `draft_path`;遵循 `data.tip`,后续始终使用 `@./`。
+- CLI 会创建独占的 `work_dir` 并保存 `.presentation-decision.json` 作为固定基线,**但不会创建 `draft_path` 指向的 XML**。`draft_path` 是当前任务可直接写入的新文件路径;要求、资料或 contract 实质变化时,提交新决策并重新初始化,不得直接改基线。
+
+### Step 5:生成 release candidate。
+
+读取 [`lark-doc-xml.md`](lark-doc-xml.md),并结合 Presentation Decision、适用 contract 和 Philosophy 生成完整 XML。使用扩展标签时按需读取 [`拓展标签`](lark-doc-xml-extended-blocks.md)。
+
+1. 公开网络图片使用 `
`;已有本地图片使用 `
`;画板使用 `` 并遵循[`画板工作流`](lark-doc-whiteboard.md);HTML 使用 `` 并遵循[`拓展标签`](lark-doc-xml-extended-blocks.md)。
+2. 直接在 Step 4 返回的 `draft_path` 创建并写入完整 release candidate。
+3. 首次写入后,发现 XML 语法问题时只修复最小范围,不无故重写正确内容。
+
+### Step 6:执行 Draft Profile Check。
+
+1. 执行 `lark-cli docs +script --command parse --content "@./" --format json`,用于容错提取 block / 字数画像、检查 Presentation Decision,并预检资源。该命令不是严格 XML schema 校验;命令成功且 `data.warning` 为空,只表示画像、决策约束和资源预检通过,不表示 XML 一定能被写入服务端。命令失败或返回 warning 时,只修改对应的最小片段;仅当草稿为空、截断或无法形成有效文档结构时才全文重建。
+2. Profile Check 通过后,按 [`lark-doc-xml.md`](lark-doc-xml.md) 复查标签、属性和值,并依据 Philosophy 检查事实与来源、用户硬约束、适用 contract / adapter 以及 `visual_plan`。最终 XML 能否写入以 `docs +create` 的服务端结果为准。
+
+### Step 7:创建文档并处理局部失败。
+
+1. 只有最新 release candidate 完成 Draft Profile Check 和 XML 规则复查后,才读取 [`lark-doc-create.md`](lark-doc-create.md),使用同一个 `draft_path` 创建文档。
+2. 创建结果存在 warning、局部资源失败或回查发现局部问题时,不得再次新建文档;读取 [`lark-doc-update.md`](lark-doc-update.md),对已创建文档做最小范围修复,并按 update 流程 fetch 验证。
+
+### Step 8:清理并交付。
+
+无论创建成功、失败或被阻塞,只要 Step 4 已返回 `work_dir`,就先离开该目录,再使用当前运行时的文件删除能力精确删除整个 `work_dir`;不要使用通配符,也不要删除目录外的用户原始文件。最终只交付用户需要的结果,并说明必要来源、未关闭缺口、异常、失败或阻塞原因,以及文档 URL 或 token。
diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md
index 461e76ec01..974020bea2 100644
--- a/skills/lark-doc/references/lark-doc-create.md
+++ b/skills/lark-doc/references/lark-doc-create.md
@@ -1,24 +1,15 @@
# docs +create(创建飞书云文档)
-> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
-> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
-> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
->
-> **未读完以上文件就生成内容会导致格式错误。**
+从 XML(默认)或 Markdown 内容创建一个新的飞书云文档;语义创作默认使用 XML,只有 Authoring 明确判定为 Markdown 例外时才使用 Markdown。
-从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
-
-> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
+写入前必须按 `--doc-format` 读取对应格式参考:`xml` 读取 [`lark-doc-xml.md`](lark-doc-xml.md),`markdown` 读取 [`lark-doc-md.md`](lark-doc-md.md);Markdown 中使用 XML 扩展标签时还须读取 `lark-doc-xml.md`。
## 命令
```bash
-# 创建 XML 文档(默认格式,推荐)
-lark-cli docs +create --content '项目计划目标
记录本周重点。
'
-
-# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
-lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
+# 简单内容优先使用 `--content -`,文件导入如下:
+lark-cli docs +create --doc-format xml --content "@"
+lark-cli docs +create --doc-format markdown --content "@./draft.md"
```
## 返回值
@@ -35,46 +26,29 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
"new_blocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
]
- }
+ },
+ "warnings": [],
+ "tips": ""
}
}
```
-- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作
-
-> \[!IMPORTANT]
-> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
->
-> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
-> - `status = granted`:当前 CLI 用户已获得该文档的可管理权限
-> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限
-> - `status = failed`:文档已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文档
->
-> `permission_grant.perm = full_access` 表示该资源已授予”可管理权限”。
->
-> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
+- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update` 的 `--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token,可交给 `lark-whiteboard` 等 skill 继续操作。
+- **`warnings`**:服务端返回的警告列表;`ok=true` 时也要检查,按提示确认是否存在降级或未完全处理的内容。
+- **`tips`**:服务端返回的后续处理建议;为空表示没有额外建议,非空本身不表示创建失败。
+- **`permission_grant`**:仅以 bot 身份创建时返回。CLI 会尝试为当前 CLI 用户授予新文档的 `full_access`;`status` 为 `granted` 表示授权成功,`skipped` 表示没有可用的当前用户 `open_id`,`failed` 表示文档已创建但授权失败。`perm` 固定为 `full_access`,失败或跳过时按 `message` / `hint` 处理。**自动授权不等于 owner 转移;用户要求转移 owner 时必须单独确认。**
## 参数
-| 参数 | 必填 | 说明 |
-| ------------------- | -- |---------------------------------------------|
-| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
-| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
-| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
-| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
-| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
-| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
-
-## 最佳实践
-
-- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
-- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
+|参数|必填|说明|
+|-|-|-|
+|`--title`|否|文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个|
+|`--content`|视情况|文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title`|
+|`--reference-map`|否|结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、任务独占目录内的相对 `@file`,或 `-` 从 stdin 读取。|
+|`--doc-format`|否|CLI 与语义创作均默认 `xml`,并建议显式传入;仅用户明确要求 Markdown 或保真导入 Markdown 时使用 `markdown`。不要混用完整的 XML 与 Markdown 文档格式;Markdown 中允许使用文档已定义的 XML 扩展标签。|
+|`--parent-token`|否|父文件夹或知识库节点 token(与 `--parent-position` 互斥)|
+|`--parent-position`|否|父节点位置,如 `my_library`(与 `--parent-token` 互斥)|
-## 参考
+## 需要回查文档
-- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
-- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
-- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
-- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
-- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
-- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
+用 `lark-cli docs +fetch --doc "" --detail with-ids` 回查,若需要更多信息可查看 [`+fetch`](lark-doc-fetch.md)。
diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md
index 04e1ad1515..359bc19973 100644
--- a/skills/lark-doc/references/lark-doc-fetch.md
+++ b/skills/lark-doc/references/lark-doc-fetch.md
@@ -1,81 +1,78 @@
+# docs +fetch(读取飞书云文档)
-# docs +fetch(获取飞书云文档)
+读取整篇文档,或按目录、章节、区间和关键词获取局部内容。
-## 命令
+## 常用示例
```bash
-# 获取文档(默认 XML,simple)
-lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc"
+# 读取整篇文档
+lark-cli docs +fetch --doc "文档URL或token"
-# Markdown 格式
-lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown
+# 按 URL 中的 #share 锚点局部读取
+lark-cli docs +fetch --doc '文档URL#share-anchor'
-# 带 block ID(用于后续 block 级更新)
-lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
+# 按关键词定位
+lark-cli docs +fetch --doc Z1Fj...tnAc --scope keyword --keyword "部署|发布|上线"
-# 只拿目录
+# 先查看目录,再读取指定章节
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
-
-# 按 block id 区间精读
-lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids
-
-# URL 带 #share 选区锚点时自动局部读取
-lark-cli docs +fetch --doc 'docURL#share-anchor'
-
-# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
-lark-cli docs +fetch --doc Z1Fj...tnAc \
- --scope section --start-block-id <标题id> --detail with-ids
-
-# 按关键词定位(多关键词用 | 分隔,任一命中即返回)
-lark-cli docs +fetch --doc Z1Fj...tnAc \
- --scope keyword --keyword "部署|发布|上线"
+lark-cli docs +fetch --doc Z1Fj...tnAc --scope section --start-block-id blkTitle
```
-## 选 `--detail`(每块详细度)
-
-| 意图 | `--detail` | 说明 |
-|------|-----------|------|
-| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 |
-| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 ``),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 |
-| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
+## 参数
-## 选 `--scope`(读取范围)
+|参数|必填|说明|
+|-|-|-|
+|`--doc`|是|文档 URL 或 token,支持 `/docx/`、`/wiki/` 和带 `#share-...` 的选区链接|
+|`--doc-format`|否|`xml`(默认)\| `markdown` \| `im-markdown`(供后续 `lark-im` 场景使用)|
+|`--detail`|否|`simple`(默认)\| `with-ids` \| `full`|
+|`--revision-id`|否|文档版本号;`-1` 表示最新版本(默认)|
+|`--scope`|否|`outline` \| `range` \| `keyword` \| `section`;省略则读取整篇|
+|`--start-block-id`|否|`range` 的起点,或 `section` 的锚点(`section` 必填)|
+|`--end-block-id`|否|`range` 的终点;`-1` 表示读到末尾|
+|`--keyword`|否|`keyword` 模式的关键词;支持多级自动匹配和多分支 OR|
+|`--context-before`|否|返回命中项之前的顶层兄弟块数量(默认 `0`)|
+|`--context-after`|否|返回命中项之后的顶层兄弟块数量(默认 `0`)|
+|`--max-depth`|否|`outline` 表示标题层级上限;其它模式表示子树深度(默认 `-1`,不限)|
+|`--format`|否|`json`(默认)\| `pretty`|
-`--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
+## 选择详细度:`--detail`
-| 模式 | 何时用 | 关键参数 | 行为要点 |
-|-|-|-|-|
-| `outline` | 不知道结构,先看目录 | `--max-depth`(标题层级上限) | 扁平列出所有标题,**包括嵌在容器里的内嵌标题**(如 callout 里的 h3);这些 id 可直接作后续 `section` / `range` 端点 |
-| `section` | 读某个标题对应的整节 | `--start-block-id`(必填) | 顶层标题 → 展开到下一同级/更高级标题前;容器内节点(含内嵌标题) → 按"最小包容单元"返回容器/表格切片,不做 heading 扩展;顶层非标题块 → 仅该块 |
-| `range` | 已知精确起止 | `--start-block-id` / `--end-block-id` 至少一个;`-1` = 读到末尾 | 两端同顶层 → 顶层序列切片;两端同一容器 → 容器整体;两端同一表格 → 瘦身切片;**跨顶层 → 端点所在顶层块整块输出,不做瘦身** |
-| `keyword` | 只有模糊关键词 | `--keyword`(**多级自动 fallback**:子串 → 归一化 → 分词形变 → RE2 正则;`\|` 分隔多分支 OR) | 每处命中按"最小包容单元"输出;**自动去重**(同容器多命中 → 单个容器,同表格多行命中 → 合并切片) |
+|目的|取值|返回内容|
+|-|-|-|
+|浏览、总结|`simple`(默认)|简洁 XML/Markdown,不含 block ID、样式和引用元数据|
+|定位、跳转|`with-ids`|包含 block ID,可用于 `+update --block-id`,也可拼成 `文档URL#block_id` 直达链接|
+|编辑文档|`full`|包含 block ID、样式和引用元数据,保留完整结构信息|
-> 💡 **多关键词用 `\|` 拼接(OR 语义,任一命中即返回)**:例 `"部署\|发布\|上线"`,三词任一命中都进结果,适合**同义词/别名/多业务术语**一次召回(如 `bug\|缺陷\|故障`)。
+需要修改文档时使用 `full`;只读场景通常不必获取额外元数据。
-**设置 `--scope` 时共用** `--context-before` / `--context-after` / `--max-depth`。
+## 选择读取范围:`--scope`
-- `--max-depth`:`outline` = 标题层级上限(3 = h1~h3);其它模式 = 被选块的子树遍历深度(`-1` 不限,`0` 仅块自身)。
-- `--context-before/--context-after`:**只对整块顶层单元生效**;命中落在容器/表格内(返回容器或切片)时 before/after 被忽略,需要更大范围改用 `section` / `range` 显式指定。
+`--scope` 与 `--detail` 可以组合。优先读取满足任务所需的最小范围;只有确需全文时才省略 `--scope`。
-**决策顺序**(核心原则:**局部获取优于全量获取**,根据需求形态选起点,必要时多步组合收敛范围):
-1. 需求**直接给出待查的具体术语/错误码/标识** → 直接走 `keyword` 粗匹配(多级 fallback 自动覆盖形变),需要更大上下文时用返回的 `top-block-id` 走 `section` / `range`
-2. 需求**指向某个章节/标题**("修改 XX 章"、"总结第 3 节"、"关于 xx 的内容")→ 先 `outline --max-depth 3` 拿目录 → `section --start-block-id <标题id>` 精读
-3. 已知**精确起止 / 跨节连续区间** → `range`
-4. **结构未知且无明确关键词/章节线索** → `outline` 探测,再回到 2/3
-5. **兜底**:仅在确需整篇时才省略 `--scope`;不要为省事直接读整篇
+|模式|适用场景|关键参数|返回行为|
+|-|-|-|-|
+|`outline`|结构未知,先查看目录|`--max-depth`|扁平列出标题;返回的标题 ID 可作为 `section` 或 `range` 的端点|
+|`section`|读取某个标题对应的整节|`--start-block-id`(必填)|顶层标题展开到下一个同级或更高级标题之前;容器内节点(含内嵌标题)按最小包容单元返回容器或表格切片|
+|`range`|已知精确起止位置|`--start-block-id`、`--end-block-id` 至少一个|同一顶层序列按区间切片;同一容器返回整个容器;同一表格返回瘦身切片;跨顶层时完整返回端点所在的顶层块|
+|`keyword`|只有关键词或模糊线索|`--keyword`(必填)|按最小包容单元返回命中;同一容器的多处命中自动去重,同一表格的多行命中合并为切片|
-## 局部读取的输出结构:`` 与 ``
+`keyword` 会依次尝试子串、归一化、分词形变和 RE2 正则匹配。多关键词使用 `|` 表示 OR,例如 `部署|发布|上线`;任一分支命中即返回。
-设置 `--scope` 时返回的 `content` 被一个 `` 节点包裹,属性包含 `mode` / `requested-start` / `requested-end` / `keyword`(按需)。子节点只有两种形态:
+范围参数的共同规则:
-- **顶层块**:完整块直接作为 `` 的子节点,无额外包裹。
-- **``**:非顶层节选(容器整体 / 表格瘦身切片)。
- - `top-block-id`:所在顶层块 id,想看该块全貌时作 `section` / `range` 锚点再拉一次。
- - `parent-block-path`:从顶层块到 excerpt 内容直接父节点的 id 路径,`/` 分隔(表格切片时即表格自身 id)。
+- `--max-depth`:`outline` 中 `3` 表示列出 h1~h3;其它模式中 `0` 表示仅返回块自身,`-1` 表示不限深度。
+- `--context-before` / `--context-after`:仅对完整的顶层块生效。命中位于容器或表格内时会被忽略;如需更大范围,改用 `section` 或 `range`。
-**看到 `` 即意味着这是节选**,不能假设看到了该顶层块的全貌。
+推荐选择顺序:
-**表格默认瘦身**:即便 `` 本身是顶层块也只返回 thead + 命中 tr。想拿整张表 → `range --start-block-id --end-block-id `;切片范围恰好覆盖全部 tr 时 SDK 自动升级为整块、不包 ``。
+|已知信息|首选方式|后续动作|
+|-|-|-|
+|具体术语、错误码或标识|`keyword`|上下文不足时,用返回的 `top-block-id` 再执行 `section` 或 `range`|
+|章节或标题|`outline --max-depth 3`|获取标题 ID 后执行 `section`|
+|精确起止位置|`range`|按需调整端点或深度|
+|没有关键词,也不了解结构|`outline`|根据目录转入 `section` 或 `range`|
+|确实需要整篇|省略 `--scope`|—|
## 返回值
@@ -85,7 +82,7 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
"identity": "user",
"data": {
"document": {
- "document_id": "doxcnXXXX",
+ "document_id": "docToken",
"revision_id": 12,
"content": "标题文档内容...
",
"reference_map": {
@@ -100,49 +97,35 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
}
}
```
-
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `` 包裹,详见上文"局部读取的输出结构"。
+### 理解局部读取结果
+
## 参数
-| 参数 | 必填 | 说明 |
-|------|------|------|
-| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
-| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
-| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
-| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
-| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
-| `--start-block-id` | 否 | `range`/`section` 起始/锚点 id(`section` 必填) |
-| `--end-block-id` | 否 | `range` 结束 id;`-1` 表示读到末尾 |
-| `--keyword` | 否 | `keyword` 模式关键词,**4 层自动 fallback**(子串 → 归一化 → 分词形变 → RE2 正则);`\|` 分隔多分支 OR |
-| `--context-before` | 否 | 命中前拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
-| `--context-after` | 否 | 命中后拉几个兄弟块(仅对顶层单元生效,默认 `0`) |
-| `--max-depth` | 否 | `outline` = 标题层级上限;其它 = 子树深度(`-1` 不限,默认) |
-| `--format` | 否 | `json`(默认)\| `pretty` |
-
-## 图片、文件、画板的处理
-
-**文档中的素材以 XML 标签形式出现:**
-
-```xml
-
-
-
-```
+设置 `--scope` 后,`content` 外层是 ``,并按需携带 `mode`、`requested-start`、`requested-end` 或 `keyword` 属性。其子节点有两种形式:
+
+- **顶层块**:直接作为 `` 的子节点,表示返回了完整块。
+- **``**:表示只返回了容器或表格中的节选。
+ - `top-block-id` 是节选所在的顶层块 ID。需要查看完整块时,可将它作为 `section` 或 `range` 的锚点重新读取。
+ - `parent-block-path` 是从顶层块到节选内容直接父节点的 ID 路径,以 `/` 分隔;表格切片中即表格自身 ID。
+
+看到 `` 时,不要假设已经获取了整个顶层块。
-- `
` / `` 带 `url` 时,直接用该 URL 下载即可(普通 HTTP GET),无需走 shortcut。
-- 没有 `url`、或只想预览 → `docs +media-preview --token --output ./preview_media`
-- 明确下载,或目标是 ``(画板只能走 shortcut) → `docs +media-download --token --output ./downloaded_media`
-- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs +resource-download/+resource-update/+resource-delete --type cover`
+表格默认瘦身:即使 `` 本身是顶层块,也只返回表头和命中的行。读取整张表时,使用 `range --start-block-id --end-block-id `。如果切片覆盖全部数据行,SDK 会自动返回完整表格,不再包裹 ``。
-## 嵌入电子表格 / 多维表格
+## 处理文档内嵌资源
-返回中可能含 ``、``、``。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。
+|返回内容|处理方式|
+|-|-|
+|`
`、``|有 `url` 时仅下载可信的公开 HTTPS URL:拒绝 userinfo 及解析到 private、loopback、link-local、multicast、unspecified 地址的 host,并逐次校验重定向;不满足时禁止请求。无 `url` 时提取 `token`,预览用 `docs +media-preview`,下载用 `docs +media-download`|
+|``|提取 `token`,使用 `docs +media-download`|
+|``、``|提取 `token` 和 `sheet-id`,转到 [`lark-sheets`](../../lark-sheets/SKILL.md)|
+|``、``|提取 `token` 和 `table-id`,转到 [`lark-base`](../../lark-base/SKILL.md)|
+|``|提取 `vc-node-id`,使用 [`lark-note`](../../lark-note/SKILL.md) 的 `note +detail`|
+|``|提取 `src-token` 和 `src-block-id`,读取源文档并定位 block|
## 参考
-- [lark-doc-create](lark-doc-create.md) — 创建文档
-- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
-- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
-- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图
+- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材或画板缩略图
diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md
index b8ae2d0a32..c115144122 100644
--- a/skills/lark-doc/references/lark-doc-md.md
+++ b/skills/lark-doc/references/lark-doc-md.md
@@ -48,7 +48,7 @@
自行构造 Markdown 内容写入时同理:如字面文本 `a]b` 应写为 `a\]b`,`C:\Users` 应写为 `C:\\Users`。
## Shell 传参
-- **首选文件传参**:`--content` 支持 `@path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`)
+- **首选文件传参**:`--content` 支持 `@./path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`)
- **⚠️ `@file` 路径限制**:`@file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下(如 `./_content.md`),用完自行清理。
- **默认用单引号 `'...'`**:完全字面量,`$`、`` ` ``、`\`、`>`、`\` 等全部原样保留
- **双引号 `"..."`**:会展开 `$变量`、反引号和 `$(...)` 命令替换,`\` 仍参与转义,易踩坑
@@ -66,6 +66,10 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
- URL 支持 `http://` 和 `https://` 协议
- 对应的 XML 格式为:`
`
+本地图片使用 ``(路径含空格时写作 ``);路径必须位于当前工作目录内,`alt` 会作为 caption。附件使用 ``
+
+目前不支持将 Base64 Data URI(如 `data:image/png;base64,...`)直接作为 Markdown 图片地址传入;如仅有 Base64 数据,请先解码为本地图片文件,再使用上述 `@./...` 路径上传。
+
## Markdown 不支持的 Block 类型
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。
diff --git a/skills/lark-doc/references/lark-doc-media-insert.md b/skills/lark-doc/references/lark-doc-media-insert.md
index ca54130041..85c8137f2f 100644
--- a/skills/lark-doc/references/lark-doc-media-insert.md
+++ b/skills/lark-doc/references/lark-doc-media-insert.md
@@ -85,8 +85,8 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard --width 800 --height
| `--type ` | 否 | `image`(默认)或 `file`。`--from-clipboard` 目前只产出 image。 |
| `--align ` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
| `--caption ` | 否 | 仅图片:图片描述 |
-| `--width ` | 否 | Image display width in pixels (only for `--type=image`). If `--height` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
-| `--height ` | 否 | Image display height in pixels (only for `--type=image`). If `--width` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF; other formats (WebP, BMP, etc.) require both `--width` and `--height`. |
+| `--width ` | 否 | Image display width in pixels (only for `--type=image`). If `--height` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF, WebP, BMP, and TIFF. |
+| `--height ` | 否 | Image display height in pixels (only for `--type=image`). If `--width` is omitted, it is auto-computed from the source image aspect ratio. Supported auto-detection formats: PNG, JPEG, GIF, WebP, BMP, and TIFF. |
> [!IMPORTANT]
> 如果上一步是 [`lark-doc-create`](lark-doc-create.md),并且它在知识库/知识空间场景下返回的是 `/wiki/...` 形式的 `doc_url`,后续调用 `docs +media-insert` 时应优先传 `doc_id`,不要直接传这个 `doc_url`。
diff --git a/skills/lark-doc/references/lark-doc-script.md b/skills/lark-doc/references/lark-doc-script.md
new file mode 100644
index 0000000000..8b0df19a1b
--- /dev/null
+++ b/skills/lark-doc/references/lark-doc-script.md
@@ -0,0 +1,76 @@
+# `docs +script`
+
+## 脚本列表
+
+| `--command` | 用途 |
+|-|-|
+| `init-draft` | 创建带 Presentation Decision 基线的独占工作区,并预留尚不存在的 XML 路径。 |
+| `parse` | 解析本地或在线文档,返回画像并检查决策与资源。 |
+
+每个脚本只使用其小节列出的专用参数;所有脚本均可使用文末的通用参数。
+
+## `init-draft`
+
+### 参数
+
+| 参数 | 必填 | 用法 |
+|-|-|-|
+| `--command init-draft` | 是 | 选择本脚本。 |
+| `--presentation-decision` | 是 | 完整决策 JSON;接受内联 JSON、`@./decision.json` 形式的 CWD 下相对路径或 `-`(stdin)。 |
+
+```bash
+lark-cli docs +script --command init-draft \
+ --presentation-decision '<完整 Presentation Decision JSON>' \
+ --format json
+```
+
+`data` 的结构如下;实际随机段为 8 位十六进制字符:
+
+```json
+{
+ "workspace": "draft_a1b2c3d4_folder",
+ "draft_path": "draft_a1b2c3d4_folder/draft.xml",
+ "tip": "The workspace directory has been created successfully. draft_path points to a new XML file that does not exist yet. Create and write the file directly without reading it first."
+}
+```
+
+- 在生成正文前执行;不要自行创建工作目录或决策文件。CLI 固定生成 `draft_<8位十六进制字符>_folder/draft.xml`,以返回的实际路径为准。
+- 决策必须是单个 JSON 对象,包含 `audience`、`reader_task`、`genre_contract`、`adapter`、`presentation_mode` 和 `visual_plan`。`presentation_mode` 取 `formal|normal|rich`;`genre_contract`、`adapter` 使用固定短名、`"none"` 或 `null`。
+- `visual_plan` 包含非空 `reason` 和 `blocks` 数组;每项为 `{type,min_count,purpose}`,`type` 不重复,`min_count` 为正整数。按本 Skill 创建文档时,`blocks` 只对 `whiteboard`、`img`、`html5-block` 设置最低数量,其他表达按内容需要使用但不设数量约束;三类均无需约束时写 `[]`。CLI 为外部决策兼容 `type: "list"`,检查时将 `` 与 `` 的数量相加。仅有字数要求时添加 `word_count: {min,max}`;未指定的一侧写 `null`,至少一侧为正整数,且 `min <= max`。
+- 返回 `data.workspace`(已创建的随机工作区)、`data.draft_path`(可直接写入的 XML 路径)和英文操作提示 `data.tip`。工作区及其中的 `.presentation-decision.json` 已存在,但 XML 尚不存在;遵循提示直接使用文件创建/写入能力在 `draft_path` 写入完整 XML,首次写入前不要读取该路径。
+- 后续始终使用 `draft_path`,不得另建 XML、复用其他任务的路径或修改工作区中的 `.presentation-decision.json`;使用完后精确删除 `workspace`。
+
+## `parse`
+
+### 参数
+
+| 参数 | 必填 | 用法 |
+|-|-|-|
+| `--command parse` | 是 | 选择本脚本。 |
+| `--content` | 二选一 | 本地 XML 的字面内容、`@./document.xml` 形式的 CWD 下相对路径或 `-`(stdin)。 |
+| `--doc` | 二选一 | 在线 Docx/Wiki URL 或 token;与 `--content` 互斥。 |
+| `--presentation-decision` | 否 | 用于检查当前输入的完整决策 JSON;支持内联、`@./decision.json` 形式的 CWD 下相对路径或 `-`。 |
+
+```bash
+lark-cli docs +script --command parse --content "@./document.xml" --format json
+lark-cli docs +script --command parse --doc "" --format json
+lark-cli docs +script --command parse --content "@./document.xml" --presentation-decision '' --format json
+```
+
+- `--content` 与 `--presentation-decision` 同时使用时,最多一个参数读取 stdin。
+- 决策必须包含 `audience`、`reader_task`、`genre_contract`、`adapter`、`presentation_mode` 和 `visual_plan`;`presentation_mode` 取 `formal|normal|rich`。`visual_plan` 包含非空 `reason` 和不重复的 `{type,min_count,purpose}` 数组;兼容的 `list` 约束按 `` 与 `` 的合计数量检查。仅有字数要求时添加合法的 `word_count: {min,max}`。
+- 使用 `--content "@./"` 时自动加载保存的决策;显式 `--presentation-decision` 优先。
+- `--doc` 需要 `docx:document:readonly`;`--content` 不调用 OpenAPI。
+- 返回 `data.profile`,包含 `word_count`、`char_count`、`block_count` 和 `blocks[]`。决策或资源预检问题写入 `data.warning[]`;存在 warning 时命令以 `ok:false` 和退出码 1 返回 partial failure,但仍保留完整 profile 与 warning,修复后重新解析。
+- `parse` 不是 XML/SDK schema validator。成功且无 warning 也不保证服务端接受;写入前仍须按 XML 规则复查。
+
+## 所有脚本通用参数
+
+| 参数 | 用法 |
+|-|-|
+| `--as user|bot` | 选择身份。 |
+| `--dry-run` | 只返回执行计划,不联网、解析或写文件。 |
+| `--format` | 输出格式:`json|pretty|table|ndjson|csv`;模型使用默认的 `json`。 |
+| `--json` | `--format json` 的别名。 |
+| `--jq` / `-q` | 裁剪 JSON;不得与非 JSON 格式同时使用。 |
+| `-h` / `--help` | 查看帮助。 |
diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md
index 905d69175a..b62acc6208 100644
--- a/skills/lark-doc/references/lark-doc-update.md
+++ b/skills/lark-doc/references/lark-doc-update.md
@@ -1,174 +1,78 @@
-
# docs +update(更新飞书云文档)
-> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
-> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
-> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
-> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
->
-> **未读完以上文件就生成内容会导致格式错误。**
-
-通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作。
-
-> **⚠️ 格式选择规则:**
-> - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。
-> - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。
->
-> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
-
-## 参数
-
-| 参数 | 必填 | 说明 |
-|------|------|------|
-| `--doc` | 是 | 文档 URL 或 token |
-| `--command` | 是 | 操作指令(见下方指令速查表) |
-| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
-| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
-| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
-| `--pattern` | 视指令 | 匹配文本(str_replace) |
-| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
-| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
-| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1`) |
-
-## 指令速查表
-
-| 指令 | 说明 | 必需参数 |
-|------|------|----------|
-| `str_replace` | 全文文本查找替换(replacement 支持富文本标签;`--content` 传空字符串即为删除) | `--pattern` `--content` |
-| `block_insert_after` | 在指定 block 之后插入新内容 | `--block-id` `--content` |
-| `block_copy_insert_after` | 复制源 block 并插入到锚点之后(源块不变) | `--block-id` `--src-block-ids` |
-| `block_replace` | 替换指定 block(同一 block 仅限一次) | `--block-id` `--content` |
-| `block_delete` | 删除指定 block(逗号分隔可批量) | `--block-id` |
-| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
-| `append` | ⚠️ 在文档**末尾**追加内容(等价于 `block_insert_after --block-id -1`)。**不适用于逐章填充**——逐章写入请用 `block_insert_after` 并指定对应标题的 `--block-id` | `--content` |
-| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` `--src-block-ids` |
+使用文本或 block 指令精确更新飞书云文档。默认使用 XML;仅在用户明确要求或必须保真 Markdown 时使用 Markdown。
-## Block ID 生命周期
+写入前必须按 `--doc-format` 读取对应格式参考:`xml` 读取 [`lark-doc-xml.md`](lark-doc-xml.md),`markdown` 读取 [`lark-doc-md.md`](lark-doc-md.md);
-写操作后不要默认复用之前 fetch 到的 block ID:
-
-- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
-- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
-- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
-- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
-
-## 指令示例
-
-### str_replace — 全文文本替换
-
-> **匹配范围:**
-> - **XML 模式(默认)**:`--pattern` 只支持**行内匹配**,不能跨 block / 跨段落匹配。涉及整段或多 block 的改动,请改用 `block_replace`。
-> - **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行匹配**,可以用多行字符串匹配并替换一整段内容。
-> - 还支持**`前缀...后缀` 省略号语法**:用 `...`(三个英文句点)串联起始与结束片段,匹配从前缀到后缀之间的全部内容(含中间被省略部分)。适合一段很长、但首尾特征明显的文本,避免把整段都塞进 `--pattern`。
-> - 前缀、后缀本身仍遵循 Markdown 转义规则;省略号中间的内容**会被替换**为 `--content` 的完整文本,不会被保留。
+## 常用示例
```bash
-# 简单文本替换
-lark-cli docs +update --doc "" --command str_replace \
- --pattern "张三" --content "李四"
-
-# 替换为富文本(加粗 + 链接)
-lark-cli docs +update --doc "" --command str_replace \
- --pattern "旧链接" --content '新链接 点击查看'
-
-# 仅当用户明确要求时才使用 Markdown
-lark-cli docs +update --doc "" --command str_replace \
- --doc-format markdown --pattern "旧内容" --content "新内容"
-
-# Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量)
-# 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑
-lark-cli docs +update --doc "" --command str_replace \
- --doc-format markdown \
- --pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \
- --content - <<'EOF'
-## 新标题
+# 先定位内容并获取最新 block ID
+lark-cli docs +fetch --doc "文档URL或token" --scope keyword --keyword "key1|key2" --detail with-ids
-改写后的第一段
+# 替换文本;--content "" 可删除文本
+lark-cli docs +update --doc "xx" --command str_replace --pattern "旧内容" --content "新内容"
-改写后的第二段
-EOF
+# 替换或插入 block
+lark-cli docs +update --doc "xx" --command block_replace --block-id blkTarget --content '新段落
'
+lark-cli docs +update --doc "xx" --command block_insert_after --block-id blkAnchor --content '新章节
章节内容
'
-# Markdown 模式下使用 `前缀...后缀` 省略号匹配首尾特征明显的大段内容
-# 下例会把「## 旧标题」到「结束语。」之间的所有内容整体替换
-lark-cli docs +update --doc "" --command str_replace \
- --doc-format markdown \
- --pattern "## 旧标题...结束语。" \
- --content - <<'EOF'
-## 新标题
-
-重写后的正文...
-
-新的结束语。
-EOF
-
-# 删除文本:--content 传空字符串即可
-lark-cli docs +update --doc "" --command str_replace \
- --pattern "废弃的内容" --content ""
+# 删除多个 block
+lark-cli docs +update --doc "xx" --command block_delete --block-id "blkA,blkB"
```
-### block_insert_after — 在指定 block 之后插入
-
-```bash
-lark-cli docs +update --doc "" --command block_insert_after \
- --block-id "目标 block_id" \
- --content '新章节
'
-```
+## 推荐流程
-### block_replace — 替换指定 block
+1. **Observe(读取现状)**:先 `docs +fetch` 读取当前文档状态,并按意图选择最小范围。
+ - 改某一节或大文档:先 `--scope outline --max-depth 2` 找章节,再 `--scope section --start-block-id <标题id> --detail with-ids`
+ - 精确跨节区间:用 `--scope range --start-block-id xxx --end-block-id yyy`
+ - 只有模糊关键词:用 `--scope keyword --keyword "key1|key2" --context-before 1 --context-after 1 --detail with-ids`
+ - 明确整篇重构才读 `--detail with-ids` 全文;只读摘要或确认事实时用更轻的 fetch
+2. **Diagnose(诊断问题)**:判断用户目标、当前结构、语气、重复、断流、事实口径和需要保留的资源;识别哪些 block 必须原样保留。
+3. **Patch Plan(制定局部计划)**:把修改拆成最小安全操作:简单行内文本替换用 `str_replace`,但它不支持资源替换,涉及多个 block 时优先使用 `block_replace`;整段/整块重写用 `block_replace`;增补章节用 `block_insert_after`;删冗余用 `block_delete`;调整顺序用 `block_move_after`。
+4. **Patch(精确修改)**:按 block / section 执行局部命令。保护 ``、`
`、``、``、``、``、`` 等 token 化内容,不要改成纯文本或占位符。同一 block 的多处修改合并成一次 `block_replace`。
+5. **Verify(fetch 验证)**:每轮写操作后按影响范围重新 fetch,检查用户要求、结构、语气、事实、资源块和 block ID 是否符合预期;不满足就基于最新 fetch 结果继续 Diagnose / Patch,不要沿用上一轮 block ID。
-```bash
-lark-cli docs +update --doc "" --command block_replace \
- --block-id "目标 block_id" \
- --content '替换后的段落内容
'
-```
+除非用户明确要求完全重建,或原文已无保留价值,否则不要使用 `overwrite`;它可能丢失评论和暂不支持的资源。
-### block_delete — 删除指定 block
-
-```bash
-# 删除多个块时用逗号 "," 分隔
-lark-cli docs +update --doc "" --command block_delete \
- --block-id "block_id_1,block_id_2,block_id_3"
-```
-
-### overwrite — 全文覆盖
-
-```bash
-lark-cli docs +update --doc "" --command overwrite \
- --content '全新文档概述
新的内容
'
-```
+## 生成 block 直达链接
-> ⚠️ 会清空文档后重写,可能丢失图片、评论等。仅在需要完全重建文档时使用。
+用户需要某个 block 的直达链接时,只定位 block,不执行文档写操作:
-### append — 在文档末尾追加
+1. 使用局部 `docs +fetch --detail with-ids` 获取目标 `block_id`。
+2. 返回 `文档基础 URL#block_id`;没有 `block_id` 时不得猜测。
-```bash
-lark-cli docs +update --doc "" --command append \
- --content '新增章节
追加的内容
'
-```
-
-> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
-
-### block_copy_insert_after — 复制块并插入
-
-将一个或多个源块复制到锚点块之后,源块保持不变。`--src-block-ids` 为逗号分隔的源块 ID,按顺序依次插入到锚点之后。
-
-```bash
-# 复制多个块(按顺序插入:anchor → a → b → c)
-lark-cli docs +update --doc "" --command block_copy_insert_after \
- --block-id "锚点 block_id" \
- --src-block-ids "block_a,block_b,block_c"
-```
-
-### block_move_after — 移动已有 block
+## 参数
-将文档中已有的 block 移动到指定锚点之后。使用 `--src-block-ids` 指定要移动的块 ID,无需 `--content`。
-
-```bash
-# 移动到页面末尾
-lark-cli docs +update --doc "" --command block_move_after \
- --block-id "-1表示末尾,page_id表示开头,blk" \
- --src-block-ids "block_a,block_b"
-```
+|参数|必填|说明|
+|-|-|-|
+|`--doc`|是|文档 URL 或 token|
+|`--command`|是|更新指令,见下表|
+|`--doc-format`|否|`xml`(默认)或 `markdown`|
+|`--content`|视指令|写入内容;`str_replace` 传空字符串可删除文本|
+|`--pattern`|视指令|`str_replace` 的简单行内匹配文本;不要用于多行、整段或多个 block|
+|`--block-id`|视指令|目标 block ID;批量删除时用逗号分隔;`-1` 表示文档末尾,`0` 表示文档开头(仅适用于支持这些锚点的指令)|
+|`--src-block-ids`|视指令|要复制或移动的源 block ID,多个 ID 用逗号分隔|
+|`--reference-map`|否|保留或回放既有 `reference_map`,需与 `--content` 配合;支持 JSON、任务目录内的相对 `@file` 或 stdin `-`|
+|`--revision-id`|否|基准版本号,默认 `-1`(最新版本)|
+
+## 指令速查
+
+|指令|用途与限制|必需参数|
+|-|-|-|
+|`str_replace`|全文查找替换;支持富文本内的文本替换,但不支持资源替换;涉及多个 block 时建议用 `block_replace`;空 `--content` 表示删除|`--pattern`、`--content`|
+|`block_insert_after`|在指定 block 后插入内容;逐章填充时指定对应标题的 block ID|`--block-id`、`--content`|
+|`block_copy_insert_after`|按 ID 顺序复制源 block,源 block 不变;基础标签均支持,资源块仅支持 `img`、`source`、`whiteboard`、`sheet`、`chat_card`、`sub-page-list`,不支持 `task`、`bitable`、`base_ref`、`synced_reference`、`synced_source`、`okr`|`--block-id`、`--src-block-ids`|
+|`block_replace`|替换指定 block;同一 block 一次操作中只能替换一次|`--block-id`、`--content`|
+|`block_delete`|删除一个或多个 block|`--block-id`|
+|`block_move_after`|移动已有 block,支持所有块类型;|`--block-id`、`--src-block-ids`|
+|`append`|仅在文末追加,等价于 `block_insert_after --block-id -1`|`--content`|
+|`overwrite`|清空后重写全文,丢失图片、评论等内容,非必要不使用|`--content`|
+
+## 通用安全规则
+
+- 每次写操作后都按 block ID 已变化处理。新插入或复制的内容一定使用新 ID;替换、删除和覆盖会使旧 ID 失效;移动会改变章节与 range 语义。
+- 同一 block 有多处修改时,应合并为一次 `block_replace`,避免连续使用旧 ID。
## 返回值
@@ -178,83 +82,27 @@ lark-cli docs +update --doc "" --command block_move_after \
"identity": "user",
"data": {
"document": {
- "revision_id": 13,
+ "revision_id": 2,
"new_blocks": [
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
]
},
"result": "success",
- "updated_blocks_count": 3,
- "warnings": []
+ "updated_blocks_count": 1,
+ "warnings": [],
+ "tips": ""
}
}
```
-| 字段 | 说明 |
-|------|------|
-| `result` | `success` \| `partial_success` \| `failed` |
-| `updated_blocks_count` | 实际更新的 block 数量 |
-| `warnings` | 警告信息列表 |
-| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token(如画板)可交给 `lark-whiteboard` 等 skill 继续操作 |
-
-## 典型工作流
-
-### 精确 block 级更新
-
-1. **获取文档内容和 block ID**:
- ```bash
- lark-cli docs +fetch --doc "" --detail with-ids
- ```
-
-2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
-
-3. **执行更新**:
- ```bash
- # 替换特定 block
- lark-cli docs +update --doc "" --command block_replace \
- --block-id "blkcnXXXX" --content "新内容
"
-
- # 在某 block 后插入
- lark-cli docs +update --doc "" --command block_insert_after \
- --block-id "blkcnXXXX" --content "追加的章节
"
- ```
-
-### 简单文本替换
-
-不需要 block ID,直接匹配替换:
-
-```bash
-lark-cli docs +update --doc "" --command str_replace \
- --pattern "v1.0" --content "v2.0"
-```
-
-## 画板处理
-
-> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
-
-画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
-
-## 最佳实践
-
-- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
-- **str_replace 的匹配范围取决于格式**:
- - **XML 模式(默认)**:`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级(列表、表格、分栏、引用块等)改动请改用 `block_replace` 指定 block_id 重建。
- - **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
-- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
-- **str_replace 的 replacement 支持富文本**:可以用行内标签 ``、``、``、`` 等替换普通文本为富文本
-- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
-- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
-- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
- 1. 用 `block_insert_after` 在目标位置插入新的富文本结构
- 2. 用 `block_delete` 批量删除旧的 block
- 3. 这样可以保留文档中其他不相关的内容(图片、评论等)
-- **表达形式**:插入或替换内容时,优先沿用用户要求和已有文档风格;需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要为了固定丰富度主动添加组件
+|字段|说明|
+|-|-|
+|`result`|`success` \| `partial_success` \| `failed`|
+|`updated_blocks_count`|实际更新的 block 数量|
+|`warnings`|服务端返回的警告列表;即使 `result=success` 也要检查是否存在降级或未完全处理的内容|
+|`tips`|服务端返回的后续处理建议;为空表示没有额外建议,非空本身不表示更新失败|
+|`document.new_blocks`|新增 block;`block_id` 用于后续编辑,资源块的 `block_token` 可交给对应 skill 继续处理|
-## 参考
+## 需要查文档
-- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
-- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
-- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
-- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
-- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
-- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
+可查看 [`+fetch`](lark-doc-fetch.md)。
diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md
index ab296130a2..c383a40fb2 100644
--- a/skills/lark-doc/references/lark-doc-whiteboard.md
+++ b/skills/lark-doc/references/lark-doc-whiteboard.md
@@ -1,12 +1,10 @@
# lark-doc 画板处理指南
-> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
-
## 两个 Skill 的职责边界
| Skill | 核心职责 | 约束 |
|-------------------|-----------------------------------------------------------|---------------------------------|
-| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
+| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单图表或复杂空白画板 | 简单图可由主 Agent 直接写入;复杂图再隔离到 SubAgent |
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
## 画板适用规则
@@ -29,11 +27,9 @@
> [!IMPORTANT]
> ⚠️ **分别对每个图表进行决策**
-如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
-中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
-SubAgent 插入 SVG。
+如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B。思维导图、时序图、类图、饼图、甘特图可插入 mermaid 块;其他类型图表使用 SVG,简单图由主 Agent 直接写入,复杂图再启动 SubAgent。
-建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
+简单 Mermaid / SVG 图可由主 Agent 直接写入本地 XML;需要专门视觉设计、信息密度较高或容易布局翻车的 SVG,再启动 SubAgent 产出完整片段。
### 步骤 2A: 使用 mermaid 插入图表
@@ -44,7 +40,7 @@ SubAgent 插入 SVG。
```
-如果 Mermaid 已在本地文件中,可写成 ``;CLI 会在写入前读取文件并展开为内联内容。
+如果 Mermaid 已在本地文件中,可写成 ``;CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
@@ -58,7 +54,7 @@ SubAgent 插入 SVG。
```
-如果 SVG 已在本地文件中,可写成 ``;PlantUML 文件同理使用 ``。
+如果 SVG 已在本地文件中,可写成 ``;PlantUML 文件同理使用 ``。
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
diff --git a/skills/lark-doc/references/lark-doc-word-stat.md b/skills/lark-doc/references/lark-doc-word-stat.md
deleted file mode 100644
index 156b859121..0000000000
--- a/skills/lark-doc/references/lark-doc-word-stat.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# 文档统计:总字数 / 总字符数
-
-当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。
-
-## 调用方式
-
-在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope:
-
-```bash
-lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
- | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty
-```
-
-`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
-
-## 统计范围
-
-先判断用户要求的是**整篇文档**还是**局部内容**:
-
-- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
-- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
-
-如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
-
-```bash
-lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
- | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown
-```
-
-## 如何读取结果
-
-脚本输出 JSON。对用户汇报时默认只读两个核心字段:
-
-- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。
-- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
-
-其余字段用于排查或解释:
-
-- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。
-- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。
-- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。
-- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。
-
-如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
-
-## 字数遵循校验
-
-当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
-
-1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;``
+- ``:`action` 可为 `OpenLink`、`DuplicatePage` 或 `FollowPage`;可选 `background-color`、`src`。
+- ``:使用毫秒时间戳。
+- ``:创建空白表格;``:复制已有表格。
+- ``:挂载任务,`task-id` 为任务 GUID。
+- ``:挂载聊天卡片。
+- ``:子页面列表块,仅 wiki 文档可插入。
+
+
## HTML5 block
-1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 ``;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
+1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 ``;已有 `data-ref` 时配合 `--reference-map @./reference-map.json`。读取时 `` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@./doc-fetch-resources/...html` 文件。
2. 格式如下:
```html
@@ -26,24 +36,19 @@
### 布局与高度
-- `lark-cli` 会读取 `.html` 文件并原样写入 `reference_map`,不会解析或校验 `html-box-height-mode`;创建或更新文档前在 `` 中显式声明 `auto` 或 `viewport`。
-- 生成时只使用 `auto` 或 `viewport`,不要臆造 `fixed`、`initial` 或像素值等其他 mode。
-- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%`、`max-width: 100%`、`box-sizing: border-box`。
-
-四种策略:
-
-1. 内容自然撑开:`auto` + 普通文档流;根容器不设固定高度或 `overflow: hidden`。
-2. 仅按初始内容定高:`auto` + 首次渲染后不再追加或展开内容。
-3. 固定像素操作区:`auto` + 业务容器按场景设置固定的 CSS `height` 和 `overflow: auto`;高度数值不写进 meta。
-4. 单屏应用:`viewport` + `100vh` + 内部滚动、切页或缩放;适用于游戏、幻灯片、Dashboard、canvas 编辑器。
+只使用 `auto` 或 `viewport`:正文需要在文档中完整展开时使用 `auto`;内容需要在 HTML Block 内滚动或单屏呈现时使用 `viewport`。`lark-cli` 会将 HTML 原样写入 `reference_map`,不会校验该字段,因此创建或更新前必须在 `` 中显式声明。
-正文需要在飞书文档中完整展开时选 `auto`;内容应在 HTML Block 内滚动时选 `viewport`。`lark-cli` 不参与页面加载后的高度刷新,不要臆造相关 CLI flag。
+- `auto`:使用普通文档流,不给根容器设置固定高度或 `overflow: hidden`。需要固定操作区时,在业务容器上设置 CSS `height` 和 `overflow: auto`,不要把像素值写入 meta。
+- `viewport`:使用 `100vh` 和内部滚动、切页或缩放,适用于游戏、幻灯片、Dashboard、canvas 编辑器。
+- 页面加载后的内容追加或展开不会由 `lark-cli` 刷新高度,不要臆造相关 CLI flag。
+- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%`、`max-width: 100%`、`box-sizing: border-box`。
### 内容限制
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
## OKR block
+``:创建时仅支持 root-only。
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `` 挂载已有 OKR,不构造 Objective/KR/Progress 子树。
diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md
index 0c1b1d9259..f16765cf74 100644
--- a/skills/lark-doc/references/lark-doc-xml.md
+++ b/skills/lark-doc/references/lark-doc-xml.md
@@ -1,183 +1,54 @@
-基于 HTML 子集的 XML 格式描述飞书文档内容。
-
-# 一、标准 HTML 标签
-p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr, img, b, em, u, del, a, br, span 语义不变
-
-# 二、扩展标签速查表
-## 块级标签
-|标签|说明|关键属性|
-|-|-|-|
-| `` | 文档标题(每篇唯一)| `align` |
-| `` | 待办项| `done="true"\|"false"` |
-
-## 容器标签
-|标签|说明|关键属性|
-|-|-|-|
-| `` | 高亮框,子块仅支持文本块(如 ``)、标题、列表、待办、引用;禁止裸文本及 `
`、`
`、``、`
`、``、``、`` 等其他块级标签或资源块 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
-| `` + `` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
-| `` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
-| `` | (代码块,内含 `code`)| `lang`, `caption` |
-| `` | 视图容器 | `view-type` |
-| `` | 书签链接 | ``,必传 name 和 href |
-
-## 行内组件
-| 标签 | 说明 | 关键属性 |
-|-|-|-|
-| `` | @人 | XML 导入时必须显式传入 `user-id`:`` |
-| `` | @文档 | `` |
-| `` | 行内公式 | `E = mc^2` |
-| `
` | 图片(可独立成块或内联) | `
` |
-| `` | 文件附件(可独立成块或内联) | `` |
-| `` | 预览卡片 | `标题` |
-| `