不调用外部 typst 命令:Draftmark 用 FRB 内嵌排版引擎的实现

项目仓库:https://atomgit.com/nutpi/Draftmark

FRB 项目:https://atomgit.com/oh-flutter/flutter_rust_bridge

Rust 社区:https://xuanwu.openatom.cn/

做一个 Typst 编辑器,最容易想到的办法是调用命令行:把源码写到临时文件,执行 typst compile,再读取生成的 PDF。用来验证排版效果很方便,但做实时预览时,还有几件事要处理:编译器随应用怎么分发,连续输入时怎样管理编译任务,报错后怎样把位置标回编辑器。

Draftmark 是一个面向 HarmonyOS PC 和二合一设备的本地文档编辑器。这次采用的做法是,把 Typst 编译器作为 Rust 库编进应用,Flutter 通过 flutter_rust_bridge(下文简称 FRB)调用它。用户修改源码后,Rust 返回分页图片、诊断信息和耗时,Flutter 据此更新预览。整个排版过程在设备本地完成,运行时不需要另外安装 Typst CLI。

下面从环境准备开始,沿着"输入源码、生成预览、保存文档、导出 PDF"这条流程看实现。

技术选型与复现入口

1. Flutter、Rust、Typst 与 FRB 的职责

组成 在 Draftmark 中的职责 选择它的价值
Flutter 编辑器、文档列表、分页预览、诊断展示和导出操作 管理交互与展示,保持输入流畅
Rust 文档存储、字体与 World、Typst 编译/渲染、诊断和 PDF 导出 将排版与文件一致性集中在可测试的原生层
Typst 提供内嵌排版编译器、分页文档和 PDF 生成能力 应用内完成排版,运行时不依赖外部 CLI
FRB 生成 Dart/Rust 绑定,传递源码、分页 PNG、诊断和错误 减少手写 FFI 转换,保持接口类型一致

2. 工具链检查

Draftmark/apps/draftmark 目录复现前,先确认 Flutter-OH、Rust、OHOS target 和 FRB 生成器:

bash 复制代码
flutter --version
flutter doctor -v
rustc --version
cargo --version
rustup target list --installed
flutter pub get
flutter_rust_bridge_codegen generate

生成绑定后,再按仓库说明执行 Rust 测试、Flutter 测试和 HarmonyOS 构建。若问题能在最小 FRB 工程中复现,提交 FRB 仓库;若涉及 Typst、字体、文档保存或预览状态,则在 Draftmark 仓库反馈。

3. 旋武社区与 FRB

开放原子旋武开源社区提供 Rust 学习与开源协作入口;flutter_rust_bridge负责生成 Dart/Rust 跨语言绑定。Draftmark 的排版和存储问题进入 Draftmark 仓库,通用绑定或类型映射问题再进入 FRB 仓库。

一、认识旋武社区与 FRB
1. 旋武社区:Rust 学习与开源协作入口

旋武社区是开放原子开源基金会旗下的 Rust 中国社区,提供 Rust 发行版、学习资料、在线编码体验,也孵化和展示 Rust 开源项目。准备在实际应用中使用 Rust,可以从这里查找入门资料、社区项目和技术活动。

社区地址:https://xuanwu.openatom.cn/

2. FRB:让 Flutter 调用 Rust 的排版接口

flutter_rust_bridge 是连接 Flutter/Dart 与 Rust 的跨语言绑定工具。开发者定义 Rust 接口后,由代码生成器生成两端的绑定代码,Dart 就能调用这些接口,并接收转换后的数据。项目地址:https://atomgit.com/oh-flutter/flutter_rust_bridge。鸿蒙相关的接入说明、示例和问题反馈,可以从这个仓库查起。

在 Draftmark 中,FRB 连接的是 Flutter 工作区和 draftmark-bridge 原生库。Flutter 管理输入框、文档列表和预览状态;Rust 管理文档文件,调用 Typst 编译、渲染和导出。Typst 的 World、字体集合、分页文档等内部对象留在 Rust 侧,Flutter 只接收界面需要的数据。

二、为什么 Draftmark 选择 FRB

Draftmark 需要在同一个工作区里完成源码编辑、排版、诊断和导出。Typst 已经提供了 Rust 排版能力,接下来要解决的是怎样把这些能力接到 Flutter 界面上。

需求 项目中的做法 FRB 带来的价值
应用安装后就能本地排版 将 Typst 作为 Rust 依赖编入 draftmark-bridge Flutter 通过生成接口调用内嵌引擎,运行时无需另装 Typst CLI
预览要返回多页图片和尺寸 Rust 返回 Vec<RenderedPage>,每页包含 PNG 字节与宽高 减少列表、结构体和字节数据的手写跨语言转换
报错后要定位到源码 Rust 将 Span 转换为诊断说明、提示和行列号 Dart 接收结构化诊断,界面不必解析 stderr
编辑、保存和编译各自更新状态 控制器等待异步结果,用 revision 判断是否过时 生成的调用接口便于接入 Dart 的异步流程,结果有效性由应用管理
排版与存储需要独立测试 Rust 测试编译、渲染和持久化,Flutter 测试界面状态 核心逻辑留在 Rust,跨语言接口集中在桥接层

这套设计的好处在修改接口时比较直观。例如预览需要新增一个字段,可以先改 Rust 返回类型,再重新生成绑定,Flutter 就能通过对应字段读取,不必同时维护一套手写的数据转换代码。

内嵌也有成本:Rust 库和字体会增加包体,原生构建环境需要配齐,FRB 两端依赖与代码生成器需要匹配。Draftmark 的主要能力都在 Rust 排版引擎里,因此接受这部分成本。预览是否流畅,还要继续看防抖、渲染分辨率和文档复杂度。

三、环境搭建:引用已有教程,再补齐项目依赖
1. 先完成 Flutter-OH 与 Rust 基础环境

基础安装直接参考下面两篇文章:

  1. Flutter-OH 环境《2026 年如何上车 Flutter-OH:环境搭建与上手流程》,作者:程序媛夏天。重点看 Flutter-OH SDK、DevEco Studio、SDK 路径配置和设备连接。
  2. Rust 开发环境《Rust | VS Code搭建Rust开发环境的超详细图文教程总结(含Rust开发常用插件)》,作者:CHENG-JustDoIt。可参考 Rust 工具链安装、Cargo 命令及 VS Code 配置。该文以 Windows 为例,macOS 开发者需要使用对应平台的工具链;鸿蒙目标和链接环境仍需按本项目配置。

教程用于理解安装流程,复现 Draftmark 时,还要核对仓库要求的工具版本、鸿蒙目标和签名配置。

2. 对齐当前仓库的版本与运行条件

Draftmark 仓库 README 记录的开发环境如下:

工具 仓库记录的版本或要求
Flutter-OH 3.35.8-ohos-0.0.3
Dart 3.9.2
FRB 2.13.0-beta.6
Rust 1.92 或更高
DevEco Studio / SDK DevEco Studio 6.0、OpenHarmony API 22
Rust 目标 aarch64-unknown-linux-ohos
真机签名 匹配应用包名,并包含目标设备 UDID

安装完成后,先确认当前终端用的是 Flutter-OH,并且能找到设备:

bash 复制代码
flutter --version
flutter doctor -v
flutter devices
rustc --version
cargo --version
rustup target add aarch64-unknown-linux-ohos

rustup target add 安装的是目标平台的 Rust 标准库,OHOS 链接器和 SDK 仍由鸿蒙开发环境提供。项目的 rust_builder/ohos 已接入 Cargokit,后续随 Flutter-OH 工程一起构建原生库。

3. 获取项目并生成桥接代码

下面命令以 macOS 的终端为例。克隆后的目录名统一使用 Draftmark

bash 复制代码
git clone https://atomgit.com/nutpi/Draftmark.git
cd Draftmark/apps/draftmark
flutter pub get

当前仓库中,Dart 和 Rust 两侧的 FRB 依赖都固定为 2.13.0-beta.6。需要重新生成绑定时,先准备相同版本的生成器:

bash 复制代码
cargo install flutter_rust_bridge_codegen --version 2.13.0-beta.6 --locked
flutter_rust_bridge_codegen --version
flutter_rust_bridge_codegen generate

以上生成命令在 Draftmark/apps/draftmark 下执行,对应配置是:

yaml 复制代码
rust_input: crate::api
rust_root: ../../crates/draftmark-bridge/
dart_output: lib/src/rust

修改 crates/draftmark-bridge/src/api 下的接口后,需要重新生成绑定。只改 Flutter 布局时,可以跳过这一步。生成器版本也应跟随项目依赖调整,避免一端更新、另一端仍使用旧生成物。

四、功能闭环与核心代码分析
1. 先看编辑、预览、保存和导出的调用关系

Flutter 工作区通过控制器调用 FRB,Rust 桥接层负责组织排版与存储。各层处理的内容如下:

负责的工作 交给下一层的内容
Flutter 工作区 源码输入、文档切换、分页展示和状态提示 当前源码、标题及操作请求
StudioController 防抖、异步状态、保存与编译版本判断 FRB 调用参数,或可展示的最新结果
Rust Bridge API 提供编译、导出和文档操作入口 交给编译器与文档存储的参数
Typst 与渲染组件 编译源码、转换诊断、生成 PNG 和 PDF 页面字节、诊断及 PDF 数据
文档存储 保存源码、元数据和导出文件 文档列表、文档内容及文件路径

一次普通编辑会触发两项工作:停止输入约 420 ms 后请求编译,停止输入约 700 ms 后请求保存。这两个计时器分别重置,因此连续输入时,不会每按一次键就执行一轮完整排版。

读代码时,可以先看这几个位置:

text 复制代码
Draftmark/
├── apps/draftmark/
│   ├── lib/main.dart
│   ├── lib/src/app/studio_controller.dart  编辑、保存与编译状态
│   ├── lib/src/rust/                      FRB 生成的 Dart 绑定
│   ├── rust_builder/ohos/                 鸿蒙原生构建接入
│   └── flutter_rust_bridge.yaml
└── crates/draftmark-bridge/src/
    ├── api/mod.rs                         对外接口与数据结构
    ├── api/typeset.rs                     编译与导出入口
    ├── api/documents.rs                   文档操作入口
    ├── compiler.rs                        World、字体、PNG 与 PDF
    └── store.rs                           文档源码与元数据存储
2. 数据结构:一次编译返回哪些内容

api/mod.rs 定义了一轮编译返回的内容:

rust 复制代码
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileDiagnostic {
    pub severity: String,
    pub message: String,
    pub hints: Vec<String>,
    pub line: Option<u32>,
    pub column: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedPage {
    pub number: u32,
    pub width: u32,
    pub height: u32,
    pub png_data: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileOutput {
    pub pages: Vec<RenderedPage>,
    pub diagnostics: Vec<CompileDiagnostic>,
    pub elapsed_ms: u64,
}

RenderedPage 带有页码、像素宽高和 PNG 字节。Flutter 可以用 Image.memory(page.pngData) 显示页面,不必等一张临时图片写完再去读。诊断单独放进列表,成功时的 warning 和语法错误都能送到界面上。

这里要区分两类失败:Typst 源码有语法错误时,编译函数仍返回 Ok(CompileOutput),其中页面为空、诊断包含错误;如果 PNG 编码或存储初始化等环节失败,则通过 Result 的错误返回,由 Dart 侧捕获异常。前者需要用户改文档,后者需要检查运行环境或程序实现。

3. 字体初始化:在第一次编译前传入 Rust

Flutter 能显示中文,不代表 Rust 里的 Typst 也能找到同一套字体。项目启动时会从 assets 读取 HarmonyOS Sans,再把字体字节交给编译器。下面节选初始化顺序,省略了界面状态和异常处理:

dart 复制代码
await RustLib.init();
final font = await rootBundle.load('assets/fonts/HarmonyOS_Sans_SC.ttf');
final bytes = Uint8List.sublistView(font);
await bridge.configureCompiler(fontData: [bytes]);

final support = await getApplicationSupportDirectory();
final snapshot = await bridge.initialize(
  dataDir: '${support.path}/draftmark',
);
// 随后恢复文档列表,加载文档并进行首次编译。

Rust 将传入字体与 Typst 内置字体合并到 FontStore,并用 OnceLock 保存资源。这样每次输入后不用重新解析字体文件,但也意味着初始化顺序不能颠倒:如果先编译,资源会按默认字体初始化,之后再调用 configureCompiler 不会替换已经建立的集合。

4. 内嵌引擎:调用 Typst 编译并逐页渲染

AppWorld 实现 Typst 的 World trait,提供当前源码、项目文件、字体和标准库。应用 API 把文档目录传给它,供相对路径文件读取使用。当前实现没有接入 Typst 包下载,带有外部包依赖的模板不能直接按在线环境的行为使用。

compiler.rs 中的编译函数如下:

rust 复制代码
pub(crate) fn compile(
    source: &str,
    project_root: &Path,
    pixels_per_point: f64,
) -> Result<CompileOutput, String> {
    let started = Instant::now();
    let world = AppWorld::new(source, project_root);
    let Warned { output, warnings } = typst::compile::<PagedDocument>(&world);
    let mut diagnostics = warnings
        .iter()
        .map(|diagnostic| convert_diagnostic(&world, diagnostic))
        .collect::<Vec<_>>();

    let document = match output {
        Ok(document) => document,
        Err(errors) => {
            diagnostics.extend(
                errors.iter().map(|diagnostic| convert_diagnostic(&world, diagnostic)),
            );
            return Ok(CompileOutput {
                pages: vec![],
                diagnostics,
                elapsed_ms: elapsed_ms(started),
            });
        }
    };

    let scale = pixels_per_point.clamp(0.6, 3.0);
    let options = RenderOptions {
        render_bleed: false,
        format: PngFormatOptions { pixel_per_pt: Some(Scalar::new(scale)) },
    }
    .resolve(document.options().get::<PngFormat>());
    let mut pages = Vec::with_capacity(document.pages().len());
    for (index, page) in document.pages().iter().enumerate() {
        let pixmap = typst_render::render(page, &options);
        let png_data = pixmap
            .encode_png()
            .map_err(|error| format!("PNG 编码失败:{error}"))?;
        pages.push(RenderedPage {
            number: index as u32 + 1,
            width: pixmap.width(),
            height: pixmap.height(),
            png_data,
        });
    }

    Ok(CompileOutput {
        pages,
        diagnostics,
        elapsed_ms: elapsed_ms(started),
    })
}

typst::compile::<PagedDocument>(&world) 得到分页文档,typst_render::render 把每一页栅格化,最后编码为 PNG。整个函数直接调用 Rust 库,没有创建 typst 子进程。

缩放比例在 Rust 侧限制为 0.6~3.0,用来约束正常数值输入下的渲染分辨率。当前实现每轮都会渲染全部页面,适合先把编辑到预览的流程跑通。页数增加后,可以测量编译、PNG 编码和 Flutter 解码各自的耗时,再决定是否增加按可见页渲染等优化。

elapsed_ms 从函数开始计时,到页面编码结束为止,包含这段 Rust 编译和渲染过程,不包含完整的跨语言传输、Flutter 图片解码和屏幕绘制时间。

5. 诊断定位:把 Span 转成行列号

Typst 诊断中的位置是源码 Span。转换时,先找到对应源码和字节范围,再映射为行列号:

rust 复制代码
let (line, column) = diagnostic
    .span
    .id()
    .and_then(|id| world.source(id).ok())
    .and_then(|source| {
        let range = world.range(diagnostic.span)?;
        source.lines().byte_to_line_column(range.start)
    })
    .map(|(line, column)| {
        (Some(line as u32 + 1), Some(column as u32 + 1))
    })
    .unwrap_or((None, None));

底层索引从 0 开始,界面展示从 1 开始。如果诊断没有可映射的位置,就保留 None,只显示说明和提示。这样不会凭空出现"第 0 行第 0 列"。

6. 异步状态:分别管理保存和编译结果

标题变化只需要保存,源码变化才需要同时保存和编译。控制器通过一个参数区分这两种情况:

dart 复制代码
  void _markEdited({required bool compile}) {
    if (_current == null) return;
    _dirty = true;
    _editRevision++;
    _runtimeMessage = null;
    _saveTimer?.cancel();
    _saveTimer = Timer(const Duration(milliseconds: 700), () {
      unawaited(save());
    });
    if (compile) {
      _compileTimer?.cancel();
      _compileTimer = Timer(const Duration(milliseconds: 420), () {
        unawaited(compileNow());
      });
    }
    notifyListeners();
  }

保存时会记录 _editRevision。如果保存期间用户又输入了内容,旧任务完成后不能把最新草稿标成"已保存",控制器会继续安排保存。

编译则用 _compileRevision 判断结果是否已经过时:

dart 复制代码
  Future<void> compileNow() async {
    if (_current == null) return;
    _compileTimer?.cancel();
    final revision = ++_compileRevision;
    final source = sourceController.text;
    _compiling = true;
    notifyListeners();
    try {
      final output = await bridge.compileSource(
        source: source,
        pixelsPerPoint: _pixelsPerPoint,
      );
      if (revision != _compileRevision) return;
      _pages = output.pages;
      _diagnostics = output.diagnostics;
      _lastCompileMs = output.elapsedMs.toInt();
      _runtimeMessage = null;
    } catch (error) {
      if (revision == _compileRevision) {
        _runtimeMessage = _message(error);
      }
    } finally {
      if (revision == _compileRevision) {
        _compiling = false;
        notifyListeners();
      }
    }
  }

每次发起编译时递增版本号,返回时与当前版本比较。后续编译已经发出后,前一次结果就不能再覆盖预览。这个判断只负责丢弃结果,不会取消已经进入 Rust 的任务,也不等于每次按键都立即让在途结果失效。

当前控制器直接用 output.pages 替换预览,因此语法编译失败时,预览会变为空页,同时显示诊断。如果以后改成保留上次成功页面,还需要明确标识旧预览,避免用户把它当成当前源码的排版结果。

两套计时器表示应用状态分别管理,也不代表底层操作一定并行。当前 Rust API 经由 with_store 持有存储写锁,编译、保存等操作可能互相等待。大文档下若保存延迟明显,这也是需要检查的位置。

7. 文档闭环:保存、重新打开与 PDF 导出

文档存储由 store.rs 处理,目录位于系统提供的应用支持目录下:

text 复制代码
draftmark/
├── metadata.json
├── documents/
│   └── <文档 ID>.draft
└── exports/
    └── <文档标题>.pdf

源码和元数据分别通过"写临时文件,再重命名"的方式更新。文档列表从元数据恢复,打开文档时再读取对应源码。两个文件各自替换,不应把它理解为一次覆盖两者的事务。

导出时,Flutter 先等待待保存任务,再把编辑器当前源码和标题传给 exportPdf。Rust 重新编译得到 PagedDocument,调用 typst_pdf::pdf 生成 PDF 字节,清理文件名后写入 exports 目录,并返回路径、页数和诊断。PDF 来自分页文档,预览 PNG 不参与 PDF 生成。

当前版本默认写入应用支持目录,没有在这条流程中让用户任选系统目录。导出完成后,应根据返回路径查找文件;保存、预览和 PDF 写入各自都有失败可能,需要分别看结果。

五、运行方式、效果图与验证
1. 先运行主机检查

在仓库根目录执行 Rust 测试,然后进入应用目录检查 Dart 代码:

bash 复制代码
# 当前目录:Draftmark
cargo test --package draftmark-bridge

cd apps/draftmark
dart analyze lib test integration_test
flutter test test

仓库中有文档持久化、编译渲染、源码诊断和 PDF 元数据等测试。命令是否通过,以当前检出版本和本机执行结果为准。

2. 构建并安装 HarmonyOS PC 应用

首次配置签名时,在 Draftmark/apps/draftmark 下复制模板;如果已经配置过,直接使用已有本地文件:

bash 复制代码
cp ohos/build-profile.example.json5 ohos/build-profile.json5

用 DevEco Studio 打开 ohos 目录,配置 default 签名项,并让 default 产品选择它。应用包名为 com.draftmark.editor,Profile 要匹配这个包名并包含目标设备。证书、密码和本机签名路径留在本地。

连接设备后,在应用目录运行,<device-id> 替换为 flutter devices 显示的设备 ID:

bash 复制代码
flutter run -d <device-id> --release

也可以单独构建 HAP,再安装和启动:

bash 复制代码
flutter build hap --release
hdc install -r build/ohos/hap/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.draftmark.editor

以上假定 hdc 已加入 PATH,且只连接了一台设备。多设备环境需要用 hdc -t <设备标识> 指定目标。

3. 输入源码并完成一次编辑与导出

打开应用后,新建文档,输入下面的内容:

typst 复制代码
#set page(paper: "a4")
#set text(font: "HarmonyOS Sans SC", size: 11pt)

= FRB 工程记录

Rust 负责排版,Flutter 负责编辑与预览。

$ 1 + 1 = 2 $

#table(
  columns: 3,
  [模块], [职责], [输出],
  [Flutter], [编辑与展示], [工作区],
  [Rust], [编译与渲染], [页面与诊断],
)

停止输入后切换到预览,检查标题、公式和表格。再把标题改成一段容易辨认的文字,确认页面跟着变化。等待自动保存后切换文档再返回,检查修改是否还在。

接着删掉 #table(...) 最后的右括号,查看是否出现带位置的错误;补回括号,确认预览恢复。这一来一回可以检查源码、编译器和预览之间的对应关系。

最后点击导出,检查返回路径下的 PDF,用阅读器核对内容和页数。应用支持目录可能位于沙箱内,真机取出文件需要结合调试工具或后续提供的分享能力处理。

4. HarmonyOS PC 真机效果

这是项目联调时记录的 HarmonyOS PC 真机画面:应用显示"1 页",预览中包含中文标题、公式和表格,底部状态为"就绪 / 47 ms"。这里的 47 ms 是该次文档的 Rust 编译与渲染耗时,换文档、字体或设备后会变化。

这张图对应临时签名的功能联调包。正式包名 com.draftmark.editor 对应的签名 Profile 仍需补齐,PDF 真机导出也尚需独立验证;现有截图记录的是内嵌引擎生成分页预览的效果。

5. 各类验证分别检查什么

仓库还提供了设备集成测试,可以单独验证原生库初始化、FRB 编译调用和 PNG 返回数据:

bash 复制代码
# 当前目录:Draftmark/apps/draftmark
flutter test integration_test/simple_test.dart -d <device-id>

该用例检查一页预览、PNG 签名字节和无 error 诊断,使用的是内置字体下的英文与公式样例,中文字体和 PDF 导出需要另外验证。

验证层级 可以检查的内容 需要另外验证的内容
Rust 单元测试 文档持久化、编译渲染、诊断位置和 PDF 元数据 鸿蒙原生库加载与设备文件访问
Dart 静态分析与 Flutter 测试 类型使用、控制器状态和界面交互 目标设备上的实际排版与字体
设备集成测试 FRB 初始化、编译调用、一页 PNG 返回和无 error 诊断 中文字体、复杂文档与 PDF 真机导出
当前真机截图 该次联调包中的中文、公式、表格与一页预览 正式包签名、导出文件和长文档表现
PDF 导出检查 返回路径、文件是否可读、内容和页数 应结合目标设备单独记录结果

这些检查对应不同环节。主机测试通过后,还要在目标设备核对字体和原生库;预览出现后,再核对保存与导出。当前真机资料的范围见上面的图片说明。

六、FAQ:如何反馈问题并贡献修复
1. 遇到框架问题,如何提 Issue?

先判断问题发生在哪一层。如果不经过 Flutter,Rust 单测就能复现,优先检查排版或存储实现;如果 Rust 调用正常,而经过 FRB 后才出现类型转换、绑定生成或动态库加载问题,再进一步缩小到桥接层。

确认与框架有关后,打开 FRB AtomGit 仓库,进入 Issues,先搜索相同报错和版本,再按仓库模板新建问题。没有模板时,可以按下面的格式整理:

text 复制代码
标题:[OHOS][版本] 触发操作 + 实际错误

环境:
- 主机系统与 CPU 架构:
- Flutter-OH / Dart / Rust / Cargo 版本:
- FRB Dart、Rust 依赖与 codegen 版本:
- DevEco / SDK API / 目标设备系统版本:
- Draftmark commit:

复现:
1. 获取哪个版本或最小示例
2. 执行哪些命令,修改哪些配置或 API
3. 输入哪段源码,执行什么操作后出现问题

预期结果:
实际结果:
完整日志与堆栈:
最小复现仓库或补丁:
已排查步骤及结果:

FRB 问题尽量缩减为一个 Rust 函数、一个数据结构和一次 Dart 调用。如果涉及排版,附上最小 Typst 源码;如果涉及中文字体,写清字体名称、加载方式以及首次编译前是否完成配置。日志保留首个错误及上下文,提交前去掉签名密码、令牌等与复现无关的信息。

如果问题只涉及 Draftmark 的文档保存、预览状态或界面,直接到 Draftmark 仓库 的 Issues 反馈,附上最小文档和操作步骤。

2. 问题修复后,如何提 PR?

如果修复在自己手中,可以先在 Issue 中说明原因和修改方向,再把代码提交为 Pull Request。通常先提交修复 PR、完成评审合并,再关闭 Issue,不必等 Issue 关闭后才开始贡献。若维护者已经合入修复,验证结果并补充必要反馈即可,避免重复提交。

一般流程如下,具体以目标仓库的贡献说明为准:

  1. Fork 对应仓库,从维护者要求的目标分支创建修复分支。
  2. 提交范围明确的修复,并增加能复现原问题的回归用例。若修改公开 API,同时按仓库要求更新绑定或文档。
  3. 运行仓库规定的格式、静态检查和测试。涉及 OHOS 原生构建或动态库加载时,补充设备型号、系统版本及真机结果。
  4. 推送到个人 Fork,在 AtomGit 的 Pull Requests 中选择自己的修复分支和上游目标分支。
  5. 在 PR 描述中关联 Issue,说明触发条件、修复前后的行为和验证结果;根据评审意见继续更新。
  6. 合并后记录修复提交或版本。如果修改了 FRB,在 Draftmark 中更新对应依赖、重新生成绑定并构建,再复测原场景。

提交前检查差异,去掉本机签名配置、构建产物和无关格式化。若修的是 Draftmark,就向 Draftmark 提交;若修的是 FRB 通用能力,就向 FRB 对应仓库提交。目标分支不确定时,在原 Issue 中与维护者对齐即可。

PR 描述可以按下面几项填写:

text 复制代码
问题:哪段源码、哪个操作或调用顺序会触发错误。
原因:问题位于接口转换、字体初始化、编译、状态管理还是文件操作。
修改:修复后页面、诊断、保存或导出行为有什么变化。
验证:回归用例、检查命令、设备与实际结果。
关联:Issue #<实际编号或链接>。

如果只是 SDK 或签名配置错误,修好环境后反馈排查结果即可;维护者已经合入的修复,更新版本后复测。新增代码、测试或文档改动时,再提交对应 PR。

3. 其他常见问题及解决方案
现象 优先检查 解决方向
找不到 flutter build hap,或没有 HarmonyOS 工具链 PATH 和 Flutter SDK 来源 使用 Flutter-OH,按环境教程核对 SDK 配置,再运行 flutter doctor -v
Rust 找不到 OHOS 目标,或链接失败 target、DevEco SDK、架构和链接器 安装 aarch64-unknown-linux-ohos,确认 SDK 完整并使用匹配的原生构建配置
修改 Rust API 后,Dart 方法或参数对不上 FRB 两端依赖、codegen 版本及生成目录 在应用目录重新生成绑定,再重建原生库,避免单独修改生成文件
Flutter 中文正常,排版页却缺字 字体 asset、传入字节和初始化顺序 在首次编译前完成 configureCompiler,资源已初始化时需重启后按正确顺序加载
复制的 Typst 模板提示找不到包 模板中的外部包依赖 当前 AppWorld 未实现包解析与下载,先使用无外部包依赖的样例验证
连续输入时预览短暂落后 防抖等待、编译耗时和 revision 确认旧请求结果被丢弃;当前实现不会取消已进入 Rust 的任务
bundleName ... does not match ... SigningConfigs 包名、Profile、设备和产品签名项 使用匹配 com.draftmark.editor 且包含目标设备的签名配置
arm64 构建仍提示缺少 flutter_native_x86_64 架构切换后残留的 oh_modules 生成链接 按仓库 README 核对架构,清理相关旧生成链接后重建
无法连接 Dart VM Service 调试连接及 module.json5 权限声明 按仓库说明检查 ohos.permission.INTERNET 并确认设备连接正常
预览正常,却找不到导出的 PDF 导出返回值、错误信息和实际路径 检查应用支持目录中的 draftmark/exports,按返回路径核对文件

本项目使用的 Rust 特性

Draftmark 的 Rust 代码围绕"内嵌 Typst 编译、分页渲染、诊断定位和可靠存储"组织,使用的语言特性都能在桥接层与编译器代码中找到对应场景:

Rust 特性 在 Draftmark 中的实际用法
structenum RenderedPageDiagnostic、文档模型等结构体承载跨语言数据;编译结果和诊断级别使用枚举表达有限状态。
derive 派生 为跨 FRB 类型派生 CloneDebugPartialEq 等 trait,便于绑定转换、日志和测试比较。
Option<T> 字体、父目录、可选导出路径和诊断字段允许缺省,代码用 if letunwrap_or 明确处理缺失情况。
Result<T, E>? Typst 编译、字体加载、文件写入、重命名和 PDF 导出错误沿调用链传播,在 API 边界转换为 FRB 可返回的错误。
trait 与 World 实现 AppWorld 为 Typst 提供源码、字体、文件和时间等能力,通过 trait 接口把引擎依赖与应用存储隔离。
所有权与借用 编译期间以 &str&Path 和借用的源码访问输入,返回的 PNG/PDF 字节由结果结构体拥有,避免悬垂引用。
泛型与集合 Vec<RenderedPage>Vec<u8> 保存分页结果和二进制数据;泛型辅助函数复用 JSON/文件写入逻辑。
闭包与迭代器 遍历字体、页面和诊断时使用迭代器与闭包,集中完成映射、过滤和错误转换。
异步任务与状态隔离 FRB API 以异步函数执行编译、保存和导出;Flutter 侧用 revision 丢弃过时结果,Rust 侧不阻塞 UI 线程。
RAII 与文件 API File 离开作用域自动释放,临时文件写入、sync_all 后再 rename,保证元数据替换过程可恢复。
FRB 导出标记 #[flutter_rust_bridge::frb] 和可转换的数据类型生成 Dart 绑定,Flutter 不直接接触 Typst 内部对象。
七、当前范围与项目入口

Draftmark 已将文档存储、Typst 内嵌编译、分页 PNG、结构化诊断和 PDF 导出接入 Flutter 工作区。当前预览会逐页渲染整份文档,外部 Typst 包下载尚未接入;后续遇到长文档或复杂模板时,需要分别检查渲染开销和资源依赖。

现有 HarmonyOS PC 资料记录了临时签名联调包中的一页实际预览。正式 com.draftmark.editor 包名的签名和 PDF 真机导出仍需补充验证,完成后再更新对应结果。

Draftmark 的这部分代码集中在 StudioControllerapi/mod.rscompiler.rs。读完后可以从修改示例文档开始:让一个新的标题出现在预览中,再制造并修复一次语法错误,就能顺着调用关系把 FRB 两端的代码串起来。

源码、环境资料和问题反馈可以从以下入口查阅:

相关推荐
威哥爱编程2 小时前
HarmonyOS 首开提速实战:首屏白屏的三种归因与对症方案
harmonyos·arkts
威哥爱编程2 小时前
HarmonyOS 折叠屏与鸿蒙电脑适配:布局一崩,八成是断点没定义
华为·harmonyos·arkts
威哥爱编程2 小时前
HarmonyOS 穿戴独立 UX 实战:三道坎、两个降级、一张自检清单
华为·harmonyos·arkts
贾伟康2 小时前
【HarmonyOS 7新能力|038】游戏快启工程封装:把接入逻辑放进可维护的分层结构
性能优化·harmonyos·arkts·游戏开发·软件架构
威哥爱编程2 小时前
HarmonyOS LTPO 帧率实战:别把刷新率锁死 120Hz,expected 按内容填
harmonyos·arkts
威哥爱编程2 小时前
HarmonyOS 7 Agent A2A 实战:让日程智能体和打车智能体自己谈成一单
华为·harmonyos·arkts
威哥爱编程2 小时前
HarmonyOS 跨设备互通实战:平板点一下,手机镜头帮你拍照
华为·harmonyos·arkts
贾伟康2 小时前
【HarmonyOS 7新能力|036】分布式数字身份工程封装:把接入逻辑放进可维护的分层结构
harmonyos·arkts·软件架构·隐私保护·数字身份
HwJack202 小时前
【HarmonyOS开发小实践】ArkUI 交互事件与手势:从触摸到组合手势
ui·华为·性能优化·harmonyos