WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,176 @@
// Package anydoc converts office documents to Markdown inside the Go process,
// without the Python docreader service.
//
// The conversion itself is the anydoc Rust library, linked as a static archive
// through cgo (see third_party/anydoc-go). That archive needs a Rust toolchain
// to produce, so it is opt-in: only builds tagged `anydoc` link it. Every other
// build compiles the stub in backend_stub.go, where Available reports false and
// the engine registry hides the engine with a reason the UI can show.
//
// The exported surface is deliberately narrow — bytes in, Markdown and embedded
// images out — so the backend can be replaced (a WASI runtime, a future
// upstream Go module) without touching the parser code that calls it.
package anydoc
import (
"errors"
"fmt"
"path/filepath"
"slices"
"strings"
)
// ImageDir is the markdown path prefix for extracted images. The image
// resolver matches references by this path and swaps them for storage URLs.
const ImageDir = "images/"
// Result is one converted document.
type Result struct {
// Markdown is GitHub-Flavored Markdown for the whole document.
// When WithAssets is set, embedded images appear in place as
// `![alt](images/image-N.ext)` via anydoc's official serializer.
Markdown string
// Assets are the images embedded in the document, in document order.
// Always empty for PDF, which anydoc renders straight to Markdown
// without a document model.
Assets []Asset
// AssetsError explains why Assets is empty when images were asked for.
// The document-model parse is what both extracts images and places them
// in the markdown; when it fails, Markdown still comes from the text-only
// renderer so the conversion succeeds and the caller decides whether to
// log or ignore the loss.
AssetsError error
}
// Asset is one image embedded in a document.
type Asset struct {
// ID is the document-model asset index, as referenced by in-place image
// sources. It is stable for a single conversion.
ID uint64
// Name is a generated, extension-carrying file name ("image-1.png"):
// embedded assets have no name of their own inside the container.
Name string
// MediaType is the IANA media type the container declared.
MediaType string
// Data is the raw image bytes.
Data []byte
// Alt is the image's alternative text as the document author wrote it,
// empty when the document carries none.
Alt string
// Section is the text of the nearest heading above the image, empty when
// the image sits before the first heading.
Section string
}
// Options tunes a single conversion.
type Options struct {
// Format names the parser explicitly ("docx", "csv", ...). Empty means
// the format is detected from the content, which every format except
// CSV carries a signature for.
Format string
// WithAssets extracts embedded images and renders them in place in the
// markdown using anydoc's official GFM serializer (asset images are
// rewritten to `images/image-N.ext` links first). Ignored for PDF.
WithAssets bool
}
// ErrUnavailable is returned by Convert when the binding is not linked into
// this build. Callers that can fall back to another engine should check for it
// with errors.Is.
var ErrUnavailable = errors.New("anydoc: binding not built into this binary")
// supportedExtensions are the file types anydoc converts, mapped to the format
// name its parser selector uses. The list is static so that engine metadata
// (file types shown in the UI) is identical whether or not the binding is
// linked in.
var supportedExtensions = map[string]string{
"doc": "doc",
"docx": "docx",
"docm": "docx",
"odt": "odt",
"rtf": "rtf",
"ppt": "ppt",
"pptx": "pptx",
"pptm": "pptx",
"odp": "odp",
"xls": "xlsx",
"xlsx": "xlsx",
"xlsm": "xlsx",
"ods": "ods",
"epub": "epub",
"csv": "csv",
"pdf": "pdf",
}
// SupportedFileTypes returns the extensions anydoc can convert, sorted so the
// engine list is stable across restarts.
func SupportedFileTypes() []string {
types := make([]string, 0, len(supportedExtensions))
for ext := range supportedExtensions {
types = append(types, ext)
}
slices.Sort(types)
return types
}
// FormatForFile resolves the anydoc format name for a file type or file name.
// ok is false for anything anydoc does not convert.
func FormatForFile(fileType, fileName string) (format string, ok bool) {
ext := normalizeExt(fileType)
if ext == "" {
ext = normalizeExt(filepath.Ext(fileName))
}
format, ok = supportedExtensions[ext]
return format, ok
}
// Supports reports whether anydoc converts this file type, regardless of
// whether the binding is linked into this build.
func Supports(fileType, fileName string) bool {
_, ok := FormatForFile(fileType, fileName)
return ok
}
// Available reports whether conversions can actually run in this binary.
func Available() bool { return backendAvailable() }
// UnavailableReason explains, for the UI, why Available is false. It returns
// "" when the binding is available.
func UnavailableReason() string { return backendUnavailableReason() }
// Version reports the anydoc release the linked binding was built from, or ""
// when no binding is linked.
func Version() string { return backendVersion() }
// Convert turns document bytes into Markdown. It returns ErrUnavailable when
// the binding is not linked into this build.
func Convert(data []byte, opts Options) (*Result, error) {
if len(data) == 0 {
return nil, fmt.Errorf("anydoc: empty document")
}
if !backendAvailable() {
return nil, fmt.Errorf("%w: %s", ErrUnavailable, backendUnavailableReason())
}
// PDF has no document model, so asset extraction would only produce an
// error the caller has to special-case. Drop the request instead.
if opts.Format == "pdf" {
opts.WithAssets = false
}
return backendConvert(data, opts)
}
// PDFNeedsOCR reports whether a Convert error means the PDF has no usable
// text layer. Scanned exam papers fail this way; the caller should fall back
// to an engine that can rasterize pages for OCR.
func PDFNeedsOCR(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "ocr is required") ||
strings.Contains(msg, "no extractable text")
}
func normalizeExt(s string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s), "."))
}
@@ -0,0 +1,84 @@
package anydoc
import (
"errors"
"slices"
"testing"
)
func TestFormatForFileResolvesTypeAndName(t *testing.T) {
cases := []struct {
name string
fileType string
fileName string
wantFormat string
wantOK bool
}{
{name: "file type", fileType: "docx", wantFormat: "docx", wantOK: true},
{name: "dotted file type", fileType: ".DOCX", wantFormat: "docx", wantOK: true},
{name: "macro format maps to base format", fileType: "pptm", wantFormat: "pptx", wantOK: true},
{name: "legacy excel maps to xlsx parser", fileType: "xls", wantFormat: "xlsx", wantOK: true},
{name: "falls back to file name", fileName: "report.odt", wantFormat: "odt", wantOK: true},
{name: "unsupported type", fileType: "png", wantOK: false},
{name: "nothing to go on", wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
format, ok := FormatForFile(tc.fileType, tc.fileName)
if ok != tc.wantOK {
t.Fatalf("FormatForFile(%q, %q) ok = %v, want %v", tc.fileType, tc.fileName, ok, tc.wantOK)
}
if format != tc.wantFormat {
t.Fatalf("FormatForFile(%q, %q) = %q, want %q", tc.fileType, tc.fileName, format, tc.wantFormat)
}
})
}
}
// The engine list must look the same whether or not the converter is linked
// in, so that the UI can show the file types with a "not built in" reason.
func TestSupportedFileTypesIsStableAndSorted(t *testing.T) {
types := SupportedFileTypes()
if !slices.IsSorted(types) {
t.Fatalf("SupportedFileTypes is not sorted: %v", types)
}
for _, want := range []string{"docx", "pptx", "xlsx", "epub", "pdf", "csv"} {
if !slices.Contains(types, want) {
t.Errorf("SupportedFileTypes does not include %q: %v", want, types)
}
}
}
func TestPDFNeedsOCR(t *testing.T) {
if PDFNeedsOCR(nil) {
t.Fatal("nil error was treated as needing OCR")
}
if !PDFNeedsOCR(errors.New("PDF has no extractable text (Scanned, 5 pages): OCR is required")) {
t.Error("scanned-PDF error was not detected")
}
if PDFNeedsOCR(errors.New("not a readable zip archive")) {
t.Error("unrelated error was treated as needing OCR")
}
}
func TestConvertRejectsEmptyInput(t *testing.T) {
if _, err := Convert(nil, Options{Format: "docx"}); err == nil {
t.Fatal("Convert(nil) succeeded, want an error")
}
}
// Without the build tag there is no converter, and callers must be able to
// detect that so they can fall back to another engine.
func TestConvertReportsUnavailableBackend(t *testing.T) {
if Available() {
t.Skip("converter is linked into this build")
}
_, err := Convert([]byte("id,name\n1,a\n"), Options{Format: "csv"})
if !errors.Is(err, ErrUnavailable) {
t.Fatalf("Convert error = %v, want ErrUnavailable", err)
}
if UnavailableReason() == "" {
t.Error("UnavailableReason is empty while the converter is unavailable")
}
}
@@ -0,0 +1,207 @@
//go:build anydoc && cgo
package anydoc
import (
"fmt"
"mime"
"strings"
upstream "github.com/firecrawl/anydoc/go"
)
// This build links the anydoc Rust archive through the upstream cgo bindings
// (vendored under third_party/anydoc-go until they are published as a module).
// Conversion runs in-process: no subprocess, no HTTP hop, no Python.
func backendAvailable() bool { return true }
func backendUnavailableReason() string { return "" }
func backendVersion() string { return upstream.Version }
func backendConvert(data []byte, opts Options) (*Result, error) {
format, err := upstreamFormat(opts.Format)
if err != nil {
return nil, err
}
if !opts.WithAssets {
markdown, err := upstream.ToMarkdownBytes(data, format)
if err != nil {
return nil, fmt.Errorf("anydoc: markdown conversion failed: %w", err)
}
return &Result{Markdown: markdown}, nil
}
// Official GFM serializer after rewriting Asset images to External URLs
// (`images/image-N.ext`). ToDocument still supplies the image bytes.
document, err := upstream.ToDocument(data, format)
if err != nil {
markdown, mdErr := upstream.ToMarkdownBytes(data, format)
if mdErr != nil {
return nil, fmt.Errorf("anydoc: markdown conversion failed: %w", mdErr)
}
return &Result{
Markdown: markdown,
AssetsError: fmt.Errorf("anydoc: image extraction failed: %w", err),
}, nil
}
markdown, err := upstream.ToMarkdownWithAssetLinks(data, format)
if err != nil {
return nil, fmt.Errorf("anydoc: markdown conversion failed: %w", err)
}
return &Result{Markdown: markdown, Assets: collectAssets(document)}, nil
}
// upstreamFormat maps our format name onto the binding's constant. An empty
// name means "detect from content".
func upstreamFormat(name string) (*upstream.Format, error) {
if name == "" {
return nil, nil
}
format, ok := upstream.FormatFromExtension(name)
if !ok {
return nil, fmt.Errorf("anydoc: unsupported format %q", name)
}
return &format, nil
}
// collectAssets names each embedded image after its position in the document,
// because containers store assets without a usable file name of their own, and
// attaches the alt text and section the image appeared under.
func collectAssets(document *upstream.Document) []Asset {
if document == nil || len(document.Assets) == 0 {
return nil
}
placements := placeAssets(document.Blocks)
assets := make([]Asset, 0, len(document.Assets))
for i, asset := range document.Assets {
if len(asset.Data) == 0 {
continue
}
placement := placements[uint64(i)]
assets = append(assets, Asset{
ID: uint64(i),
Name: fmt.Sprintf("image-%d%s", i+1, extensionFor(asset.MediaType)),
MediaType: asset.MediaType,
Data: asset.Data,
Alt: placement.alt,
Section: placement.section,
})
}
return assets
}
// placement is where in the document an asset was referenced from.
type placement struct {
alt string
section string
}
// placeAssets walks the document body and records, per asset, the alt text of
// the image that referenced it and the heading it sat under. The walk
// recurses into lists, tables, and quotes, since an image can be nested
// anywhere a paragraph can.
func placeAssets(blocks []upstream.Block) map[uint64]placement {
placements := make(map[uint64]placement)
section := ""
walkBlocks(blocks, &section, placements)
return placements
}
func walkBlocks(blocks []upstream.Block, section *string, placements map[uint64]placement) {
for _, block := range blocks {
switch block.Kind {
case "heading":
*section = inlineText(block.Content)
walkInlines(block.Content, *section, placements)
case "paragraph":
walkInlines(block.Content, *section, placements)
case "block_quote":
walkBlocks(block.Blocks, section, placements)
case "list":
if block.List != nil {
for _, item := range block.List.Items {
walkBlocks(item.Blocks, section, placements)
}
}
case "table":
if block.Table != nil {
for _, row := range block.Table.Grid {
for _, slot := range row {
if slot.Cell != nil {
walkBlocks(slot.Cell.Blocks, section, placements)
}
}
}
}
}
}
}
func walkInlines(inlines []upstream.Inline, section string, placements map[uint64]placement) {
for _, inline := range inlines {
switch inline.Kind {
case "image":
if inline.Source == nil || inline.Source.Kind != "asset" || inline.Source.AssetID == nil {
continue
}
alt := ""
if inline.Alt != nil {
alt = strings.TrimSpace(*inline.Alt)
}
// First reference wins: the same asset can be placed more than
// once, and the earliest placement is the one a reader would
// associate it with.
if _, seen := placements[*inline.Source.AssetID]; !seen {
placements[*inline.Source.AssetID] = placement{alt: alt, section: section}
}
case "link":
walkInlines(inline.Content, section, placements)
}
}
}
// inlineText flattens inline content to plain text, which is all a heading is
// needed for here.
func inlineText(inlines []upstream.Inline) string {
var text strings.Builder
for _, inline := range inlines {
switch inline.Kind {
case "text":
if inline.Text != nil {
text.WriteString(*inline.Text)
}
case "link":
text.WriteString(inlineText(inline.Content))
}
}
return strings.TrimSpace(text.String())
}
func extensionFor(mediaType string) string {
switch strings.ToLower(strings.TrimSpace(mediaType)) {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "image/bmp":
return ".bmp"
case "image/tiff":
return ".tiff"
case "image/svg+xml":
return ".svg"
}
// Anything else (EMF/WMF drawings, unknown object payloads) keeps
// whatever extension the media type registry knows, and .bin otherwise,
// so downstream storage never writes an extension-less blob.
if exts, err := mime.ExtensionsByType(mediaType); err == nil && len(exts) > 0 {
return exts[0]
}
return ".bin"
}
@@ -0,0 +1,24 @@
//go:build !anydoc || !cgo
package anydoc
// The default build links no converter: the anydoc archive is a Rust build
// artifact, and requiring a Rust toolchain to compile WeKnora would be a steep
// price for one optional engine. Everything still compiles and the engine
// registry simply reports the engine as unavailable.
const unavailableReason = "anydoc engine not built into this binary " +
"(rebuild with `make build-anydoc` / `-tags anydoc`; " +
"Docker images need `--build-arg WITH_ANYDOC=1`)"
func backendAvailable() bool { return false }
func backendUnavailableReason() string { return unavailableReason }
func backendVersion() string { return "" }
func backendConvert(_ []byte, _ Options) (*Result, error) {
// Convert checks Available first, so this is only reachable if a caller
// bypasses it.
return nil, ErrUnavailable
}
@@ -0,0 +1,379 @@
//go:build anydoc && cgo
package anydoc
import (
"archive/zip"
"bytes"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// These tests exercise the linked Rust converter, so they only build with the
// `anydoc` tag. Run them with:
//
// scripts/build-anydoc-lib.sh && go test -tags anydoc ./internal/infrastructure/docparser/...
// SupportedFileTypes is a static list, so that the engine can advertise its
// file types in builds that link no converter. This is the check that it still
// agrees with what the converter actually accepts, in either direction.
func TestSupportedFileTypesMatchTheConverter(t *testing.T) {
for _, fileType := range SupportedFileTypes() {
format, ok := FormatForFile(fileType, "")
if !ok {
t.Errorf("%q is advertised but maps to no format", fileType)
continue
}
if _, err := upstreamFormat(format); err != nil {
t.Errorf("%q maps to format %q, which the converter rejects: %v", fileType, format, err)
}
}
}
func TestConvertCSV(t *testing.T) {
result, err := Convert([]byte("quarter,widgets\nQ1,12\nQ2,15\n"), Options{Format: "csv"})
if err != nil {
t.Fatalf("Convert: %v", err)
}
if !strings.Contains(result.Markdown, "| quarter | widgets |") {
t.Fatalf("expected a markdown table header, got:\n%s", result.Markdown)
}
if !strings.Contains(result.Markdown, "| Q1 | 12 |") {
t.Fatalf("expected the first data row, got:\n%s", result.Markdown)
}
}
func TestConvertDocxWithEmbeddedImage(t *testing.T) {
document := buildDocx(t)
result, err := Convert(document, Options{Format: "docx", WithAssets: true})
if err != nil {
t.Fatalf("Convert: %v", err)
}
if !strings.Contains(result.Markdown, "# Quarterly report") {
t.Fatalf("expected the heading, got:\n%s", result.Markdown)
}
if !strings.Contains(result.Markdown, "Widgets shipped on time.") {
t.Fatalf("expected the body paragraph, got:\n%s", result.Markdown)
}
if !strings.Contains(result.Markdown, "Closing remarks.") {
t.Fatalf("expected the trailing paragraph, got:\n%s", result.Markdown)
}
if len(result.Assets) != 1 {
t.Fatalf("got %d assets, want 1", len(result.Assets))
}
asset := result.Assets[0]
if asset.Name != "image-1.png" {
t.Errorf("asset name = %q, want image-1.png", asset.Name)
}
if !bytes.Equal(asset.Data, onePixelPNG()) {
t.Errorf("asset data does not round-trip the embedded image")
}
if asset.Alt != "Shipping chart" {
t.Errorf("asset alt = %q, want %q", asset.Alt, "Shipping chart")
}
if asset.Section != "Quarterly report" {
t.Errorf("asset section = %q, want %q", asset.Section, "Quarterly report")
}
imageLink := "![Shipping chart](images/image-1.png)"
if !strings.Contains(result.Markdown, imageLink) {
t.Fatalf("expected the image in place, got:\n%s", result.Markdown)
}
before := strings.Index(result.Markdown, "Widgets shipped on time.")
at := strings.Index(result.Markdown, imageLink)
after := strings.Index(result.Markdown, "Closing remarks.")
if before < 0 || at < 0 || after < 0 || !(before < at && at < after) {
t.Fatalf("image is not between the surrounding paragraphs:\n%s", result.Markdown)
}
}
func TestConvertTextlessPDFNeedsOCR(t *testing.T) {
_, err := Convert(textlessPDF(), Options{Format: "pdf"})
if err == nil {
t.Fatal("Convert succeeded on a PDF with no text, want an error")
}
if !PDFNeedsOCR(err) {
t.Fatalf("Convert error = %v, want an OCR-required error", err)
}
}
// The ABI reports error messages through a thread-local slot that a second
// call reads, so a goroutine resumed on another OS thread would report an
// empty or someone else's message. The binding pins the goroutine for the
// pair; this is the regression test for that.
func TestErrorDetailSurvivesConcurrency(t *testing.T) {
const conversions = 4000
var wg sync.WaitGroup
var empty, crossed atomic.Int64
for i := 0; i < conversions; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// docx and xlsx failures carry distinctive messages, so a detail
// naming the other format proves it came from the wrong thread.
format, want, other := "docx", "not a readable zip archive", "unreadable workbook"
if i%2 == 1 {
format, want, other = "xlsx", "unreadable workbook", "not a readable zip archive"
}
_, err := Convert([]byte(fmt.Sprintf("garbage %d", i)), Options{Format: format})
if err == nil {
t.Errorf("Convert(garbage) succeeded")
return
}
detail := err.Error()
switch {
case strings.Contains(detail, other):
crossed.Add(1)
case !strings.Contains(detail, want):
empty.Add(1)
}
}(i)
}
wg.Wait()
if empty.Load() > 0 || crossed.Load() > 0 {
t.Fatalf("error details lost or crossed between goroutines: lost=%d crossed=%d (of %d)",
empty.Load(), crossed.Load(), conversions)
}
}
// Detection reads the container itself, so a document whose format is not
// named still converts.
func TestConvertDetectsFormatFromContent(t *testing.T) {
result, err := Convert(buildDocx(t), Options{})
if err != nil {
t.Fatalf("Convert: %v", err)
}
if !strings.Contains(result.Markdown, "# Quarterly report") {
t.Fatalf("expected the heading, got:\n%s", result.Markdown)
}
}
func TestConvertRejectsGarbage(t *testing.T) {
if _, err := Convert([]byte("not a document at all"), Options{Format: "docx"}); err == nil {
t.Fatal("Convert succeeded on garbage input, want an error")
}
}
// PDF has no document model, so WithAssets has to be dropped rather than turn
// a working conversion into an error.
func TestConvertPDFIgnoresAssetRequest(t *testing.T) {
result, err := Convert(minimalPDF(), Options{Format: "pdf", WithAssets: true})
if err != nil {
t.Fatalf("Convert: %v", err)
}
if !strings.Contains(result.Markdown, "Shipping summary") {
t.Fatalf("expected the page text, got:\n%s", result.Markdown)
}
if len(result.Assets) != 0 {
t.Errorf("got %d assets for a PDF, want 0", len(result.Assets))
}
}
// A PDF whose catalog holds a deeply nested array is the shape of
// RUSTSEC-2026-0187: lopdf 0.41 recursed per nesting level and killed the
// process with an uncatchable stack overflow, which no Go-side or Rust-side
// recover can contain. The pinned dependency in third_party/anydoc-go bounds
// the recursion, so this must come back as an ordinary error — and if a
// dependency bump ever reintroduces it, this test crashes rather than passing
// quietly.
func TestDeeplyNestedPDFFailsWithoutKillingTheProcess(t *testing.T) {
const depth = 50000
nested := strings.Repeat("[", depth) + strings.Repeat("]", depth)
_, err := Convert(nestedPDF(nested), Options{Format: "pdf"})
if err == nil {
t.Fatal("Convert succeeded on a PDF with no text, want an error")
}
}
// A show-text operator with no operand made the page-classification scan walk
// back over the whole content stream looking for one, so a stream of bare
// `] TJ` tokens cost time quadratic in its length: before pdf-inspector 1.14.2
// this input took 26 seconds of CPU, and a 2 MB one took nearly two minutes.
// Unbounded work is the half of the hostile-PDF problem that guarded() cannot
// catch — it never panics, it just holds the core — so the bound is pinned
// here. The budget is deliberately far above the ~7ms a bounded lookback needs:
// what it has to distinguish is linear from quadratic, not fast from slow.
func TestDetectorLookbackStaysLinear(t *testing.T) {
const (
operators = 200000
budget = 15 * time.Second
)
done := make(chan struct{})
go func() {
defer close(done)
// The result is irrelevant: the page carries one real text run, so
// this converts either way. Only how long it takes is under test.
_, _ = Convert(unmatchedShowTextPDF(operators), Options{Format: "pdf"})
}()
select {
case <-done:
case <-time.After(budget):
t.Fatalf("converting a PDF with %d unmatched TJ operators took over %s; "+
"the detector's operand lookback is no longer bounded", operators, budget)
}
}
// buildDocx writes the smallest OOXML package that carries a heading, a
// paragraph, and one embedded image.
func buildDocx(t *testing.T) []byte {
t.Helper()
parts := map[string]string{
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
"word/styles.xml": `<?xml version="1.0" encoding="UTF-8"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:pPr><w:outlineLvl w:val="0"/></w:pPr>
</w:style>
</w:styles>`,
"word/_rels/document.xml.rels": `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
</Relationships>`,
"word/document.xml": `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Quarterly report</w:t></w:r></w:p>
<w:p><w:r><w:t>Widgets shipped on time.</w:t></w:r></w:p>
<w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="Chart" descr="Shipping chart"/>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart"/><pic:cNvPicPr/></pic:nvPicPr>
<pic:blipFill><a:blip r:embed="rId10"/></pic:blipFill>
<pic:spPr/></pic:pic>
</a:graphicData></a:graphic>
</wp:inline></w:drawing></w:r></w:p>
<w:p><w:r><w:t>Closing remarks.</w:t></w:r></w:p>
</w:body>
</w:document>`,
}
var buf bytes.Buffer
archive := zip.NewWriter(&buf)
for name, content := range parts {
writer, err := archive.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := writer.Write([]byte(content)); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
image, err := archive.Create("word/media/image1.png")
if err != nil {
t.Fatalf("create image part: %v", err)
}
if _, err := image.Write(onePixelPNG()); err != nil {
t.Fatalf("write image part: %v", err)
}
if err := archive.Close(); err != nil {
t.Fatalf("close archive: %v", err)
}
return buf.Bytes()
}
// onePixelPNG is a 1x1 transparent PNG: the smallest thing a container will
// accept as an image part.
func onePixelPNG() []byte {
return []byte{
0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R',
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
0x00, 0x00, 0x00, 0x0a, 'I', 'D', 'A', 'T',
0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01,
0x0d, 0x0a, 0x2d, 0xb4,
0x00, 0x00, 0x00, 0x00, 'I', 'E', 'N', 'D', 0xae, 0x42, 0x60, 0x82,
}
}
// minimalPDF is a one-page PDF with a single text run, written by hand so the
// test carries no binary fixture.
func minimalPDF() []byte {
content := "BT /F1 12 Tf 20 100 Td (Shipping summary) Tj ET\n"
return writePDF([]string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R " +
"/Resources << /Font << /F1 5 0 R >> >> >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
})
}
// nestedPDF is a one-page PDF carrying the given nested value in its catalog.
func nestedPDF(nested string) []byte {
return writePDF([]string{
"<< /Type /Catalog /Pages 2 0 R /Nested " + nested + " >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
})
}
// unmatchedShowTextPDF is a one-page PDF whose content stream shows text once
// and then repeats a TJ operator whose array operand is never opened.
func unmatchedShowTextPDF(operators int) []byte {
content := "BT /F1 12 Tf 20 100 Td (Shipping summary) Tj ET\n" +
strings.Repeat("] TJ\n", operators)
return writePDF([]string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R " +
"/Resources << /Font << /F1 5 0 R >> >> >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
})
}
// textlessPDF is a one-page PDF with no text stream: the shape of a scanned
// exam paper as far as pdf-inspector is concerned.
func textlessPDF() []byte {
return writePDF([]string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
})
}
func writePDF(objects []string) []byte {
var pdf bytes.Buffer
offsets := make([]int, len(objects))
pdf.WriteString("%PDF-1.4\n")
for i, object := range objects {
offsets[i] = pdf.Len()
fmt.Fprintf(&pdf, "%d 0 obj\n%s\nendobj\n", i+1, object)
}
xref := pdf.Len()
fmt.Fprintf(&pdf, "xref\n0 %d\n0000000000 65535 f \n", len(objects)+1)
for _, offset := range offsets {
fmt.Fprintf(&pdf, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&pdf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xref)
return pdf.Bytes()
}