Skip to content

feat(backend): add extract-text node for PDF/DOCX documents - #29

Merged
LukasHirt merged 3 commits into
mainfrom
feat/extract-text-node
Jul 29, 2026
Merged

feat(backend): add extract-text node for PDF/DOCX documents#29
LukasHirt merged 3 commits into
mainfrom
feat/extract-text-node

Conversation

@LukasHirt

Copy link
Copy Markdown
Collaborator

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 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 — 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)

  • New textextract package: DetectFormat (by extension, falling back to content-sniffing magic bytes/zip structure) and Extract, which converts PDF/DOCX to plain text and passes through anything else (including plain text) unchanged — no behavior change for existing .txt/.md workflows.
  • PDF parsing uses github.com/ledongthuc/pdf — pure Go, BSD-3-Clause (a maintained fork of the Go team's own rsc.io/pdf), no cgo, license compatible with this project's existing dependencies. License text and NOTICE.md/REUSE.toml entries added; reuse lint passes.
  • DOCX parsing uses only the standard library (archive/zip + encoding/xml) — a .docx is 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.
  • New executor node kind extractText (runExtractText, parallel to runLLM/runAction): reads the same file.content/file.name vars Run already loads, extracts text, and writes it to vars["file.text"] by default or node.Data["outputVariable"] when set. result.Output gets a short "N characters extracted" summary (per spec, not the full text — unlike llm.output, which can be arbitrarily large here).

Frontend

  • Modeled as a new CanvasNodeKind ('extractText'), not a new actionType. Rationale: it has no "target file" side effect the way tag/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 how llm already works instead of overloading the action-node switch.
  • New AI-category picker entry (file-text icon), a new canvas node component (ExtractTextNode.vue, mirroring LlmNode.vue), and the optional output-variable-override field wired into NodeDetailsPanel.vue.
  • Reuses the existing outputVariable field already declared (but unused) on WorkflowNodeData rather 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

  • Backend: TDD — wrote pkg/textextract tests first (confirmed they failed to compile with no implementation), then implemented DetectFormat/Extract/PDF/DOCX extraction. Tests build minimal-but-valid PDF/DOCX fixtures in-process (real byte-accurate PDF with xref table; real DOCX zip with word/document.xml) rather than checking in binary fixtures.
  • Backend: extended pkg/executor/executor_test.go with runExtractText tests — 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, downstream llm node doesn't run).
  • cd backend && go build ./... && go vet ./... && go test ./... — all green.
  • Frontend: added tests/unit/nodeTypes.spec.ts (Extract Text registered in NODE_TYPES under AI_CATEGORY) and tests/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 (added LICENSES/BSD-3-Clause.txt, NOTICE.md/REUSE.toml entries for ledongthuc/pdf).

🤖 Generated with Claude Code

@LukasHirt
LukasHirt requested a review from a team as a code owner July 24, 2026 16:29
@LukasHirt LukasHirt self-assigned this Jul 24, 2026
@LukasHirt
LukasHirt force-pushed the feat/extract-text-node branch from 1ccbcf9 to 32a191c Compare July 24, 2026 21:00
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>
@LukasHirt
LukasHirt force-pushed the feat/extract-text-node branch from 32a191c to d00777a Compare July 27, 2026 14:23

@dj4oC dj4oC left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/Extract split in textextract.go is 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 buildTestPDF is correct (I traced through the byte-offset bookkeeping).
  • License/attribution hygiene for the new ledongthuc/pdf dependency is thorough and consistent with how the existing apprise-go dependency 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

@LukasHirt
LukasHirt merged commit 733ead9 into main Jul 29, 2026
5 checks passed
@LukasHirt
LukasHirt deleted the feat/extract-text-node branch July 29, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants