上周收到一份 OFD 格式的电子发票,想转成 PDF 归档。我习惯性地把文件拖进浏览器里的在线转换站,结果先弹广告、再要求上传。一张几百 KB 的发票,得从别人服务器过一遍。我直接把页面关了。
文件转换这件事存在了二十多年,桌面端的选择却一直别扭:要么装几百兆的 Office,要么把文件交到网上。Filee 想解决的,正是这一小段摩擦。

图 1:Filee 官方 banner,右侧是它标志性的环形格式菜单。图片来源:项目仓库 docs/media/banner-zh-CN.jpg。
它是什么
Filee 是一个免费、开源、完全离线的文件转换器,官网给它的定位只有一句话:一个靠拖拽完成工作的文件转换器。
交互方式很特别。在资源管理器里按住 Ctrl,把文件拖起来,光标旁边会展开一个圆环,环上全是这个文件能转成的格式;把文件丢到某一格,转换就开始,结果保存在原文件旁边。
整个过程没有主窗口,没有“打开软件”这一步。你人在哪,工具就在哪。
它覆盖 255 种格式:图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体、CAD、3D 模型、地理矢量数据。
更关键的是,这些转换不需要装 Office,也不需要装 Hancom Office 或 LibreOffice。只有少数冷门格式才需要额外的可选引擎。

图 2:拖起文件后,光标处弹出格式圆环,松手即开始转换。动图来源:项目仓库 docs/media/demo-zh-CN.webp。
它不只是一个图形界面玩具。同一个安装包还带一个命令行程序 filee,能把同样的转换能力接进脚本和流水线。
我认为这才是它真正的价值所在:图形界面负责日常的小麻烦,命令行负责批量的大麻烦,两者共用一套引擎。
用什么写的
Filee 用 C# 写成,基于 .NET 10,界面框架是 Avalonia。
这个组合值得说一句。它没有走 Electron 那条路,不是把浏览器打包进桌面,而是一个原生的 .NET 桌面程序,启动快、内存占用稳,Windows、Linux、macOS 三个平台共用一套界面代码。
底层依赖基本都是各自领域里最成熟的那一批:
- 图像处理用 Magick.NET,覆盖 JPEG XL、PSD、相机 RAW、DICOM 这些;
- PDF 用 PDFsharp、PDFium、PdfPig,分别负责写、渲染、抽文字;
- 矢量图用 Svg.Skia 和 SkiaSharp;
- CAD 用 ACadSharp;
- 邮件用 MimeKit 和 MsgReader;
- 表格读取用 ExcelDataReader;
- 3D 模型用 SharpGLTF;
- Markdown 用 Markdig;
- 数据格式用 System.Text.Json、YamlDotNet、Tomlyn。
两个外部程序是随安装包一起发布的:rhwp(Rust 写的 HWP 渲染器)和 7-Zip。它们体积小,跟着程序走,保证换一台电脑行为一致。
体积大的引擎则做成可选下载:FFmpeg 处理音视频,LibreOffice 处理 DOC、PPT 这类老格式,calibre 处理冷门电子书,Ghostscript 处理 EPS/PS,Pandoc 处理 Markdown 与各种标记语言。
这套分层让安装包保持在几十兆,同时又能覆盖 255 种格式。
架构与实现原理
翻完它的架构文档,我发现整个项目其实只围绕一个核心模型展开:把格式当节点,把转换器当带权重的边,然后做图搜索。
这条思路解释了很多看起来“凭空出现”的能力。
比如 HWPX 转 PNG。没有任何一个引擎直接支持这件事,但 Filee 会自己拼出一条两步路线:先用 rhwp 把 HWPX 渲染成 PDF,再用 PDFium 把 PDF 页转成 PNG。
同理,你只要新增一条 epub → pdf 的边,epub → png、epub → jpg 这些路线会自动解锁,不需要改别的代码。
路径规划器是这么写的:
// Dijkstra over formats. The graph is small (a few hundred nodes).
var dist = new Dictionary<string, int> { [from] = 0 };
var prev = new Dictionary<string, (string Node, RouteStep Step)>();
var hops = new Dictionary<string, int> { [from] = 0 };
var queue = new PriorityQueue<string, int>();
queue.Enqueue(from, 0);
while (queue.TryDequeue(out var node, out var cost))
{
if (cost > dist[node])
continue; // stale queue entry
if (node == to)
break;
if (hops[node] >= MaxSteps)
continue;
foreach (var (converter, edge, edgeCost) in EdgesFrom(node))
{
if (edge.To == edge.From || edge.FrameSequence || edge.MotionPhoto || edge.LivePhoto
|| (edge.ImageSourcesOnly && !imageSource) || (edge.NotForImageSources && imageSource)
|| (edge.Unshaped && shaping)
|| (edge.DirectOnly && (node != from || edge.To != to)))
continue;
var next = cost + edgeCost;
if (dist.TryGetValue(edge.To, out var known) && known <= next)
continue;
dist[edge.To] = next;
hops[edge.To] = hops[node] + 1;
prev[edge.To] = (node, new RouteStep(converter, edge.From, edge.To, Unshaped: edge.Unshaped));
queue.Enqueue(edge.To, next);
}
}
这段代码在 src/Filee.Core/Conversion/RoutePlanner.cs 里,是真实实现,不是示意。
几个细节很见功力。
最大跳数被硬限制在 3。 官方注释写得很直白:更长的链路几乎必然是又慢又有损。这条约束挡住了一堆“能转但结果不能看”的组合。
每条边可以带限制条件。 图片描摹(转 SVG)只允许从位图出发,标成 ImageSourcesOnly;PDF 转 PPTX 的图片版只允许作为独立路线,标成 DirectOnly,永远不能当中转站。
引擎优先级不改变跳数,只做同分裁决。 用户可以在设置里调整引擎顺序,这个顺序被换算成 0 到 9 的微小代价,加到边上。它只用来打破平局,不会让原本两步的路线变成三步。
文本整形是个显式排除项。 rhwp 从左到右逐字绘制,不会做阿拉伯文、希伯来文、印度语系的连写整形。所以当源文件含有这类文字时,任务队列会重新规划一条绕开 Unshaped 边的路线,宁可变慢,也不产出错乱的 PDF。
再看执行层。JobQueue 按每个引擎声明的 MaxParallelism 控制并发,中间文件放在临时目录,最终输出由路径分配器统一命名——源文件永远不被覆盖,重名时自动加编号。
整个程序分成三层:Filee.App 是 Avalonia 界面与拖拽交互,Filee.Core 是格式注册表、路线规划、任务队列、预设与输出规则,Filee.Engines 是各种转换器的实现。命令行是第四层 Filee.Cli,它复用同一套 Core 和同一份预设,只是没有界面。

图 3:环形工具栏可以按文件类型配置,每一面放 12 格,正面是常用格式,背面是冷门格式和工具。图片来源:项目仓库 docs/media/page-toolbar-zh-CN.png。
还有一个细节我挺喜欢:“不装 Office 也能把 DOCX 转 PDF”是靠自己实现渲染链做到的。
内置的读取器把 DOCX、XLSX、PPTX 解析成文档模型,由 HWPX 写入器写成 HWPX,再交给 rhwp 渲染成 PDF。整条链路在进程内或随包程序里完成,不调用任何系统上已安装的办公软件。
可选引擎的治理也很克制。每个引擎的下载地址、SHA-256、体积都固定写在 engines.json 里,安装前校验哈希,解压到暂存目录,完整之后才整体替换。
引擎装在 %LOCALAPPDATA%\Filee\engines,不在程序目录里,所以升级程序不会丢引擎,卸载程序又会一并清掉。

图 4:只有音视频、老式文档、冷门电子书才需要额外引擎,安装时按需勾选。图片来源:项目仓库 docs/media/engine-setup-en.png。
怎么部署
有三种拿法:装发行版、用便携包、从源码构建。
Windows 10 / 11
从 Releases 页面下载 Filee-1.12.0-win-Setup.exe,双击安装。
安装程序是 Filee 自己写的(src/Filee.Setup),不是 Inno Setup 套壳,所以界面语言和主程序一致,也不需要预装 .NET 运行时。
需要静默安装的话,它兼容 Inno Setup 的参数:
.\Filee-1.12.0-win-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
不想安装就用便携版,解压后直接运行:
Expand-Archive .\Filee-1.12.0-win-Portable.zip -DestinationPath C:\Tools\Filee
C:\Tools\Filee\Filee.exe
安装时可以选择把 filee 命令加进 PATH,也可以勾选资源管理器右键菜单。
Linux(x64 / ARM64)
要求 Ubuntu 22.04 及以上,不需要 root,但需要 Python 3:
tar -xzf Filee-1.12.0-linux-x64.tar.gz
cd Filee-1.12.0-linux-x64
./install.sh
拖拽手势依赖 X11。Wayland 下环形菜单的“按住按键拖拽”用不了,但主页的拖放区和文件管理器右键菜单仍然可用。
macOS 13+
目前是预览版,包没有做公证:
tar -xzf Filee-1.12.0-osx-arm64.tar.gz
cd Filee-1.12.0-osx-arm64
./install.sh
第一次启动会被系统拦下,需要到“系统设置 → 隐私与安全性”里点“仍要打开”。
从源码构建
需要 .NET 10 SDK。仓库的构建脚本是 PowerShell 写的,Linux 和 macOS 上装个 pwsh 就能跑:
git clone https://github.com/KnifeLemon/Filee.git
cd Filee
# 拉取随包引擎,只取需要的两个,避免下载全部
pwsh build/fetch-engines.ps1 -Only rhwp,pandoc
# 启动桌面程序
dotnet run --project src/Filee.App
# 跑测试
dotnet test
fetch-engines.ps1 读取的也是 engines.json,所以开发环境和正式发行版用的是同一批引擎版本,不会出现“本地能转、装完不能转”的错位。
怎么用
下面这些命令都是官方文档里的真实用法,我把参数补齐成了可以直接跑的形式。
场景一:把一堆 HEIC 和 RAW 批量转成 JPG
iPhone 拍的照片默认是 HEIC,很多老系统打不开。单个转换很简单:
filee convert IMG_2041.heic --to jpg
整个目录连同子目录一起处理,输出到指定文件夹,并保留原始时间戳:
filee convert D:\Photos --recursive --to jpg -o D:\Photos\jpg --quality 88 --keep-dates
--quality 接受 1 到 100,只对 JPG、WebP、AVIF 这类有损格式生效。
同名文件默认不会被覆盖,Filee 会自动加编号;想强制覆盖就加 --overwrite,想跳过已存在的就加 --skip。
场景二:扫描件合成 PDF,顺手压一压
把一叠扫描图片转成 PDF,用内置预设 to-pdf:
filee convert D:\Scans --recursive --preset to-pdf -o D:\Scans\pdf
已有的多个 PDF 想合并成一个:
filee convert D:\Reports\*.pdf --preset merge-pdf -o D:\Reports\merged
PDF 太大想压缩:
filee convert D:\Reports\merged\*.pdf --preset compress-pdf -o D:\Reports\small
预设不只在图形界面里存在,filee presets 能列出全部预设的 id,命令行直接按 id 或名字调用。
场景三:处理中文办公格式
OFD 是国内电子发票和公文的常用格式,很多工具读不了。Filee 内置了 OFD 读取器,可以直接转 PDF 或 DOCX:
filee convert 发票202610.ofd --to pdf
filee convert 发票202610.ofd --to docx
韩文办公文档 HWP 也能处理,靠的是随包的 rhwp:
filee convert 보고서.hwp --to docx
filee convert 보고서.hwp --to hwpx
表格类文件同样可以进出:
filee convert 台账.xlsx --to csv
filee convert 台账.xlsx --to json
场景四:盯着一个文件夹,来了就转
这是我觉得最实用的一条命令。下载目录里不断有文件进来,让它们自动变成 PDF:
filee watch D:\Inbox --to pdf --move-originals --settle 3
它会等文件大小和修改时间稳定 3 秒、并且没有被其他程序占用之后才动手,避免转到一半的文件。
转换结果默认写进 D:\Inbox\converted,原件被移到 D:\Inbox\originals。
为了不让“输出又被当成新输入”造成死循环,它会在被监视目录里写一个 .filee-watch-<hash>.json 状态文件,记录自己生成过哪些文件。这个状态文件在重启之后依然有效。
场景五:把 CLI 接进脚本
--json 会输出一份结构化结果,适合给流水线判断。字段结构在源码里是固定的:preset、target、converted、skipped、failed,以及 files 数组,每项含 source、state、outputs,失败时多一个 error。
一段可以直接跑的 PowerShell:
$result = filee convert D:\Scans --recursive --preset to-pdf -o D:\Scans\pdf --json | ConvertFrom-Json
Write-Host ("转换完成 {0} 个,跳过 {1} 个,失败 {2} 个" -f $result.converted, $result.skipped, $result.failed)
foreach ($file in $result.files | Where-Object { $_.state -eq 'failed' }) {
Write-Warning ("{0} 失败:{1}" -f $file.source, $file.error)
}
if ($result.failed -gt 0) { exit 1 }
退出码本身也有语义:0 全部成功,1 有文件失败,2 参数错误或输入不存在,3 没有可转换的文件。
如果要在 C# 服务里调用,用进程封装就够了,不需要任何 SDK:
using System.Diagnostics;
using System.Text.Json;
var psi = new ProcessStartInfo("filee")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in new[] { "convert", @"D:\Scans", "--recursive", "--preset", "to-pdf", "--json" })
psi.ArgumentList.Add(arg);
using var process = Process.Start(psi)!;
var stdout = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
using var document = JsonDocument.Parse(stdout);
var root = document.RootElement;
Console.WriteLine($"converted={root.GetProperty("converted").GetInt32()}");
Console.WriteLine($"failed={root.GetProperty("failed").GetInt32()}");
foreach (var file in root.GetProperty("files").EnumerateArray())
{
var state = file.GetProperty("state").GetString();
if (state == "failed")
Console.WriteLine($"{file.GetProperty("source")} -> {file.GetProperty("error")}");
}
return process.ExitCode;
如果你需要的是异步批量处理,把多个目录拆成多个进程并发跑,比单进程塞一个大任务更容易控制失败边界。
场景六:给它加一种转换
项目把扩展路径写得很清楚:一个转换器就是一个实现 IConverter 的类。
public sealed class SuperEpubConverter : IConverter
{
public string Id => "superepub";
public string DisplayName => "SuperEpub";
public int MaxParallelism => 0; // 0 = 每个 CPU 核心一个
public IReadOnlyList<ConversionEdge> Edges { get; } =
[
new("epub", "pdf"),
];
public EngineStatus GetStatus() => EngineStatus.Available();
public Task<IReadOnlyList<string>> ConvertAsync(
ConversionStep step, IProgress<double>? progress, CancellationToken ct) =>
Task.Run<IReadOnlyList<string>>(() =>
{
var output = step.Output.Allocate("pdf");
if (output is null)
return [];
SuperEpub.Convert(step.InputPath, output);
progress?.Report(1);
return [output];
}, ct);
}
然后在 EngineRegistry.CreateAll 里加一行注册,这条边就进入了全局格式图,所有经过它的路线立刻可用。
规则有三条:不许修改输入文件;输出路径只能从 step.Output.Allocate 拿;外部程序必须走 ProcessRunner,并且只找随包或用户安装的引擎,不去翻系统 PATH。
License 与项目地址
Filee 使用 MIT 协议,版权归 Myeonggu Kim(KnifeLemon)及贡献者所有。
随包和下载的第三方组件各自保留原协议,全部列在仓库的 THIRD-PARTY-NOTICES.md 里。
我的看法
我一开始以为这只是个把转换按钮做得好看一点的桌面小工具。
看完架构文档我改了判断。它真正的设计是把“格式”抽象成图上的节点,让每新增一个引擎,自动解锁一整片转换能力。 这个模型不难,但愿意在最开始就把模型想清楚的项目不多。
另一个让我意外的地方是它对边界的诚实。文档里明确写了 rhwp 不做复杂文本整形、Wayland 下拖拽不可用、macOS 包还没公证、PDF 转 PPTX 只是把每页贴成图片。
这些限制很多项目会藏起来,它写在文档正文里。
不适合谁也要说清楚:它不做 OCR,扫描件里的文字不会自动识别成可编辑文本;它不是 SDK,不能当成库嵌进你的程序,只能通过命令行调用;它也不做云端协作,天然是单机工具。
适合谁:需要批量处理素材的设计和运营;要把文档转换接进构建流水线或数据管道的后端;经常收到 OFD、HWP、WPS 这类格式的国内办公场景;以及想读一个结构干净的 .NET 桌面项目的人。
回到开头那张发票。现在我把文件拖起来,丢到 PDF 那一格,两秒钟,结束。
没有上传,没有广告,也没有装 Office。