鸿蒙PC集成libharu:这6个NAPI坑我替你踩过了(附完整代码)
*欢迎加入【开源鸿蒙PC社区】,一起共建鸿蒙化C/C++三方库生态。
欢迎在【PC社区】平台贡献你的项目。
仓库: libharu/libharu v2.4.4 — 开源 C 语言 PDF 生成库
集成平台: 鸿蒙PC | 测试SDK: HarmonyOS 6.1.0(23)
项目源代码:https://atomgit.com/unisources/OHOSLibharuSample


前置说明
| 项目 | 说明 |
|---|---|
| 集成库 | libharu v2.4.4 |
| 库类型 | 动态库(.so),依赖 zlib 1.2.13 + libpng 16.39.0 |
| 目标平台 | 鸿蒙PC |
| SDK 版本 | HarmonyOS 6.1.0(23),兼容 6.0.0(20) |
| 开发工具 | DevEco Studio |
| 原生编译器 | BiSheng(build-profile.json5 中 nativeCompiler: BiSheng) |
| 交叉编译工具链 | lycium_plusplus |
| 三方库 .so | libhpdf.so.2.4.4 + libpng16.so.16.39.0 + libz.so.1.2.13 |
| NAPI 接口数 | 4 个(generatePdf / readFileBase64 / haruVersion / textWidth) |
| UI 框架 | ArkTS Stage 模式,三栏桌面布局 + Web 组件 PDF 预览 |
libharu 与一般三方库的关键区别:它是动态库且有传递依赖。hpdf → png → zlib,三层依赖链决定了 CMake 链接顺序和 .so 部署策略,这是集成中最容易出错的地方。
传统方式的效率瓶颈
| 阶段 | 主要痛点 | 传统耗时 |
|---|---|---|
| 工程搭建 | 手动建目录、改 module.json5、配 main_pages | 10分钟 |
| 库文件部署 | 三个 .so + 33 个头文件 + zlib/png 头文件,目录结构嵌套深 | 10分钟 |
| CMake 配置 | IMPORTED 目标路径、链接顺序(hpdf→png→zlib)、include 路径 | 20分钟 |
| NAPI 桥接 | 字符串两段式读取、base64 编码、字体路径参数 | 40分钟 |
| 类型声明 | Index.d.ts 签名必须与 C++ 精确匹配,可选参数易遗漏 | 15分钟 |
| UI 页面 | 三栏布局、纸张预设、生成按钮状态管理 | 30分钟 |
| PDF 预览 | Web 组件 controller 必填、data URL 乱码、runJavaScript 签名 | 40分钟 |
| 编译排错 | .so 部署路径、链接顺序、字体权限、SDK API 变更 | 60-180分钟 |
总计 3.5-6 小时,而且大部分时间花在「运行期报错→排查→修复→重新构建」的循环上。libharu 的三个依赖库让这个循环比单库集成多转 2-3 圈。
AtomCode + Skills 全流程
本次集成全程使用 AtomCode(GLM-5.2 模型)驱动,从工程创建到 PDF 预览一气呵成。下面按 7 个环节拆解。
环节 1:工程创建与模板复用
AtomCode 识别到 /home/hoapp 下已有 OHOSGlmSample 等多个示例工程,选择结构最接近的作为模板:
cp -r /home/hoapp/OHOSGlmSample /home/hoapp/OHOSLibharuSample
# 清理模板残留
rm -rf .git entry/build entry/.cxx oh_modules
rm -rf entry/src/main/cpp/thirdparty/glm
为什么选 GLM 模板:GLM 工程已有完整的 Stage 模型结构(CMake + napi_init.cpp + Index.d.ts + Index.ets),且 build-profile.json5 已配置 BiSheng + arm64-v8a,省去工程搭建环节。
环节 2:库文件部署
libharu 产物来自 lycium_plusplus 交叉编译,位于 /home/lycium_plusplus/lycium/usr/libharu/arm64-v8a/。部署分两步:
① 头文件 → cpp/thirdparty/libharu/include/
cp -r /home/lycium_plusplus/lycium/usr/libharu/arm64-v8a/include/* \
entry/src/main/cpp/thirdparty/libharu/include/
# zlib 和 libpng 头文件也要部署(libharu 编译时依赖)
cp /home/lycium_plusplus/lycium/usr/zlib/arm64-v8a/include/*.h \
entry/src/main/cpp/thirdparty/libharu/include/zlib/
cp /home/lycium_plusplus/lycium/usr/libpng/arm64-v8a/include/png*.h \
entry/src/main/cpp/thirdparty/libharu/include/png/
② .so 文件 → 两处部署
这是 第一个坑 的源头——.so 必须部署到两个位置:
| 位置 | 用途 | 路径 |
|---|---|---|
cpp/thirdparty/libharu/libs/arm64-v8a/ | CMake 链接期(编译时) | IMPORTED 目标引用 |
entry/libs/arm64-v8a/ | HAP 打包(运行时) | OHOS 只打包此目录下的 .so |
漏掉 entry/libs/ 会导致运行期 dlopen 找不到库,后面踩坑专区详述。
环节 3:CMake 配置
libharu 是动态库且有传递依赖,CMake 配置比 header-only 库复杂得多:
cmake_minimum_required(VERSION 3.5.0)
project(OHOSLibharuSample)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(HARU_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty/libharu)
if(DEFINED PACKAGE_FIND_FILE)
include(${PACKAGE_FIND_FILE})
endif()
# 头文件:libharu + zlib + png 三个目录
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include
${HARU_ROOT}/include
${HARU_ROOT}/include/zlib
${HARU_ROOT}/include/png)
# IMPORTED 目标:精确指向带版本号的 .so
set(HARU_LIB_DIR ${HARU_ROOT}/libs/arm64-v8a)
add_library(hpdf SHARED IMPORTED)
set_target_properties(hpdf PROPERTIES IMPORTED_LOCATION
${HARU_LIB_DIR}/libhpdf.so.2.4.4)
add_library(png16 SHARED IMPORTED)
set_target_properties(png16 PROPERTIES IMPORTED_LOCATION
${HARU_LIB_DIR}/libpng/libpng16.so.16.39.0)
add_library(z SHARED IMPORTED)
set_target_properties(z PROPERTIES IMPORTED_LOCATION
${HARU_LIB_DIR}/zlib/libz.so.1.2.13)
add_library(entry SHARED napi_init.cpp)
# 链接顺序:被依赖的库放后面!
target_link_libraries(entry PUBLIC libace_napi.z.so)
target_link_libraries(entry PUBLIC hpdf png16 z)
三个关键点:
- IMPORTED_LOCATION 指向带版本号的 .so(
libhpdf.so.2.4.4),不要指向符号链接,否则构建系统可能解析失败 - 链接顺序
hpdf png16 z:png 依赖 zlib,hpdf 依赖 png+zlib,被依赖者放后面 - zlib/png 头文件也要 include:libharu 的
hpdf_config.h引用了 zlib/png 的宏
环节 4:NAPI 桥接
4 个接口,每个都有特定考量:
generatePdf — 7 参数含可选字体路径
static napi_value GeneratePdf(napi_env env, napi_callback_info info) {
size_t argc = 7;
napi_value a[7] = {};
napi_get_cb_info(env, info, &argc, a, nullptr, nullptr);
if (argc < 3) return Str(env, "need >= 3 args: title, body, outPath");
// 两段式字符串读取(NAPI 经典模式)
size_t sz = 0;
std::string title, body, outPath, fontPath;
napi_get_value_string_utf8(env, a[0], nullptr, 0, &sz); // ① 先拿长度
title.resize(sz);
napi_get_value_string_utf8(env, a[0], &title[0], sz + 1, &sz); // ② 再拿内容
// ... 其余参数类似 ...
double w = 595, h = 842, fs = 14;
if (argc >= 4) napi_get_value_double(env, a[3], &w);
if (argc >= 5) napi_get_value_double(env, a[4], &h);
if (argc >= 6) napi_get_value_double(env, a[5], &fs);
if (argc >= 7) { /* 读取 fontPath */ }
std::string err = BuildSamplePdf(outPath, title, body, w, h, fs, fontPath);
return Str(env, err);
}
字体处理是核心差异点:libharu 内置 Helvetica 不含中文字形,必须加载 TTF 字体:
HPDF_Font font = nullptr;
if (!fontPath.empty()) {
HPDF_UseUTFEncodings(pdf); // 启用 UTF-8 编码
const char *fontName = HPDF_LoadTTFontFromFile(
pdf, fontPath.c_str(), HPDF_TRUE); // 嵌入字体
if (!fontName) { err = "LoadTTFont failed: " + g_lastError; break; }
font = HPDF_GetFont(pdf, fontName, "UTF-8");
} else {
font = HPDF_GetFont(pdf, "Helvetica", nullptr); // 降级
}
readFileBase64 — 读回文件转 base64 供 Web 预览
static napi_value ReadFileBase64(napi_env env, napi_callback_info info) {
// ... 读取 filePath 参数 ...
auto bytes = ReadFile(path);
if (bytes.empty()) return Str(env, "ERROR:file read failed or empty");
return Str(env, Base64Encode(bytes.data(), bytes.size()));
}
这个接口的设计有故事——最初实现的是 generatePdfBase64,内部写临时文件到 /data/local/tmp/,应用无权限导致失败。改为 readFileBase64 直接读已生成的 PDF 文件,权限问题自然消除。设计原则:复用已有产物,避免重复 IO。
haruVersion / textWidth — 简单查询接口
static napi_value HaruVersion(napi_env env, napi_callback_info info) {
return Str(env, HPDF_VERSION_TEXT); // 宏,编译期确定
}
环节 5:类型声明
export const generatePdf: (
title: string, body: string, outPath: string,
pageWidth?: number, pageHeight?: number, fontSize?: number,
fontPath?: string
) => string;
export const readFileBase64: (filePath: string) => string;
export const haruVersion: () => string;
export const textWidth: (text: string, fontSize: number) => number;
可选参数规则:C++ 侧用 argc >= N 判断,d.ts 侧用 ? 标记。两者必须逐参数对应,否则运行时类型错误。
环节 6:UI 页面
三栏布局(文档大纲 + 编辑/预览/代码 + API 参考),关键交互:
生成 PDF → 读回 base64 → Web 组件渲染:
private async genPdf(): Promise<void> {
const out = `${this.abilityCtx.filesDir}/libharu_sample.pdf`;
const err = haru.generatePdf(
this.pdfTitle, this.pdfBody, out,
this.p(this.pageWidth), this.p(this.pageHeight), this.p(this.fontSize),
this.fontPath // 中文字体路径
);
if (err.length === 0) {
// 读回 PDF 转 base64 供 Web 预览
const b64 = haru.readFileBase64(out);
this.pdfDataUrl = 'data:application/pdf;base64,' + b64;
this.activeTab = '预览'; // 自动跳转
}
}
Web 组件渲染 PDF(解决 data URL 直接加载乱码):
Web({ src: $rawfile('pdf_viewer.html'), controller: this.webController })
.onPageEnd(() => {
this.webController.runJavaScript({
script: `loadPdf("${this.pdfBase64}")`
});
})
HTML 包装页用 <embed type="application/pdf"> 加载 base64 PDF,Chromium 内置 PDF 查看器正确渲染。
环节 7:字体准备
HarmonyOS 系统字体在 /system/fonts/,应用无直接读取权限。必须先复制到沙箱:
import { fileIo as fs } from '@kit.CoreFileKit';
private prepareFont(): void {
const dest = `${this.abilityCtx.filesDir}/HarmonyOS_Sans_SC.ttf`;
this.fontPath = dest;
if (fs.accessSync(dest)) return; // 已有则跳过
const sysFonts = [
'/system/fonts/HarmonyOS_Sans_SC_Regular.ttf',
'/system/fonts/HarmonyOS_Sans_SC.ttf',
];
for (const src of sysFonts) {
try {
if (fs.accessSync(src)) { fs.copyFileSync(src, dest); return; }
} catch (_) {}
}
this.fontPath = ''; // 全部失败则降级 Helvetica
}
踩坑专区
坑 1:三方 .so 未部署到 entry/libs 导致运行期 so 未加载
现象:
点击「生成 PDF」按钮,提示 ✗ so 未加载,ArkTS 侧 import haru from 'libentry.so' 返回 undefined。
根因:
libharu/zlib/png 三个 .so 只放在 cpp/thirdparty/ 供 CMake 链接。但 OHOS 打包 HAP 时只会把 entry/libs/<abi>/ 下的 .so 打进包。运行期 dlopen("libhpdf.so") 找不到库 → libentry.so 加载失败。
修复:
将三个 .so 同时部署到 entry/libs/arm64-v8a/:
mkdir -p entry/libs/arm64-v8a
cp libhpdf.so.2.4.4 libpng16.so.16.39.0 libz.so.1.2.13 entry/libs/arm64-v8a/
# 建立 SONAME 软链
cd entry/libs/arm64-v8a
ln -sf libhpdf.so.2.4.4 libhpdf.so
ln -sf libpng16.so.16.39.0 libpng16.so
ln -sf libz.so.1.2.13 libz.so
CMake 链接路径 ≠ 运行期加载路径,两者都要部署。这是 OHOS 集成三方动态库的第一个必知规则。
坑 2:getContext(this) 在异步方法中返回 undefined
现象:
点击「生成 PDF」,报 TypeError: Cannot read property filesDir of undefined。
根因:
getContext(this) 写成 getter,在异步方法 genPdf() 内调用时,this 上下文已非组件实例,返回 undefined。
修复:
在 aboutToAppear 同步生命周期中一次性获取 context:
// 错误 —— getter 在异步方法中失效
private get ctx(): common.UIAbilityContext {
return getContext(this) as common.UIAbilityContext;
}
// 正确 —— aboutToAppear 预存
private abilityCtx: common.UIAbilityContext | null = null;
aboutToAppear(): void {
this.abilityCtx = getContext(this) as common.UIAbilityContext;
}
private async genPdf(): Promise<void> {
if (!this.abilityCtx) { /* 错误处理 */ return; }
const dir = this.abilityCtx.filesDir;
}
ArkTS 异步方法中使用
getContext(this)是典型坑——必须在同步生命周期中提前捕获。
坑 3:Web 组件直接加载 data URL 显示乱码
现象:
PDF 生成成功,base64 数据正确,但 Web 组件加载 data:application/pdf;base64,... 后显示乱码(二进制文本)。
根因:
Chromium 内核通过 Web({ src: dataUrl }) 加载时,未正确识别 application/pdf MIME 类型,将 PDF 二进制当纯文本渲染。
修复:
用 HTML 包装页 + <embed> 标签,Chromium 内置 PDF 查看器会正确处理 <embed> 中的 PDF:
// 错误 —— 直接 data URL 乱码
Web({ src: this.pdfDataUrl, controller: this.webController })
// 正确 —— HTML 包装页 + runJavaScript 注入
Web({ src: $rawfile('pdf_viewer.html'), controller: this.webController })
.onPageEnd(() => {
this.webController.runJavaScript({
script: `loadPdf("${this.pdfBase64}")`
});
})
pdf_viewer.html 核心逻辑:
<script>
function loadPdf(base64) {
var dataUrl = 'data:application/pdf;base64,' + base64;
var embed = document.createElement('embed');
embed.src = dataUrl;
embed.type = 'application/pdf';
embed.width = '100%';
embed.height = '100%';
document.getElementById('pdf-container').appendChild(embed);
}
</script>
坑 4:Web 组件 controller 为必填参数
现象:
编译报错 Argument of type '{ src: string; }' is not assignable to parameter of type 'WebOptions'. Property 'controller' is missing。
根因:
ArkUI 的 Web 组件中 controller 是必填项,不可省略。
修复:
// 错误
Web({ src: url })
// 正确
private webController: WebController = new WebController();
Web({ src: url, controller: this.webController })
坑 5:runJavaScript 参数签名变更
现象:
编译报错 Argument of type 'string' is not assignable to parameter of type '{ script: string; callback?: ... }'。
根因:
新版 HarmonyOS SDK 中 runJavaScript 签名从 (script: string) 改为 (options: { script: string; callback?: ... })。
修复:
// 旧版 SDK
this.webController.runJavaScript(`loadPdf("${b64}")`);
// 新版 SDK(6.1.0+)
this.webController.runJavaScript({ script: `loadPdf("${b64}")` });
SDK API 签名变更不写进 changelog 是鸿蒙生态的常见痛点,编译报错是唯一提示。
坑 6:系统字体路径无权限导致 LoadTTFont 失败
现象:
中文内容生成 PDF 报 LoadTTFont failed: HPDF error: 0x1017 detail:2。错误码 0x1017 = HPDF_TTF_CANNOT_OPEN_FILE。
根因:
直接传 /system/fonts/HarmonyOS_Sans_SC_Regular.ttf 给 HPDF_LoadTTFontFromFile,应用沙箱无权限读取系统字体目录。
修复:
用 @kit.CoreFileKit 的 fileIo.copyFileSync 复制到沙箱,传沙箱路径:
import { fileIo as fs } from '@kit.CoreFileKit';
const dest = `${this.abilityCtx.filesDir}/HarmonyOS_Sans_SC.ttf`;
fs.copyFileSync('/system/fonts/HarmonyOS_Sans_SC_Regular.ttf', dest);
this.fontPath = dest; // 传沙箱路径给 libharu
C++ 侧收到非空 fontPath 后:
HPDF_UseUTFEncodings(pdf);
const char *fontName = HPDF_LoadTTFontFromFile(pdf, fontPath.c_str(), HPDF_TRUE);
font = HPDF_GetFont(pdf, fontName, "UTF-8");
OHOS 应用的文件权限边界:只能读写自己的沙箱(
filesDir/cacheDir),系统目录只读且部分不可访问。三方库的文件 IO 必须在沙箱内完成。
通用集成模板(拿来即用)
动态库 CMakeLists.txt 模板(含传递依赖)
适用于 libharu 这类有依赖链的 .so 库:
cmake_minimum_required(VERSION 3.5.0)
project({Project} C CXX)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(LIB_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty/{lib})
if(DEFINED PACKAGE_FIND_FILE)
include(${PACKAGE_FIND_FILE})
endif()
# 头文件(含依赖库头文件)
include_directories(${NATIVERENDER_ROOT_PATH}
${NATIVERENDER_ROOT_PATH}/include
${LIB_ROOT}/include)
# IMPORTED 目标(指向带版本号的 .so)
set(LIB_DIR ${LIB_ROOT}/libs/arm64-v8a)
add_library({lib} SHARED IMPORTED)
set_target_properties({lib} PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/lib{lib}.so.{version})
# 如有依赖,同样声明 IMPORTED 目标
add_library(dep SHARED IMPORTED)
set_target_properties(dep PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/libdep.so.{dep_version})
add_library(entry SHARED napi_init.cpp)
# 链接顺序:被依赖的库放后面!
target_link_libraries(entry PUBLIC libace_napi.z.so)
target_link_libraries(entry PUBLIC {lib} dep)
.so 双重部署模板
# ① CMake 链接期:thirdparty 目录
mkdir -p entry/src/main/cpp/thirdparty/{lib}/libs/arm64-v8a
cp lib{lib}.so.{ver} entry/src/main/cpp/thirdparty/{lib}/libs/arm64-v8a/
# ② HAP 打包期:entry/libs 目录(必须!)
mkdir -p entry/libs/arm64-v8a
cp lib{lib}.so.{ver} entry/libs/arm64-v8a/
cd entry/libs/arm64-v8a && ln -sf lib{lib}.so.{ver} lib{lib}.so
NAPI 字符串读取 5 步模板
static napi_value MyFunction(napi_env env, napi_callback_info info) {
// ① 解析参数
size_t argc = 2;
napi_value argv[2] = {};
napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr);
// ② 边界检查
if (argc < 2) return Str(env, "error: need 2 args");
// ③ 两段式字符串读取(NAPI 必须!)
size_t sz = 0;
std::string text;
napi_get_value_string_utf8(env, argv[0], nullptr, 0, &sz); // 先拿长度
text.resize(sz);
napi_get_value_string_utf8(env, argv[0], &text[0], sz + 1, &sz); // 再拿内容
// ④ 调用 C API
// ⑤ 返回 NAPI 值(用 Str/Num 助手统一处理)
}
Web 组件 PDF 预览模板
// ① 声明 controller(必填!)
private webController: WebController = new WebController();
// ② 加载 HTML 包装页
Web({ src: $rawfile('pdf_viewer.html'), controller: this.webController })
.onPageEnd(() => {
// ③ 注入 base64 数据(注意参数对象形式)
this.webController.runJavaScript({
script: `loadPdf("${this.pdfBase64}")`
});
})
总结
libharu 集成鸿蒙的难点不在 CMake 链接(IMPORTED 目标模式已成熟),而在 运行期 .so 部署路径 和 中文 TTF 字体权限 两个暗坑。前者是 OHOS 打包机制决定的——entry/libs/ 是唯一入口;后者是沙箱安全模型决定的——系统字体必须先复制再加载。Web 组件预览 PDF 的 data URL → HTML embed 方案,是 Chromium 内核 MIME 识别问题的通用解法。
你在 NAPI 集成中遇到过什么奇怪的错误?是 .so 找不到、字体加载失败还是 Web 预览乱码?欢迎在评论区分享你的经验。
如果本文对你有帮助,请 点赞、收藏、转发 支持一下~
更多推荐


所有评论(0)