From bfe4ba1eff3aaf8b40ca925f764a12006dc1ed79 Mon Sep 17 00:00:00 2001 From: Junian Triajianto Date: Fri, 7 Aug 2026 18:14:41 +0700 Subject: [PATCH 1/4] feat: Implement C# bindings for Anydoc using csbindgen --- Cargo.lock | 45 ++ Cargo.toml | 2 +- README.md | 2 +- dotnet/.gitignore | 484 ++++++++++++++++++ dotnet/Firecrawl.Anydoc.Tests/AnydocTests.cs | 149 ++++++ .../Firecrawl.Anydoc.Tests.csproj | 31 ++ dotnet/Firecrawl.Anydoc.slnx | 8 + dotnet/Firecrawl.Anydoc/AnydocConverter.cs | 267 ++++++++++ .../Firecrawl.Anydoc/Firecrawl.Anydoc.csproj | 46 ++ dotnet/Firecrawl.Anydoc/Model.cs | 197 +++++++ dotnet/Firecrawl.Anydoc/NativeMethods.cs | 60 +++ dotnet/Firecrawl.Anydoc/NativeMethods.g.cs | 102 ++++ dotnet/README.md | 97 ++++ dotnet/build.sh | 79 +++ dotnet/native/Cargo.toml | 27 + dotnet/native/build.rs | 18 + dotnet/native/src/lib.rs | 229 +++++++++ dotnet/native/src/serialize.rs | 236 +++++++++ 18 files changed, 2077 insertions(+), 2 deletions(-) create mode 100644 dotnet/.gitignore create mode 100644 dotnet/Firecrawl.Anydoc.Tests/AnydocTests.cs create mode 100644 dotnet/Firecrawl.Anydoc.Tests/Firecrawl.Anydoc.Tests.csproj create mode 100644 dotnet/Firecrawl.Anydoc.slnx create mode 100644 dotnet/Firecrawl.Anydoc/AnydocConverter.cs create mode 100644 dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj create mode 100644 dotnet/Firecrawl.Anydoc/Model.cs create mode 100644 dotnet/Firecrawl.Anydoc/NativeMethods.cs create mode 100644 dotnet/Firecrawl.Anydoc/NativeMethods.g.cs create mode 100644 dotnet/README.md create mode 100755 dotnet/build.sh create mode 100644 dotnet/native/Cargo.toml create mode 100644 dotnet/native/build.rs create mode 100644 dotnet/native/src/lib.rs create mode 100644 dotnet/native/src/serialize.rs diff --git a/Cargo.lock b/Cargo.lock index 4f390e21..0ad2afc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,16 @@ dependencies = [ "zip", ] +[[package]] +name = "anydoc-dotnet" +version = "0.1.7" +dependencies = [ + "anydoc", + "base64", + "csbindgen", + "serde_json", +] + [[package]] name = "anydoc-node" version = "0.1.0" @@ -150,6 +160,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -406,6 +422,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csbindgen" +version = "1.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950f59b281d7e20f050b4efd56d7c36c0deb853bf9ea1f20b985a75ae5b03b34" +dependencies = [ + "regex", + "syn 2.0.119", +] + [[package]] name = "csv" version = "1.4.0" @@ -1365,6 +1391,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1750,6 +1789,12 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 871b9bdf..8a90eb54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["node", "python", "wasm"] +members = ["node", "python", "wasm", "dotnet/native"] # cargo-fuzz keeps its own lockfile and nightly-only dependencies. exclude = ["fuzz"] diff --git a/README.md b/README.md index ed4cb9e4..9d590530 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![skills.sh](https://skills.sh/b/firecrawl/anydoc)](https://skills.sh/firecrawl/anydoc) -Fast Rust library that converts documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF) into clean GitHub-Flavored Markdown. Includes bindings for [Node.js](node/README.md), [Python](python/README.md), and the [browser](wasm/README.md) (WebAssembly). +Fast Rust library that converts documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF) into clean GitHub-Flavored Markdown. Includes bindings for [Node.js](node/README.md), [Python](python/README.md), the [browser](wasm/README.md) (WebAssembly), and [.NET](dotnet/README.md) (NuGet `Firecrawl.Anydoc`). Built by [Firecrawl](https://firecrawl.dev) to turn any office document into LLM-ready Markdown in single-digit milliseconds, with one consistent output no matter which format goes in. It powers [Firecrawl Parse](https://firecrawl.dev/parse), so if you'd rather not run it yourself, the hosted API gives you the same conversion plus our OCR models for the scanned pages anydoc can't read on its own. diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 00000000..e548e8b2 --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp + +runtimes/ diff --git a/dotnet/Firecrawl.Anydoc.Tests/AnydocTests.cs b/dotnet/Firecrawl.Anydoc.Tests/AnydocTests.cs new file mode 100644 index 00000000..ce00edcd --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Tests/AnydocTests.cs @@ -0,0 +1,149 @@ +using System.Text; +using Firecrawl.Anydoc.Model; +using Xunit; + +namespace Firecrawl.Anydoc.Tests; + +/// Smoke test: the bindings load and every entry point round-trips a +/// fixture, mirroring the Node and Python test suites. +public class AnydocTests +{ + private readonly AnydocConverter converter = new(); + + private static string Fixture(params string[] path) => + Path.Combine(new[] { AppContext.BaseDirectory, "fixtures" }.Concat(path).ToArray()); + + private static readonly string Outline = Fixture("docx", "handmade-outline.docx"); + private static readonly string Rich = Fixture("docx", "handmade-rich.docx"); + private static readonly string Csv = Fixture("csv", "sheet.csv"); + private static readonly string Encrypted = Fixture("malformed", "encrypted--errors.odt"); + private static readonly string Zipbomb = Fixture("abuse", "zipbomb--errors.docx"); + + [Fact] + public void ToMarkdown_detects_the_format_from_the_file_content() + { + string markdown = converter.ToMarkdown(Outline); + Assert.Matches("(?m)^# ", markdown); + } + + [Fact] + public void ToMarkdownBytes_converts_in_memory_and_round_trips_unicode() + { + string markdown = converter.ToMarkdownBytes(File.ReadAllBytes(Rich), Format.Docx); + Assert.Contains("| Quarter | Widgets |", markdown); + } + + [Fact] + public void ToMarkdownBytes_detects_the_format_when_none_is_named() + { + string markdown = converter.ToMarkdownBytes(File.ReadAllBytes(Rich)); + Assert.Contains("| Quarter | Widgets |", markdown); + // CSV carries no signature, so it has to be named. + var csvBytes = File.ReadAllBytes(Csv); + var exception = Assert.Throws(() => converter.ToMarkdownBytes(csvBytes)); + Assert.Equal(ConvertErrorKind.Unsupported, exception.Kind); + Assert.Matches("unrecognized file content", exception.Message); + Assert.Contains("| --- |", converter.ToMarkdownBytes(csvBytes, Format.Csv)); + } + + [Fact] + public void ToDocument_exposes_the_document_model() + { + Document document = converter.ToDocument(File.ReadAllBytes(Outline)); + Block heading = document.Blocks.First(b => b.Kind == "heading"); + Assert.InRange(heading.Level!.Value, 1, 6); + Assert.IsType(heading.Content![0].Text); + Assert.Equal("text", heading.Content[0].Kind); + Assert.IsType(heading.Content[0].Style!.Bold); + } + + [Fact] + public void ToDocument_carries_embedded_assets_as_bytes() + { + Document document = converter.ToDocument(File.ReadAllBytes(Rich)); + Asset image = document.Assets.Single(a => a.MediaType == "image/png"); + Assert.NotEmpty(image.Data); + Assert.Equal(image.Id, document.Assets.IndexOf(image)); + } + + [Theory] + [InlineData(".pptm", Format.Pptx)] + [InlineData("xls", Format.Excel)] + public void DetectFormatByExtension_maps_container_variants(string extension, Format expected) + { + Assert.Equal(expected, converter.DetectFormatByExtension(extension)); + } + + [Fact] + public void DetectFormat_reads_content_extension_and_path() + { + Assert.Equal(Format.Docx, converter.DetectFormat(File.ReadAllBytes(Rich))); + // CSV carries no signature: only the extension names it. + Assert.Null(converter.DetectFormat(File.ReadAllBytes(Csv))); + Assert.Equal(Format.Odt, converter.DetectFormatByPath("report.odt")); + Assert.Null(converter.DetectFormatByPath("report.unknown")); + } + + [Fact] + public void Conversion_errors_throw_the_kind_that_names_the_failure() + { + // Nothing about these bytes is a package part (Malformed). + var malformed = Assert.Throws(() => + converter.ToMarkdownBytes(Encoding.UTF8.GetBytes("not a document"), Format.Docx)); + Assert.Equal(ConvertErrorKind.Malformed, malformed.Kind); + + var unsupported = Assert.Throws(() => + converter.ToMarkdownBytes(File.ReadAllBytes(Csv))); + Assert.Equal(ConvertErrorKind.Unsupported, unsupported.Kind); + + var encrypted = Assert.Throws(() => + converter.ToMarkdownBytes(File.ReadAllBytes(Encrypted), Format.Odt)); + Assert.Equal(ConvertErrorKind.Encrypted, encrypted.Kind); + + var limit = Assert.Throws(() => + converter.ToMarkdownBytes(File.ReadAllBytes(Zipbomb), Format.Docx)); + Assert.Equal(ConvertErrorKind.ResourceLimit, limit.Kind); + // The message carries the limit name, matching the Rust detail. + Assert.Contains("max_entry_bytes", limit.Message); + + // A readable package carrying none of the parts a docx is made of. + Assert.Equal(ConvertErrorKind.MissingPart, + Assert.Throws(() => ToMarkdownOfEmptyDocx()).Kind); + } + + [Fact] + public async Task Async_variants_match_the_sync_results() + { + byte[] outline = File.ReadAllBytes(Outline); + byte[] rich = File.ReadAllBytes(Rich); + + Assert.Equal(converter.ToMarkdown(Outline), await converter.ToMarkdownAsync(Outline)); + Assert.Equal(converter.ToMarkdownBytes(outline), await converter.ToMarkdownBytesAsync(outline)); + Assert.Equal( + converter.ToMarkdownBytes(rich, Format.Docx), + await converter.ToMarkdownBytesAsync(rich, Format.Docx)); + + Document document = await converter.ToDocumentAsync(outline); + Assert.Contains(document.Blocks, b => b.Kind == "heading"); + } + + [Fact] + public void An_unreadable_file_raises_the_io_kind() + { + var exception = Assert.Throws(() => converter.ToMarkdown("no-such-file.docx")); + Assert.Equal(ConvertErrorKind.Io, exception.Kind); + } + + /// A ZIP package without the parts a docx needs -> missingPart. + private static string ToMarkdownOfEmptyDocx() + { + using var package = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(package, System.IO.Compression.ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("[Content_Types].xml"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(""); + } + return new AnydocConverter().ToMarkdownBytes(package.ToArray(), Format.Docx); + } +} \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.Tests/Firecrawl.Anydoc.Tests.csproj b/dotnet/Firecrawl.Anydoc.Tests/Firecrawl.Anydoc.Tests.csproj new file mode 100644 index 00000000..737de469 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Tests/Firecrawl.Anydoc.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + latest + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.slnx b/dotnet/Firecrawl.Anydoc.slnx new file mode 100644 index 00000000..20d5ca9c --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dotnet/Firecrawl.Anydoc/AnydocConverter.cs b/dotnet/Firecrawl.Anydoc/AnydocConverter.cs new file mode 100644 index 00000000..dacad465 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/AnydocConverter.cs @@ -0,0 +1,267 @@ +using System.Runtime.InteropServices; +using System.Text; +using Firecrawl.Anydoc.Native; +using Firecrawl.Anydoc.Model; + +namespace Firecrawl.Anydoc; + +/// Input format, named after the extension that identifies it. +/// Container variants that share a parser (`.docm`, `.xlsm`, `.ppsx`, ...) map +/// onto these via or +/// . +public enum Format +{ + Doc = 0, + Docx = 1, + Odt = 2, + /// Converted with pdf-inspector, which emits Markdown directly; + /// is unsupported for PDFs. Scanned or + /// image-only PDFs (needing OCR) error as unsupported. + Pdf = 3, + Ppt = 4, + Pptx = 5, + Rtf = 6, + Epub = 7, + Excel = 8, + Ods = 9, + Odp = 10, + Csv = 11, +} + +/// The kind of a failed conversion, matching the stable +/// error.code() strings the Rust engine and the other bindings +/// publish. +public enum ConvertErrorKind +{ + /// The format is unknown, or cannot be converted at all: a scanned + /// or image-only PDF needs OCR, which anydoc does not do. + Unsupported, + /// The document is structurally unusable; no meaningful content + /// could be extracted. + Malformed, + /// The document is encrypted or password-protected. + Encrypted, + /// A fixed safety limit was crossed (decompression, nesting depth, + /// node count, repeat expansion, or retained asset bytes). + ResourceLimit, + /// A part required for any meaningful output is absent. + MissingPart, + /// The input could not be read. + Io, + /// An error kind this binding version does not know yet. + Unknown, +} + +/// Thrown when meaningful conversion is impossible. +/// names the failure the same way callers of the Node and Python bindings +/// branch on. +public sealed class AnydocException : Exception +{ + public AnydocException(ConvertErrorKind kind, string? message) : base(message) + { + Kind = kind; + Code = kind switch + { + ConvertErrorKind.Unsupported => "unsupported", + ConvertErrorKind.Malformed => "malformed", + ConvertErrorKind.Encrypted => "encrypted", + ConvertErrorKind.ResourceLimit => "resourceLimit", + ConvertErrorKind.MissingPart => "missingPart", + ConvertErrorKind.Io => "io", + _ => "unknown", + }; + } + + /// The kind of failure. + public ConvertErrorKind Kind { get; } + + /// Stable, machine-readable name for the kind: what callers branch + /// on, identical to the `code` the Node bindings put on their errors. + public string Code { get; } + + internal static AnydocException From(string? code, string? message) + { + ConvertErrorKind kind = code switch + { + "unsupported" => ConvertErrorKind.Unsupported, + "malformed" => ConvertErrorKind.Malformed, + "encrypted" => ConvertErrorKind.Encrypted, + "resourceLimit" => ConvertErrorKind.ResourceLimit, + "missingPart" => ConvertErrorKind.MissingPart, + "io" => ConvertErrorKind.Io, + _ => ConvertErrorKind.Unknown, + }; + return new AnydocException(kind, message); + } +} + +/// Converts documents to GitHub-Flavored Markdown, backed by the anydoc +/// Rust engine loaded as a native library. Safe to share across threads and +/// reuse: the underlying native calls are stateless, so instances hold no +/// process-lifetime resources and do not need to be disposed. +public sealed class AnydocConverter +{ + private const int NoFormat = -1; + + /// Detect the format from the content itself: the signature and + /// identity each container specification designates (PDF header, RTF open + /// group, OLE stream names, ZIP package mimetype/content types). Plain-text + /// formats (CSV) carry no signature and return ; so + /// does anything unrecognized. + public unsafe Format? DetectFormat(ReadOnlySpan bytes) + { + unsafe + { + fixed (byte* p = bytes) + { + return FormatFromCode(AnydocNative.anydoc_format_from_bytes(p, (nuint)bytes.Length)); + } + } + } + + /// The format an extension names, with or without a leading + /// dot. + public unsafe Format? DetectFormatByExtension(string extension) + { + byte[] text = Encoding.UTF8.GetBytes(extension); + unsafe + { + fixed (byte* p = text) + { + return FormatFromCode(AnydocNative.anydoc_format_from_extension(p, (nuint)text.Length)); + } + } + } + + /// The format a path's extension names. + public unsafe Format? DetectFormatByPath(string path) + { + byte[] text = Encoding.UTF8.GetBytes(path); + unsafe + { + fixed (byte* p = text) + { + return FormatFromCode(AnydocNative.anydoc_format_from_path(p, (nuint)text.Length)); + } + } + } + + /// Convert a document file to Markdown. The format is detected from + /// the file content; the extension is the fallback for signature-less + /// formats (CSV) and unrecognizable containers. + /// when conversion is impossible; a file + /// that cannot be read has . + public unsafe string ToMarkdown(string path) + { + byte[] text = Encoding.UTF8.GetBytes(path); + AnyResult result = default; + unsafe + { + fixed (byte* p = text) + { + AnydocNative.anydoc_to_markdown_path(p, (nuint)text.Length, &result); + } + } + byte[] data = TakeResult(&result); + return Encoding.UTF8.GetString(data); + } + + /// Asynchronous , offloaded to the + /// thread pool so the calling thread is not blocked by the native + /// conversion. + public Task ToMarkdownAsync(string path) => Task.Run(() => ToMarkdown(path)); + + /// Convert an in-memory document to Markdown. Without a format, it is + /// detected from the content, which signature-less formats (CSV) have to + /// name explicitly. + public string ToMarkdownBytes(ReadOnlySpan data) => ToMarkdownBytesCore(data, null); + + /// Convert an in-memory document to Markdown, naming the format + /// explicitly. + public string ToMarkdownBytes(ReadOnlySpan data, Format format) => + ToMarkdownBytesCore(data, format); + + /// Asynchronous . + public Task ToMarkdownBytesAsync(ReadOnlySpan data) + { + byte[] copy = data.ToArray(); + return Task.Run(() => ToMarkdownBytes(copy)); + } + + /// Asynchronous . + public Task ToMarkdownBytesAsync(ReadOnlySpan data, Format format) + { + byte[] copy = data.ToArray(); + return Task.Run(() => ToMarkdownBytes(copy, format)); + } + + /// Parse an in-memory document into the document model, which also + /// carries the embedded assets. The format is detected from the content. + /// Unsupported for : PDF conversion produces + /// Markdown directly and has no document-model form; use + /// . + public unsafe Document ToDocument(ReadOnlySpan bytes) + { + AnyResult result = default; + unsafe + { + fixed (byte* p = bytes) + { + AnydocNative.anydoc_to_document(p, (nuint)bytes.Length, NoFormat, &result); + } + } + byte[] data = TakeResult(&result); + return Document.FromJson(Encoding.UTF8.GetString(data)); + } + + /// Asynchronous . + public Task ToDocumentAsync(ReadOnlySpan bytes) + { + byte[] copy = bytes.ToArray(); + return Task.Run(() => ToDocument(copy)); + } + + private static unsafe string ToMarkdownBytesCore(ReadOnlySpan data, Format? format) + { + AnyResult result = default; + unsafe + { + fixed (byte* p = data) + { + AnydocNative.anydoc_to_markdown_bytes(p, (nuint)data.Length, CodeFor(format), &result); + } + } + byte[] bytes = TakeResult(&result); + return Encoding.UTF8.GetString(bytes); + } + + private static int CodeFor(Format? format) => format is null ? NoFormat : (int)format; + + private static Format? FormatFromCode(int code) => code < 0 ? null : (Format)code; + + /// Turn a filled into managed + /// bytes, throwing the matching when the + /// conversion failed, and always releasing the Rust-side allocation. + private static unsafe byte[] TakeResult(AnyResult* result) + { + try + { + if (!result->ok) + { + string? code = Marshal.PtrToStringUTF8((nint)result->error_code); + string? message = Marshal.PtrToStringUTF8((nint)result->error_message); + throw AnydocException.From(code, message); + } + byte[] data = new byte[(int)result->data.len]; + if (data.Length > 0) + { + Marshal.Copy((nint)result->data.ptr, data, 0, data.Length); + } + return data; + } + finally + { + AnydocNative.anydoc_free_result(result); + } + } +} diff --git a/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj b/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj new file mode 100644 index 00000000..567b1abe --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + enable + enable + true + latest + + Firecrawl.Anydoc + Firecrawl.Anydoc + + 0.1.7 + Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown, backed by the anydoc Rust engine. + Firecrawl.Anydoc + Firecrawl;Junian Triajianto + Firecrawl;junian + MIT + https://github.com/firecrawl/anydoc + https://github.com/firecrawl/anydoc + markdown; docx; pptx; xlsx; epub; pdf; converter; document + README.md + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc/Model.cs b/dotnet/Firecrawl.Anydoc/Model.cs new file mode 100644 index 00000000..2d96b4e2 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/Model.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Firecrawl.Anydoc.Model; + +/// A parsed document: its body, its notes, and the bytes of everything +/// it embedded. +public sealed class Document +{ + public List Blocks { get; set; } = new(); + /// Footnote and endnote bodies, referenced from text by a + /// inline. + public List Notes { get; set; } = new(); + public List Assets { get; set; } = new(); + + internal static Document FromJson(string json) => + JsonSerializer.Deserialize(json, Json.Options)!; +} + +/// One block-level piece of a document body. +public sealed class Block +{ + /// heading, paragraph, list, table, + /// block_quote, code_block, or rule. + public string Kind { get; set; } = ""; + /// heading: 1-6. + public int? Level { get; set; } + /// heading: stable anchor id when the source document targets this + /// heading (bookmark, chapter fragment, ...). + public string? Anchor { get; set; } + /// heading, paragraph: + public List? Content { get; set; } + public Firecrawl.Anydoc.Model.List? List { get; set; } + public Table? Table { get; set; } + /// block_quote: nested blocks. + public List? Blocks { get; set; } + /// code_block: language hint when the source names one. + public string? Lang { get; set; } + /// code_block: the literal text, newlines intact. + public string? Text { get; set; } +} + +/// One span of inline content. +public sealed class Inline +{ + /// text, link, image, anchor (a + /// zero-width marker for an internal link target), note_ref, or + /// line_break. + public string Kind { get; set; } = ""; + /// text. + public string? Text { get; set; } + /// text: fully resolved character style. + public Style? Style { get; set; } + /// link: nested inline content. + public List? Content { get; set; } + /// link. + public LinkTarget? Target { get; set; } + /// image: alt text, empty when the source gives none. + public string? Alt { get; set; } + /// image. + public ImageSource? Source { get; set; } + /// anchor: the anchor id. + public string? Anchor { get; set; } + /// note_ref: the id of the note in . + public string? NoteId { get; set; } +} + +/// Fully resolved character style. +public sealed class Style +{ + public bool Bold { get; set; } + public bool Italic { get; set; } + public bool Strike { get; set; } + public bool Code { get; set; } +} + +/// Where a link points. +public sealed class LinkTarget +{ + /// external (absolute URL with a scheme), relative + /// (scheme-less relative reference), or anchor (internal target: a + /// heading anchor or an anchor inline). + public string Kind { get; set; } = ""; + /// The URL, relative reference, or anchor id. + public string Value { get; set; } = ""; +} + +/// Where an image's bytes live. +public sealed class ImageSource +{ + /// external (absolute URL), asset (embedded image, + /// carried in ), or unavailable (no + /// usable source: only the alt text remains). + public string Kind { get; set; } = ""; + /// external. + public string? Url { get; set; } + /// asset: index into . + public int? AssetId { get; set; } +} + +/// The marker family a list uses in the source document. +public sealed class List +{ + /// bullet, decimal, lower_alpha, + /// upper_alpha, lower_roman, or upper_roman. + public string Marker { get; set; } = ""; + /// Ordinal the first item counts from. + public ulong Start { get; set; } + public List Items { get; set; } = new(); +} + +/// One item of a , which may hold nested blocks +/// including further lists. +public sealed class ListItem +{ + public List Blocks { get; set; } = new(); + /// Task-list state, when the item carries a checkbox. + public bool? Checked { get; set; } + /// Literal marker text that overrides the list marker when the + /// source number text cannot be reproduced (composite number text such as + /// 1-a)). + public string? MarkerLabel { get; set; } +} + +/// Canonical table grid: every logical grid position appears exactly +/// once. Content and spans live on the origin slot, and each position a span +/// covers holds a covered slot pointing back at that origin. +public sealed class Table +{ + public List> Grid { get; set; } = new(); + /// Number of leading rows that are header rows (0 = no header). + public int HeaderRows { get; set; } + /// data (a real data table) or layout (layout + /// scaffolding: text boxes, positioning tables). + public string Kind { get; set; } = ""; +} + +/// One position in a : either a cell or the +/// shadow of one. +public sealed class CellSlot +{ + /// origin or covered. + public string Kind { get; set; } = ""; + /// origin. + public Cell? Cell { get; set; } + /// covered: row of the origin this position belongs to. + public int? OriginRow { get; set; } + /// covered: column of the origin this position belongs to. + public int? OriginCol { get; set; } +} + +/// A table cell and the extent it spans. +public sealed class Cell +{ + public List Blocks { get; set; } = new(); + /// Columns covered, at least 1. + public uint ColSpan { get; set; } + /// Rows covered, at least 1. + public uint RowSpan { get; set; } +} + +/// Footnote or endnote body, referenced from text by an +/// . id is document-scoped. +public sealed class Note +{ + public string Id { get; set; } = ""; + /// footnote or endnote. + public string Kind { get; set; } = ""; + public List Blocks { get; set; } = new(); +} + +/// An embedded binary asset (image, object payload). Bytes are always +/// retained, so a document stays self-contained. +public sealed class Asset +{ + /// Index into , as referenced by an + /// image source. + public int Id { get; set; } + /// MIME type, e.g. image/png. + public string MediaType { get; set; } = ""; + /// Package part or stream the asset came from, for provenance. + public string OriginPart { get; set; } = ""; + /// The payload, exactly as stored in the source. + public byte[] Data { get; set; } = Array.Empty(); +} + +internal static class Json +{ + public static readonly JsonSerializerOptions Options = new() + { + // The Rust serializers emit snake_case fields (col_span, media_type, ...). + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = false, + // Optional fields are omited when unset. + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +} \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc/NativeMethods.cs b/dotnet/Firecrawl.Anydoc/NativeMethods.cs new file mode 100644 index 00000000..199fc4f2 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/NativeMethods.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; + +namespace Firecrawl.Anydoc.Native +{ + /// + /// DllImport surface, extended with the platform-specific loader that maps + /// the logical library name to runtimes/{platform}-{arch}/native/, + /// the layout both NuGet and this project's build output use. + /// + internal static unsafe partial class AnydocNative + { + static AnydocNative() + { + NativeLibrary.SetDllImportResolver(typeof(AnydocNative).Assembly, ResolveLibrary); + } + + private static nint ResolveLibrary(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != __DllName) + { + return nint.Zero; + } + + string platform = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" + : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" + : throw new PlatformNotSupportedException($"anydoc has no native library for {RuntimeInformation.OSDescription}"); + + string arch = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException($"anydoc has no native library for {RuntimeInformation.OSArchitecture}"), + }; + + string name = libraryName; + string prefix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "" : "lib"; + string ext = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".dll" + : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? ".dylib" + : ".so"; + + string relative = $"runtimes/{platform}-{arch}/native/{prefix}{name}{ext}"; + foreach (string baseDir in new[] { AppContext.BaseDirectory, Path.GetDirectoryName(typeof(AnydocNative).Assembly.Location) ?? "" }) + { + if (string.IsNullOrEmpty(baseDir)) + { + continue; + } + string candidate = Path.Combine(baseDir, relative); + if (File.Exists(candidate)) + { + return NativeLibrary.Load(candidate, assembly, searchPath); + } + } + // Last chance: let the default resolution attempt it (NuGet hosts + // native libs on the default search path too). + return NativeLibrary.Load(relative, assembly, searchPath); + } + } +} diff --git a/dotnet/Firecrawl.Anydoc/NativeMethods.g.cs b/dotnet/Firecrawl.Anydoc/NativeMethods.g.cs new file mode 100644 index 00000000..2737ebee --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/NativeMethods.g.cs @@ -0,0 +1,102 @@ +// +// This code is generated by csbindgen. +// DON'T CHANGE THIS DIRECTLY. +// +#pragma warning disable CS8500 +#pragma warning disable CS8981 +using System; +using System.Runtime.InteropServices; + + +namespace Firecrawl.Anydoc.Native +{ + internal static unsafe partial class AnydocNative + { + const string __DllName = "anydoc_dotnet"; + + + + + + /// + /// `format_from_bytes`: detect the format from content. + /// + [DllImport(__DllName, EntryPoint = "anydoc_format_from_bytes", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int anydoc_format_from_bytes(byte* bytes, nuint len); + + /// + /// `format_from_extension`: the format a (possibly dot-prefixed) extension names. + /// + [DllImport(__DllName, EntryPoint = "anydoc_format_from_extension", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int anydoc_format_from_extension(byte* bytes, nuint len); + + /// + /// `format_from_path`: the format a path's extension names. + /// + [DllImport(__DllName, EntryPoint = "anydoc_format_from_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int anydoc_format_from_path(byte* bytes, nuint len); + + /// + /// `to_markdown(path)`: convert a file, detecting the format from its content. + /// + [DllImport(__DllName, EntryPoint = "anydoc_to_markdown_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void anydoc_to_markdown_path(byte* path, nuint len, AnyResult* result); + + /// + /// `to_markdown_bytes`: convert in-memory bytes; `format` of `-1` detects it. + /// + [DllImport(__DllName, EntryPoint = "anydoc_to_markdown_bytes", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void anydoc_to_markdown_bytes(byte* bytes, nuint len, int format, AnyResult* result); + + /// + /// `to_document`: parse in-memory bytes into the document model, returned as + /// JSON. Unsupported for PDF, like the other bindings. + /// + [DllImport(__DllName, EntryPoint = "anydoc_to_document", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void anydoc_to_document(byte* bytes, nuint len, int format, AnyResult* result); + + /// + /// Release everything `*result` holds: the error strings and the data buffer. + /// + [DllImport(__DllName, EntryPoint = "anydoc_free_result", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void anydoc_free_result(AnyResult* result); + + + } + + /// + /// An owned byte buffer; `ptr` is null and `len` zero when empty. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct ByteBuffer + { + public byte* ptr; + public nuint len; + public nuint capacity; + } + + /// + /// The outcome of a conversion. `ok` selects between the payload and the error + /// fields; the caller hands the whole struct back to [`anydoc_free_result`]. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct AnyResult + { + [MarshalAs(UnmanagedType.U1)] public bool ok; + /// + /// Machine-readable kind (`error.code()`), null when `ok`. + /// + public byte* error_code; + /// + /// Human-readable message, null when `ok`. + /// + public byte* error_message; + /// + /// Markdown, or the document model as JSON, when `ok`. + /// + public ByteBuffer data; + } + + + +} diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 00000000..7417a7f6 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,97 @@ +# Firecrawl.Anydoc + +.NET bindings for **anydoc**, the fast Rust library that converts documents +(Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF) into clean +GitHub-Flavored Markdown. This package bundles the native engine for every +supported platform, so it installs and runs with no extra setup. + +``` +dotnet add package Firecrawl.Anydoc +``` + +Supported platforms: **macOS, Windows, and Linux** on both **x64** and **arm64**. + +## Usage + +```csharp +using Firecrawl.Anydoc; + +// Construct once and reuse for every conversion. +// Instances are thread-safe and hold no disposable state. +var anydoc = new AnydocConverter(); + +// From a file path (format detected from the content): +string markdown = await anydoc.ToMarkdownAsync("report.docx"); + +// From bytes, with the format detected from the content: +string fromBytes = await anydoc.ToMarkdownBytesAsync(bytes); + +// Or name it, which signature-less formats (CSV) need: +string fromCsv = await anydoc.ToMarkdownBytesAsync(bytes, Format.Csv); + +// Or stop at the document model, which also carries embedded assets: +Document document = await anydoc.ToDocumentAsync(bytes); +``` + +Synchronous variants (`ToMarkdown`, `ToMarkdownBytes`, `ToDocument`) behave the +same way when you are not in an `async` context. + +### Format detection + +```csharp +Format? byBytes = anydoc.DetectFormat(bytes); // content signature +Format? byExt = anydoc.DetectFormatByExtension(".pptm"); // => Format.Pptx +Format? byPath = anydoc.DetectFormatByPath("sheet.csv"); // => Format.Csv +``` + +### Errors + +Failures throw `AnydocException`, whose `Kind` (a `ConvertErrorKind`) names the +reason the same way the Node and Python bindings do: + +```csharp +try +{ + string md = anydoc.ToMarkdown(bytes, Format.Docx); +} +catch (AnydocException e) +{ + switch (e.Kind) + { + case ConvertErrorKind.Unsupported: // scanned PDF, unknown format + case ConvertErrorKind.Malformed: // structurally unusable document + case ConvertErrorKind.Encrypted: // password-protected + case ConvertErrorKind.ResourceLimit:// a fixed safety limit was crossed + case ConvertErrorKind.MissingPart: // a required part is absent + case ConvertErrorKind.Io: // the file could not be read + } +} +``` + +The document model (`Firecrawl.Anydoc.Model`) encodes every block, inline, +list, table grid, note, and embedded asset, mirroring the Node and Python +bindings exactly (`Kind` string discriminants, `NoteId`, `MediaType`, etc.). + +## Building + +The C# wrapper is generated from the Rust FFI with +[csbindgen](https://github.com/Cysharp/csbindgen): `cargo build -p anydoc-dotnet` +regenerates `src/NativeMethods.g.cs`. + +To rebuild the native binaries and produce the NuGet package: + +```sh +sh dotnet/build.sh # build the host's native library and pack +sh dotnet/build.sh --all # build every supported platform's library +``` + +Each architecture's library is laid out under +`src/runtimes/{rid}/native/` — NuGet's native-library convention — so a single +package carries all six targets. `dotnet pack` bundles them with the managed +wrapper. + +Run the tests with: + +```sh +dotnet test dotnet/Firecrawl.Anydoc.slnx +``` \ No newline at end of file diff --git a/dotnet/build.sh b/dotnet/build.sh new file mode 100755 index 00000000..a316fa74 --- /dev/null +++ b/dotnet/build.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# Build the native cdylib for the listed targets and lay each one out under +# dotnet/Firecrawl.Anydoc/runtimes/{rid}/native so `dotnet pack` bundles a NuGet package +# (Firecrawl.Anydoc) that carries macOS, Windows, and Linux binaries for both +# x64 and arm64. +# +# Cross-compiling to a target you do not natively develop on needs that target +# installed (`rustup target add `). Windows/MSVC targets cannot be +# produced on macOS or Linux hosts; build those on Windows runners. +# +# Usage (from the repo root, or dotnet/): +# sh dotnet/build.sh # host target only +# sh dotnet/build.sh --all # every supported native target +# sh dotnet/build.sh osx-arm64 ... # a specific RID subset +set -eu + +cd "$(dirname "$0")" +root=$(pwd) +repo=$(cd .. && pwd) + +# RID -> rust target triple -> artifact filename +# (cargo names cdylibs lib.dylib/.so and .dll on their platforms) +targets() { + echo osx-x64:x86_64-apple-darwin:libanydoc_dotnet.dylib + echo osx-arm64:aarch64-apple-darwin:libanydoc_dotnet.dylib + echo win-x64:x86_64-pc-windows-msvc:anydoc_dotnet.dll + echo win-arm64:aarch64-pc-windows-msvc:anydoc_dotnet.dll + echo linux-x64:x86_64-unknown-linux-gnu:libanydoc_dotnet.so + echo linux-arm64:aarch64-unknown-linux-gnu:libanydoc_dotnet.so +} + +host_rid() { + case "$(uname -s)-$(uname -m)" in + Darwin-arm64) echo osx-arm64 ;; + Darwin-x86_64|Darwin-i386) echo osx-x64 ;; + Linux-x86_64|Linux-amd64) echo linux-x64 ;; + Linux-aarch64|Linux-arm64) echo linux-arm64 ;; + *) echo "unsupported host $(uname -s) $(uname -m)" >&2; exit 2 ;; + esac +} + +build_rid() { + rid=$1; shift + line=$(targets | grep "^$rid:" || true) + [ -n "$line" ] || { echo "unknown RID: $rid" >&2; exit 2; } + rest=${line#*:} # strip rid + triple=${rest%%:*} # rust target triple + artifact=${rest#*:} # cdylib filename + + echo "-- building $rid ($triple)" + if [ "$triple" = unknown ] || ! rustup target list --installed 2>/dev/null | grep -qx "$triple"; then + echo " target $triple not installed; skipping (add it with: rustup target add $triple)" >&2 + return 1 + fi + cargo build --manifest-path "$root/native/Cargo.toml" --release --target "$triple" -p anydoc-dotnet + + out=$(find "$root/../target" "$root/native/target" -type f -name "$artifact" 2>/dev/null | head -n 1) + [ -n "$out" ] || { echo " artifact $artifact not found" >&2; return 1; } + mkdir -p "$root/Firecrawl.Anydoc/runtimes/$rid/native" + cp "$out" "$root/Firecrawl.Anydoc/runtimes/$rid/native/$artifact" + echo " -> dotnet/Firecrawl.Anydoc/runtimes/$rid/native/$artifact" +} + +if [ "$#" -eq 0 ]; then + build_rid "$(host_rid)" +elif [ "$1" = --all ]; then + for rid in $(targets | cut -d: -f1); do + build_rid "$rid" || true + done +else + for rid in "$@"; do + build_rid "$rid" + done +fi + +echo "--packing dotnet" +dotnet pack "$root/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj" -c Release -o "$root/artifacts" +echo "package:" +ls -1 "$root/artifacts"/Firecrawl.Anydoc.*.nupkg 2>/dev/null || true \ No newline at end of file diff --git a/dotnet/native/Cargo.toml b/dotnet/native/Cargo.toml new file mode 100644 index 00000000..44f37969 --- /dev/null +++ b/dotnet/native/Cargo.toml @@ -0,0 +1,27 @@ +# The cdylib backing the Firecrawl.Anydoc NuGet package. csbindgen reads the +# extern "C" surface in `src/lib.rs` (via `build.rs`) and emits the C# DllImport +# wrapper into `../src/NativeMethods.g.cs`. +[package] +name = "anydoc-dotnet" +version = "0.1.7" +edition = "2024" +description = ".NET bindings for anydoc" +license = "MIT" +repository = "https://github.com/firecrawl/anydoc" +publish = false + +[lib] +name = "anydoc_dotnet" +crate-type = ["cdylib"] + +[dependencies] +anydoc = { path = "../.." } +base64 = "0.22" +serde_json = "1" + +[build-dependencies] +csbindgen = "1.9" + +# The workspace root already sets the release profile (LTO, symbol strip); +# the cdylib carries no unit bodies, so there is nothing to test without a +# managed host (the same constraint the Node/Python bindings note). \ No newline at end of file diff --git a/dotnet/native/build.rs b/dotnet/native/build.rs new file mode 100644 index 00000000..bea27f28 --- /dev/null +++ b/dotnet/native/build.rs @@ -0,0 +1,18 @@ +//! Generate the C# DllImport surface with csbindgen and place it next to the +//! managed wrapper it belongs to. +use std::path::Path; + +fn main() { + let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/NativeMethods.g.cs"); + csbindgen::Builder::default() + .input_extern_file("src/lib.rs") + .csharp_dll_name("anydoc_dotnet") + .csharp_namespace("Firecrawl.Anydoc.Native") + .csharp_class_name("AnydocNative") + .csharp_class_accessibility("internal") + .csharp_use_function_pointer(true) + .generate_csharp_file(out.to_str().expect("csharp output path is utf-8")) + .unwrap(); + println!("cargo:rerun-if-changed=src/lib.rs"); + println!("cargo:rerun-if-changed=src/serialize.rs"); +} diff --git a/dotnet/native/src/lib.rs b/dotnet/native/src/lib.rs new file mode 100644 index 00000000..d8d5781b --- /dev/null +++ b/dotnet/native/src/lib.rs @@ -0,0 +1,229 @@ +//! C ABI exposed to .NET, consumed by csbindgen (`build.rs`) and marshalled by +//! the C# wrapper in `../src`. +//! +//! Inputs are UTF-8 byte slices and results are a small by-value struct whose +//! payload is an owned buffer the caller frees with [`anydoc_free_result`]. +//! This keeps every crossing a plain `{ptr,len}` pair, so the ABI is identical +//! on all six supported targets and never relies on `repr(C)` layout sharing a +//! struct with C#. + +use std::ffi::{c_char, CString}; +use std::panic; +use std::path::Path; +use std::slice; + +use anydoc::ConvertError; + +mod serialize; + +/// Format codes shared with the C# `Format` enum (ordinal match). `-1` means +/// "unspecified": let detection name it. +const DOC: i32 = 0; +const DOCX: i32 = 1; +const ODT: i32 = 2; +const PDF: i32 = 3; +const PPT: i32 = 4; +const PPTX: i32 = 5; +const RTF: i32 = 6; +const EPUB: i32 = 7; +const EXCEL: i32 = 8; +const ODS: i32 = 9; +const ODP: i32 = 10; +const CSV: i32 = 11; + +fn format_code(format: anydoc::Format) -> i32 { + match format { + anydoc::Format::Doc => DOC, + anydoc::Format::Docx => DOCX, + anydoc::Format::Odt => ODT, + anydoc::Format::Pdf => PDF, + anydoc::Format::Ppt => PPT, + anydoc::Format::Pptx => PPTX, + anydoc::Format::Rtf => RTF, + anydoc::Format::Epub => EPUB, + anydoc::Format::Excel => EXCEL, + anydoc::Format::Ods => ODS, + anydoc::Format::Odp => ODP, + anydoc::Format::Csv => CSV, + } +} + +fn format_from_code(code: i32) -> Option { + Some(match code { + DOC => anydoc::Format::Doc, + DOCX => anydoc::Format::Docx, + ODT => anydoc::Format::Odt, + PDF => anydoc::Format::Pdf, + PPT => anydoc::Format::Ppt, + PPTX => anydoc::Format::Pptx, + RTF => anydoc::Format::Rtf, + EPUB => anydoc::Format::Epub, + EXCEL => anydoc::Format::Excel, + ODS => anydoc::Format::Ods, + ODP => anydoc::Format::Odp, + CSV => anydoc::Format::Csv, + _ => return None, + }) +} + +/// An owned byte buffer; `ptr` is null and `len` zero when empty. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ByteBuffer { + pub ptr: *mut u8, + pub len: usize, + pub capacity: usize, +} + +impl ByteBuffer { + fn empty() -> Self { + Self { ptr: std::ptr::null_mut(), len: 0, capacity: 0 } + } + + fn from_vec(bytes: Vec) -> Self { + let mut bytes = std::mem::ManuallyDrop::new(bytes); + Self { ptr: bytes.as_mut_ptr(), len: bytes.len(), capacity: bytes.capacity() } + } +} + +/// The outcome of a conversion. `ok` selects between the payload and the error +/// fields; the caller hands the whole struct back to [`anydoc_free_result`]. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct AnyResult { + pub ok: bool, + /// Machine-readable kind (`error.code()`), null when `ok`. + pub error_code: *mut c_char, + /// Human-readable message, null when `ok`. + pub error_message: *mut c_char, + /// Markdown, or the document model as JSON, when `ok`. + pub data: ByteBuffer, +} + +impl AnyResult { + fn ok(bytes: Vec) -> Self { + Self { ok: true, error_code: std::ptr::null_mut(), error_message: std::ptr::null_mut(), data: ByteBuffer::from_vec(bytes) } + } + + fn err(error: ConvertError) -> Self { + let code = CString::new(error.code()).unwrap(); + let message = CString::new(error.to_string()).unwrap(); + Self { + ok: false, + error_code: code.into_raw(), + error_message: message.into_raw(), + data: ByteBuffer::empty(), + } + } +} + +unsafe fn utf8<'a>(ptr: *const u8, len: usize) -> &'a [u8] { + if len == 0 { &[] } else { unsafe { slice::from_raw_parts(ptr, len) } } +} + +/// `format_from_bytes`: detect the format from content. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_format_from_bytes(bytes: *const u8, len: usize) -> i32 { + let bytes = unsafe { utf8(bytes, len) }; + anydoc::Format::from_bytes(bytes).map(format_code).unwrap_or(-1) +} + +/// `format_from_extension`: the format a (possibly dot-prefixed) extension names. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_format_from_extension(bytes: *const u8, len: usize) -> i32 { + let bytes = unsafe { utf8(bytes, len) }; + let ext = std::str::from_utf8(bytes).unwrap_or_default(); + anydoc::Format::from_extension(ext.trim_start_matches('.')).map(format_code).unwrap_or(-1) +} + +/// `format_from_path`: the format a path's extension names. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_format_from_path(bytes: *const u8, len: usize) -> i32 { + let bytes = unsafe { utf8(bytes, len) }; + let path = std::str::from_utf8(bytes).unwrap_or_default(); + anydoc::Format::from_path(Path::new(path)).map(format_code).unwrap_or(-1) +} + +/// `to_markdown(path)`: convert a file, detecting the format from its content. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_to_markdown_path(path: *const u8, len: usize, result: *mut AnyResult) { + let path = unsafe { utf8(path, len) }; + let path = std::str::from_utf8(path).unwrap_or_default(); + let outcome = panic::catch_unwind(|| anydoc::to_markdown(Path::new(path)).map(|s| s.into_bytes())); + fill(result, outcome); +} + +/// `to_markdown_bytes`: convert in-memory bytes; `format` of `-1` detects it. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_to_markdown_bytes( + bytes: *const u8, + len: usize, + format: i32, + result: *mut AnyResult, +) { + let bytes = unsafe { utf8(bytes, len) }; + let outcome = panic::catch_unwind(|| { + anydoc::to_markdown_bytes(bytes, format_from_code(format)).map(|s| s.into_bytes()) + }); + fill(result, outcome); +} + +/// `to_document`: parse in-memory bytes into the document model, returned as +/// JSON. Unsupported for PDF, like the other bindings. +#[unsafe(no_mangle)] +pub extern "C" fn anydoc_to_document( + bytes: *const u8, + len: usize, + format: i32, + result: *mut AnyResult, +) { + let bytes = unsafe { utf8(bytes, len) }; + let outcome = panic::catch_unwind(|| { + anydoc::to_document(bytes, format_from_code(format)).map(|document| { + serde_json::to_vec(&serialize::document(document)).expect("model serializes") + }) + }); + fill(result, outcome); +} + +/// Release everything `*result` holds: the error strings and the data buffer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn anydoc_free_result(result: *mut AnyResult) { + if result.is_null() { + return; + } + unsafe { + let result = &mut *result; + if !result.error_code.is_null() { + drop(CString::from_raw(result.error_code)); + } + if !result.error_message.is_null() { + drop(CString::from_raw(result.error_message)); + } + if !result.data.ptr.is_null() { + drop(Vec::from_raw_parts( + result.data.ptr, + result.data.len, + result.data.capacity, + )); + } + } +} + +fn fill( + result: *mut AnyResult, + outcome: Result, ConvertError>, Box>, +) { + if result.is_null() { + return; + } + let value = match outcome { + Ok(Ok(bytes)) => { + unsafe { *result = AnyResult::ok(bytes) }; + return; + } + Ok(Err(error)) => AnyResult::err(error), + Err(_) => AnyResult::err(ConvertError::Unsupported("internal conversion panic".into())), + }; + unsafe { *result = value }; +} diff --git a/dotnet/native/src/serialize.rs b/dotnet/native/src/serialize.rs new file mode 100644 index 00000000..a26872d0 --- /dev/null +++ b/dotnet/native/src/serialize.rs @@ -0,0 +1,236 @@ +//! Serialize the document model to plain JSON, shaped to match the Node and +//! Python bindings (string `kind` discriminants, optional variant payloads, +//! snake_case). Bytes are base64, suited to a byte-transfer ABI. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use serde_json::{Map, Value}; + +use anydoc::model::{Asset, Block, Cell, CellSlot, Document, ImageSource, Inline, LinkTarget, List, ListItem, MarkerKind, Note, NoteKind, Style, Table}; + +/// A `serde_json` object builder with an ergonomic "set if present" helper. +struct Obj(Map); + +impl Obj { + fn new() -> Self { + Self(Map::new()) + } + + fn set(mut self, key: &str, value: Value) -> Self { + self.0.insert(key.to_string(), value); + self + } + + fn maybe(mut self, key: &str, value: Option) -> Self { + if let Some(value) = value { + self.0.insert(key.to_string(), value); + } + self + } + + fn join(self) -> Value { + Value::Object(self.0) + } +} + +pub fn str(value: impl Into) -> Value { + Value::String(value.into()) +} + +pub fn list(items: impl IntoIterator) -> Value { + Value::Array(items.into_iter().collect()) +} + +pub fn document(document: Document) -> Value { + Obj::new() + .set("blocks", list(document.blocks.into_iter().map(block))) + .set("notes", list(document.notes.into_iter().map(note))) + .set("assets", list(document.assets.into_iter().map(asset))) + .join() +} + +pub fn block(value: Block) -> Value { + match value { + Block::Heading { level, anchor, content } => Obj::new() + .set("kind", str("heading")) + .set("level", Value::from(level)) + .maybe("anchor", anchor.map(str)) + .set("content", list(content.into_iter().map(inline))) + .join(), + Block::Paragraph(content) => Obj::new() + .set("kind", str("paragraph")) + .set("content", list(content.into_iter().map(inline))) + .join(), + Block::List(v) => { + Obj::new().set("kind", str("list")).set("list", list_value(&v)).join() + } + Block::Table(v) => { + Obj::new().set("kind", str("table")).set("table", table(v)).join() + } + Block::BlockQuote(blocks) => Obj::new() + .set("kind", str("block_quote")) + .set("blocks", list(blocks.into_iter().map(block))) + .join(), + Block::CodeBlock { lang, text } => Obj::new() + .set("kind", str("code_block")) + .maybe("lang", lang.map(str)) + .set("text", str(text)) + .join(), + Block::Rule => Obj::new().set("kind", str("rule")).join(), + } +} + +pub fn inline(value: Inline) -> Value { + match value { + Inline::Text { text, style: st } => Obj::new() + .set("kind", str("text")) + .set("text", str(text)) + .set("style", style(st)) + .join(), + Inline::Link { content, target } => Obj::new() + .set("kind", str("link")) + .set("content", list(content.into_iter().map(inline))) + .set("target", link_target(target)) + .join(), + Inline::Image { alt, source } => Obj::new() + .set("kind", str("image")) + .set("alt", str(alt)) + .set("source", image_source(source)) + .join(), + Inline::Anchor(id) => { + Obj::new().set("kind", str("anchor")).set("anchor", str(id)).join() + } + Inline::NoteRef(id) => { + Obj::new().set("kind", str("note_ref")).set("note_id", str(id)).join() + } + Inline::LineBreak => Obj::new().set("kind", str("line_break")).join(), + } +} + +pub fn style(style: Style) -> Value { + Obj::new() + .set("bold", Value::from(style.bold)) + .set("italic", Value::from(style.italic)) + .set("strike", Value::from(style.strike)) + .set("code", Value::from(style.code)) + .join() +} + +pub fn link_target(target: LinkTarget) -> Value { + match target { + LinkTarget::External(value) => { + Obj::new().set("kind", str("external")).set("value", str(value)).join() + } + LinkTarget::Relative(value) => { + Obj::new().set("kind", str("relative")).set("value", str(value)).join() + } + LinkTarget::Anchor(value) => { + Obj::new().set("kind", str("anchor")).set("value", str(value)).join() + } + } +} + +pub fn image_source(source: ImageSource) -> Value { + match source { + ImageSource::External(url) => { + Obj::new().set("kind", str("external")).set("url", str(url)).join() + } + ImageSource::Asset(id) => { + Obj::new().set("kind", str("asset")).set("asset_id", Value::from(id.0)).join() + } + ImageSource::Unavailable => Obj::new().set("kind", str("unavailable")).join(), + } +} + +pub fn marker(marker: MarkerKind) -> Value { + str(match marker { + MarkerKind::Bullet => "bullet", + MarkerKind::Decimal => "decimal", + MarkerKind::LowerAlpha => "lower_alpha", + MarkerKind::UpperAlpha => "upper_alpha", + MarkerKind::LowerRoman => "lower_roman", + MarkerKind::UpperRoman => "upper_roman", + }) +} + +pub fn list_value(value: &List) -> Value { + Obj::new() + .set("marker", marker(value.marker)) + .set("start", Value::from(value.start)) + .set("items", list(value.items.iter().map(list_item))) + .join() +} + +pub fn list_item(item: &ListItem) -> Value { + Obj::new() + .set("blocks", list(item.blocks.iter().cloned().map(block))) + .maybe("checked", item.checked.map(Value::from)) + .maybe("marker_label", item.marker_label.clone().map(str)) + .join() +} + +pub fn table(table: Table) -> Value { + Obj::new() + .set( + "grid", + list( + table + .grid + .iter() + .map(|row| list(row.iter().map(cell_slot))), + ), + ) + .set("header_rows", Value::from(table.header_rows)) + .set( + "kind", + str(match table.kind { + anydoc::model::TableKind::Data => "data", + anydoc::model::TableKind::Layout => "layout", + }), + ) + .join() +} + +pub fn cell_slot(slot: &CellSlot) -> Value { + match slot { + CellSlot::Origin(v) => { + Obj::new().set("kind", str("origin")).set("cell", cell(v)).join() + } + CellSlot::Covered { origin_row, origin_col } => Obj::new() + .set("kind", str("covered")) + .set("origin_row", Value::from(*origin_row)) + .set("origin_col", Value::from(*origin_col)) + .join(), + } +} + +pub fn cell(cell: &Cell) -> Value { + Obj::new() + .set("blocks", list(cell.blocks.iter().cloned().map(block))) + .set("col_span", Value::from(cell.col_span)) + .set("row_span", Value::from(cell.row_span)) + .join() +} + +pub fn note(note: Note) -> Value { + Obj::new() + .set("id", str(note.id)) + .set( + "kind", + str(match note.kind { + NoteKind::Footnote => "footnote", + NoteKind::Endnote => "endnote", + }), + ) + .set("blocks", list(note.blocks.into_iter().map(block))) + .join() +} + +pub fn asset(asset: Asset) -> Value { + Obj::new() + .set("id", Value::from(asset.id.0)) + .set("media_type", str(asset.media_type)) + .set("origin_part", str(asset.origin_part)) + .set("data", str(STANDARD.encode(&asset.bytes))) + .join() +} \ No newline at end of file From c686a809db95273f9e47eccec9fee724d644f50b Mon Sep 17 00:00:00 2001 From: Junian Triajianto Date: Fri, 7 Aug 2026 19:52:39 +0700 Subject: [PATCH 2/4] Add Blazor WebAssembly support with Firecrawl.Anydoc.Wasm --- dotnet/.gitignore | 6 + .../Firecrawl.Anydoc.Wasm.Tests.csproj | 22 ++ .../WasmAnydocTests.cs | 250 ++++++++++++++++++ .../Firecrawl.Anydoc.Wasm/AnydocConverter.cs | 210 +++++++++++++++ .../Firecrawl.Anydoc.Wasm.csproj | 49 ++++ .../wwwroot/anydoc-wasm.js | 65 +++++ dotnet/Firecrawl.Anydoc.slnx | 8 +- dotnet/Firecrawl.Anydoc/AnydocConverter.cs | 90 ------- dotnet/Firecrawl.Anydoc/Types.cs | 91 +++++++ dotnet/README.md | 43 +++ dotnet/build-wasm.sh | 47 ++++ .../AnydocBlazorWasmDemo.csproj | 21 ++ .../examples/AnydocBlazorWasmDemo/App.razor | 5 + .../Layout/MainLayout.razor | 27 ++ .../AnydocBlazorWasmDemo/Layout/NavMenu.razor | 13 + .../Pages/MarkdownConverter.razor | 244 +++++++++++++++++ .../AnydocBlazorWasmDemo/Pages/NotFound.razor | 5 + .../examples/AnydocBlazorWasmDemo/Program.cs | 15 ++ .../Properties/launchSettings.json | 25 ++ .../AnydocBlazorWasmDemo/_Imports.razor | 12 + .../AnydocBlazorWasmDemo/wwwroot/css/app.css | 218 +++++++++++++++ .../AnydocBlazorWasmDemo/wwwroot/favicon.ico | Bin 0 -> 15086 bytes .../AnydocBlazorWasmDemo/wwwroot/icon-192.png | Bin 0 -> 2626 bytes .../AnydocBlazorWasmDemo/wwwroot/index.html | 33 +++ 24 files changed, 1408 insertions(+), 91 deletions(-) create mode 100644 dotnet/Firecrawl.Anydoc.Wasm.Tests/Firecrawl.Anydoc.Wasm.Tests.csproj create mode 100644 dotnet/Firecrawl.Anydoc.Wasm.Tests/WasmAnydocTests.cs create mode 100644 dotnet/Firecrawl.Anydoc.Wasm/AnydocConverter.cs create mode 100644 dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj create mode 100644 dotnet/Firecrawl.Anydoc.Wasm/wwwroot/anydoc-wasm.js create mode 100644 dotnet/Firecrawl.Anydoc/Types.cs create mode 100755 dotnet/build-wasm.sh create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/AnydocBlazorWasmDemo.csproj create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/App.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Layout/MainLayout.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Layout/NavMenu.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Pages/MarkdownConverter.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Pages/NotFound.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Program.cs create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/Properties/launchSettings.json create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/_Imports.razor create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/wwwroot/css/app.css create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/wwwroot/favicon.ico create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/wwwroot/icon-192.png create mode 100644 dotnet/examples/AnydocBlazorWasmDemo/wwwroot/index.html diff --git a/dotnet/.gitignore b/dotnet/.gitignore index e548e8b2..61203f29 100644 --- a/dotnet/.gitignore +++ b/dotnet/.gitignore @@ -482,3 +482,9 @@ $RECYCLE.BIN/ *.swp runtimes/ + +# compiled WebAssembly engine (produced by build-wasm.sh) +Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm.js +Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm_bg.wasm +Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm.d.ts +Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm_bg.wasm.d.ts diff --git a/dotnet/Firecrawl.Anydoc.Wasm.Tests/Firecrawl.Anydoc.Wasm.Tests.csproj b/dotnet/Firecrawl.Anydoc.Wasm.Tests/Firecrawl.Anydoc.Wasm.Tests.csproj new file mode 100644 index 00000000..faa1e771 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Wasm.Tests/Firecrawl.Anydoc.Wasm.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + latest + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.Wasm.Tests/WasmAnydocTests.cs b/dotnet/Firecrawl.Anydoc.Wasm.Tests/WasmAnydocTests.cs new file mode 100644 index 00000000..4cf8eeb8 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Wasm.Tests/WasmAnydocTests.cs @@ -0,0 +1,250 @@ +using System.Collections.Concurrent; +using Firecrawl.Anydoc; +using Firecrawl.Anydoc.Model; +using Microsoft.JSInterop; +using Xunit; + +namespace Firecrawl.Anydoc.Wasm.Tests; + +/// Exercises the managed Wasm binding against a fake JS runtime: a +/// browser cannot run under `dotnet test`, so the interop layer is stubbed with +/// a module returning canned results. This validates the C# glue (arg +/// marshalling, enum-name mapping, error mapping) independent of the Engine. +public class AnydocWasmTests +{ + private static readonly byte[] Docx = new byte[] { 0x50, 0x4B, 0x03 }; + + [Fact] + public async Task ToMarkdownBytesAsync_pass_bytes_and_returns_the_markdown() + { + var js = FakeJs.Empty(); + string? seenFormat = null; + js.Module.On("toMarkdownBytes", args => + { + seenFormat = args[1] as string; + return ""; + }); + await using var converter = new AnydocConverter(js); + + string markdown = await converter.ToMarkdownBytesAsync(Docx, Format.Docx); + + Assert.Equal("", markdown); + Assert.Equal("docx", seenFormat); // the enum is marshalled by its wasm name + } + + [Fact] + public async Task ToMarkdownBytesAsync_leaves_the_format_undefined_when_detecting() + { + var js = FakeJs.Empty(); + js.Module.On("toMarkdownBytes", args => ""); + await using var converter = new AnydocConverter(js); + + string markdown = await converter.ToMarkdownBytesAsync(Docx); + + Assert.Equal("", markdown); + } + + [Fact] + public async Task Detecting_the_format_maps_the_wasm_name_back_to_the_enum() + { + foreach ((string name, Format expected) in new[] + { + ("docx", Format.Docx), + ("odt", Format.Odt), + ("pdf", Format.Pdf), + ("xlsx", Format.Excel), + ("csv", Format.Csv), + }) + { + var js = FakeJs.Returning("formatFromBytes", name); + await using var converter = new AnydocConverter(js); + Assert.Equal(expected, await converter.DetectFormatAsync(Docx)); + } + } + + [Fact] + public async Task Detect_the_format_returns_null_when_nothing_matches() + { + var js = FakeJs.Returning("formatFromBytes", null); + await using var converter = new AnydocConverter(js); + Assert.Null(await converter.DetectFormatAsync(Docx)); + } + + [Fact] + public async Task Detecting_by_extension_and_path_use_their_own_entry_points() + { + var ext = FakeJs.Returning("formatFromExtension", "xlsx"); + await using var cExt = new AnydocConverter(ext); + Assert.Equal(Format.Excel, await cExt.DetectFormatByExtensionAsync(".xlsx")); + + var path = FakeJs.Returning("formatFromPath", "pdf"); + await using var cPath = new AnydocConverter(path); + Assert.Equal(Format.Pdf, await cPath.DetectFormatByPathAsync("report.pdf")); + } + + [Fact] + public async Task ToDocumentAsync_returns_the_deserialized_model() + { + var js = FakeJs.Returning("toDocument", new Document + { + Blocks = + { + new Block + { + Kind = "heading", + Level = 1, + Content = new List { new Inline { Kind = "text" } }, + }, + }, + }); + await using var converter = new AnydocConverter(js); + + Document document = await converter.ToDocumentAsync(Docx); + + Assert.Equal("heading", Assert.Single(document.Blocks).Kind); + Assert.Equal("text", Assert.Single(Assert.Single(document.Blocks).Content!).Kind); + } + + [Fact] + public async Task A_js_engine_error_becomes_an_AnydocException_with_the_right_kind() + { + var js = FakeJs.Throwing("toMarkdownBytes", new JSException("anydoc:encrypted:the file is password-protected")); + await using var converter = new AnydocConverter(js); + + var exception = await Assert.ThrowsAsync(() => converter.ToMarkdownBytesAsync(Docx)); + + Assert.Equal(ConvertErrorKind.Encrypted, exception.Kind); + Assert.Equal("encrypted", exception.Code); + } + + [Fact] + public async Task A_js_error_with_a_future_code_maps_to_unknown() + { + var js = FakeJs.Throwing("toMarkdownBytes", new JSException("anydoc:negotiator hatchback:surprise!")); + await using var converter = new AnydocConverter(js); + + var exception = await Assert.ThrowsAsync(() => converter.ToMarkdownBytesAsync(Docx)); + + Assert.Equal(ConvertErrorKind.Unknown, exception.Kind); + } + + [Fact] + public void The_sync_members_require_the_async_variant() + { + var js = FakeJs.Empty(); + var converter = new AnydocConverter(js); + + foreach (Action act in new Action[] + { + () => converter.DetectFormat(Docx), + () => converter.DetectFormatByExtension(".docx"), + () => converter.DetectFormatByPath("a.docx"), + () => converter.ToMarkdownBytes(Docx), + () => converter.ToMarkdownBytes(Docx, Format.Docx), + () => converter.ToDocument(Docx), + }) + { + Assert.Throws(act); + } + } + + [Fact] + public async Task Path_conversion_is_unsupported_and_its_message_points_at_the_byte_api() + { + await using var converter = new AnydocConverter(FakeJs.Empty()); + + var exception = await Assert.ThrowsAsync( + () => converter.ToMarkdownAsync("a.docx")); + + Assert.Contains("ToMarkdownBytesAsync", exception.Message); + } + + [Fact] + public async Task Reusing_one_converter_reuses_a_single_imported_module() + { + var js = FakeJs.Empty(); + js.Module.On("toMarkdownBytes", args => ""); + await using var converter = new AnydocConverter(js); + + await converter.ToMarkdownBytesAsync(Docx); + await converter.ToMarkdownBytesAsync(Docx); + + Assert.Equal(1, js.ImportCount); + } +} + +/// A minimal IJSRuntime that returns a hand-driven module for the +/// `import` call and routes every module invocation to a registered handler. +internal sealed class FakeJs : IJSRuntime +{ + public FakeModule Module { get; } = new(); + public int ImportCount { get; private set; } + + public ValueTask InvokeAsync(string identifier, object?[]? args) => + InvokeAsync(identifier, CancellationToken.None, args); + + public ValueTask InvokeAsync( + string identifier, CancellationToken cancellationToken, object?[]? args) + { + if (identifier == "import") + { + ImportCount++; + var task = Task.FromResult((TValue)(object)Module); + return new ValueTask(task); + } + throw new InvalidOperationException($"unexpected js identifier {identifier}"); + } + + public static FakeJs Empty() => new(); + + public static FakeJs Returning(string identifier, object? result) + { + var js = new FakeJs(); + js.Module.On(identifier, _ => result); + return js; + } + + public static FakeJs Throwing(string identifier, JSException exception) + { + var js = new FakeJs(); + js.Module.ThrowOn(identifier, exception); + return js; + } +} + +internal sealed class FakeModule : IJSObjectReference +{ + private readonly ConcurrentDictionary> _handlers = new(); + private readonly ConcurrentDictionary _throwing = new(); + + public void On(string identifier, Func handler) => _handlers[identifier] = handler; + public void ThrowOn(string identifier, JSException exception) => _throwing[identifier] = exception; + + public ValueTask InvokeAsync(string identifier, object?[]? args) => + InvokeAsync(identifier, CancellationToken.None, args); + + public ValueTask InvokeAsync( + string identifier, CancellationToken cancellationToken, object?[]? args) + { + try + { + if (_throwing.TryGetValue(identifier, out JSException? thrown)) + { + throw thrown; + } + if (_handlers.TryGetValue(identifier, out var handler)) + { + object? result = handler(args ?? Array.Empty()); + var task = Task.FromResult((TValue)result!); + return new ValueTask(task); + } + } + catch (JSException) + { + throw; + } + throw new InvalidOperationException($"no handler registered for {identifier}"); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.Wasm/AnydocConverter.cs b/dotnet/Firecrawl.Anydoc.Wasm/AnydocConverter.cs new file mode 100644 index 00000000..a987b7e8 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Wasm/AnydocConverter.cs @@ -0,0 +1,210 @@ +using Firecrawl.Anydoc.Model; +using Microsoft.JSInterop; + +namespace Firecrawl.Anydoc; + +/// Converts documents to GitHub-Flavored Markdown inside a Blazor +/// WebAssembly app. Backed by the anydoc Rust engine compiled to WebAssembly +/// and reached through JavaScript interop, so no native platform library is +/// loaded. +/// +/// Blazor WASM has no synchronous JS interop, so every entry point is +/// async (`DetectFormatAsync`, `ToMarkdownBytesAsync`, ...). The sync-named +/// members exist for API parity with the native Firecrawl.Anydoc binding +/// but throw ; a browser has no +/// filesystem either, so path-based conversion is unsupported. +public sealed class AnydocConverter : IAsyncDisposable +{ + private const string ModulePath = "./_content/Firecrawl.Anydoc.Wasm/anydoc-wasm.js"; + + private readonly IJSRuntime _js; + private IJSObjectReference? _module; + + /// Create a converter for the app's JS runtime. Safe to reuse + /// across conversions; the WASM module is instantiated once, lazily. + public AnydocConverter(IJSRuntime js) => _js = js; + + /// Detect the format from the content itself: the signature and + /// identity each container specification designates (PDF header, RTF open + /// group, OLE stream names, ZIP package mimetype/content types). Plain-text + /// formats (CSV) carry no signature and return ; so + /// does anything unrecognized. + public async Task DetectFormatAsync(byte[] bytes) + { + IJSObjectReference module = await ModuleAsync(); + string? name = await module.InvokeAsync("formatFromBytes", bytes); + return FormatFromWasmName(name); + } + + /// The format an extension names, with or without a leading + /// dot. + public async Task DetectFormatByExtensionAsync(string extension) + { + IJSObjectReference module = await ModuleAsync(); + string? name = await module.InvokeAsync("formatFromExtension", extension); + return FormatFromWasmName(name); + } + + /// The format a path's extension names. + public async Task DetectFormatByPathAsync(string path) + { + IJSObjectReference module = await ModuleAsync(); + string? name = await module.InvokeAsync("formatFromPath", path); + return FormatFromWasmName(name); + } + + /// Convert an in-memory document to Markdown. Without a format, it + /// is detected from the content, which signature-less formats (CSV) have to + /// name explicitly. + public async Task ToMarkdownBytesAsync(byte[] data) => + await ToMarkdownBytesCoreAsync(data, format: null); + + /// Convert an in-memory document to Markdown, naming the format + /// explicitly. + public async Task ToMarkdownBytesAsync(byte[] data, Format format) => + await ToMarkdownBytesCoreAsync(data, format); + + /// Parse an in-memory document into the document model, which also + /// carries the embedded assets. The format is detected from the content. + /// Unsupported for : PDF conversion produces + /// Markdown directly and has no document-model form; use + /// . + public async Task ToDocumentAsync(byte[] bytes) + { + IJSObjectReference module = await ModuleAsync(); + try + { + return await module.InvokeAsync("toDocument", bytes, (string?)null); + } + catch (JSException e) + { + throw FromJsException(e); + } + } + + /// Not supported in the browser: a WebAssembly host has no + /// filesystem to read the file from. Fetch the bytes first and use + /// . + public Task ToMarkdownAsync(string path) => + throw new PlatformNotSupportedException( + "The WebAssembly binding has no filesystem; fetch the file's bytes and call ToMarkdownBytesAsync instead."); + + /// + /// always: Blazor WASM has + /// no synchronous JS interop; use . + public Format? DetectFormat(ReadOnlySpan bytes) => + throw SyncUnsupported(); + + /// + public Format? DetectFormatByExtension(string extension) => + throw SyncUnsupported(); + + /// + public Format? DetectFormatByPath(string path) => + throw SyncUnsupported(); + + /// + public string ToMarkdownBytes(ReadOnlySpan data) => + throw SyncUnsupported(); + + /// + public string ToMarkdownBytes(ReadOnlySpan data, Format format) => + throw SyncUnsupported(); + + /// + public Document ToDocument(ReadOnlySpan bytes) => + throw SyncUnsupported(); + + private async Task ToMarkdownBytesCoreAsync(byte[] data, Format? format) + { + IJSObjectReference module = await ModuleAsync(); + string? name = FormatToWasmName(format); + try + { + return await module.InvokeAsync("toMarkdownBytes", data, name); + } + catch (JSException e) + { + throw FromJsException(e); + } + } + + private async Task ModuleAsync() + { + if (_module is null) + { + _module = await _js.InvokeAsync("import", ModulePath); + } + return _module; + } + + private static PlatformNotSupportedException SyncUnsupported() => + new( + "Blazor WebAssembly has no synchronous JS interop; use the Async variant " + + "(for example ToMarkdownBytesAsync instead of ToMarkdownBytes)."); + + private static AnydocException FromJsException(JSException e) + { + // The shim re-throws engine errors as `anydoc::`. + const string Prefix = "anydoc:"; + string text = e.Message; + if (text.StartsWith(Prefix, StringComparison.Ordinal)) + { + string rest = text[Prefix.Length..]; + int colon = rest.IndexOf(':'); + if (colon > 0) + { + return AnydocException.From(rest[..colon], rest[(colon + 1)..]); + } + } + return AnydocException.From("unknown", text); + } + + /// The name the wasm-bindgen enum uses for a format (the JS + /// string enum is what crosses the boundary, not the ordinal). + private static string? FormatToWasmName(Format? format) => format switch + { + null => null, + Format.Doc => "doc", + Format.Docx => "docx", + Format.Odt => "odt", + Format.Pdf => "pdf", + Format.Ppt => "ppt", + Format.Pptx => "pptx", + Format.Rtf => "rtf", + Format.Epub => "epub", + Format.Excel => "xlsx", + Format.Ods => "ods", + Format.Odp => "odp", + Format.Csv => "csv", + _ => null, + }; + + private static Format? FormatFromWasmName(string? name) => name switch + { + "doc" => Format.Doc, + "docx" => Format.Docx, + "odt" => Format.Odt, + "pdf" => Format.Pdf, + "ppt" => Format.Ppt, + "pptx" => Format.Pptx, + "rtf" => Format.Rtf, + "epub" => Format.Epub, + "xlsx" => Format.Excel, + "ods" => Format.Ods, + "odp" => Format.Odp, + "csv" => Format.Csv, + _ => null, + }; + + /// Release the JavaScript module reference. Idempotent; the + /// converter is unusable afterwards. + public async ValueTask DisposeAsync() + { + if (_module is not null) + { + await _module.DisposeAsync(); + _module = null; + } + } +} \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj b/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj new file mode 100644 index 00000000..cb8a73e3 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj @@ -0,0 +1,49 @@ + + + + net8.0 + enable + enable + latest + + Firecrawl.Anydoc.Wasm + Firecrawl.Anydoc + + 0.1.7 + WebAssembly binding for anydoc: convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown inside a Blazor WebAssembly app, without any native platform library. + Firecrawl.Anydoc.Wasm + Firecrawl;Junian Triajianto + Firecrawl;junian + MIT + https://github.com/firecrawl/anydoc + https://github.com/firecrawl/anydoc + markdown; docx; pptx; xlsx; epub; pdf; converter; blazor; wasm; document + README.md + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.Wasm/wwwroot/anydoc-wasm.js b/dotnet/Firecrawl.Anydoc.Wasm/wwwroot/anydoc-wasm.js new file mode 100644 index 00000000..de014028 --- /dev/null +++ b/dotnet/Firecrawl.Anydoc.Wasm/wwwroot/anydoc-wasm.js @@ -0,0 +1,65 @@ +// Firecrawl.Anydoc.Wasm interop shim. +// +// Loaded once from managed code via Blazor's `import` helper. It lazy-loads +// the wasm-bindgen glue produced from the anydoc `wasm/` crate, instantiates +// the engine, and re-exposes the conversion entry points as plain functions +// that the C# AnydocConverter calls through IJSRuntime. +// +// Conversion errors thrown by the engine (a JS Error with a `code` property) +// are re-thrown with a parseable `anydoc::` message so the +// managed side can reconstruct an AnydocException with the right Kind. + +let gluePromise = null; + +function glue() { + if (!gluePromise) { + gluePromise = (async () => { + const m = await import("./anydoc_wasm.js"); + // The wasm binary sits next to this shim; resolve it against + // import.meta.url, not the app's base path. + const wasmUrl = new URL("./anydoc_wasm_bg.wasm", import.meta.url); + await m.default(wasmUrl); + return m; + })(); + } + return gluePromise; +} + +function rethrowConversion(error) { + const code = (error && error.code) || "unknown"; + const message = (error && error.message) || String(error); + throw new Error(`anydoc:${code}:${message}`); +} + +export async function formatFromBytes(bytes) { + const m = await glue(); + return m.formatFromBytes(bytes); +} + +export async function formatFromExtension(extension) { + const m = await glue(); + return m.formatFromExtension(extension); +} + +export async function formatFromPath(path) { + const m = await glue(); + return m.formatFromPath(path); +} + +export async function toMarkdownBytes(bytes, format) { + const m = await glue(); + try { + return m.toMarkdownBytes(bytes, format || undefined); + } catch (error) { + throw rethrowConversion(error); + } +} + +export async function toDocument(bytes, format) { + const m = await glue(); + try { + return m.toDocument(bytes, format || undefined); + } catch (error) { + throw rethrowConversion(error); + } +} \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc.slnx b/dotnet/Firecrawl.Anydoc.slnx index 20d5ca9c..9741466a 100644 --- a/dotnet/Firecrawl.Anydoc.slnx +++ b/dotnet/Firecrawl.Anydoc.slnx @@ -5,4 +5,10 @@ - + + + + + + + \ No newline at end of file diff --git a/dotnet/Firecrawl.Anydoc/AnydocConverter.cs b/dotnet/Firecrawl.Anydoc/AnydocConverter.cs index dacad465..741fc29a 100644 --- a/dotnet/Firecrawl.Anydoc/AnydocConverter.cs +++ b/dotnet/Firecrawl.Anydoc/AnydocConverter.cs @@ -5,96 +5,6 @@ namespace Firecrawl.Anydoc; -/// Input format, named after the extension that identifies it. -/// Container variants that share a parser (`.docm`, `.xlsm`, `.ppsx`, ...) map -/// onto these via or -/// . -public enum Format -{ - Doc = 0, - Docx = 1, - Odt = 2, - /// Converted with pdf-inspector, which emits Markdown directly; - /// is unsupported for PDFs. Scanned or - /// image-only PDFs (needing OCR) error as unsupported. - Pdf = 3, - Ppt = 4, - Pptx = 5, - Rtf = 6, - Epub = 7, - Excel = 8, - Ods = 9, - Odp = 10, - Csv = 11, -} - -/// The kind of a failed conversion, matching the stable -/// error.code() strings the Rust engine and the other bindings -/// publish. -public enum ConvertErrorKind -{ - /// The format is unknown, or cannot be converted at all: a scanned - /// or image-only PDF needs OCR, which anydoc does not do. - Unsupported, - /// The document is structurally unusable; no meaningful content - /// could be extracted. - Malformed, - /// The document is encrypted or password-protected. - Encrypted, - /// A fixed safety limit was crossed (decompression, nesting depth, - /// node count, repeat expansion, or retained asset bytes). - ResourceLimit, - /// A part required for any meaningful output is absent. - MissingPart, - /// The input could not be read. - Io, - /// An error kind this binding version does not know yet. - Unknown, -} - -/// Thrown when meaningful conversion is impossible. -/// names the failure the same way callers of the Node and Python bindings -/// branch on. -public sealed class AnydocException : Exception -{ - public AnydocException(ConvertErrorKind kind, string? message) : base(message) - { - Kind = kind; - Code = kind switch - { - ConvertErrorKind.Unsupported => "unsupported", - ConvertErrorKind.Malformed => "malformed", - ConvertErrorKind.Encrypted => "encrypted", - ConvertErrorKind.ResourceLimit => "resourceLimit", - ConvertErrorKind.MissingPart => "missingPart", - ConvertErrorKind.Io => "io", - _ => "unknown", - }; - } - - /// The kind of failure. - public ConvertErrorKind Kind { get; } - - /// Stable, machine-readable name for the kind: what callers branch - /// on, identical to the `code` the Node bindings put on their errors. - public string Code { get; } - - internal static AnydocException From(string? code, string? message) - { - ConvertErrorKind kind = code switch - { - "unsupported" => ConvertErrorKind.Unsupported, - "malformed" => ConvertErrorKind.Malformed, - "encrypted" => ConvertErrorKind.Encrypted, - "resourceLimit" => ConvertErrorKind.ResourceLimit, - "missingPart" => ConvertErrorKind.MissingPart, - "io" => ConvertErrorKind.Io, - _ => ConvertErrorKind.Unknown, - }; - return new AnydocException(kind, message); - } -} - /// Converts documents to GitHub-Flavored Markdown, backed by the anydoc /// Rust engine loaded as a native library. Safe to share across threads and /// reuse: the underlying native calls are stateless, so instances hold no diff --git a/dotnet/Firecrawl.Anydoc/Types.cs b/dotnet/Firecrawl.Anydoc/Types.cs new file mode 100644 index 00000000..0b5be0bf --- /dev/null +++ b/dotnet/Firecrawl.Anydoc/Types.cs @@ -0,0 +1,91 @@ +namespace Firecrawl.Anydoc; + +/// Input format, named after the extension that identifies it. +/// Container variants that share a parser (`.docm`, `.xlsm`, `.ppsx`, ...) map +/// onto these via or +/// . +public enum Format +{ + Doc = 0, + Docx = 1, + Odt = 2, + /// Converted with pdf-inspector, which emits Markdown directly; + /// is unsupported for PDFs. Scanned or + /// image-only PDFs (needing OCR) error as unsupported. + Pdf = 3, + Ppt = 4, + Pptx = 5, + Rtf = 6, + Epub = 7, + Excel = 8, + Ods = 9, + Odp = 10, + Csv = 11, +} + +/// The kind of a failed conversion, matching the stable +/// error.code() strings the Rust engine and the other bindings +/// publish. +public enum ConvertErrorKind +{ + /// The format is unknown, or cannot be converted at all: a scanned + /// or image-only PDF needs OCR, which anydoc does not do. + Unsupported, + /// The document is structurally unusable; no meaningful content + /// could be extracted. + Malformed, + /// The document is encrypted or password-protected. + Encrypted, + /// A fixed safety limit was crossed (decompression, nesting depth, + /// node count, repeat expansion, or retained asset bytes). + ResourceLimit, + /// A part required for any meaningful output is absent. + MissingPart, + /// The input could not be read. + Io, + /// An error kind this binding version does not know yet. + Unknown, +} + +/// Thrown when meaningful conversion is impossible. +/// names the failure the same way callers of the Node and Python bindings +/// branch on. +public sealed class AnydocException : Exception +{ + public AnydocException(ConvertErrorKind kind, string? message) : base(message) + { + Kind = kind; + Code = kind switch + { + ConvertErrorKind.Unsupported => "unsupported", + ConvertErrorKind.Malformed => "malformed", + ConvertErrorKind.Encrypted => "encrypted", + ConvertErrorKind.ResourceLimit => "resourceLimit", + ConvertErrorKind.MissingPart => "missingPart", + ConvertErrorKind.Io => "io", + _ => "unknown", + }; + } + + /// The kind of failure. + public ConvertErrorKind Kind { get; } + + /// Stable, machine-readable name for the kind: what callers branch + /// on, identical to the `code` the Node bindings put on their errors. + public string Code { get; } + + internal static AnydocException From(string? code, string? message) + { + ConvertErrorKind kind = code switch + { + "unsupported" => ConvertErrorKind.Unsupported, + "malformed" => ConvertErrorKind.Malformed, + "encrypted" => ConvertErrorKind.Encrypted, + "resourceLimit" => ConvertErrorKind.ResourceLimit, + "missingPart" => ConvertErrorKind.MissingPart, + "io" => ConvertErrorKind.Io, + _ => ConvertErrorKind.Unknown, + }; + return new AnydocException(kind, message); + } +} \ No newline at end of file diff --git a/dotnet/README.md b/dotnet/README.md index 7417a7f6..4a3b7525 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -72,6 +72,49 @@ The document model (`Firecrawl.Anydoc.Model`) encodes every block, inline, list, table grid, note, and embedded asset, mirroring the Node and Python bindings exactly (`Kind` string discriminants, `NoteId`, `MediaType`, etc.). +## Blazor WebAssembly: `Firecrawl.Anydoc.Wasm` + +For client-side Blazor there is a sibling package, `Firecrawl.Anydoc.Wasm`, +that runs the same engine compiled to WebAssembly. No native library is +loaded, so a `browser-wasm` publish needs no RID-specific runtime: the engine +ships as static web assets next to the managed wrapper. + +```csharp +using Firecrawl.Anydoc; +using Microsoft.JSInterop; + +// Injected into a Blazor component or service: +var anydoc = new AnydocConverter(jsRuntime); + +// A browser has no filesystem, so conversion always starts from bytes and +// every call is async (Blazor WASM has no synchronous JS interop): +byte[] bytes = await httpClient.GetByteArrayAsync("report.docx"); +string markdown = await anydoc.ToMarkdownBytesAsync(bytes); +Document document = await anydoc.ToDocumentAsync(bytes); + +Format? format = await anydoc.DetectFormatAsync(bytes); +``` + +One converter instance is enough for the whole app: it instantiates the WASM +module lazily and caches it. The API matches the native package — `Format`, +`ConvertErrorKind`, `AnydocException`, and the `Firecrawl.Anydoc.Model` types +are the same shared source — but the sync-named members (`ToMarkdownBytes`, +`ToDocument`, `DetectFormat`, ...) throw `PlatformNotSupportedException` +because Blazor WASM cannot block on JavaScript interop. + +### Building the WebAssembly engine + +The compiled assets are gitignored and produced from the `wasm/` crate: + +```sh +sh dotnet/build-wasm.sh # needs the wasm32-unknown-unknown target and wasm-pack +``` + +This writes `anydoc_wasm.js` (wasm-bindgen glue) and `anydoc_wasm_bg.wasm` +into `Firecrawl.Anydoc.Wasm/wwwroot/`, which the package ships as static web +assets. `wwwroot/anydoc-wasm.js` — the small interop shim the managed wrapper +loads via Blazor's `import` — is checked in and never regenerated. + ## Building The C# wrapper is generated from the Rust FFI with diff --git a/dotnet/build-wasm.sh b/dotnet/build-wasm.sh new file mode 100755 index 00000000..9ef3a3d3 --- /dev/null +++ b/dotnet/build-wasm.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Build the anydoc WASM engine from the repo's wasm/ crate and lay the +# wasm-bindgen output into Firecrawl.Anydoc.Wasm/wwwroot so the NuGet package +# ships them as static web assets. +# +# Produces: +# Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm.js wasm-bindgen glue +# Firecrawl.Anydoc.Wasm/wwwroot/anydoc_wasm_bg.wasm engine binary +# +# The static wwwroot/anydoc-wasm.js interop shim is checked in (not generated). +# +# Prerequisites: +# rustup target add wasm32-unknown-unknown +# cargo install wasm-pack (or use the wasm-bindgen CLI directly) +set -eu + +cd "$(dirname "$0")" +root=$(pwd) +wasm_dir="$root/../wasm" +out="$root/Firecrawl.Anydoc.Wasm/wwwroot" + +build_with_wasm_pack() { + (cd "$wasm_dir" && wasm-pack build --release --target web --out-dir pkg) + cp "$wasm_dir/pkg/anydoc_wasm.js" "$out/anydoc_wasm.js" + cp "$wasm_dir/pkg/anydoc_wasm_bg.wasm" "$out/anydoc_wasm_bg.wasm" +} + +build_with_wasm_bindgen() { + rustup target add wasm32-unknown-unknown >/dev/null + (cd "$wasm_dir" && cargo build --release --target wasm32-unknown-unknown --package anydoc-wasm) + wasm-bindgen "$root/../target/wasm32-unknown-unknown/release/anydoc_wasm.wasm" \ + --target web --out-dir "$out" --out-name anydoc_wasm +} + +rm -f "$out/anydoc_wasm.js" "$out/anydoc_wasm_bg.wasm" +if command -v wasm-pack >/dev/null 2>&1; then + build_with_wasm_pack +elif command -v wasm-bindgen >/dev/null 2>&1; then + build_with_wasm_bindgen +else + echo "no wasm-pack or wasm-bindgen toolchain found." >&2 + echo "install one: cargo install wasm-pack (or cargo install wasm-bindgen-cli)" >&2 + exit 1 +fi + +echo "-> $out/anydoc_wasm.js" +echo "-> $out/anydoc_wasm_bg.wasm" \ No newline at end of file diff --git a/dotnet/examples/AnydocBlazorWasmDemo/AnydocBlazorWasmDemo.csproj b/dotnet/examples/AnydocBlazorWasmDemo/AnydocBlazorWasmDemo.csproj new file mode 100644 index 00000000..34eb0f8e --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/AnydocBlazorWasmDemo.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/dotnet/examples/AnydocBlazorWasmDemo/App.razor b/dotnet/examples/AnydocBlazorWasmDemo/App.razor new file mode 100644 index 00000000..2835fc6f --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/App.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Layout/MainLayout.razor b/dotnet/examples/AnydocBlazorWasmDemo/Layout/MainLayout.razor new file mode 100644 index 00000000..e497344e --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Layout/MainLayout.razor @@ -0,0 +1,27 @@ +@inherits LayoutComponentBase + + + + AnydocBlazorWasmDemo + + + + + + +
+ @Body +
+
+ +
@ex.Message
+
+
+
+
+ + Documentation and demos + + About Blazor + +
diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Layout/NavMenu.razor b/dotnet/examples/AnydocBlazorWasmDemo/Layout/NavMenu.razor new file mode 100644 index 00000000..7c8851f0 --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Layout/NavMenu.razor @@ -0,0 +1,13 @@ + + +@code { + private bool expanded = true; +} \ No newline at end of file diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Pages/MarkdownConverter.razor b/dotnet/examples/AnydocBlazorWasmDemo/Pages/MarkdownConverter.razor new file mode 100644 index 00000000..341b8080 --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Pages/MarkdownConverter.razor @@ -0,0 +1,244 @@ +@page "/" +@using Firecrawl.Anydoc + +Document to Markdown + + + + + + + + Document → Markdown + + Convert documents to GitHub-Flavored Markdown right in your browser. + Files never leave your machine. + + + + + + + + + + + + + + + @BusyText + + + @ProgressTitle + @if (Busy) + { + + } + + + @if (_items.Any()) + { + + Clear + + } + + + + Supported formats + + @foreach (var f in SupportedFormats) + { + @f + } + + + + + + @if (_items.Any()) + { + + @foreach (var item in _items) + { + + + + + @item.Name + @if (item.Format is not null) + { + @item.Format + } + @item.SizeLabel + + @if (item.Success) + { + Copy + } + + + @if (item.Success && !string.IsNullOrEmpty(item.Markdown)) + { + + } + else if (!item.Success) + { + + @item.Error + + } + + + } + + } + + + +@code { + private bool Busy { get; set; } + public string BusyText => Busy ? "Converting…" : "Ready"; + public string ProgressTitle { get; private set; } = string.Empty; + private int ProgressPercent { get; set; } + + private readonly List _items = new(); + private readonly Dictionary _uploads = new(); + + private static readonly string[] SupportedFormats = + { + ".docx", ".doc", ".pdf", ".pptx", ".ppt", ".xlsx", ".xls", + ".odt", ".ods", ".odp", ".rtf", ".epub", ".csv", + }; + + [Inject] private AnydocConverter Anydoc { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + + private async Task OnProgressChangeAsync(FluentInputFileEventArgs e) + { + ProgressTitle = e.ProgressTitle; + ProgressPercent = e.ProgressPercent; + + if (!_uploads.TryGetValue(e.Index, out var stream)) + { + stream = new MemoryStream(); + _uploads[e.Index] = stream; + } + + await stream.WriteAsync(e.Buffer.Data.AsMemory(0, e.Buffer.BytesRead)); + } + + private async Task OnCompletedAsync(IEnumerable files) + { + Busy = true; + try + { + foreach (var e in files) + { + if (_uploads.Remove(e.Index, out var stream)) + { + await using (stream) + { + await ConvertAsync(new FileItem { Name = e.Name, Size = e.Size }, stream.ToArray()); + } + } + } + } + finally + { + Busy = false; + } + } + + private async Task ConvertAsync(FileItem item, byte[] data) + { + try + { + var format = await ResolveFormatAsync(item.Name, data); + item.Format = format?.ToString(); + item.Markdown = format is null + ? await Anydoc.ToMarkdownBytesAsync(data) + : await Anydoc.ToMarkdownBytesAsync(data, format.Value); + item.Success = true; + } + catch (AnydocException ex) + { + item.Error = $"{ex.Code}: {ex.Message}"; + } + catch (Exception ex) + { + item.Error = ex.Message; + } + + _items.Insert(0, item); + } + + private async Task ResolveFormatAsync(string fileName, byte[] data) + { + var ext = Path.GetExtension(fileName); + if (!string.IsNullOrEmpty(ext)) + { + var byExt = await Anydoc.DetectFormatByExtensionAsync(ext); + if (byExt is not null) + { + return byExt; + } + } + + return await Anydoc.DetectFormatAsync(data); + } + + private void ClearAll() + { + if (!Busy) + { + _items.Clear(); + } + } + + private async Task CopyAsync(FileItem item) + { + if (string.IsNullOrEmpty(item.Markdown)) return; + await JS.InvokeVoidAsync("navigator.clipboard.writeText", item.Markdown); + } + + private sealed class FileItem + { + public string Name { get; init; } = ""; + public long Size { get; init; } + public bool Success { get; set; } + public string? Format { get; set; } + public string? Markdown { get; set; } + public string? Error { get; set; } + + public string SizeLabel => Size < 1024 + ? $"{Size} bytes" + : $"{Size / 1024.0:N1} KB"; + + public Icon ItemIcon() + { + if (!Success) + { + return new Icons.Filled.Size20.DocumentError(); + } + + return string.IsNullOrEmpty(Markdown) + ? (Icon)new Icons.Filled.Size20.Clock() + : (Icon)new Icons.Filled.Size20.DocumentCheckmark(); + } + } +} \ No newline at end of file diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Pages/NotFound.razor b/dotnet/examples/AnydocBlazorWasmDemo/Pages/NotFound.razor new file mode 100644 index 00000000..f74a7a35 --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Program.cs b/dotnet/examples/AnydocBlazorWasmDemo/Program.cs new file mode 100644 index 00000000..bfd277d9 --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Program.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.FluentUI.AspNetCore.Components; +using Firecrawl.Anydoc; +using AnydocBlazorWasmDemo; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddFluentUIComponents(); +builder.Services.AddScoped(); + +await builder.Build().RunAsync(); diff --git a/dotnet/examples/AnydocBlazorWasmDemo/Properties/launchSettings.json b/dotnet/examples/AnydocBlazorWasmDemo/Properties/launchSettings.json new file mode 100644 index 00000000..17284922 --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5017", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7191;http://localhost:5017", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/examples/AnydocBlazorWasmDemo/_Imports.razor b/dotnet/examples/AnydocBlazorWasmDemo/_Imports.razor new file mode 100644 index 00000000..1bc3fb4c --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.JSInterop +@using AnydocBlazorWasmDemo +@using AnydocBlazorWasmDemo.Layout diff --git a/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/css/app.css b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/css/app.css new file mode 100644 index 00000000..b1c4b3ae --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/css/app.css @@ -0,0 +1,218 @@ +@import '../_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css'; + +body { + --body-font: "Segoe UI Variable", "Segoe UI", sans-serif; + font-family: var(--body-font); + font-size: var(--type-ramp-base-font-size); + line-height: var(--type-ramp-base-line-height); + margin: 0; +} + +.navmenu-icon { + display: none; +} + +.main { + min-height: calc(100dvh - 86px); + color: var(--neutral-foreground-rest); + align-items: stretch !important; +} + +.body-content { + align-self: stretch; + height: calc(100dvh - 86px) !important; + display: flex; +} + +.content { + padding: 0.5rem 1.5rem; + align-self: stretch !important; + width: 100%; +} + +footer { + background: var(--neutral-layer-4); + color: var(--neutral-foreground-rest); + align-items: center; + padding: 10px 10px; +} + + footer a { + color: var(--neutral-foreground-rest); + text-decoration: none; + } + + footer a:focus { + outline: 1px dashed; + outline-offset: 3px; + } + + footer a:hover { + text-decoration: underline; + } + +.alert { + border: 1px dashed var(--accent-fill-rest); + padding: 5px; +} + +.page-stack { + width: 100%; +} + +.hero-card, +.control-card { + width: 100%; +} + +.result-card { + width: 100%; +} + +.result-card .md-output { + width: 100%; + display: block; +} + +.result-card fluent-text-area.md-output::part(control) { + width: 100%; + max-width: none; +} + +.md-output::part(textarea) { + font-family: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, monospace; + font-size: 13px; + line-height: 1.5; + white-space: pre; + min-width: 100%; +} + + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; + margin: 20px 0; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::before { + content: "An error has occurred. " + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +@media (max-width: 600px) { + .header-gutters { + margin: 0.5rem 3rem 0.5rem 1.5rem !important; + } + + [dir="rtl"] .header-gutters { + margin: 0.5rem 1.5rem 0.5rem 3rem !important; + } + + .main { + flex-direction: column !important; + row-gap: 0 !important; + } + + nav.sitenav { + width: 100%; + height: 100%; + } + + #main-menu { + width: 100% !important; + } + + #main-menu > div:first-child:is(.expander) { + display: none; + } + + .navmenu { + width: 100%; + } + + #navmenu-toggle { + appearance: none; + } + + #navmenu-toggle ~ nav { + display: none; + } + + #navmenu-toggle:checked ~ nav { + display: block; + } + + .navmenu-icon { + cursor: pointer; + z-index: 10; + display: block; + position: absolute; + top: 15px; + left: unset; + right: 20px; + width: 20px; + height: 20px; + border: none; + } + + [dir="rtl"] .navmenu-icon { + left: 20px; + right: unset; + } +} diff --git a/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/favicon.ico b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e189d8e579e552ab2b3c82a949bccbc5ea00f3ed GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/icon-192.png b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..166f56da7612ea74df6a297154c8d281a4f28a14 GIT binary patch literal 2626 zcmV-I3cdA-P)v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 diff --git a/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/index.html b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/index.html new file mode 100644 index 00000000..70893a4b --- /dev/null +++ b/dotnet/examples/AnydocBlazorWasmDemo/wwwroot/index.html @@ -0,0 +1,33 @@ + + + + + + + AnydocBlazorWasmDemo + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + From f77bdc86ce742d55f2e96637e6b74a2e77aa25bd Mon Sep 17 00:00:00 2001 From: Junian Triajianto Date: Fri, 7 Aug 2026 19:57:30 +0700 Subject: [PATCH 3/4] feat: Add shared model and types for native and WebAssembly bindings --- .../{Firecrawl.Anydoc => Firecrawl.Anydoc.Shared}/Model.cs | 0 .../{Firecrawl.Anydoc => Firecrawl.Anydoc.Shared}/Types.cs | 0 dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj | 4 ++-- dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj | 7 +++++++ 4 files changed, 9 insertions(+), 2 deletions(-) rename dotnet/{Firecrawl.Anydoc => Firecrawl.Anydoc.Shared}/Model.cs (100%) rename dotnet/{Firecrawl.Anydoc => Firecrawl.Anydoc.Shared}/Types.cs (100%) diff --git a/dotnet/Firecrawl.Anydoc/Model.cs b/dotnet/Firecrawl.Anydoc.Shared/Model.cs similarity index 100% rename from dotnet/Firecrawl.Anydoc/Model.cs rename to dotnet/Firecrawl.Anydoc.Shared/Model.cs diff --git a/dotnet/Firecrawl.Anydoc/Types.cs b/dotnet/Firecrawl.Anydoc.Shared/Types.cs similarity index 100% rename from dotnet/Firecrawl.Anydoc/Types.cs rename to dotnet/Firecrawl.Anydoc.Shared/Types.cs diff --git a/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj b/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj index cb8a73e3..e6447b6b 100644 --- a/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj +++ b/dotnet/Firecrawl.Anydoc.Wasm/Firecrawl.Anydoc.Wasm.csproj @@ -34,8 +34,8 @@ - - + + diff --git a/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj b/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj index 567b1abe..abbd7820 100644 --- a/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj +++ b/dotnet/Firecrawl.Anydoc/Firecrawl.Anydoc.csproj @@ -39,6 +39,13 @@ CopyToOutputDirectory="PreserveNewest" /> + + + + + + From f65a38d68845526e399925072a27bc5da6fadf5a Mon Sep 17 00:00:00 2001 From: Junian Triajianto Date: Fri, 7 Aug 2026 20:09:26 +0700 Subject: [PATCH 4/4] feat: Add Avalonia UI project structure with main window and view models for document conversion --- .../AnydocAvaloniaDemo.csproj | 29 ++++++ dotnet/examples/AnydocAvaloniaDemo/App.axaml | 15 +++ .../examples/AnydocAvaloniaDemo/App.axaml.cs | 28 ++++++ .../Assets/avalonia-logo.ico | Bin 0 -> 175875 bytes dotnet/examples/AnydocAvaloniaDemo/Program.cs | 24 +++++ .../AnydocAvaloniaDemo/ViewLocator.cs | 37 +++++++ .../ViewModels/MainViewModel.cs | 91 ++++++++++++++++++ .../ViewModels/ViewModelBase.cs | 7 ++ .../AnydocAvaloniaDemo/Views/MainWindow.axaml | 59 ++++++++++++ .../Views/MainWindow.axaml.cs | 56 +++++++++++ .../examples/AnydocAvaloniaDemo/app.manifest | 18 ++++ 11 files changed, 364 insertions(+) create mode 100644 dotnet/examples/AnydocAvaloniaDemo/AnydocAvaloniaDemo.csproj create mode 100644 dotnet/examples/AnydocAvaloniaDemo/App.axaml create mode 100644 dotnet/examples/AnydocAvaloniaDemo/App.axaml.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/Assets/avalonia-logo.ico create mode 100644 dotnet/examples/AnydocAvaloniaDemo/Program.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/ViewLocator.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/ViewModels/MainViewModel.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/ViewModels/ViewModelBase.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/Views/MainWindow.axaml create mode 100644 dotnet/examples/AnydocAvaloniaDemo/Views/MainWindow.axaml.cs create mode 100644 dotnet/examples/AnydocAvaloniaDemo/app.manifest diff --git a/dotnet/examples/AnydocAvaloniaDemo/AnydocAvaloniaDemo.csproj b/dotnet/examples/AnydocAvaloniaDemo/AnydocAvaloniaDemo.csproj new file mode 100644 index 00000000..0f1550cb --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/AnydocAvaloniaDemo.csproj @@ -0,0 +1,29 @@ + + + WinExe + net10.0 + enable + app.manifest + + + + + + + + + + + + + + + + + + None + All + + + + diff --git a/dotnet/examples/AnydocAvaloniaDemo/App.axaml b/dotnet/examples/AnydocAvaloniaDemo/App.axaml new file mode 100644 index 00000000..c8f723c1 --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/App.axaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/examples/AnydocAvaloniaDemo/App.axaml.cs b/dotnet/examples/AnydocAvaloniaDemo/App.axaml.cs new file mode 100644 index 00000000..625edfd7 --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using AnydocAvaloniaDemo.ViewModels; +using AnydocAvaloniaDemo.Views; + +namespace AnydocAvaloniaDemo; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/dotnet/examples/AnydocAvaloniaDemo/Assets/avalonia-logo.ico b/dotnet/examples/AnydocAvaloniaDemo/Assets/avalonia-logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..f7da8bb5863b7cecec2adcdebd948fe2f9418d0c GIT binary patch literal 175875 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 BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/dotnet/examples/AnydocAvaloniaDemo/ViewLocator.cs b/dotnet/examples/AnydocAvaloniaDemo/ViewLocator.cs new file mode 100644 index 00000000..104e53d3 --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using AnydocAvaloniaDemo.ViewModels; + +namespace AnydocAvaloniaDemo; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/dotnet/examples/AnydocAvaloniaDemo/ViewModels/MainViewModel.cs b/dotnet/examples/AnydocAvaloniaDemo/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..824dacae --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/ViewModels/MainViewModel.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Firecrawl.Anydoc; + +namespace AnydocAvaloniaDemo.ViewModels; + +public partial class MainViewModel : ViewModelBase +{ + private readonly AnydocConverter _converter = new(); + + [ObservableProperty] + public partial string SelectedFile { get; set; } = ""; + + [ObservableProperty] + public partial string Status { get; set; } = "Select a document to convert."; + + [ObservableProperty] + public partial string Markdown { get; set; } = ""; + + [ObservableProperty] + public partial bool IsBusy { get; set; } + + [ObservableProperty] + public partial bool HasError { get; set; } + + [ObservableProperty] + public partial string? Format { get; set; } + + [ObservableProperty] + public partial bool HasResult { get; set; } + + [RelayCommand] + public void SetFile(string path) + { + SelectedFile = path; + Status = Path.GetFileName(path); + Markdown = ""; + HasError = false; + HasResult = false; + Format = null; + } + + [RelayCommand] + public async Task ConvertAsync() + { + if (string.IsNullOrWhiteSpace(SelectedFile)) + { + return; + } + + IsBusy = true; + HasError = false; + HasResult = false; + Markdown = ""; + Status = "Converting…"; + + try + { + byte[] bytes = await File.ReadAllBytesAsync(SelectedFile); + + Format? format = _converter.DetectFormatByExtension(Path.GetExtension(SelectedFile)) + ?? _converter.DetectFormat(bytes); + + string markdown = format is null + ? await _converter.ToMarkdownBytesAsync(bytes) + : await _converter.ToMarkdownBytesAsync(bytes, format.Value); + + Format = format?.ToString(); + Markdown = markdown; + HasResult = true; + Status = $"Converted {bytes.Length:N0} bytes to Markdown."; + } + catch (AnydocException ex) + { + HasError = true; + Status = $"{ex.Code}: {ex.Message}"; + } + catch (Exception ex) + { + HasError = true; + Status = ex.Message; + } + finally + { + IsBusy = false; + } + } +} diff --git a/dotnet/examples/AnydocAvaloniaDemo/ViewModels/ViewModelBase.cs b/dotnet/examples/AnydocAvaloniaDemo/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..326f2107 --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace AnydocAvaloniaDemo.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/dotnet/examples/AnydocAvaloniaDemo/Views/MainWindow.axaml b/dotnet/examples/AnydocAvaloniaDemo/Views/MainWindow.axaml new file mode 100644 index 00000000..fcb5c836 --- /dev/null +++ b/dotnet/examples/AnydocAvaloniaDemo/Views/MainWindow.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + +