package main // office-text — docx, xlsx and pptx, read by this binary, ON THE USER'S MACHINE. // // ── WHY THIS IS NOT LIBREOFFICE ───────────────────────────────────────────── // // soffice's interface was unverified. It was installed and run // (LibreOffice 26.2.5.2) before a line of this was written, and what // it actually does rules it out for this lane on four separate counts. Every // number below is from a run, not from documentation: // // 1. IT HAS NO ONE INTERFACE FOR THE THREE FORMATS. `--convert-to txt` works // for Writer only. On a .xlsx and a .pptx it fails with "Error: no export // filter ... found, aborting" and exit 1 — Calc and Impress have no text // export filter at all. A spreadsheet needs `--convert-to csv` (which // writes ONE sheet, the first) and a deck needs `--convert-to html`, whose // output for a two-slide deck with eleven words on it is 5,837 bytes of // XHTML that then has to be stripped back down. Three filters, one of them // lossy by default and one of them not text. // // 2. TWO AT ONCE SILENTLY LOSES ONE. MEASURED, five trials of two concurrent // `--headless --convert-to` against the same user profile: in 5 of 5, one // of the pair exited 1 having written NO output file and NOTHING to stdout // OR stderr. Not an error message — an empty failure. This box runs // mgmtWorkerCount management workers, so two extractions overlapping is // normal operation, and the result would have been an intermittent, wholly // unexplained "could not read this document" on documents that are fine. // It IS fixable — a private profile per invocation // (-env:UserInstallation=file:///…) gave 10 of 10 clean runs at 296 KB and // ~0.3 s each — but the fix is a mkdtemp dance around a program that // defaults to corrupting concurrent work without saying so. // // 3. IT COSTS THE USER 332 MB. Measured with dpkg-query over the 70 packages // `libreoffice-writer libreoffice-calc libreoffice-impress` pulls in. This // lane runs on the user's own machine and what they must install is a // first-class cost, not a footnote. // // 4. ITS FAILURES ARE NOT DISTINGUISHABLE. A corrupted .xlsx reports "no // export filter" — the SAME message as asking for a format it cannot write. // A file of plain garbage named .docx exits 0, because LibreOffice sniffs // content, decides it is a text file, and hands back the garbage as the // document's text. // // What it does do well, and the reason it is worth recording rather than just // rejecting: a password-protected document does NOT hang. Headless suppresses // the password dialog and it exits 1 in 0.4 s with "Error: source file could not // be loaded" — the right behaviour, with a message that does not say the file // was encrypted. // // ── WHAT THIS DOES INSTEAD ────────────────────────────────────────────────── // // docx, xlsx and pptx are all OPC packages: a ZIP of XML parts (ECMA-376). Go's // standard library reads both halves, so this job needs NO binary on the user's // machine and NO new module in go.mod — archive/zip and encoding/xml, nothing // else. THE USER INSTALLS NOTHING. // // That is also the smaller attack surface by a wide margin. These are untrusted // documents from a user's own corpus; the alternative was handing each one to a // 332 MB office suite with a long history of parser CVEs, running as the user. // // The cost of the choice, stated plainly: the legacy BINARY formats (.doc, .xls, // .ppt — OLE2 compound files, not ZIPs) are NOT read by this and are not mapped // on the host. They are a different container and a different job, and a box // with LibreOffice on it could carry them later as a separate tool name. This // job claims the three OPC formats and nothing more. // // ── WHY ONE JOB NAME AND NOT THREE ────────────────────────────────────────── // // pdf-text and ocr are separate names because a machine can genuinely have one // and not the other. Built into the binary, all three formats are present or // absent together — there is no partial install to describe — so three names // would be three ways of saying the same true thing. // // The format is decided by WHAT IS IN THE PACKAGE, not by the mime the host // sent or the extension: measured above, the extension lies often enough that // LibreOffice itself ignores it. import ( "archive/zip" "bytes" "encoding/xml" "fmt" "io" "math" "path" "sort" "strconv" "strings" "time" ) // officeExtractTimeout bounds one office-text job. Declared in // ctxExtractTimeoutFor, which is the only place a ceiling exists. // // DERIVED FROM MEASUREMENT, not chosen. This is in-process XML parsing of a ZIP // the pull path already caps at ctxPullMaxBytes, so the worst case is bounded by // construction and was measured at it: // // 50,000 x 12 text workbook 6.2 MB packed, 35.1 MB of sheet XML 0.45 s // numeric workbook at the // unpack ceiling 0.36 MB packed, 65.0 MB of sheet XML 0.55-0.61 s // // Both stopped at ctxExtractMaxText rather than at a clock, which is the general // case: the text ceiling is reached long before a time limit is. // // 20 s is therefore >30x the worst case measurable at the source ceiling, which // leaves room for a slow disk and a loaded machine without letting a // pathological package hold a management worker. It is deliberately the same // number as the plain text-layer read: both are "parse a file this box already // has", and giving them different ceilings would imply a difference in the work // that is not there. const officeExtractTimeout = ctxExtractTimeout // officeMaxUnpacked is the wall on DECOMPRESSED bytes across all parts of one // package, and it is what a zip bomb hits. // // A 64 MiB upload can declare petabytes of uncompressed content; archive/zip // will happily stream it, so a size check on the file proves nothing about the // cost of reading it. The number is NOT a new one: it is ctxPullMaxBytes, this // box's existing ceiling on one source document. Reusing it states the rule // rather than inventing a second threshold — a package may not expand to more // than the largest document this lane would have accepted whole. const officeMaxUnpacked = ctxPullMaxBytes // officeRowSep separates a spreadsheet row's cells. Pipe rather than comma or // tab because it survives being read back as prose, needs no quoting rules, and // keeps one record on one line — see the emission note on readXLSX. const officeRowSep = " | " // officeKind is which of the three a package turned out to be. type officeKind string const ( kindDOCX officeKind = "docx" kindXLSX officeKind = "xlsx" kindPPTX officeKind = "pptx" ) // officeResult is what one extraction produced, beyond the text. type officeResult struct { Kind officeKind // Parts is sheets for a workbook, slides for a deck, and 1 for a document. // Reported so "no text" from a 40-slide deck of photographs reads // differently from "no text" out of an empty package. Parts int // Partial is set when a part failed to parse and the text is what came out // of the parts that did. The alternative — throwing away nine readable // slides because the tenth has malformed XML — helps nobody. Partial bool // Deadline is set when the clock ran out mid-package. Deadline bool } // runOfficeText is the office-text job, in extractMulti's shape. // // It is in extractMulti rather than extractArgv because it is not an argv: it is // zero commands, which is as far from "one command writing to stdout" as the // three commands pdf-ocr runs. Putting it there means the drift gates already // cover it and no third table had to be invented. // // root is unused: this job needs no scratch of its own, which is one more thing // that cannot leak a user's document. func runOfficeText(root, in string, deadline time.Time) (string, []ocrPageResult, int, bool, error) { text, res, err := extractOffice(in, deadline) if err != nil { return "", nil, 0, false, err } return text, nil, res.Parts, res.Deadline, nil } // extractOffice reads one OPC package off disk. func extractOffice(in string, deadline time.Time) (string, officeResult, error) { zr, err := zip.OpenReader(in) if err != nil { // THE "CANNOT" ANSWER, and it has to be distinguishable from an empty // document. A file that is not a ZIP was never one of these three // formats, whatever it was called. return "", officeResult{}, fmt.Errorf( "this is not a docx, xlsx or pptx — it is not an Office package at all "+ "(%v). A .doc/.xls/.ppt saved in the older binary format is a different "+ "container and this job does not read it", err) } defer zr.Close() return extractOfficeZip(&zr.Reader, deadline) } // extractOfficeZip is the reader itself, taking an already-opened package. Split // out so the capability PROBE can run the whole path over a package built in // memory — proving the reader works, not that a file exists. func extractOfficeZip(zr *zip.Reader, deadline time.Time) (string, officeResult, error) { budget := &unpackBudget{left: officeMaxUnpacked} files := map[string]*zip.File{} for _, f := range zr.File { files[path.Clean(f.Name)] = f } switch { case files["word/document.xml"] != nil: return readDOCX(files, budget, deadline) case files["xl/workbook.xml"] != nil: return readXLSX(files, budget, deadline) case files["ppt/presentation.xml"] != nil: return readPPTX(files, budget, deadline) } // A ZIP with none of the three main parts. An ENCRYPTED OOXML file never // reaches here (it is an OLE2 container, so zip.OpenReader already refused // it), but an ODF document does, and saying which is more use than "cannot // read". if f := files["mimetype"]; f != nil { if mt, err := readPart(f, budget, 200); err == nil && strings.HasPrefix(mt, "application/vnd.oasis") { return "", officeResult{}, fmt.Errorf( "this is an OpenDocument file (%s), not a docx/xlsx/pptx — this job "+ "reads the Office formats only", strings.TrimSpace(mt)) } } return "", officeResult{}, fmt.Errorf( "this is a ZIP but not an Office document: it has no word/document.xml, " + "xl/workbook.xml or ppt/presentation.xml") } // ── relationships ─────────────────────────────────────────────────────────── // // An OPC package NEVER links its parts by filename. Every reference from part X // to part Y goes through X's own relationship part — the sibling // /_rels/.rels — which maps a relationship id and a type url to a // target path. Filenames look like they encode the link (slide1 ↔ notesSlide1, // sheet1 ↔ the first sheet) and they do not; the numbering is creation order and // nothing more. Reading the rels is the only way to be right, and the defects // that come of guessing are silent misattribution rather than a visible failure. // officeRel is one entry, with its target already resolved to a // package path this reader can look up. type officeRel struct { id string relType string target string } // officeRelsMax bounds one relationship part. Rels parts are a handful of lines // even in a large package; this is generous by orders of magnitude and exists // only so a malicious package cannot spend the whole unpack budget here. const officeRelsMax = 4 << 20 // partRels reads the relationship part belonging to `part` and resolves every // target against `part`'s own directory, which is what the spec says a relative // target is relative to. An EXTERNAL target (a hyperlink, a linked image on // someone's disk) is dropped: it names something outside the package, and this // reader must never be the thing that goes and looks. func partRels(files map[string]*zip.File, part string, b *unpackBudget) []officeRel { dir := path.Dir(part) rf := files[path.Join(dir, "_rels", path.Base(part)+".rels")] if rf == nil { return nil } body, err := readPart(rf, b, officeRelsMax) if err != nil { return nil } var out []officeRel d := xml.NewDecoder(strings.NewReader(body)) for { tok, err := d.Token() if err != nil { break } se, ok := tok.(xml.StartElement) if !ok || se.Name.Local != "Relationship" { continue } var r officeRel external := false for _, a := range se.Attr { switch a.Name.Local { case "Id": r.id = a.Value case "Type": r.relType = a.Value case "Target": r.target = a.Value case "TargetMode": external = strings.EqualFold(a.Value, "External") } } if external || r.id == "" || r.target == "" { continue } if strings.HasPrefix(r.target, "/") { // Package-absolute: "/ppt/slides/slide1.xml". r.target = path.Clean(strings.TrimPrefix(r.target, "/")) } else { r.target = path.Clean(path.Join(dir, r.target)) } out = append(out, r) } return out } // ── the budget ────────────────────────────────────────────────────────────── type unpackBudget struct{ left int64 } // readPart decompresses one part, bounded twice: by what is left of the // package's whole allowance and by what this part could sanely be. func readPart(f *zip.File, b *unpackBudget, max int64) (string, error) { if b.left <= 0 { return "", fmt.Errorf("this package expands to more than %d bytes, which is "+ "more than the largest document this lane reads", officeMaxUnpacked) } if max > b.left { max = b.left } rc, err := f.Open() if err != nil { return "", err } defer rc.Close() // max+1 so hitting the wall is detectable rather than silently truncating. data, err := io.ReadAll(io.LimitReader(rc, max+1)) b.left -= int64(len(data)) if err != nil { return "", err } if int64(len(data)) > max { return "", fmt.Errorf("%s is larger than this lane unpacks", f.Name) } return string(data), nil } // officePartMax is the wall on ONE content part, and it is the SAME number as // the package budget on purpose. // // It was briefly 32 MiB, a smaller "surely no single part is bigger than this" // number, and that was wrong twice over. MEASURED: a plain 50,000-row x // 12-column worksheet — 6.2 MB packed — is 35.1 MB of sheet XML, so the cap // refused an ordinary large spreadsheet. And it refused it QUIETLY: the sheet // was skipped, the reply came back with zero characters and exit 0, and the // only trace was an internal flag that never reached the user. A second // threshold bought nothing the package budget was not already enforcing, and // cost a silently empty extraction of a perfectly good workbook. const officePartMax = officeMaxUnpacked // ── docx ──────────────────────────────────────────────────────────────────── // readDOCX walks word/document.xml. The text of a Word document lives in // runs inside paragraphs; a text box's words are in the same part (inside // w:txbxContent) so they come out with it — ONCE, see the alternate-content note // on runsToText, which is what stops the same text box being read out of both // halves of an . // // Headers, footers and footnotes are separate parts and are NOT read. They are // page furniture — a filename, a page number, a confidentiality stamp repeated // on every page — and folding them in would put the same line into the document // forty times, which costs an embedding lane more than it gives it. // // COMMENTS ARE READ, AND LABELLED — see readDOCXComments for why, and for the // alternative that was rejected. // // TRACKED CHANGES come out in their FINAL-VIEW form: an insertion is inside an // ordinary w:r and is emitted, a deletion is in w:delText which is not a w:t and // is therefore not. That is the same text the document shows with markup turned // off, which is the right answer for a corpus, but it is a consequence of the // element names rather than something this chose, so it is written down. func readDOCX(files map[string]*zip.File, b *unpackBudget, deadline time.Time) (string, officeResult, error) { body, err := readPart(files["word/document.xml"], b, officePartMax) if err != nil { return "", officeResult{Kind: kindDOCX}, fmt.Errorf("word/document.xml could not be read: %w", err) } text, partial := runsToText(body, "t", "p", "tab", "br", deadline) comments, cpartial := readDOCXComments(files, b, deadline) partial = partial || cpartial text += commentsBlock(comments, len(text)) return text, officeResult{Kind: kindDOCX, Parts: 1, Partial: partial, Deadline: pastDeadline(deadline)}, nil } // ── docx comments ─────────────────────────────────────────────────────────── // // THE CALL: a document's comments ARE extracted, into their own labelled block // after the document's text, attributed to whoever wrote each one. // // The reasoning is the reasoning readPPTX already gives for speaker notes, and // the two cases are the same case. A comment is the most prose-like text in a // Word document — someone's actual sentences about the thing, where the body is // often headings and figures — and "the document where Dana said the number was // stale" is exactly the search a person runs. Dropping them loses that, and // loses it silently: the words are in the file the user handed over and nothing // in the reply says they were left behind. // // The objection is real and it is answered by LABELLING rather than by dropping: // a comment is not something the document SAYS, and merging the two would put a // reviewer's "this is wrong" into the corpus as the document's own assertion. // So comments go under their own heading, one per line, each named with its // author — which makes a retrieval hit on one visibly a comment, the same way // "Notes:" makes a hit on a speaker note visibly a note. // // THE ALTERNATIVE, rejected: keep dropping them, on the grounds that a corpus // should hold what a document says and nothing else. That is a coherent line and // it is the wrong one here, because this lane's job is to make the user's own // files findable by their own words, and a comment is their own words. It also // fails the test the rest of this file applies to itself — a user searching a // phrase they typed into their own document would get nothing back, with no way // to tell that the phrase was ever read. // // AUTHOR NAMES: included, because "what did Sam say about this" is usually the // question, and a comment with the name stripped off answers half of it. The // name goes exactly where the rest of the document's words go and no further — // back to the user's own host, as this document's text. // docxComment is one : its author when the writer recorded one, and // its text. type docxComment struct { author string text string } // officeCommentsHeading labels the comment block. It is the same "--- … ---" // shape as a slide heading so one reader of this output learns one convention. const officeCommentsHeading = "--- Comments ---" // readDOCXComments reads the comments part, found THROUGH THE RELATIONSHIP // rather than at word/comments.xml, for the reason stated on partRels: the // filename is not the link. The suffix match is exact — "/commentsExtended", // "/commentsIds" and "/commentsExtensible" are threading and identity metadata, // not text, and do not end in "/comments". // // A document with no comments part has no comments; there is no fallback to a // guessed path. func readDOCXComments(files map[string]*zip.File, b *unpackBudget, deadline time.Time) ([]docxComment, bool) { target := "" for _, r := range partRels(files, "word/document.xml", b) { if strings.HasSuffix(r.relType, "/comments") { target = r.target break } } f := files[target] if target == "" || f == nil { return nil, false } body, err := readPart(f, b, officePartMax) if err != nil { // A comments part that will not unpack is a partial read of the document, // not a failure of it: the body text is still the user's. return nil, true } return commentsFromXML(body, deadline) } // commentsFromXML walks a WordprocessingML comments part. // // It is its own walker rather than a runsToText call because the BOUNDARIES // matter here — one comment must not run into the next, and each one carries an // author — and runsToText has no notion of a group. What it does NOT duplicate is // the alternate-content rule: mcSelect is the same object runsToText uses, so a // text box inside a comment is read once here for the same reason it is read // once in the body. // // Returns the comments and whether the part failed to parse part-way through. func commentsFromXML(body string, deadline time.Time) ([]docxComment, bool) { d := xml.NewDecoder(strings.NewReader(body)) mc := newMCSelect() var out []docxComment var cur docxComment var line, text strings.Builder depth, commentAt := 0, -1 inT, checked, taken := false, 0, 0 flushLine := func() { s := strings.TrimSpace(line.String()) line.Reset() if s == "" { return } if text.Len() > 0 { text.WriteString("\n") } text.WriteString(s) } for { checked++ if checked%512 == 0 && pastDeadline(deadline) { return out, false } tok, err := d.Token() if err == io.EOF { break } if err != nil { return out, true } switch t := tok.(type) { case xml.StartElement: depth++ skip := mc.start(t) if !skip && commentAt < 0 && t.Name.Local == "comment" { commentAt = depth cur = docxComment{} text.Reset() line.Reset() for _, a := range t.Attr { if a.Name.Local == "author" { cur.author = strings.TrimSpace(a.Value) } } continue } if skip || commentAt < 0 { continue } switch t.Name.Local { case "t": inT = true case "tab": line.WriteString("\t") case "br": flushLine() } case xml.CharData: if inT && commentAt >= 0 && !mc.skipping() { line.Write(t) } case xml.EndElement: skip := mc.end(t) closing := commentAt >= 0 && depth == commentAt && t.Name.Local == "comment" depth-- if closing { flushLine() cur.text = strings.TrimSpace(text.String()) commentAt = -1 inT = false if cur.text == "" { continue } out = append(out, cur) taken += len(cur.text) // The whole comment block still has to fit inside one reply. This // is the same ceiling the body text has, reached the same way. if taken >= ctxExtractMaxText { return out, false } continue } if skip || commentAt < 0 { continue } switch t.Name.Local { case "t": inT = false case "p": flushLine() } } } return out, false } // commentsBlock renders the comments that FIT after `used` characters of document // text, and says plainly when some did not. // // Comments are cut at a whole comment, never mid-sentence, and the count of what // was left out is stated — a half-quoted remark attributed to a named person is // the one way this could be worse than dropping them. func commentsBlock(comments []docxComment, used int) string { if len(comments) == 0 { return "" } var b strings.Builder head := "\n" + officeCommentsHeading + "\n" if used+len(head)+officeTailReserve > ctxExtractMaxText { return "" } b.WriteString(head) left := 0 for i, c := range comments { line := "Comment: " + c.text + "\n" if c.author != "" { line = "Comment (" + c.author + "): " + c.text + "\n" } if used+b.Len()+len(line)+officeTailReserve > ctxExtractMaxText { left = len(comments) - i break } b.WriteString(line) } if left > 0 { word := "comments" if left == 1 { word = "comment" } fmt.Fprintf(&b, "--- %d more %s on this document were not included: it is larger "+ "than one extraction returns ---\n", left, word) } return b.String() } // ── pptx ──────────────────────────────────────────────────────────────────── // readPPTX walks the slides and takes the SPEAKER NOTES with them. // // The notes are the reason this is worth doing well. A slide is a handful of // noun phrases; the notes are the sentences the person actually meant to say, // and they are the most prose-like text in the whole file — the best thing in a // deck for someone searching "the deck where we explained the pricing change". // They are labelled, not merged, so a hit on a note is visibly a note. // // ── NOTES ARE PAIRED BY RELATIONSHIP, NEVER BY FILENAME ───────────────────── // // ppt/notesSlides/notesSlideN.xml is NOT slideN.xml's notes, and nothing in the // format says it is. The parts are numbered in the order they are CREATED, so a // deck where someone wrote notes on slide 3 before slide 2 has notesSlide1.xml // attached to slide 3 — VERIFIED on a real package: slide1 has no notes // relationship at all, slide2 points at notesSlide2.xml and slide3 points at // notesSlide1.xml. The only thing that knows is the relationship in // ppt/slides/_rels/slideN.xml.rels, so that is what is read. // // Pairing by filename, which this used to do, quotes one slide's words // under another slide's heading. That is worse than losing them: a user who // searches a phrase from their own notes is sent, with a citation, to a slide // that does not contain it. // // A slide with no notesSlide relationship has no notes. There is deliberately no // filename fallback — a fallback here is the defect, reinstated for the cases // nobody checked. // // ── SLIDE ORDER IS p:sldIdLst, NEVER THE FILE NUMBERING ───────────────────── // // ppt/slides/slideN.xml is numbered in CREATION order, the same way notesSlideN // is. The order the deck is PRESENTED in — the order it plays, and the numbers // under the thumbnails in PowerPoint — is in ppt/presentation.xml, // whose entries carry r:id values resolved through // ppt/_rels/presentation.xml.rels. Dragging a slide to a new position rewrites // that list and does not touch the parts, so the two disagree on any deck anyone // has edited. // // MEASURED on a deck built with three slides and then reordered by // moving the third to the front: sldIdLst reads rId9, rId7, rId8 while the // targets are slide1, slide2, slide3, and LibreOffice 26.2.5.2 handed that exact // file writes it back out with the THIRD slide as slide1.xml. An office suite // agrees with the list. // // Reading file numbers instead — which this used to do — returned the // deck in the wrong sequence AND labelled every slide with a number the user // cannot find in their own deck, so a citation to "slide 1" landed on a different // slide. The label here is now the slide's POSITION in the presentation, which is // the number the user sees. // // A slide part that the list does not name is not in the deck and is not read: // it is a leftover, and emitting it would put text in the corpus that the // document does not show. If the list cannot be read at all — no // presentation.xml, no relationships, a malformed part — the file numbering is // the fallback, because some order is better than refusing a readable deck. func readPPTX(files map[string]*zip.File, b *unpackBudget, deadline time.Time) (string, officeResult, error) { slides := presentationSlides(files, b) if len(slides) == 0 { for _, p := range numberedParts(files, "ppt/slides/slide", ".xml") { slides = append(slides, p.name) } } if len(slides) == 0 { return "", officeResult{Kind: kindPPTX}, fmt.Errorf( "this presentation has no slides in it") } var out strings.Builder partial := false for i, name := range slides { n := i + 1 if pastDeadline(deadline) { return out.String(), officeResult{Kind: kindPPTX, Parts: len(slides), Partial: partial, Deadline: true}, nil } f := files[name] if f == nil { // The list names a part the package does not contain. Say so rather // than skip: a slide missing from the file is a fact about the file. partial = true fmt.Fprintf(&out, "--- Slide %d ---\n(this slide is in the presentation but its "+ "part is not in the file)\n\n", n) continue } body, err := readPart(f, b, officePartMax) if err != nil { partial = true fmt.Fprintf(&out, "--- Slide %d ---\n(this slide could not be read: %v)\n\n", n, err) continue } // DrawingML: text runs are inside paragraphs. txt, p := runsToText(body, "t", "p", "", "br", deadline) partial = partial || p if slideIsHidden(body) { fmt.Fprintf(&out, "--- Slide %d (hidden) ---\n", n) } else { fmt.Fprintf(&out, "--- Slide %d ---\n", n) } if strings.TrimSpace(txt) != "" { out.WriteString(txt) if !strings.HasSuffix(txt, "\n") { out.WriteString("\n") } } notesName := "" for _, r := range partRels(files, name, b) { if strings.HasSuffix(r.relType, "/notesSlide") { notesName = r.target break } } if nf := files[notesName]; notesName != "" && nf != nil { if nb, err := readPart(nf, b, officePartMax); err == nil { if ntxt, np := runsToText(nb, "t", "p", "", "br", deadline); strings.TrimSpace(ntxt) != "" { partial = partial || np out.WriteString("Notes: ") out.WriteString(strings.TrimSpace(ntxt)) out.WriteString("\n") } } } out.WriteString("\n") } return out.String(), officeResult{Kind: kindPPTX, Parts: len(slides), Partial: partial, Deadline: pastDeadline(deadline)}, nil } // officeRelNS is the officeDocument relationships namespace, and it is needed // here for ONE reason: carries two attributes // whose local name is "id". Matching on the local name alone picks whichever // comes first and would read the slide's internal identifier as a relationship // id, which resolves to nothing. const officeRelNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" // presentationSlides returns the deck's slide parts in PRESENTATION order, or // nil when the package does not say what that order is. func presentationSlides(files map[string]*zip.File, b *unpackBudget) []string { pf := files["ppt/presentation.xml"] if pf == nil { return nil } body, err := readPart(pf, b, officePartMax) if err != nil { return nil } rels := map[string]string{} for _, r := range partRels(files, "ppt/presentation.xml", b) { if strings.HasSuffix(r.relType, "/slide") { rels[r.id] = r.target } } var out []string d := xml.NewDecoder(strings.NewReader(body)) inList := false for { tok, err := d.Token() if err != nil { break } switch t := tok.(type) { case xml.StartElement: switch t.Name.Local { case "sldIdLst": inList = true case "sldId": if !inList { continue } for _, a := range t.Attr { if a.Name.Space != officeRelNS || a.Name.Local != "id" { continue } if target := rels[a.Value]; target != "" { out = append(out, target) } } } case xml.EndElement: if t.Name.Local == "sldIdLst" { inList = false } } } return out } // slideIsHidden reads show="0" off the slide part's ROOT element, which // is what PowerPoint's "Hide Slide" writes: the slide stays in the deck and in // the file and is skipped when the deck is played. // // Only the root element is looked at, and the walk stops at the first start tag // either way. "show" is an ordinary word and appears on other elements inside a // slide; the hidden flag is the root's attribute and nothing else's. func slideIsHidden(body string) bool { d := xml.NewDecoder(strings.NewReader(body)) for { tok, err := d.Token() if err != nil { return false } se, ok := tok.(xml.StartElement) if !ok { continue } if se.Name.Local != "sld" { return false } for _, a := range se.Attr { if a.Name.Local == "show" { return a.Value == "0" || strings.EqualFold(a.Value, "false") } } return false } } // ── xlsx ──────────────────────────────────────────────────────────────────── // // A SPREADSHEET IS NOT PROSE, and flattening one naively produces a wall of // digits that embeds badly and retrieves worse: "300" carries no meaning and // every workbook in a corpus contains it. // // What makes a spreadsheet findable is its LABELS — the names the user gave the // sheets, the header row they typed, and the text cells. So the emission keeps // every label next to the values it labels: // // === Sheet: Q3 Forecast === // Region | Q1 | Q2 // North | 100 | 200 // // - THE SHEET NAME IS A HEADING. It is the user's own title for that data and // it is often the only prose in the file. A workbook's sheet names alone // ("Headcount", "Q3 Forecast", "Churn by cohort") make it findable. // - ONE ROW IS ONE LINE, so a retrieval hit hands back a whole record rather // than an isolated number, and the header line sits in the same chunk as // the rows under it. // - EMPTY ROWS AND TRAILING EMPTY CELLS ARE DROPPED. A sheet is a sparse grid // with a used range far larger than its content; emitting the padding would // bury the content in separators. // - AN INTERIOR EMPTY CELL IS KEPT, AS AN EMPTY FIELD. It is the opposite case // and it is not symmetric with the one above: closing a middle gap moves // every value after it under the wrong header, which is a false record // rather than a noisy one. The column comes from the cell's own r="D3" // reference — see sheetRows. // - A HIDDEN SHEET IS READ, AND ITS HEADING SAYS IT IS HIDDEN. See // sheetRef.heading. The same call is made for a hidden SLIDE in readPPTX. // - FORMULAS EMIT THEIR RESULT, not their source. `=SUM(B2:B9)` is not what // the user reads and not what they would search for; 4,150 is. OOXML stores // both, and this takes the cached value. // - DATES EMIT AS DATES. This is the one place the raw value is actively // WRONG rather than merely terse: a date is stored as a day count and would // come out as "45200" — a number no one will ever search for, in place of // "2023-09-15". The number format decides, read from styles.xml. // // The row cap is not a new number. Emission stops when the accumulated text // reaches ctxExtractMaxText, the ceiling this lane already has for a reply, and // says which sheet and row it stopped at. For scale: Elasticsearch's // ingest-attachment defaults to 100,000 indexed characters and Tika's // parseToString to the same; ctxExtractMaxText is 4 MiB, roughly 40x that, so a // workbook that hits this wall is far past what a text index would have kept. type sheetRef struct { name string // the part path title string // state is the workbook's : "", "hidden" or "veryHidden". // It lives on the workbook entry and nowhere else — a hidden sheet's own // worksheet part is an ordinary one, full of the user's cells. state string } // heading is the sheet's line in the output, and it is where a HIDDEN sheet is // declared to be hidden. // // THE CALL: a hidden sheet is READ, and MARKED. Not skipped. // // The case for skipping is that hiding a sheet is how people put working aside — // scratch columns, an old version, the lookup table behind a dropdown — and a // corpus that quotes it back is quoting something the user does not think of as // part of their document. // // It loses to two things. First, hiding is also how people keep a sheet they // still rely on out of the way, and a reader that silently drops it produces the // worst answer this lane has: a search that finds nothing, from a document that // contains the words, with nothing anywhere saying why. Every other decision in // this file goes the other way on exactly that trade — an unreadable sheet says // so in the text, a slide with no words on it is still printed, a workbook that // stopped says where. Second, the confusing part of a hidden-sheet hit is not // that it exists, it is that the user cannot find it in the file they have open; // and that is fixed by saying "(hidden)", not by deleting the content. // // So the user can always tell, and the marking is on the heading a hit is // retrieved with rather than in a flag they never see. // // "veryHidden" is named separately because it is a different act: Excel's own // interface cannot unhide it — it takes the VBA editor — so a user seeing that // sheet quoted back is even further from being able to find it. func (s sheetRef) heading() string { switch { case strings.EqualFold(s.state, "veryHidden"): return fmt.Sprintf("=== Sheet: %s (very hidden) ===", s.title) case strings.EqualFold(s.state, "hidden"): return fmt.Sprintf("=== Sheet: %s (hidden) ===", s.title) } return fmt.Sprintf("=== Sheet: %s ===", s.title) } // officeTailReserve is room held back at the end of a workbook's text for the // sentences that say the extraction stopped early. // // It exists because those sentences were being written PAST the reply's own // ceiling and cut back off by the truncation at contextextract.go. MEASURED // on a 200,000-row single-column sheet: the reply came back at exactly // ctxExtractMaxText characters ending in "--- stopped after 1", with the rest of // the sentence, and everything that would have followed it, gone. Whether it // survived depended on how wide the last row happened to be — a sheet of wide // rows left enough slack and a sheet of narrow ones did not. // // 1 KiB against a 4 MiB reply is 0.02% of the text, and it holds the stop marker // (about 90 characters) plus the unread-sheet list bounded below. const officeTailReserve = 1024 // officeUnreadNamesMax bounds how many sheet names the unread list prints, so a // workbook with 300 sheets cannot spend the reserve on its own table of contents. const officeUnreadNamesMax = 12 // officeUnreadNameMax bounds one printed sheet name, for the same reason: a title // is untrusted text and can be as long as the writer allowed. const officeUnreadNameMax = 60 func readXLSX(files map[string]*zip.File, b *unpackBudget, deadline time.Time) (string, officeResult, error) { shared := readSharedStrings(files, b) dateStyles := readDateStyles(files, b) sheets := workbookSheets(files, b) if len(sheets) == 0 { return "", officeResult{Kind: kindXLSX}, fmt.Errorf("this workbook has no sheets in it") } var out strings.Builder partial, stopped := false, false var unread []sheetRef for i, sh := range sheets { if pastDeadline(deadline) { return out.String(), officeResult{Kind: kindXLSX, Parts: len(sheets), Partial: partial, Deadline: true}, nil } f := files[sh.name] if f == nil { partial = true fmt.Fprintf(&out, "%s\n(this sheet is named in the workbook but "+ "its data is not in the file)\n\n", sh.heading()) continue } body, err := readPart(f, b, officePartMax) if err != nil { // SAY IT IN THE TEXT, not only in a flag. A sheet that could not be // read and a sheet with nothing in it are different facts, and the // user is the one who needs to tell them apart. partial = true fmt.Fprintf(&out, "%s\n(this sheet could not be read: %v)\n\n", sh.heading(), err) continue } fmt.Fprintf(&out, "%s\n", sh.heading()) rows, p, hitWall := sheetRows(body, shared, dateStyles, &out, deadline) partial = partial || p if rows == 0 { out.WriteString("(no cells with anything in them)\n") } out.WriteString("\n") if hitWall { fmt.Fprintf(&out, "--- stopped after %d rows of %q: this workbook is larger "+ "than one extraction returns ---\n", rows, sh.title) stopped, unread = true, sheets[i+1:] break } } // WHAT THE EXTRACTION NEVER GOT TO, NAMED. // // This is where `_ = stopped` went. The flag was computed and thrown away, // which left the reply saying where it stopped INSIDE a sheet and saying // nothing at all about the sheets behind it — so a workbook whose data sheet // runs long came back reading like a complete extraction of a workbook with // one sheet in it. The sheet names are the most findable thing a workbook has // (see the emission note above), and losing them without a word is the blind // answer readPPTX refuses to give for a slide with no text on it. if stopped && len(unread) > 0 { out.WriteString(unreadSheetsLine(unread)) } return out.String(), officeResult{Kind: kindXLSX, Parts: len(sheets), Partial: partial, Deadline: pastDeadline(deadline)}, nil } // unreadSheetsLine names the sheets an extraction stopped before reaching, // bounded so the line always fits inside officeTailReserve. func unreadSheetsLine(unread []sheetRef) string { names := make([]string, 0, officeUnreadNamesMax) for _, sh := range unread { if len(names) == officeUnreadNamesMax { names = append(names, "…") break } title := sh.title if len(title) > officeUnreadNameMax { title = title[:officeUnreadNameMax] + "…" } names = append(names, strconv.Quote(title)) } word, verb := "sheets", "were" if len(unread) == 1 { word, verb = "sheet", "was" } return fmt.Sprintf("--- %d more %s in this workbook %s not read: %s ---\n", len(unread), word, verb, strings.Join(names, ", ")) } // sheetRows streams one worksheet, writing rows as it goes. // // A CELL'S COLUMN IS ITS r="…" REFERENCE, NOT ITS POSITION AMONG ITS SIBLINGS. // A writer emits a only for cells that exist, and an untouched interior cell // is absent from the XML entirely — MEASURED on files written by openpyxl 3.1.5 // AND by LibreOffice 26.2.5.2, both of which emit `` for a row whose B and C were never typed in. So appending // cells in document order slides every value after a gap to the left, and the // emitted line then asserts that a number belongs to a column it does not belong // to — a clean-looking labelled record that is simply false, which nothing // downstream can detect. That is what this used to do. // // The gap is emitted as an EMPTY FIELD rather than closed up, so a value stays // under the header it sits under. Trailing empties are still dropped, since a // sheet's used range runs far past its content and the padding would bury it. // // Returns rows written, whether the XML failed part-way, and whether emission // stopped at the wall — which is ctxExtractMaxText less officeTailReserve, so // that the sentences readXLSX writes afterwards to say it stopped are inside the // reply rather than past its end. func sheetRows(body string, shared []string, dateStyles map[int]bool, out *strings.Builder, deadline time.Time) (int, bool, bool) { d := xml.NewDecoder(strings.NewReader(body)) rows, checked := 0, 0 var cells []string inCell, cellType, cellStyle, cellRef := false, "", 0, "" var val, inline strings.Builder inV, inT := false, false for { // The deadline is checked every 512 tokens rather than every one: a // time.Now() per token on a 60 MB part is measurable, and 512 tokens is // microseconds of overshoot. checked++ if checked%512 == 0 && pastDeadline(deadline) { return rows, false, false } tok, err := d.Token() if err == io.EOF { break } if err != nil { // Malformed part-way. Keep the rows already emitted and say so — // see officeResult.Partial. return rows, true, false } switch t := tok.(type) { case xml.StartElement: switch t.Name.Local { case "row": cells = cells[:0] case "c": inCell, cellType, cellStyle, cellRef = true, "", 0, "" val.Reset() inline.Reset() for _, a := range t.Attr { switch a.Name.Local { case "t": cellType = a.Value case "s": cellStyle, _ = strconv.Atoi(a.Value) case "r": cellRef = a.Value } } case "v": inV = inCell case "t": inT = inCell } case xml.CharData: if inV { val.Write(t) } else if inT { inline.Write(t) } case xml.EndElement: switch t.Name.Local { case "v": inV = false case "t": inT = false case "c": txt := cellText(cellType, cellStyle, val.String(), inline.String(), shared, dateStyles) // A cell with no usable reference — which no writer emits, but a // corrupt or hand-made part can — falls back to its position, the // only other thing available. if col := colFromRef(cellRef); col >= 0 { for len(cells) <= col { cells = append(cells, "") } cells[col] = txt } else { cells = append(cells, txt) } inCell = false case "row": line := trimTrailing(cells) if len(line) == 0 { continue } joined := strings.Join(line, officeRowSep) if out.Len()+len(joined)+1 > ctxExtractMaxText-officeTailReserve { return rows, false, true } out.WriteString(joined) out.WriteString("\n") rows++ } } } return rows, false, false } // xlsxMaxColumns is the format's own ceiling — column XFD, 16,384 — and it is // here as a bound on ALLOCATION, not as a policy. A reference is three letters // of untrusted input; without this, r="ZZZ1" asks for 18,278 empty strings and a // longer one asks for more. Anything past the ceiling is not a column this // format can have, so it is treated as no reference at all. const xlsxMaxColumns = 16384 // colFromRef turns a cell reference's letters into a zero-based column index — // "A" is 0, "D" is 3, "AA" is 26. Returns -1 when there is nothing usable to // read, which the caller handles by falling back to sibling position. // // The letters are bijective base-26: there is no zero digit, so "AA" is // 1*26 + 1 = 27, i.e. index 26, and the plain positional reading is off by one // at every carry. func colFromRef(ref string) int { n, letters := 0, 0 for i := 0; i < len(ref); i++ { c := ref[i] if c >= 'a' && c <= 'z' { c -= 'a' - 'A' } if c < 'A' || c > 'Z' { break } letters++ if letters > 3 { return -1 } n = n*26 + int(c-'A') + 1 } if letters == 0 || n > xlsxMaxColumns { return -1 } return n - 1 } // cellText resolves one cell to what a person reading the sheet would see. func cellText(cellType string, style int, v, inlineStr string, shared []string, dateStyles map[int]bool) string { switch cellType { case "s": // shared string: v is an index into the table if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && i >= 0 && i < len(shared) { return shared[i] } return "" case "inlineStr": return strings.TrimSpace(inlineStr) case "str": // a formula that returned text — v IS the result return strings.TrimSpace(v) case "b": if strings.TrimSpace(v) == "1" { return "TRUE" } return "FALSE" case "e": // an error value: #DIV/0! and friends, as the user sees them return strings.TrimSpace(v) } v = strings.TrimSpace(v) if v == "" { return "" } if dateStyles[style] { if s := serialToDate(v); s != "" { return s } } return v } // serialToDate turns Excel's day count into an ISO date, which is the form // anyone would actually search for. // // The 1900 epoch with its leap-year bug: Excel treats 1900 as a leap year, so // serial 60 is a day that never existed and every serial above it is one greater // than the true day count. Day 1 is 1900-01-01, so subtracting 2 from serials // past 60 lands on the right date — the arithmetic every reader of this format // does. The 1904 (Mac) epoch is not handled; it is rare and getting it wrong by // four years is visible, not silent. func serialToDate(v string) string { f, err := strconv.ParseFloat(v, 64) if err != nil || f < 1 || f > 2958466 { // 2958465 = 9999-12-31 return "" } days := math.Floor(f) frac := f - days off := 2.0 if days < 60 { off = 1.0 } t := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days-off)) if frac > 0 { t = t.Add(time.Duration(frac * 24 * float64(time.Hour))) return t.Format("2006-01-02 15:04") } return t.Format("2006-01-02") } // readDateStyles maps a cell's style index to "this is a date". // // xl/styles.xml holds , one per style index. The // built-in ids 14-22 and 45-47 are the date and time formats every writer uses; // a custom format is a date when its code contains a day, month-as-date or year // token. Anything unreadable here simply yields no date styles, and the cell // falls back to its raw value — worse output, never a failed extraction. func readDateStyles(files map[string]*zip.File, b *unpackBudget) map[int]bool { out := map[int]bool{} f := files["xl/styles.xml"] if f == nil { return out } body, err := readPart(f, b, 8<<20) if err != nil { return out } customDate := map[int]bool{} d := xml.NewDecoder(strings.NewReader(body)) inCellXfs, idx := false, 0 for { tok, err := d.Token() if err != nil { break } switch t := tok.(type) { case xml.StartElement: switch t.Name.Local { case "numFmt": id, code := -1, "" for _, a := range t.Attr { switch a.Name.Local { case "numFmtId": id, _ = strconv.Atoi(a.Value) case "formatCode": code = a.Value } } if id >= 0 && isDateFormatCode(code) { customDate[id] = true } case "cellXfs": inCellXfs = true case "xf": if !inCellXfs { continue } id := 0 for _, a := range t.Attr { if a.Name.Local == "numFmtId" { id, _ = strconv.Atoi(a.Value) } } if builtinDateFmt(id) || customDate[id] { out[idx] = true } idx++ } case xml.EndElement: if t.Name.Local == "cellXfs" { inCellXfs = false } } } return out } func builtinDateFmt(id int) bool { return (id >= 14 && id <= 22) || (id >= 45 && id <= 47) } // isDateFormatCode reads a custom number format's code. Quoted literals and the // colour/condition brackets are skipped so a currency format like // [$-409]#,##0.00 or "dollars"0.00 cannot be mistaken for a date by the 'd' in // its literal text. func isDateFormatCode(code string) bool { inQuote, inBracket := false, false for i := 0; i < len(code); i++ { c := code[i] switch { case c == '"': inQuote = !inQuote case c == '[': inBracket = true case c == ']': inBracket = false case inQuote || inBracket: case c == '\\': i++ case c == 'y' || c == 'Y' || c == 'd' || c == 'D': return true case c == 'm' || c == 'M': // 'm' is minutes next to h/s and months otherwise. Either way the // cell is a date or a time and the serial needs converting. return true } } return false } // readSharedStrings loads xl/sharedStrings.xml, the table every text cell in a // workbook points into. func readSharedStrings(files map[string]*zip.File, b *unpackBudget) []string { f := files["xl/sharedStrings.xml"] if f == nil { return nil } body, err := readPart(f, b, officePartMax) if err != nil { return nil } var out []string d := xml.NewDecoder(strings.NewReader(body)) var cur strings.Builder depth, inT := 0, false for { tok, err := d.Token() if err != nil { break } switch t := tok.(type) { case xml.StartElement: switch t.Name.Local { case "si": depth++ cur.Reset() case "t": inT = depth > 0 } case xml.CharData: if inT { cur.Write(t) } case xml.EndElement: switch t.Name.Local { case "t": inT = false case "si": depth-- // A string split across formatting runs arrives as several // in one and is concatenated, which is what the cell shows. out = append(out, strings.TrimSpace(cur.String())) } } } return out } // workbookSheets pairs each sheet's USER-GIVEN NAME with the part that holds it. // // The name is the point: it is the label the sheet is findable by. It lives in // xl/workbook.xml against a relationship id, and the part path lives in // xl/_rels/workbook.xml.rels against the same id, so both are read. If the rels // part is missing or does not resolve, the sheets fall back to file order — // the titles are still right, only the pairing could drift, and a wrong pairing // is better than no titles at all. func workbookSheets(files map[string]*zip.File, b *unpackBudget) []sheetRef { rels := map[string]string{} for _, r := range partRels(files, "xl/workbook.xml", b) { rels[r.id] = r.target } fallback := numberedParts(files, "xl/worksheets/sheet", ".xml") var out []sheetRef if wf := files["xl/workbook.xml"]; wf != nil { if body, err := readPart(wf, b, 8<<20); err == nil { d := xml.NewDecoder(strings.NewReader(body)) for { tok, err := d.Token() if err != nil { break } se, ok := tok.(xml.StartElement) if !ok || se.Name.Local != "sheet" { continue } var title, rid, state string for _, a := range se.Attr { switch a.Name.Local { case "name": title = a.Value case "id": // r:id rid = a.Value case "state": // "visible", "hidden" or "veryHidden"; absent means // visible. Carried so the heading can say so. state = a.Value } } part := rels[rid] if part == "" && len(out) < len(fallback) { part = fallback[len(out)].name } if title == "" { title = fmt.Sprintf("Sheet%d", len(out)+1) } if strings.EqualFold(state, "visible") { state = "" } out = append(out, sheetRef{name: part, title: title, state: state}) } } } if len(out) == 0 { for i, p := range fallback { out = append(out, sheetRef{name: p.name, title: fmt.Sprintf("Sheet%d", i+1)}) } } return out } // ── shared XML walking ────────────────────────────────────────────────────── // officeMCNS is the Markup Compatibility namespace (ECMA-376 Part 3). Unlike the // content dialects it is ONE url in every part of every format, which is why the // alternate-content handling below matches on it rather than on a local name: // "Choice" and "Fallback" are ordinary words and could appear elsewhere. const officeMCNS = "http://schemas.openxmlformats.org/markup-compatibility/2006" // mcSelect implements ECMA-376 Part 3's selection rule over a token stream, and // it exists as a type rather than as four local variables because TWO walkers // need it: runsToText, and the comment walker in readDOCXComments. A text box // inside a comment is the same alternate-content pair as a text box in the body, // and a second hand-written copy of this logic is how the two would drift. // // The rule it enforces is stated in full on runsToText. // // Usage: call start on every StartElement and end on every EndElement, in order, // BEFORE acting on the element. Both return "this element is inside a branch // being skipped"; skipping() answers the same question for CharData. type mcSelect struct { depth int // skipFrom is the depth of the alternate-content branch currently being // ignored, or -1 when none is. skipFrom int // taken has one entry per open — they nest, a text box // inside a text box — recording whether a branch of it has already been // taken. taken []bool } func newMCSelect() *mcSelect { return &mcSelect{skipFrom: -1} } func (m *mcSelect) start(t xml.StartElement) bool { m.depth++ if t.Name.Space == officeMCNS { switch t.Name.Local { case "AlternateContent": // Pushed even inside a skipped branch so the pop in end always has // something to take off; the guard on Choice/Fallback is what keeps a // skipped branch from claiming anything. m.taken = append(m.taken, false) case "Choice", "Fallback": if m.skipFrom < 0 && len(m.taken) > 0 { if m.taken[len(m.taken)-1] { m.skipFrom = m.depth } else { m.taken[len(m.taken)-1] = true } } } } return m.skipFrom >= 0 } func (m *mcSelect) end(t xml.EndElement) bool { m.depth-- if m.skipFrom >= 0 && m.depth < m.skipFrom { m.skipFrom = -1 } if t.Name.Space == officeMCNS && t.Name.Local == "AlternateContent" && len(m.taken) > 0 { m.taken = m.taken[:len(m.taken)-1] } return m.skipFrom >= 0 } func (m *mcSelect) skipping() bool { return m.skipFrom >= 0 } // runsToText pulls text runs out of a WordprocessingML or DrawingML part. // // tEl is the run element ("t" in both dialects), pEl the paragraph, tabEl an // element that means a tab, brEl one that means a line break. Namespace // PREFIXES are deliberately not matched for those: Go's decoder resolves them to // URLs, the two dialects use different URLs for the same local names, and // matching on local name is what makes one walker serve both. // // ── ALTERNATE CONTENT: ONE BRANCH, NEVER BOTH ─────────────────────────────── // // is how both Word and PowerPoint write anything with more // than one representation, and a TEXT BOX is the common case: an // holding the DrawingML shape and an // holding the VML one. BOTH CARRY A FULL COPY OF THE SAME w:txbxContent — // verified on a LibreOffice-written docx, where the callout's words appear twice // in word/document.xml — because they are alternatives, not parts. Part 3 has a // consumer select exactly one: the first Choice whose Requires it understands, // and the Fallback only if none matched. // // So this takes the FIRST branch of each AlternateContent and skips the rest. // Requires is not evaluated, and that is the right reading for a TEXT extractor // rather than a laziness: the namespaces it names describe how to DRAW the // thing, and this job wants only the words inside, which every branch spells the // same way. Taking the first Choice — the richest representation, and the one a // current Word opens — and falling through to Fallback only when a package has // no Choice at all is the behaviour that reads every text box exactly once. // // Walking both, which this used to do, put every callout, pull quote // and diagram label into the corpus TWICE: doubled embedding cost, two retrieval // hits for one passage, and a document that appears to say a thing twice. // // Returns the text and whether the part failed to parse part-way through. func runsToText(body, tEl, pEl, tabEl, brEl string, deadline time.Time) (string, bool) { d := xml.NewDecoder(strings.NewReader(body)) var out strings.Builder var line strings.Builder inT, checked := false, 0 mc := newMCSelect() flush := func() { s := strings.TrimRight(line.String(), " \t") line.Reset() if strings.TrimSpace(s) == "" { return } out.WriteString(s) out.WriteString("\n") } for { checked++ if checked%512 == 0 && pastDeadline(deadline) { flush() return out.String(), false } tok, err := d.Token() if err == io.EOF { break } if err != nil { flush() return out.String(), true } switch t := tok.(type) { case xml.StartElement: if mc.start(t) { break } switch t.Name.Local { case tEl: inT = true case tabEl: if tabEl != "" { line.WriteString("\t") } case brEl: flush() } case xml.CharData: if inT && !mc.skipping() { line.Write(t) if out.Len()+line.Len() > ctxExtractMaxText { flush() return out.String(), false } } case xml.EndElement: if mc.end(t) { break } switch t.Name.Local { case tEl: inT = false case pEl: flush() } } } flush() return out.String(), false } // numberedParts finds slideN.xml / sheetN.xml and returns them in NUMERIC order. // Sorting the names as strings puts slide10 before slide2. type numberedPart struct { name string n int } func numberedParts(files map[string]*zip.File, prefix, suffix string) []numberedPart { var out []numberedPart for name := range files { if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) { continue } mid := name[len(prefix) : len(name)-len(suffix)] n, err := strconv.Atoi(mid) if err != nil { continue } out = append(out, numberedPart{name: name, n: n}) } sort.Slice(out, func(i, j int) bool { return out[i].n < out[j].n }) return out } func trimTrailing(cells []string) []string { end := len(cells) for end > 0 && strings.TrimSpace(cells[end-1]) == "" { end-- } return cells[:end] } func pastDeadline(deadline time.Time) bool { return !deadline.IsZero() && time.Now().After(deadline) } // ── the probe ─────────────────────────────────────────────────────────────── // officeProbeText is the text the probe package contains and the probe expects // back out of it. const officeProbeText = "PRIVATAE OFFICE PROBE" // officeProbePackage builds a minimal, valid docx in memory: a content-types // part and a document with one paragraph. func officeProbePackage() ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) parts := [][2]string{ {"[Content_Types].xml", ``}, {"word/document.xml", `` + officeProbeText + ``}, } for _, p := range parts { w, err := zw.Create(p[0]) if err != nil { return nil, err } if _, err := io.WriteString(w, p[1]); err != nil { return nil, err } } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } // officeProve is the capability probe, and it PROVES THE CAPABILITY rather than // a proxy for it. // // The rest of this table probes a binary and takes its exit code, because that // is the only handle an external program offers. This job has no binary, so the // honest probe is the whole reader run end to end over a package built in // memory: if the ZIP layer, the XML walker and the text assembly all work, the // probe returns the document's own words back. // // The failure it is guarding against is not a missing install — there is none — // but a build where this path is broken. A box in that state must not advertise // office-text, for exactly the reason `tesseract --version` exiting 0 on a // machine with no language data must not: an advertisement that a job will // produce text, from a box that would return none. func officeProve() (bool, string) { pkg, err := officeProbePackage() if err != nil { return false, "this build cannot assemble an Office package to test itself with: " + err.Error() } zr, err := zip.NewReader(bytes.NewReader(pkg), int64(len(pkg))) if err != nil { return false, "this build's ZIP reader rejected its own test package: " + err.Error() } text, _, err := extractOfficeZip(zr, time.Time{}) if err != nil { return false, "this build's Office reader failed on its own test document: " + err.Error() } if !strings.Contains(text, officeProbeText) { return false, "this build's Office reader returned no text from its own test document" } return true, "" }