Files
privatae-bastion/client-go/contextoffice.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

1692 lines
62 KiB
Go

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
// <dir>/_rels/<name>.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 <Relationship> 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 <w:t>
// runs inside <w:p> 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 <mc:AlternateContent>.
//
// 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 <w:comment>: 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 <p:sldIdLst> 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 <a:t> inside <a:p> 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: <p:sldId id="258" r:id="rId9"/> 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 <p:sld> 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 <sheet state="…">: "", "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 <c> 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 `<row r="3"><c r="A3"/><c
// r="D3"/></row>` 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 <cellXfs>, one <xf numFmtId=…> 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 <t>
// in one <si> 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 <mc:AlternateContent> — 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 ───────────────────────────────
//
// <mc:AlternateContent> is how both Word and PowerPoint write anything with more
// than one representation, and a TEXT BOX is the common case: an
// <mc:Choice Requires="wps"> holding the DrawingML shape and an <mc:Fallback>
// 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", `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`},
{"word/document.xml", `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>` + officeProbeText + `</w:t></w:r></w:p></w:body></w:document>`},
}
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, ""
}