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
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:
@@ -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
|
||||
// `` 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, §ion, 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 := ""
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user