*欢迎加入【开源鸿蒙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
三方库 .solibhpdf.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 部署策略,这是集成中最容易出错的地方。


传统方式的效率瓶颈

失败

失败

乱码

工程搭建

库文件部署

CMake 配置

NAPI 桥接

类型声明

UI 页面

PDF 预览

编译测试

阶段主要痛点传统耗时
工程搭建手动建目录、改 module.json5、配 main_pages10分钟
库文件部署三个 .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)

三个关键点

  1. IMPORTED_LOCATION 指向带版本号的 .solibhpdf.so.2.4.4),不要指向符号链接,否则构建系统可能解析失败
  2. 链接顺序 hpdf png16 z:png 依赖 zlib,hpdf 依赖 png+zlib,被依赖者放后面
  3. 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.ttfHPDF_LoadTTFontFromFile,应用沙箱无权限读取系统字体目录。

修复
@kit.CoreFileKitfileIo.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 预览乱码?欢迎在评论区分享你的经验。

如果本文对你有帮助,请 点赞、收藏、转发 支持一下~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐