feat(backend): add extract-text node for PDF/DOCX documents - #29
Conversation
1ccbcf9 to
32a191c
Compare
Pure-Go, BSD-3-Clause-licensed PDF parser needed by the upcoming Extract Text node's PDF support. DOCX extraction needs no new dependency (archive/zip + encoding/xml, both stdlib, are enough for that format), so this is the only new module required. Signed-off-by: Lukas Hirt <info@hirt.cz>
Executor.Run has always done vars["file.content"] = string(content)
unconditionally, for every file type. That's correct for .txt/.md, but
for a PDF or .docx an LLM Prompt node referencing {{file.content}}
silently got that binary format's raw bytes stringified instead of the
document's actual text.
Add a new "extractText" node kind (pkg/textextract) that detects a
file's format by extension, falling back to content sniffing, and
converts PDF/DOCX into plain text. Plain-text and unrecognized formats
pass through unchanged. Wired into the executor as runExtractText,
writing to vars["file.text"] by default or the node's configured
outputVariable, mirroring the pattern where llm.output is also written
into result.Output.
Image OCR (scanned documents/photos) is explicitly out of scope: it
needs a bundled OCR engine or an external service call, a meaningfully
bigger lift than parsing an already-textual document format. Left as a
natural follow-up.
Signed-off-by: Lukas Hirt <info@hirt.cz>
Modeled as a new CanvasNodeKind ('extractText') rather than a new
actionType: it has no meaningful "target file" side effect the way
tag/move/comment/etc. do, it's a content-transform step like the LLM
Prompt node, and the LLM node itself is already precedent for a
single-purpose top-level kind with no sub-types. This keeps the
picker, canvas node component, and NDV wiring consistent with how llm
already works instead of overloading the action-node switch.
Adds an AI-category picker entry, a canvas node component
(ExtractTextNode.vue, mirroring LlmNode.vue), and reuses the existing
(previously unused) outputVariable field on WorkflowNodeData for the
optional output-variable override, wired into NodeDetailsPanel's NDV.
Signed-off-by: Lukas Hirt <info@hirt.cz>
32a191c to
d00777a
Compare
dj4oC
left a comment
There was a problem hiding this comment.
Review: feat(backend): add extract-text node for PDF/DOCX documents
Stats: +737/-11 across 18 files (new textextract package + executor/frontend wiring)
Overview
Adds an extractText node that converts a PDF/DOCX's vars["file.content"] into plain text under vars["file.text"] (or a custom output variable), fixing a real gap where an LLM node referencing {{file.content}} against a binary document got that format's raw bytes stringified. PDF parsing uses a new third-party dependency (github.com/ledongthuc/pdf, properly licensed/documented via NOTICE.md/REUSE.toml); DOCX parsing is hand-rolled on archive/zip + encoding/xml with no new dependency, which is a reasonable call given DOCX's simplicity (a zip with an XML part) doesn't justify pulling in a library. The Go string↔[]byte round-trip through vars["file.content"] is lossless for binary content — that's not a Go footgun here, contrary to how it might look at a glance.
Potential issues & risks
1. No cap on decompressed size — a small pathological PDF/DOCX can force a large memory allocation (Medium-High)
Both extractPDF (io.ReadAll(textReader)) and extractDOCX (io.ReadAll(rc) on the unzipped word/document.xml stream) read a fully decompressed result into memory with no limit. Both PDF content streams and DOCX's zip entries typically use DEFLATE, whose single-pass maximum compression ratio is on the order of ~1000:1 — so a document that's a few MB on disk could plausibly expand to a few GB in memory. This isn't a purely theoretical "attacking yourself" scenario: this node is a natural fit downstream of a File Event Trigger, which fires on any file uploaded to a watched space by any user with write access there, not just the workflow's owner — so in a shared/collaborative space, another user's upload (malicious or just a pathological file) can force this allocation on the workflows backend. Worth wrapping both reads in an io.LimitReader (mirroring the maxWebhookBodyBytes pattern already used elsewhere in this codebase for exactly this class of concern) and returning a clear "document too large to extract" error past that limit, rather than reading to completion unconditionally.
2. Running on a workflow with no file at all silently produces an empty result instead of a clear error (Medium)
runExtractText doesn't check whether there's any file content to work with — it calls textextract.Extract(vars["file.name"], []byte(vars["file.content"])) unconditionally. If resourcePath was never supplied (e.g. a Schedule Trigger, which — per the established behavior in scheduler.go — always calls Run with an empty resourcePath), both vars are simply absent, DetectFormat("", []byte("")) falls through to FormatPlainText, and Extract returns "", nil. The node "succeeds" with vars["file.text"] = "" and result.Output = "0 characters extracted" — silently, rather than the explicit "...action needs a target file"-style error every file-dependent action (tag/comment/move/etc.) already gives in this same situation.
This compounds with a second, related gap: the currently-open PR #22's hasUpstreamFileSource/canUpstreamProvideFile/isFileDependentActionType machinery (the file-source-reliability warning banner and node-picker hard-disable) only checks props.node.type === 'action'. extractText is a distinct top-level CanvasNodeKind, not an ActionType, so it falls completely outside that safety net — a user putting "Extract Text" downstream of a Schedule Trigger gets neither the picker warning nor the NDV banner that a tag/move action in the same spot would get, and the backend doesn't error either. Given how much care PR #22 put into exactly this kind of warning, this node type is the one gap in that coverage.
3. DOCX extraction drops tabs and in-paragraph line breaks, silently losing structure (Low-Medium)
parseWordprocessingXML only handles <w:t> (text run) and <w:p> (paragraph) boundaries. WordprocessingML also uses <w:tab/> for tab stops and <w:br/> for an explicit line break within a paragraph (Word's Shift+Enter) — both self-closing elements with no text content, so they're silently skipped entirely rather than emitting a space/tab/newline. Two adjacent text runs separated only by a tab or manual line break (common in anything table-like, or multi-line address blocks) would concatenate directly with no separator — e.g. "Item"<w:tab/>"12.00" extracts as "Item12.00". This isn't tested (the fixtures only use single-line paragraphs) and isn't mentioned in the PR's scope notes, unlike OCR, which is explicitly and correctly called out as out of scope. Worth at least documenting as a known limitation, if not fixing (emitting \t for <w:tab/> and \n for <w:br/> would be a small, contained addition to the existing switch).
Minor style note
ExtractTextNode.vue's subtitle — $gettext('Stores result in') + ' ' + (props.data.outputVariable || 'file.text') — concatenates a translated phrase with a raw value via +. That's a minor i18n regression relative to how this codebase handles similar cases elsewhere (e.g. ActionNode.vue's subtitle either shows a raw param value or a single complete $gettext(...) string, never both stitched together) — some languages can't just append a variable after a fixed phrase like this. A parameterized gettext call would be more robust, though this is cosmetic, not functional.
Code quality & style
- The
Format/DetectFormat/Extractsplit intextextract.gois clean, and the extension-first-then-magic-bytes detection strategy is a sensible, well-precedented approach (mirrors how most file-type sniffing works). - Building real, byte-accurate PDF/DOCX fixtures in-process for tests (rather than checking in binary blobs) is a nice touch — keeps the fixture and the reason it's valid visible right next to the test, and the xref-table math in
buildTestPDFis correct (I traced through the byte-offset bookkeeping). - License/attribution hygiene for the new
ledongthuc/pdfdependency is thorough and consistent with how the existingapprise-godependency was already documented.
Test coverage
Good coverage of the paths the PR set out to cover: plain-text passthrough, default vs. custom output variable, real end-to-end PDF extraction, and failure propagation on a corrupt document (confirming downstream nodes don't run). The three gaps above (no-file-at-all, oversized/pathological documents, DOCX whitespace fidelity) aren't covered, which lines up with them not being caught during review either — they're the kind of edge case that's easy to miss without specifically looking for them.
Summary
The core feature is solid and fixes a real, previously-silent gap ({{file.content}} against a binary document). The decompression-size cap (#1) is the one I'd actually want addressed before this runs against arbitrary user-uploaded files in a shared space; the other two are real but lower-stakes gaps worth a follow-up.
🤖 Generated with Claude Code
Summary
Implements the "Extract Text" node (previously just a design proposal in
docs/superpowers/specs/2026-07-24-new-workflow-node-types-design.md) end to end.Bug this fixes:
Executor.Run(backend/pkg/executor/executor.go) has always donevars["file.content"] = string(content)unconditionally for every file type. That's correct for.txt/.md, but for a PDF or.docx, an LLM Prompt node referencing{{file.content}}silently got that binary format's raw bytes stringified instead of the document's actual text — a real, user-facing gap for a perfectly reasonable "summarize this uploaded PDF" workflow.What's in scope: plain-document text extraction (PDF and DOCX). OCR is explicitly out of scope — recovering text from an image (a scanned PDF, a photo of a page) needs either a bundled OCR engine or a call out to an external service, a meaningfully bigger lift than parsing an already-textual document format. That remains a natural, separate follow-up; nothing here attempts it.
Backend (
backend/pkg/textextract,backend/pkg/executor)textextractpackage:DetectFormat(by extension, falling back to content-sniffing magic bytes/zip structure) andExtract, which converts PDF/DOCX to plain text and passes through anything else (including plain text) unchanged — no behavior change for existing.txt/.mdworkflows.github.com/ledongthuc/pdf— pure Go, BSD-3-Clause (a maintained fork of the Go team's ownrsc.io/pdf), no cgo, license compatible with this project's existing dependencies. License text and NOTICE.md/REUSE.toml entries added;reuse lintpasses.archive/zip+encoding/xml) — a.docxis just a zip with an XML part, and plain-text extraction needs nothing beyond unzip-and-walk-the-XML, so no new dependency was needed for that half.extractText(runExtractText, parallel torunLLM/runAction): reads the samefile.content/file.namevarsRunalready loads, extracts text, and writes it tovars["file.text"]by default ornode.Data["outputVariable"]when set.result.Outputgets a short"N characters extracted"summary (per spec, not the full text — unlikellm.output, which can be arbitrarily large here).Frontend
CanvasNodeKind('extractText'), not a newactionType. Rationale: it has no "target file" side effect the waytag/move/comment/etc. do — it's a content-transform step, like the LLM Prompt node. The LLM node is already precedent in this codebase for a single-purpose top-level kind with no sub-types, so this keeps the picker/canvas/NDV wiring consistent with howllmalready works instead of overloading the action-node switch.file-texticon), a new canvas node component (ExtractTextNode.vue, mirroringLlmNode.vue), and the optional output-variable-override field wired intoNodeDetailsPanel.vue.outputVariablefield already declared (but unused) onWorkflowNodeDatarather than adding a new field — same name, same shape, for consistency. (The LLM node's own NDV doesn't read it yet — this PR doesn't touch that, only extractText does.)Test plan
pkg/textextracttests first (confirmed they failed to compile with no implementation), then implementedDetectFormat/Extract/PDF/DOCX extraction. Tests build minimal-but-valid PDF/DOCX fixtures in-process (real byte-accurate PDF with xref table; real DOCX zip withword/document.xml) rather than checking in binary fixtures.pkg/executor/executor_test.gowithrunExtractTexttests — default output var, custom output var, real end-to-end PDF extraction through the executor, plain-text passthrough, and failure propagation on a corrupt document (execution stops, downstreamllmnode doesn't run).cd backend && go build ./... && go vet ./... && go test ./...— all green.tests/unit/nodeTypes.spec.ts(Extract Text registered inNODE_TYPESunderAI_CATEGORY) andtests/unit/NodeDetailsPanel.spec.ts(output-variable field renders and emits correctly), confirmed both fail before implementation, pass after.pnpm test:unit,pnpm check:types,pnpm lint,pnpm build— all green.reuse lint— compliant (addedLICENSES/BSD-3-Clause.txt,NOTICE.md/REUSE.tomlentries forledongthuc/pdf).🤖 Generated with Claude Code