mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
v1.0.79-be
...
codex/cli-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f9c6d9bfb |
@@ -16,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
|
||||
// and use --raw for direct XML stdout.
|
||||
// provided it writes reindented XML to a local file, and --raw prints
|
||||
// reindented XML to stdout; otherwise it returns the server's original
|
||||
// content unmodified in the standard JSON envelope. Use --slide-id or
|
||||
// --slide-number to fetch one page.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
@@ -30,8 +31,8 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
@@ -108,10 +109,10 @@ var SlidesXMLGet = common.Shortcut{
|
||||
}
|
||||
dry.GET(path).Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
|
||||
},
|
||||
@@ -250,22 +251,31 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
|
||||
// Only the text surfaces are reindented: --raw stdout and --output files are
|
||||
// read directly by humans and line tools. The JSON envelope carries the
|
||||
// server content verbatim instead -- inside a JSON string every newline is
|
||||
// escaped to \n, so formatting there buys no readability and only inflates
|
||||
// the payload, while passthrough keeps that read path byte-exact without
|
||||
// even parsing the content.
|
||||
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
|
||||
if outputPath == "" {
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
ContentLength: int64(len(formatted)),
|
||||
}, bytes.NewReader([]byte(formatted)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
@@ -280,6 +290,7 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
"pretty_printed": prettyPrinted,
|
||||
}
|
||||
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
|
||||
if value, ok := out[key]; ok {
|
||||
@@ -289,3 +300,17 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
runtime.Out(fileOut, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
|
||||
// content that is not strictly valid XML, callers still receive the original
|
||||
// content and a warning on stderr instead of losing the read path. The bool
|
||||
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
|
||||
// --output file metadata.
|
||||
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
|
||||
out, err := prettyPrintXML(xmlContent)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
|
||||
return xmlContent, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML (not derived by
|
||||
// calling it): a bug in prettyPrintXML itself must not be able to make
|
||||
// this assertion pass by construction.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -60,10 +64,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
if strings.Contains(stdout.String(), wantXML) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
@@ -80,8 +84,11 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
if data["pretty_printed"] != true {
|
||||
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
|
||||
}
|
||||
if data["size"] != float64(len(wantXML)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
@@ -96,7 +103,12 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// The JSON envelope carries the server content verbatim: no reindentation
|
||||
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
|
||||
// would fail this by inserting indentation; the   reference
|
||||
// additionally guards against a naive parse-and-reserialize round trip,
|
||||
// which would decode it to a literal space.
|
||||
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span> <strong>World</strong></p></content></shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -122,11 +134,14 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
@@ -136,6 +151,8 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// --jq extracts fields from the envelope, and the envelope carries the
|
||||
// server content verbatim, so the filter yields the single-line original.
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -161,15 +178,18 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -193,16 +213,32 @@ func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
if got := stdout.String(); got != wantXML {
|
||||
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
|
||||
for _, flag := range SlidesXMLGet.Flags {
|
||||
if flag.Name != "raw" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
|
||||
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("--raw flag not found")
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -244,8 +280,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved slide XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
@@ -263,6 +299,8 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The slide envelope carries the server content verbatim, like the
|
||||
// presentation envelope.
|
||||
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -305,11 +343,14 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
}
|
||||
slide := data["slide"].(map[string]interface{})
|
||||
if slide["content"] != xml {
|
||||
t.Fatalf("content = %q, want %q", slide["content"], xml)
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
|
||||
}
|
||||
if slide["slide_id"] != "slide_2" {
|
||||
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
@@ -515,3 +556,341 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
|
||||
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXML(t *testing.T) {
|
||||
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
|
||||
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Fatalf("expected reindented output with newlines, got %q", got)
|
||||
}
|
||||
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
|
||||
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
|
||||
}
|
||||
if !strings.Contains(got, "<data/>") {
|
||||
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
|
||||
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
|
||||
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
|
||||
t.Fatal("expected an error for malformed XML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
|
||||
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
|
||||
// element docs) and CR/LF references whose lexical form is needed to avoid
|
||||
// XML line-ending normalization on a later parse. An XML parser decodes the
|
||||
// references into literal whitespace. The formatter must preserve their
|
||||
// lexical representation for safe read-modify-write workflows.
|
||||
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"space in p", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"tab in p", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"space in nested span", `<content><p><span> </span></p></content>`, "<content>\n <p><span> </span></p>\n</content>\n"},
|
||||
{"hex space", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"zero-padded tab", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"carriage return", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"line feed", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"hex carriage return", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
{"hex line feed", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "title literal space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title escaped space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title whitespace CDATA",
|
||||
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "chart field literal space",
|
||||
input: `<chartData><chartField name="x"> </chartField></chartData>`,
|
||||
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
|
||||
},
|
||||
{
|
||||
name: "title adjacent text and CDATA",
|
||||
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
|
||||
// critical case:   sitting as a bare sibling text node directly between
|
||||
// two inline elements, not wrapped in its own tag -- the literal reading of
|
||||
// the schema's "标签之间...请使用 " guidance, e.g. a plain-styled space
|
||||
// between two differently formatted words at a pptx run boundary. A fix
|
||||
// that only special-cases "element whose sole content is whitespace" does
|
||||
// not cover this: the whitespace here is one of several children of <p>,
|
||||
// not the sole child of <span>.
|
||||
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
|
||||
input := `<content><p><span>Hello</span> <strong>World</strong></p></content>`
|
||||
want := "<content>\n <p><span>Hello</span> <strong>World</strong></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
|
||||
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
|
||||
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
|
||||
// feature's actual point: a shape with many paragraphs becomes navigable
|
||||
// (each <p> on its own indented line), while every paragraph's own rich
|
||||
// text -- including an inline formatting boundary -- stays byte-for-byte
|
||||
// unchanged.
|
||||
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
|
||||
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
|
||||
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLIdempotent(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a"><content><p>A  B	C D E</p></content><style/></shape></slide></presentation>`
|
||||
once, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (first pass): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(once)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (second pass): %v", err)
|
||||
}
|
||||
if once != twice {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
|
||||
content := "<presentation><title>\x0b</title><slide/></presentation>"
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != content {
|
||||
t.Fatalf("stdout = %q, want original content %q", got, content)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
|
||||
// envelope contract: the content is never parsed, so even malformed XML
|
||||
// flows through byte for byte with no fallback warning and no
|
||||
// pretty_printed field.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
|
||||
content := `<slide><data></slide>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_1",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
slide, _ := data["slide"].(map[string]interface{})
|
||||
if slide == nil {
|
||||
t.Fatalf("missing slide: %#v", data)
|
||||
}
|
||||
if got, _ := slide["content"].(string); got != content {
|
||||
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
|
||||
// the slide-scope passthrough test for the presentation-scope fetch branch,
|
||||
// which is a separate code path.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation, _ := data["xml_presentation"].(map[string]interface{})
|
||||
if presentation == nil {
|
||||
t.Fatalf("missing xml_presentation: %#v", data)
|
||||
}
|
||||
if got, _ := presentation["content"].(string); got != content {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "fallback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback XML: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("saved XML = %q, want original content %q", got, content)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["pretty_printed"] != false {
|
||||
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
260
shortcuts/slides/slides_xml_prettyprint.go
Normal file
260
shortcuts/slides/slides_xml_prettyprint.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// textBearingTags are the SML elements whose schema content model is
|
||||
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
|
||||
// container and its inline formatting children, plus chart title/subtitle.
|
||||
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
|
||||
// or tab is represented via  /	 character references. Reindentation
|
||||
// never descends into these elements; their entire subtree is copied
|
||||
// verbatim from the input, so those references keep their exact spelling.
|
||||
var textBearingTags = map[string]bool{
|
||||
"p": true,
|
||||
"strong": true,
|
||||
"em": true,
|
||||
"u": true,
|
||||
"span": true,
|
||||
"del": true,
|
||||
"a": true,
|
||||
"shadow": true,
|
||||
"outline": true,
|
||||
"chartTitle": true,
|
||||
"chartSubTitle": true,
|
||||
}
|
||||
|
||||
// tokenKind classifies a raw XML token for reindentation purposes.
|
||||
type tokenKind uint8
|
||||
|
||||
const (
|
||||
tokenStartElement tokenKind = iota // <name ...> or <name .../>
|
||||
tokenEndElement // </name>, or zero-width after <name .../>
|
||||
tokenCharData // text, character/entity references, or one CDATA section
|
||||
tokenOther // comment, processing instruction, or directive
|
||||
)
|
||||
|
||||
// rawToken records where one XML token lives inside the original input:
|
||||
// input[start:end] is the token's exact source bytes. The decoded token
|
||||
// value is deliberately discarded (only the element's local name is kept),
|
||||
// which is the core invariant of this formatter: output can only ever be
|
||||
// assembled from verbatim slices of the input, never from re-encoded data.
|
||||
type rawToken struct {
|
||||
kind tokenKind
|
||||
start int // byte offset of the token's first source byte
|
||||
end int // byte offset one past the token's last source byte
|
||||
local string // local element name (namespace prefix stripped); start elements only
|
||||
match int // start element: index of its matching end token; -1 otherwise
|
||||
}
|
||||
|
||||
// tokenize runs encoding/xml over the whole input purely as a tokenizer and
|
||||
// returns every token annotated with its raw byte range. Ranges come from
|
||||
// Decoder.InputOffset, which counts bytes (multi-byte UTF-8 content cannot
|
||||
// skew them), and consecutive tokens tile the input exactly, so slicing
|
||||
// between them loses nothing.
|
||||
//
|
||||
// The full document is decoded before anything is emitted: any syntax error
|
||||
// (mismatched or unclosed tags, invalid characters such as \x0b, undefined
|
||||
// entities, bare ]]> in text, ...) fails the whole pretty-print, keeping the
|
||||
// strict-parse behavior the fallback path in prettyPrintXMLOrOriginal
|
||||
// depends on.
|
||||
func tokenize(input string) ([]rawToken, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(input))
|
||||
var tokens []rawToken
|
||||
var openElements []int // indices into tokens of currently open start elements
|
||||
pos := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := int(decoder.InputOffset())
|
||||
raw := rawToken{start: pos, end: end, match: -1}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
raw.kind = tokenStartElement
|
||||
raw.local = t.Name.Local
|
||||
openElements = append(openElements, len(tokens))
|
||||
case xml.EndElement:
|
||||
// A strict decoder never emits an end element without its start
|
||||
// element; guard anyway so a decoder change cannot panic here.
|
||||
if len(openElements) == 0 {
|
||||
return nil, errors.New("xml: unexpected end element")
|
||||
}
|
||||
raw.kind = tokenEndElement
|
||||
startIndex := openElements[len(openElements)-1]
|
||||
openElements = openElements[:len(openElements)-1]
|
||||
tokens[startIndex].match = len(tokens)
|
||||
case xml.CharData:
|
||||
raw.kind = tokenCharData
|
||||
default: // xml.Comment, xml.ProcInst, xml.Directive
|
||||
raw.kind = tokenOther
|
||||
}
|
||||
tokens = append(tokens, raw)
|
||||
pos = end
|
||||
}
|
||||
// A strict decoder reports unclosed elements as a syntax error before
|
||||
// returning io.EOF; guard anyway so truncated output is impossible.
|
||||
if len(openElements) != 0 {
|
||||
return nil, errors.New("xml: unexpected EOF: unclosed element")
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// prettyPrintXML reindents xmlContent so structural elements (presentation,
|
||||
// slide, shape, style, ...) each sit on their own line. The server returns
|
||||
// XML as a single unbroken line, and this is what makes the --raw and
|
||||
// --output text surfaces readable; the JSON envelope path never calls it
|
||||
// (see outputSlidesXMLGetContent).
|
||||
//
|
||||
// Offset-slicing invariant: encoding/xml serves purely as a tokenizer, and
|
||||
// every byte of the output is either a verbatim slice of the input or an
|
||||
// inserted "\n"+indent run between the children of a structural element.
|
||||
// Nothing is parsed-and-reserialized, so CDATA sections, whitespace
|
||||
// character references in any spelling ( ,  , 	, ,
|
||||
// , ...), entity lexical forms, attribute quoting, and in-tag
|
||||
// whitespace all survive byte-for-byte.
|
||||
//
|
||||
// Reindentation never enters a textBearingTags element and never touches a
|
||||
// leaf element (one with no element children), so document text — including
|
||||
// whitespace-only leaves such as <title> </title> — is never altered.
|
||||
func prettyPrintXML(xmlContent string) (string, error) {
|
||||
tokens, err := tokenize(xmlContent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The decoder tolerates element-free input (plain text, a lone comment,
|
||||
// nothing at all). A document without a root element is not XML the
|
||||
// formatter should claim success on; erroring routes it to the
|
||||
// original-content fallback instead of reporting pretty_printed: true.
|
||||
if !slices.ContainsFunc(tokens, func(t rawToken) bool { return t.kind == tokenStartElement }) {
|
||||
return "", errors.New("xml: no root element")
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(xmlContent) + len(xmlContent)/8)
|
||||
reindented := false
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
if reindented {
|
||||
// Any top-level element after the first is copied verbatim;
|
||||
// well-formed XML has a single root, so this arm only runs
|
||||
// on technically invalid multi-root input the decoder
|
||||
// happens to tolerate.
|
||||
out.WriteString(xmlContent[token.start:tokens[token.match].end])
|
||||
} else {
|
||||
writeElement(&out, xmlContent, tokens, i, 0)
|
||||
reindented = true
|
||||
}
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
// Document-level prolog and epilog (XML declaration, DOCTYPE,
|
||||
// comments, whitespace) pass through verbatim.
|
||||
out.WriteString(xmlContent[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
formatted := out.String()
|
||||
if !strings.HasSuffix(formatted, "\n") {
|
||||
formatted += "\n"
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
// writeElement emits the element whose start token is tokens[startIndex],
|
||||
// indented as if at the given depth (two spaces per level).
|
||||
//
|
||||
// Text-bearing elements and leaf elements (no element children) are emitted
|
||||
// as a single verbatim input slice from open tag through close tag; for a
|
||||
// self-closing tag the synthesized end token is zero-width and the slice is
|
||||
// exactly the open tag. Structural elements (at least one element child,
|
||||
// not text-bearing) are reindented: text children that are pure literal
|
||||
// whitespace are dropped as pre-existing formatting, "\n"+indent is
|
||||
// inserted before every element, comment, and processing-instruction child,
|
||||
// kept text children stay glued in place with no indentation around them,
|
||||
// and the close tag moves to its own line unless the last kept child is
|
||||
// text.
|
||||
//
|
||||
// The whitespace-only test runs on the child's RAW source bytes: a
|
||||
// character reference ( ) or a CDATA section is not literal whitespace
|
||||
// there, so it is kept and its lexical form survives.
|
||||
func writeElement(out *strings.Builder, input string, tokens []rawToken, startIndex, depth int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteString(input[start.start:start.end])
|
||||
childIndent := "\n" + strings.Repeat(" ", depth+1)
|
||||
lastKeptIsText := false
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = true
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
out.WriteString(childIndent)
|
||||
writeElement(out, input, tokens, i, depth+1)
|
||||
lastKeptIsText = false
|
||||
i = child.match + 1
|
||||
default: // comment, processing instruction, directive
|
||||
out.WriteString(childIndent)
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = false
|
||||
i++
|
||||
}
|
||||
}
|
||||
if !lastKeptIsText {
|
||||
out.WriteString("\n")
|
||||
out.WriteString(strings.Repeat(" ", depth))
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
|
||||
// hasElementChild reports whether the element starting at tokens[startIndex]
|
||||
// has at least one direct element child. The first start-element token that
|
||||
// appears before the matching end token is necessarily a direct child, so a
|
||||
// linear scan without depth tracking suffices.
|
||||
func hasElementChild(tokens []rawToken, startIndex int) bool {
|
||||
for i := startIndex + 1; i < tokens[startIndex].match; i++ {
|
||||
if tokens[i].kind == tokenStartElement {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllWhitespace reports whether s is non-empty and consists only of
|
||||
// literal XML whitespace bytes (space, tab, CR, LF). It is applied to raw
|
||||
// source bytes, where character references and CDATA markers count as
|
||||
// non-whitespace by construction.
|
||||
func isAllWhitespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
416
shortcuts/slides/slides_xml_prettyprint_test.go
Normal file
416
shortcuts/slides/slides_xml_prettyprint_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The pure-function contract tests for prettyPrintXML (golden strings,
|
||||
// whitespace character references, leaf whitespace, CDATA, idempotency,
|
||||
// malformed rejection) live in slides_xml_get_test.go, unchanged from the
|
||||
// original etree-based implementation. This file adds engine-level cases
|
||||
// specific to the offset-slicing implementation.
|
||||
|
||||
func TestPrettyPrintXMLGoldenPresentation(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
want := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLGoldenSlide(t *testing.T) {
|
||||
input := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
want := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLRejectsMalformedInputTable pins that the whole document
|
||||
// is decoded before anything is emitted: even a late syntax error yields no
|
||||
// partial output, only the error the fallback path reports.
|
||||
func TestPrettyPrintXMLRejectsMalformedInputTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mismatched close tag", `<presentation><slide></presentation>`},
|
||||
{"unclosed slide from fallback test", `<slide><data></slide>`},
|
||||
{"invalid control character", "<presentation><title>\x0b</title><slide/></presentation>"},
|
||||
{"unclosed root", `<presentation><slide/>`},
|
||||
{"undefined entity", `<presentation><title> </title></presentation>`},
|
||||
{"bare close tag", `</presentation>`},
|
||||
{"unescaped cdata terminator in text", `<presentation><title>a]]>b</title></presentation>`},
|
||||
{"late error after valid prefix", `<presentation><slide/><slide/><slide id=></presentation>`},
|
||||
{"empty input", ``},
|
||||
{"whitespace-only input", ` `},
|
||||
{"plain text without markup", `hello`},
|
||||
{"comment-only document", `<!-- only a comment -->`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want error", tt.input, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("prettyPrintXML(%q) returned partial output %q alongside error %v", tt.input, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText pins that user content
|
||||
// resembling the previous implementation's masking placeholders
|
||||
// (LARKCLI_XML_WHITESPACE_REFERENCE_<n>_) flows through untouched now that
|
||||
// no masking exists at all.
|
||||
func TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "placeholder-shaped text in p",
|
||||
input: `<content><p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p></content>`,
|
||||
want: "<content>\n <p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped text in leaf",
|
||||
input: `<presentation><title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped attribute value",
|
||||
input: `<presentation><slide note="LARKCLI_XML_WHITESPACE_REFERENCE_0_"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide note=\"LARKCLI_XML_WHITESPACE_REFERENCE_0_\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLStructuralTable covers comments, processing
|
||||
// instructions, prolog/DOCTYPE, mixed text between structural children,
|
||||
// CRLF pre-formatting, and multi-byte UTF-8 around offset boundaries.
|
||||
// Expected outputs were verified byte-identical against the previous
|
||||
// etree-based implementation via a differential probe.
|
||||
func TestPrettyPrintXMLStructuralTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
// wantSecond is the expected output of formatting the output again.
|
||||
// Usually equal to want (idempotent); the mixed-content rows pin the
|
||||
// one known non-idempotent shape, where kept text merges with the
|
||||
// inserted indent on reparse — byte-identical to the previous
|
||||
// implementation's behavior on the same inputs. Real SML structural
|
||||
// elements carry no mixed text, so the contract's idempotency
|
||||
// guarantee is unaffected.
|
||||
wantSecond string
|
||||
}{
|
||||
{
|
||||
name: "comment child is indented like an element",
|
||||
input: `<presentation><!-- deck notes --><slide/></presentation>`,
|
||||
want: "<presentation>\n <!-- deck notes -->\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "processing instruction child is indented like an element",
|
||||
input: `<presentation><?pi data?><slide/></presentation>`,
|
||||
want: "<presentation>\n <?pi data?>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "xml declaration prolog stays glued to the root",
|
||||
input: `<?xml version="1.0" encoding="UTF-8"?><presentation><slide/></presentation>`,
|
||||
want: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "prolog with doctype and trailing newline preserved verbatim",
|
||||
input: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation><slide/></presentation>\n",
|
||||
want: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "document-level trailing comment preserved verbatim",
|
||||
input: "<presentation><slide/></presentation><!-- tail -->",
|
||||
want: "<presentation>\n <slide/>\n</presentation><!-- tail -->\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text glues to previous sibling and close tag",
|
||||
input: `<data>x<child/>y</data>`,
|
||||
want: "<data>x\n <child/>y</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y</data>\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text does not suppress indent of next element",
|
||||
input: `<data>x<child/>y<child/></data>`,
|
||||
want: "<data>x\n <child/>y\n <child/>\n</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y\n \n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "pre-existing CRLF formatting is dropped and rebuilt",
|
||||
input: "<presentation>\r\n\t<slide/>\r\n</presentation>",
|
||||
want: "<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "multi-byte UTF-8 text and attributes keep exact bytes",
|
||||
input: `<presentation><title>原生图表 📊 Chart</title><slide 备注="中文värde"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <title>原生图表 📊 Chart</title>\n <slide 备注=\"中文värde\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "namespace-prefixed p is still text-bearing",
|
||||
input: `<content xmlns:sml="urn:x"><sml:p><span>a</span> <span>b</span></sml:p></content>`,
|
||||
want: "<content xmlns:sml=\"urn:x\">\n <sml:p><span>a</span> <span>b</span></sml:p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "already formatted input is preserved",
|
||||
input: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
want: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
wantSecond := tt.wantSecond
|
||||
if wantSecond == "" {
|
||||
wantSecond = tt.want
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != wantSecond {
|
||||
t.Fatalf("second pass:\nonce: %q\ntwice: %q\nwant: %q", got, again, wantSecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged pins the cases where
|
||||
// slicing original bytes intentionally differs from the previous
|
||||
// etree-based parse-and-reserialize implementation. Each case preserves the
|
||||
// input MORE faithfully than before; none is covered by the original
|
||||
// contract tests. The etree field records the old output for the record.
|
||||
func TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string // current behavior: original bytes preserved
|
||||
etree string // what the etree-based implementation produced
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only CDATA between structural children is kept",
|
||||
input: `<data><![CDATA[ ]]><child/></data>`,
|
||||
want: "<data><![CDATA[ ]]>\n <child/>\n</data>\n",
|
||||
etree: "<data>\n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "empty element with explicit close tag is not collapsed",
|
||||
input: `<slide><data></data><shape/></slide>`,
|
||||
want: "<slide>\n <data></data>\n <shape/>\n</slide>\n",
|
||||
etree: "<slide>\n <data/>\n <shape/>\n</slide>\n",
|
||||
},
|
||||
{
|
||||
name: "non-whitespace character reference keeps its lexical form",
|
||||
input: `<presentation><title>A&中</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "single-quoted attributes keep their quoting",
|
||||
input: `<presentation><slide id='s1'><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide id='s1'>\n <shape/>\n </slide>\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "in-tag whitespace is preserved verbatim",
|
||||
input: "<presentation><slide id=\"s1\" ><shape/></slide ></presentation>",
|
||||
want: "<presentation>\n <slide id=\"s1\" >\n <shape/>\n </slide >\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "literal > in leaf text is not re-escaped",
|
||||
input: `<presentation><title>a>b</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if tt.want == tt.etree {
|
||||
t.Fatalf("case is not a divergence: want == etree == %q", tt.want)
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != got {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", got, again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadChartDemo reads the real-world chart demo shipped with the
|
||||
// lark-slides skill (~60KB, pretty-printed): the closest in-repo stand-in
|
||||
// for a full presentation read.
|
||||
func loadChartDemo(t testing.TB) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../skills/lark-slides/references/slides_chart_demo.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read chart demo fixture: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// minifyXML strips whitespace-only text children of structural (non
|
||||
// text-bearing, element-bearing) elements — the exact text nodes
|
||||
// prettyPrintXML treats as disposable formatting — producing the
|
||||
// single-line element shape the slides server actually returns.
|
||||
// Document-level tokens (prolog, trailing newline) pass through verbatim,
|
||||
// because the formatter preserves them verbatim too.
|
||||
func minifyXML(t testing.TB, input string) string {
|
||||
t.Helper()
|
||||
tokens, err := tokenize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenize for minify: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
var emitElement func(startIndex int)
|
||||
emitElement = func(startIndex int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
out.WriteString(input[start.start:start.end])
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
emitElement(i)
|
||||
i = child.match + 1
|
||||
default:
|
||||
out.WriteString(input[child.start:child.end])
|
||||
i++
|
||||
}
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
emitElement(i)
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
out.WriteString(input[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLChartDemoFixture formats the real chart demo both as
|
||||
// shipped (pretty-printed) and minified to the single-line shape the server
|
||||
// returns; both must converge on the same idempotent output.
|
||||
func TestPrettyPrintXMLChartDemoFixture(t *testing.T) {
|
||||
original := loadChartDemo(t)
|
||||
|
||||
formattedOriginal, err := prettyPrintXML(original)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(original): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(formattedOriginal)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass): %v", err)
|
||||
}
|
||||
if twice != formattedOriginal {
|
||||
t.Fatal("prettyPrintXML is not idempotent on the chart demo fixture")
|
||||
}
|
||||
|
||||
minified := minifyXML(t, original)
|
||||
if strings.Contains(minified, ">\n <") {
|
||||
t.Fatalf("minified fixture still contains structural indentation: %q", minified[:200])
|
||||
}
|
||||
// Only the doc-level newline after the XML declaration and the trailing
|
||||
// newline may remain; the whole element tree must be one line.
|
||||
if got := strings.Count(minified, "\n"); got > 2 {
|
||||
t.Fatalf("minified fixture has %d newlines, want <= 2", got)
|
||||
}
|
||||
formattedMinified, err := prettyPrintXML(minified)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(minified): %v", err)
|
||||
}
|
||||
// Formatting drops exactly the whitespace minification dropped, so both
|
||||
// paths must converge on the same output.
|
||||
if formattedMinified != formattedOriginal {
|
||||
t.Fatal("format(minified) != format(original) for the chart demo fixture")
|
||||
}
|
||||
if !strings.Contains(formattedMinified, "\n <slide>") {
|
||||
t.Fatal("formatted chart demo lacks expected slide indentation")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoMinified(b *testing.B) {
|
||||
minified := minifyXML(b, loadChartDemo(b))
|
||||
b.SetBytes(int64(len(minified)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(minified); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoPreformatted(b *testing.B) {
|
||||
original := loadChartDemo(b)
|
||||
b.SetBytes(int64(len(original)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(original); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user