1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
|
// SPDX-License-Identifier: GPL-3.0-or-later
package extract
import (
"archive/zip"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"path"
"strings"
)
// zipBudget caps the total uncompressed bytes read from one archive's
// matched entries, guarding against a zip bomb; a test lowers it. B1: the
// directory's max-read may cap a single archive further still — see
// budget (tools.go).
var zipBudget int64 = 64 << 20
// zipPatterns maps a zip-based format's extension to the path.Match
// patterns (tried in matchEntry) of the entries that carry its text.
// Invoices often carry the tax number only in a header or footer, which is
// why docx's header*/footer* entries are included; ODF keeps headers and
// footers in styles.xml, not content.xml, so both are read.
var zipPatterns = map[string][]string{
"docx": {"word/document.xml", "word/header*.xml", "word/footer*.xml", "word/footnotes.xml"},
"xlsx": {"xl/sharedStrings.xml", "xl/worksheets/sheet*.xml"},
"pptx": {"ppt/slides/slide*.xml"},
"odt": {"content.xml", "styles.xml"},
"ods": {"content.xml", "styles.xml"},
"odp": {"content.xml", "styles.xml"},
"epub": {"*.xhtml", "*.html", "*.htm"},
}
// zipText extracts text from a zip-based document (docx xlsx pptx odt ods
// odp epub): it opens the archive and, for each entry matching the
// format's zipPatterns in archive order, decodes its XML character data
// into the result with xmlText, separating entries with a newline. A
// corrupt archive returns the zip package's own error, not
// ErrUnsupported: the file claims a format it does not have, which the
// user should see. Reading stops with ErrTooLarge immediately once the
// entries read from the archive exceed budget(zipBudget, maxRead)
// uncompressed bytes in total (B1: maxRead, the directory's configured
// ceiling, may cap this lower than the fixed zipBudget). ctx is checked
// between entries so a cancelled extraction stops
// promptly.
//
// Any other per-entry failure — the entry won't open, or its XML is
// malformed — is lenient rather than fatal: whatever text that entry had
// already yielded (xmlText writes as it walks, so a syntax error partway
// through still leaves the text read up to that point) is kept, and the
// archive keeps going to its remaining entries, since one bad part (a
// corrupt header, say) should not blank out a document's otherwise
// readable body. The first such error is remembered, wrapped as "<entry
// name>: <err>", and returned only if no matched entry ever wrote any
// character data at all — an error report is more useful than silent
// empty text when nothing could be read. "Wrote any character data" is
// tracked per entry (via the builder's length just before and after that
// entry's own xmlText call, not the whole archive's final length): the
// newline zipText adds to separate a successful entry from the next one
// would otherwise make an entry that parsed cleanly but held no text of
// its own (an empty element, say) look like it had produced something,
// which could then mask a later entry's genuine failure.
func zipText(ctx context.Context, path, ext string, maxRead int64) (string, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return "", err
}
defer zr.Close()
patterns := zipPatterns[ext]
var b strings.Builder
remaining := budget(zipBudget, maxRead)
var firstErr error
wroteText := false
for _, f := range zr.File {
if err := ctx.Err(); err != nil {
return "", err
}
if !matchEntry(patterns, f.Name) {
continue
}
before := b.Len()
err := readZipEntry(f, &remaining, &b)
// Measured before the separator below is written, so a
// separator alone (an entry that parsed but held no character
// data) never counts as "wrote text" — only xmlText's own
// writes do, whether or not this entry went on to error.
if b.Len() > before {
wroteText = true
}
if err != nil {
if errors.Is(err, ErrTooLarge) {
return "", ErrTooLarge
}
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", f.Name, err)
}
continue
}
b.WriteByte('\n')
}
if firstErr != nil {
if !wroteText {
return "", firstErr
}
return b.String(), fmt.Errorf("%w: %w", ErrPartial, firstErr)
}
return b.String(), nil
}
// matchEntry reports whether name is one of the entries a format reads:
// each pattern is tried first against the full entry name — which is what
// the docx/xlsx/pptx/odt directory-qualified patterns need — and, failing
// that, against name's base name. The base-name fallback is what lets
// epub's bare "*.xhtml"/"*.html"/"*.htm" find chapters nested at any depth
// inside the archive; applied to every format, it also means a nested
// part sharing a matched base name is picked up deliberately, not by
// accident — e.g. an ODF embedded object's own "Object 1/content.xml"
// matches odt/ods/odp's bare "content.xml" pattern alongside the
// document's own content.xml, because an embedded chart's or formula's
// text is text the document shows its reader.
func matchEntry(patterns []string, name string) bool {
base := path.Base(name)
for _, p := range patterns {
if ok, _ := path.Match(p, name); ok {
return true
}
if ok, _ := path.Match(p, base); ok {
return true
}
}
return false
}
// readZipEntry opens one matched zip entry, decodes its text into b
// through a budgetedReader sharing remaining across the whole archive, and
// reports ErrTooLarge if that budget was exceeded — checked on the reader
// itself after xmlText returns, since the XML decoder may not pass the
// reader's own error through unchanged (a truncated entry can look like a
// cleanly finished document).
func readZipEntry(f *zip.File, remaining *int64, b *strings.Builder) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
br := &budgetedReader{r: rc, remaining: remaining}
err = xmlText(br, b)
if br.exceeded {
return ErrTooLarge
}
return err
}
// budgetedReader wraps a zip entry's reader, decrementing remaining — a
// counter shared across every entry read from one archive — as bytes are
// read. Once remaining is exhausted it stops reading and reports io.EOF
// instead, recording that in exceeded so the caller can tell a genuine
// end of document from a budget cutoff.
type budgetedReader struct {
r io.Reader
remaining *int64
exceeded bool
}
func (br *budgetedReader) Read(p []byte) (int, error) {
if *br.remaining <= 0 {
br.exceeded = true
return 0, io.EOF
}
if int64(len(p)) > *br.remaining {
p = p[:*br.remaining]
}
n, err := br.r.Read(p)
*br.remaining -= int64(n)
return n, err
}
// xmlText appends the character data of one XML entry to b, with the
// separators described above. inSharedCell tracks xlsx <c t="s">.
func xmlText(r io.Reader, b *strings.Builder) error {
dec := xml.NewDecoder(r)
dec.Strict = false
dec.Entity = xml.HTMLEntity
sharedCell, inV := false, false
for {
tok, err := dec.Token()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "s", "tab", "br", "line-break", "cr":
b.WriteByte(' ')
case "c":
sharedCell = false
for _, a := range t.Attr {
if a.Name.Local == "t" && a.Value == "s" {
sharedCell = true
}
}
case "v":
inV = true
}
case xml.EndElement:
switch t.Name.Local {
case "p", "h", "tc", "tr", "td", "th", "li", "si", "c", "row", "div", "title":
b.WriteByte('\n')
case "v":
inV = false
}
case xml.CharData:
if inV && sharedCell {
continue
}
b.Write(t)
}
}
}
|