欢迎加入【开源鸿蒙PC社区】,一起共建鸿蒙化C/C++三方库生态。
欢迎在【PC社区】平台贡献你的项目。
仓库: unisources/TypeFun — 鸿蒙PC文字特效编辑器
集成平台: 鸿蒙PC | 测试SDK: 6.1.1(24) | DevEco Studio 6.1

image-20260619021119885

一、前置说明

项目 说明
集成库 Cairo 1.17.8 (.so) / fontconfig 2.14.2 (.a) / libpng 1.6.39 (.a)
目标平台 鸿蒙PC
SDK 版本 HarmonyOS SDK 6.1.1(24) — API 12
开发工具 DevEco Studio 6.1
交叉编译工具链 lycium_plusplus (BiSheng 编译器)
前置依赖 zlib 1.2.13 / bzip2 / brotli / libexpat 1.8.10 / pixman 0.42.2 (周期1) + freetype2 25.0.19 / harfbuzz 6.7.10 (周期2)
项目地址 https://atomgit.com/unisources/TypeFun

一、要做什么?

TypeFun 的进一步目标是为应用添加三大能力:

  1. 文字特效渲染 — 发光(glow)、长阴影(shadow)、描边(stroke)三种特效
  2. WebP 静图/动图导出 — Cairo 画布 → WebP 编码 → 本地文件
  3. 二维码生成 — 输入文本 → libqrencode 生成 RGBA 像素 → 绘制到 Cairo 画布 → PNG 导出

这三个能力分别对应 libwebp 和 libqrencode 两个库:

用途 链接方式 NAPI 函数
libwebp WebP 静图编码 .so 动态链接 canvas.exportWebP
libwebp(libwebpmux) WebP 动画编码 .so 动态链接 canvas.exportAnimWebP
libqrencode QR 二维码生成 .a 静态链接 qr.generate

此外,canvas.drawEffect(特效渲染)和 canvas.drawPixels(RGBA 像素绘制到 Cairo 画布)虽然不直接调用这两个库,但它们是完整功能链路中的关键环节。

整体调用链路:

┌─────────────────── ArkTS 层 ───────────────────┐
│                                                  │
│  testEffect()          testExportWebP()          │
│  testQR()              testAnimWebP()            │
│       │                      │                    │
└───────┼──────────────────────┼────────────────────┘
        │ NAPI                 │ NAPI
┌───────▼──────────────────────▼────────────────────┐
│              libentry.so (NAPI 桥接层)              │
│                                                    │
│  canvas.drawEffect  ← Cairo + FreeType             │
│  canvas.exportWebP  ← libwebp/encode.h             │
│  canvas.exportAnimWebP ← libwebp/mux.h             │
│  canvas.drawPixels  ← Cairo (RGBA→ARGB32 转换)     │
│  qr.generate        ← libqrencode                  │
└────────────────────────────────────────────────────┘

二、CMake 配置 — 引入 libwebp 和 libqrencode

2.1 库路径宏

TypeFun 项目已通过 lycium_plusplus 完成交叉编译,产物放在 thirdparty/<libname>/arm64-v8a/ 下。CMake 用 setup_lib 宏统一管理:

# CMakeLists.txt
set(THIRDPARTY_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty)

macro(setup_lib LIB_NAME)
    set(${LIB_NAME}_ROOT ${THIRDPARTY_ROOT}/${LIB_NAME}/${OHOS_ARCH})
endmacro()

# 周期5 — 特效与动图导出
setup_lib(libwebp)     # .so 链接
setup_lib(libqrencode) # .a 链接

宏展开后,${libwebp_ROOT}thirdparty/libwebp/arm64-v8a${libqrencode_ROOT}thirdparty/libqrencode/arm64-v8a

2.2 头文件路径

include_directories(
    # ... 其他库 ...
    # 周期5 — 特效与动图导出
    ${libwebp_ROOT}/include
    ${libqrencode_ROOT}/include
)

libwebp 的头文件结构为 webp/encode.hwebp/mux.h,libqrencode 为 qrencode.h

2.3 链接方式

target_link_libraries(entry PUBLIC
    libace_napi.z.so
    # ... 其他库 ...
    # 周期5 — 特效与动图导出
    ${libwebp_ROOT}/lib/libwebp.so       # WebP 编码(动态库)
    ${libwebp_ROOT}/lib/libwebpmux.so    # WebP 动画复用(动态库)
    ${libqrencode_ROOT}/lib/libqrencode.a # QR 编码(静态库)
)

链接方式决策

方式 原因
libwebp .so 动态 编码器体积较大(~1.5MB),动态链接可被多模块共享;需放入 entry/libs/arm64-v8a/
libwebpmux .so 动态 动画编码依赖 libwebp,与 libwebp 一起动态分发
libqrencode .a 静态 体积极小(~50KB),静态编入 libentry.so,运行时无需额外分发

注意.so 文件必须放入 entry/libs/arm64-v8a/ 目录,否则运行时会报 dlopen: library "libwebp.so" not found.a 文件在编译时链入,无需额外分发。


三、NAPI 桥接 — canvas 命名空间扩展

周期5 在已有的 canvas 命名空间中新增 3 个函数,并新增独立的 qr 命名空间:

canvas.drawEffect(canvasId, effect, params)     → void
canvas.exportWebP(canvasId, path, quality?)     → void
canvas.exportAnimWebP(canvasId, framePaths[], delays[], outputPath) → void

qr.generate(text, size?)                        → { width, height, data: number[] }

另外新增 canvas.drawPixels 作为辅助函数(解决 RGBA 像素无法直接写入 Cairo 画布的问题):

canvas.drawPixels(canvasId, srcWidth, srcHeight, data, dstX?, dstY?) → void

3.1 命名空间注册

canvas_bridge.cppCreateCanvasNamespace 工厂函数中注册周期5 的函数:

// canvas_bridge.cpp
napi_value CreateCanvasNamespace(napi_env env)
{
    napi_value ns;
    napi_create_object(env, &ns);

    napi_property_descriptor desc[] = {
        // ... 周期1-3 已有函数 ...
        {"create",       nullptr, CanvasCreate,       nullptr, nullptr, nullptr, napi_default, nullptr},
        {"drawGlyphs",   nullptr, CanvasDrawGlyphs,   nullptr, nullptr, nullptr, napi_default, nullptr},
        {"exportPNG",    nullptr, CanvasExportPNG,    nullptr, nullptr, nullptr, napi_default, nullptr},
        // ...
        // 周期5 — 特效与动图导出
        {"drawEffect",     nullptr, CanvasDrawEffect,     nullptr, nullptr, nullptr, napi_default, nullptr},
        {"drawPixels",     nullptr, CanvasDrawPixels,     nullptr, nullptr, nullptr, napi_default, nullptr},
        {"exportWebP",     nullptr, CanvasExportWebP,     nullptr, nullptr, nullptr, napi_default, nullptr},
        {"exportAnimWebP", nullptr, CanvasExportAnimWebP, nullptr, nullptr, nullptr, napi_default, nullptr},
    };

    napi_define_properties(env, ns, sizeof(desc) / sizeof(desc[0]), desc);
    return ns;
}

qr_bridge.cpp 中创建独立的 qr 命名空间:

// qr_bridge.cpp
napi_value CreateQRNamespace(napi_env env)
{
    napi_value ns;
    napi_create_object(env, &ns);

    napi_property_descriptor desc[] = {
        {"generate", nullptr, QRGenerate, nullptr, nullptr, nullptr, napi_default, nullptr},
    };

    napi_define_properties(env, ns, sizeof(desc) / sizeof(desc[0]), desc);
    return ns;
}

napi_init.cppInit 中注册 qr 命名空间:

// napi_init.cpp
static napi_value Init(napi_env env, napi_value exports)
{
    // ... 已有注册 ...

    // 周期5: qr 命名空间
    napi_value qrNs = CreateQRNamespace(env);
    napi_set_named_property(env, exports, "qr", qrNs);

    return exports;
}

3.2 版本验证

napi_init.cppVerifyLibs 中加入 libwebp 和 libqrencode 的版本号验证:

// napi_init.cpp — VerifyLibs 函数中
// 周期5
{
    int ver = WebPGetEncoderVersion();
    char buf[32];
    snprintf(buf, sizeof(buf), "%d.%d.%d", (ver >> 16) & 0xFF, (ver >> 8) & 0xFF, ver & 0xFF);
    setProp("libwebp", buf);
}
{
    int major, minor, micro;
    QRcode_APIVersion(&major, &minor, &micro);
    char buf[32];
    snprintf(buf, sizeof(buf), "%d.%d.%d", major, minor, micro);
    setProp("libqrencode", buf);
}

ArkTS 侧调用 testNapi.verifyLibs() 即可确认两个库是否正确链接:

const versions = testNapi.verifyLibs();
// versions.libwebp     → "1.4.0"
// versions.libqrencode → "4.1.1"

四、libwebp 桥接详解 — WebP 静图与动图导出

4.1 头文件引入

libwebp 需要引入两个头文件:encode.h(编码)和 mux.h(动画复用):

// canvas_bridge.cpp 顶部
#include "thirdparty/libwebp/arm64-v8a/include/webp/encode.h"
#include "thirdparty/libwebp/arm64-v8a/include/webp/mux.h"

4.2 canvas.exportWebP — 静图导出

这是最核心的函数:从 Cairo ARGB32 画布取出像素 → 转 RGBA → WebP 编码 → 写入文件。

4.2.1 函数签名
// canvas.exportWebP(canvasId, path, quality?): void
//   - canvasId: 画布 ID
//   - path: 输出文件路径
//   - quality: 压缩质量 1-100(默认 80)
4.2.2 完整实现
static napi_value CanvasExportWebP(napi_env env, napi_callback_info info)
{
    size_t argc = 3;
    napi_value args[3];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    if (argc < 2) {
        napi_throw_error(env, nullptr, "需要 canvasId 和 path 两个参数");
        return nullptr;
    }

    // ── 参数解析 ──
    int32_t canvasId;
    napi_get_value_int32(env, args[0], &canvasId);

    if (canvasId < 0 || canvasId >= static_cast<int32_t>(g_canvases.size())
        || !g_canvases[canvasId].cr) {
        napi_throw_error(env, nullptr, "无效的 canvasId");
        return nullptr;
    }
    CanvasEntry &canvas = g_canvases[canvasId];

    char filePath[1024];
    size_t pathLen;
    napi_get_value_string_utf8(env, args[1], filePath, sizeof(filePath), &pathLen);

    float quality = 80.0f;
    if (argc >= 3) {
        napi_valuetype t;
        napi_typeof(env, args[2], &t);
        if (t == napi_number) {
            double q;
            napi_get_value_double(env, args[2], &q);
            quality = static_cast<float>(q);
        }
    }
    if (quality < 1) quality = 1;
    if (quality > 100) quality = 100;

    // ── 第一步:从 Cairo surface 获取像素数据 ──
    cairo_surface_flush(canvas.surface);
    unsigned char *srcData = cairo_image_surface_get_data(canvas.surface);
    int width = canvas.width;
    int height = canvas.height;
    int stride = cairo_image_surface_get_stride(canvas.surface);

    // ── 第二步:Cairo ARGB32 → RGBA 转换 ──
    // Cairo 预乘 ARGB: src[0]=B, src[1]=G, src[2]=R, src[3]=A
    std::vector<uint8_t> rgbaData(width * height * 4);
    for (int y = 0; y < height; y++) {
        for (int x = 0; x < width; x++) {
            uint8_t *src = srcData + y * stride + x * 4;
            uint8_t *dst = rgbaData.data() + (y * width + x) * 4;
            uint8_t a = src[3];
            if (a > 0) {
                dst[0] = (src[2] * 255 + a / 2) / a;  // R (反预乘)
                dst[1] = (src[1] * 255 + a / 2) / a;  // G
                dst[2] = (src[0] * 255 + a / 2) / a;  // B
            } else {
                dst[0] = dst[1] = dst[2] = 0;
            }
            dst[3] = a;  // A
        }
    }

    // ── 第三步:WebP 编码 ──
    WebPConfig config;
    if (!WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, quality)) {
        napi_throw_error(env, nullptr, "WebP 配置初始化失败");
        return nullptr;
    }

    WebPPicture picture;
    if (!WebPPictureInit(&picture)) {
        napi_throw_error(env, nullptr, "WebPPicture 初始化失败");
        return nullptr;
    }
    picture.width = width;
    picture.height = height;
    picture.use_argb = 1;

    if (!WebPPictureImportRGBA(&picture, rgbaData.data(), width * 4)) {
        napi_throw_error(env, nullptr, "WebP 像素导入失败");
        WebPPictureFree(&picture);
        return nullptr;
    }

    WebPMemoryWriter writer;
    WebPMemoryWriterInit(&writer);
    picture.writer = WebPMemoryWrite;
    picture.custom_ptr = &writer;

    if (!WebPEncode(&config, &picture)) {
        char errMsg[128];
        snprintf(errMsg, sizeof(errMsg), "WebP 编码失败: error code %d", picture.error_code);
        napi_throw_error(env, nullptr, errMsg);
        WebPMemoryWriterClear(&writer);
        WebPPictureFree(&picture);
        return nullptr;
    }

    // ── 第四步:写入文件 ──
    FILE *fp = fopen(filePath, "wb");
    if (fp) {
        fwrite(writer.mem, 1, writer.size, fp);
        fclose(fp);
    } else {
        napi_throw_error(env, nullptr, "无法写入 WebP 文件");
        WebPMemoryWriterClear(&writer);
        WebPPictureFree(&picture);
        return nullptr;
    }

    WebPMemoryWriterClear(&writer);
    WebPPictureFree(&picture);

    fprintf(stdout, "[TypeFun] WebP 已导出: %dx%d quality=%.0f path=%s\n",
            width, height, quality, filePath);

    napi_value result;
    napi_get_undefined(env, &result);
    return result;
}
4.2.3 像素格式转换详解(关键坑点)

这是整个 exportWebP 最容易出错的地方。Cairo 和 WebP 的像素格式完全不同:

偏移 Cairo ARGB32(预乘) WebP RGBA(标准)
[0] B(已预乘 alpha) R
[1] G(已预乘 alpha) G
[2] R(已预乘 alpha) B
[3] A A

转换需要两步:反预乘 + 通道重排

反预乘公式(以 R 通道为例):

R_standard = (R_premultiplied * 255 + alpha / 2) / alpha

+ alpha / 2 是四舍五入,避免精度损失。当 alpha = 0 时,RGB 均置为 0。

如果直接把 Cairo 像素传给 WebPPictureImportRGBA,结果会是颜色偏蓝、半透明区域发暗,因为 R/B 通道互换了,且预乘值被当作标准值处理。

4.3 canvas.exportAnimWebP — 动画导出

动画 WebP 的编码流程:多帧 PNG 图片 → 逐帧读取 → ARGB32→RGBA 转换 → WebPAnimEncoder 合成。

4.3.1 函数签名
// canvas.exportAnimWebP(canvasId, framePaths[], delays[], outputPath): void
//   - canvasId: 画布 ID(用于获取画布尺寸作为参考)
//   - framePaths: PNG 文件路径数组
//   - delays: 每帧延迟时间数组(毫秒)
//   - outputPath: 输出动画 WebP 文件路径
4.3.2 核心实现
static napi_value CanvasExportAnimWebP(napi_env env, napi_callback_info info)
{
    // ... 参数解析(canvasId, framePaths[], delays[], outputPath)...

    CanvasEntry &canvas = g_canvases[canvasId];
    int canvasW = canvas.width;
    int canvasH = canvas.height;

    // 创建动画编码器
    WebPAnimEncoderOptions encOptions;
    WebPAnimEncoderOptionsInit(&encOptions);
    WebPAnimEncoder *animEnc = WebPAnimEncoderNew(canvasW, canvasH, &encOptions);
    if (!animEnc) {
        napi_throw_error(env, nullptr, "WebP 动画编码器创建失败");
        return nullptr;
    }

    int timestamp_ms = 0;
    bool hasError = false;

    // 逐帧处理
    for (uint32_t i = 0; i < frameCount && !hasError; i++) {
        // 读取帧路径和延迟
        // ...

        // 用 Cairo 读取 PNG(复用 Cairo 的 PNG 解码能力)
        cairo_surface_t *frameSurf = cairo_image_surface_create_from_png(framePath);
        if (cairo_surface_status(frameSurf) != CAIRO_STATUS_SUCCESS) {
            // 错误处理
            hasError = true;
            break;
        }

        int fw = cairo_image_surface_get_width(frameSurf);
        int fh = cairo_image_surface_get_height(frameSurf);
        int fstride = cairo_image_surface_get_stride(frameSurf);
        unsigned char *fdata = cairo_image_surface_get_data(frameSurf);
        cairo_surface_flush(frameSurf);

        // ARGB32 → RGBA 转换(与 exportWebP 相同逻辑)
        std::vector<uint8_t> rgbaFrame(fw * fh * 4);
        for (int y = 0; y < fh; y++) {
            for (int x = 0; x < fw; x++) {
                uint8_t *src = fdata + y * fstride + x * 4;
                uint8_t *dst = rgbaFrame.data() + (y * fw + x) * 4;
                uint8_t a = src[3];
                if (a > 0) {
                    dst[0] = (src[2] * 255 + a / 2) / a;  // R
                    dst[1] = (src[1] * 255 + a / 2) / a;  // G
                    dst[2] = (src[0] * 255 + a / 2) / a;  // B
                } else {
                    dst[0] = dst[1] = dst[2] = 0;
                }
                dst[3] = a;
            }
        }
        cairo_surface_destroy(frameSurf);

        // 导入到 WebPPicture
        WebPPicture frame;
        WebPPictureInit(&frame);
        frame.width = fw;
        frame.height = fh;
        frame.use_argb = 1;
        if (!WebPPictureImportRGBA(&frame, rgbaFrame.data(), fw * 4)) {
            hasError = true;
            break;
        }

        // 添加帧到动画编码器
        WebPConfig frameConfig;
        WebPConfigPreset(&frameConfig, WEBP_PRESET_DEFAULT, 75.0f);

        if (!WebPAnimEncoderAdd(animEnc, &frame, timestamp_ms, &frameConfig)) {
            hasError = true;
            WebPPictureFree(&frame);
            break;
        }

        WebPPictureFree(&frame);
        timestamp_ms += delayMs;
    }

    // 结束编码
    if (!hasError) {
        WebPAnimEncoderAdd(animEnc, nullptr, timestamp_ms, nullptr);  // 空帧收尾
    }

    // 组装并写入文件
    WebPData webpData;
    WebPDataInit(&webpData);
    if (!hasError && WebPAnimEncoderAssemble(animEnc, &webpData)) {
        FILE *fp = fopen(outputPath, "wb");
        if (fp) {
            fwrite(webpData.bytes, 1, webpData.size, fp);
            fclose(fp);
        }
    }

    WebPDataClear(&webpData);
    WebPAnimEncoderDelete(animEnc);

    napi_value result;
    napi_get_undefined(env, &result);
    return result;
}
4.3.3 动画编码关键点
要点 说明
WebPAnimEncoder 来自 webp/mux.h,需要链接 libwebpmux.so
timestamp_ms 每帧的时间戳(毫秒),由 delays[] 数组累加
空帧收尾 WebPAnimEncoderAdd(nullptr, ...) 通知编码器所有帧已添加
PNG 读取 复用 Cairo 的 cairo_image_surface_create_from_png,避免再引入 libpng API
每帧独立转换 每帧都需要 ARGB32→RGBA 转换,因为 Cairo 读取 PNG 后也是 ARGB32 格式

五、libqrencode 桥接详解 — 二维码生成与绘制

5.1 头文件引入

// qr_bridge.cpp
#include "thirdparty/libqrencode/arm64-v8a/include/qrencode.h"

5.2 qr.generate — 生成二维码像素数据

5.2.1 函数签名
// qr.generate(text: string, size?: number): { width, height, data: number[] }
//   - text: 要编码的文本
//   - size: 输出像素尺寸(默认 256,范围 64-2048)
//   - 返回: RGBA 像素数组(width × height × 4)
5.2.2 完整实现
static napi_value QRGenerate(napi_env env, napi_callback_info info)
{
    size_t argc = 2;
    napi_value args[2];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    if (argc < 1) {
        napi_throw_error(env, nullptr, "需要 text 参数");
        return nullptr;
    }

    // ── 参数 1: text ──
    size_t textLen = 0;
    napi_get_value_string_utf8(env, args[0], nullptr, 0, &textLen);
    std::vector<char> textBuf(textLen + 1);
    napi_get_value_string_utf8(env, args[0], textBuf.data(), textLen + 1, &textLen);

    // ── 参数 2: size(可选,默认 256) ──
    int32_t outSize = 256;
    if (argc >= 2) {
        napi_valuetype t;
        napi_typeof(env, args[1], &t);
        if (t == napi_number) {
            napi_get_value_int32(env, args[1], &outSize);
        }
    }
    if (outSize < 64) outSize = 64;
    if (outSize > 2048) outSize = 2048;

    // ── 生成二维码 ──
    QRcode *qrcode = QRcode_encodeString(textBuf.data(), 0,
                                          QR_ECLEVEL_M, QR_MODE_8, 1);
    if (!qrcode) {
        napi_throw_error(env, nullptr, "二维码生成失败:输入文本可能过长或不支持");
        return nullptr;
    }

    int modules = qrcode->width;       // QR 码模块数(含 quiet zone)
    int pixelSize = outSize / modules; // 每个模块的像素数
    if (pixelSize < 1) pixelSize = 1;
    int imgSize = modules * pixelSize;

    // ── 构建 RGBA 像素数组 ──
    std::vector<uint8_t> pixels(imgSize * imgSize * 4, 255);  // 白色背景

    for (int row = 0; row < modules; row++) {
        for (int col = 0; col < modules; col++) {
            bool isDark = qrcode->data[row * modules + col] & 0x01;
            if (isDark) {
                // 填充黑色模块(pixelSize × pixelSize 像素块)
                for (int py = 0; py < pixelSize; py++) {
                    for (int px = 0; px < pixelSize; px++) {
                        int idx = ((row * pixelSize + py) * imgSize
                                   + (col * pixelSize + px)) * 4;
                        pixels[idx + 0] = 0;   // R
                        pixels[idx + 1] = 0;   // G
                        pixels[idx + 2] = 0;   // B
                        pixels[idx + 3] = 255; // A
                    }
                }
            }
        }
    }

    QRcode_free(qrcode);

    // ── 构建返回值 ──
    napi_value result;
    napi_create_object(env, &result);
    SetPropI32(env, result, "width", imgSize);
    SetPropI32(env, result, "height", imgSize);

    napi_value dataArr;
    napi_create_array_with_length(env, pixels.size(), &dataArr);
    for (size_t i = 0; i < pixels.size(); i++) {
        napi_value v;
        napi_create_int32(env, pixels[i], &v);
        napi_set_element(env, dataArr, i, v);
    }
    napi_set_named_property(env, result, "data", dataArr);

    return result;
}
5.2.3 QR 编码参数说明
参数 说明
version 0 自动选择最小版本
QR_ECLEVEL_M 中等纠错 约 15% 容错率
QR_MODE_8 8-bit 模式 支持 UTF-8 中文
casesensitive 1 区分大小写

5.3 canvas.drawPixels — RGBA 像素绘制到 Cairo 画布

这是解决二维码渲染问题的关键函数qr.generate 返回的是标准 RGBA 像素,但 Cairo 画布使用 ARGB32 格式,无法直接写入。drawPixels 完成了 RGBA → ARGB32 预乘的转换。

5.3.1 函数签名
// canvas.drawPixels(canvasId, srcWidth, srcHeight, data, dstX?, dstY?): void
//   - canvasId: 目标画布 ID
//   - srcWidth, srcHeight: 像素数据尺寸
//   - data: number[] (RGBA 像素数组)
//   - dstX, dstY: 目标位置(默认 0,0)
5.3.2 核心转换逻辑
// 读取 RGBA 数据
std::vector<uint8_t> rgbaData(dataLen);
for (uint32_t i = 0; i < dataLen; i++) {
    napi_value v;
    napi_get_element(env, args[3], i, &v);
    int32_t val;
    napi_get_value_int32(env, v, &val);
    rgbaData[i] = static_cast<uint8_t>(val);
}

// 创建临时 ARGB32 表面
cairo_surface_t *tmpSurf = cairo_image_surface_create(
    CAIRO_FORMAT_ARGB32, srcWidth, srcHeight);
unsigned char *dst = cairo_image_surface_get_data(tmpSurf);
int stride = cairo_image_surface_get_stride(tmpSurf);

// RGBA → Cairo ARGB32 (预乘 BGR-A)
for (int y = 0; y < srcHeight; y++) {
    for (int x = 0; x < srcWidth; x++) {
        int srcIdx = (y * srcWidth + x) * 4;
        uint8_t r = rgbaData[srcIdx + 0];
        uint8_t g = rgbaData[srcIdx + 1];
        uint8_t b = rgbaData[srcIdx + 2];
        uint8_t a = rgbaData[srcIdx + 3];

        double af = a / 255.0;
        int dstIdx = y * stride + x * 4;
        dst[dstIdx + 0] = static_cast<uint8_t>(b * af + 0.5);  // B pre-multiplied
        dst[dstIdx + 1] = static_cast<uint8_t>(g * af + 0.5);  // G pre-multiplied
        dst[dstIdx + 2] = static_cast<uint8_t>(r * af + 0.5);  // R pre-multiplied
        dst[dstIdx + 3] = a;                                      // A
    }
}
cairo_surface_mark_dirty(tmpSurf);

// 将临时表面绘制到画布
cairo_save(canvas.cr);
cairo_set_source_surface(canvas.cr, tmpSurf, dstX, dstY);
cairo_rectangle(canvas.cr, dstX, dstY, srcWidth, srcHeight);
cairo_clip(canvas.cr);
cairo_paint(canvas.cr);
cairo_restore(canvas.cr);

cairo_surface_destroy(tmpSurf);
5.3.3 为什么要先写入临时表面?

不能直接修改画布的像素数据,因为:

  1. 画布可能正在进行其他绘制操作
  2. cairo_surface_mark_dirty 需要标记脏区域以触发重绘
  3. 使用 cairo_set_source_surface + cairo_paint 可以正确处理 alpha 混合

流程:RGBA 数据 → 临时 ARGB32 表面 → cairo_paint 合成到目标画布。


六、特效渲染 — canvas.drawEffect

drawEffect 不直接调用 libwebp 或 libqrencode,但它是周期5 功能链路的一部分(特效渲染后通常需要导出为 WebP)。

6.1 函数签名

// canvas.drawEffect(canvasId, effect, params): void
//   - effect: 'glow' | 'shadow' | 'stroke'
//   - params: { fontId, fontSize, color, x, y, text, glowColor?, glowRadius?, strokeColor?, strokeWidth? }

6.2 三种特效实现

发光(glow)

多层渐变透明度绘制,从外到内透明度递增:

if (strcmp(effect, "glow") == 0) {
    // 绘制发光层(由外到内,透明度递增)
    for (int i = glowRadius; i > 0; i--) {
        cairo_save(canvas.cr);
        cairo_set_font_face(canvas.cr, cairoFace);
        cairo_set_font_size(canvas.cr, fontSize);
        double alpha = 0.15 * (1.0 - (double)i / glowRadius);
        cairo_set_source_rgba(canvas.cr, gr, gg, gb, alpha);
        cairo_move_to(canvas.cr, offsetX + i * 0.5, offsetY + i * 0.5);
        cairo_show_text(canvas.cr, textBuf);
        cairo_restore(canvas.cr);
    }
    // 绘制正文
    cairo_set_font_face(canvas.cr, cairoFace);
    cairo_set_font_size(canvas.cr, fontSize);
    cairo_set_source_rgba(canvas.cr, r, g, b, a);
    cairo_move_to(canvas.cr, offsetX, offsetY);
    cairo_show_text(canvas.cr, textBuf);
}
长阴影(shadow)

逐层偏移绘制黑色半透明文字:

else if (strcmp(effect, "shadow") == 0) {
    for (int i = static_cast<int>(shadowLen); i > 0; i--) {
        double alpha = 0.15 * (1.0 - (double)i / shadowLen);
        cairo_save(canvas.cr);
        cairo_set_font_face(canvas.cr, cairoFace);
        cairo_set_font_size(canvas.cr, fontSize);
        cairo_set_source_rgba(canvas.cr, 0, 0, 0, alpha);
        cairo_move_to(canvas.cr, offsetX + i, offsetY + i);
        cairo_show_text(canvas.cr, textBuf);
        cairo_restore(canvas.cr);
    }
    // 正文
    cairo_set_source_rgba(canvas.cr, r, g, b, a);
    cairo_move_to(canvas.cr, offsetX, offsetY);
    cairo_show_text(canvas.cr, textBuf);
}
描边(stroke)

cairo_text_path + cairo_stroke 画描边,再 cairo_show_text 填充:

else if (strcmp(effect, "stroke") == 0) {
    // 描边
    cairo_set_font_face(canvas.cr, cairoFace);
    cairo_set_font_size(canvas.cr, fontSize);
    cairo_set_source_rgba(canvas.cr, sr, sg, sb, sa);
    cairo_set_line_width(canvas.cr, strokeWidth);
    cairo_move_to(canvas.cr, offsetX, offsetY);
    cairo_text_path(canvas.cr, textBuf);
    cairo_stroke(canvas.cr);

    // 填充正文
    cairo_set_source_rgba(canvas.cr, r, g, b, a);
    cairo_move_to(canvas.cr, offsetX, offsetY);
    cairo_show_text(canvas.cr, textBuf);
}

6.3 ArkTS 调用示例

testEffect(): void {
  const fontId = this.getLatestFontId();
  const cId = testNapi.canvas.create(600, 200);

  // 发光特效
  testNapi.canvas.drawEffect(cId, 'glow', {
    fontId: fontId,
    fontSize: 64,
    color: '#FFFFFF',
    x: 50,
    y: 120,
    text: '你好鸿蒙',
    glowColor: '#FFD700',
    glowRadius: 8,
  });

  // 导出为 PNG 预览(也可改为 exportWebP)
  const effectPath = this.exportCanvasAsPng(cId, 'effect_glow.png');
}

七、ArkTS 类型声明

Index.d.ts 中添加周期5 的类型声明:

// ─── 周期5: 特效与动图导出 ─────────────────────────────

/** 特效绘制参数 */
export interface EffectParams {
  fontId: number;
  fontSize: number;
  color: string;
  x?: number;
  y?: number;
  text: string;
  glowColor?: string;
  glowRadius?: number;
  strokeColor?: string;
  strokeWidth?: number;
}

/** 二维码生成结果 */
export interface QRResult {
  width: number;
  height: number;
  data: number[];
}

/** 渲染画布命名空间(周期5 扩展) */
export const canvas: {
  // ... 已有函数 ...

  /** 将 RGBA 像素数组绘制到画布 */
  drawPixels(canvasId: number, srcWidth: number, srcHeight: number,
             data: number[], dstX?: number, dstY?: number): void;
  /** 绘制特效文字(glow / shadow / stroke) */
  drawEffect(canvasId: number, effect: string, params: EffectParams): void;
  /** 导出画布为 WebP 文件(quality: 0-100,默认 75) */
  exportWebP(canvasId: number, path: string, quality?: number): void;
  /** 将多帧 PNG 合成动画 WebP */
  exportAnimWebP(canvasId: number, framePaths: string[],
                 delays: number[], outputPath: string): void;
};

/** 二维码命名空间 */
export const qr: {
  /** 生成二维码,返回 RGBA 像素数据(size: 输出像素尺寸,默认 256) */
  generate(text: string, size?: number): QRResult;
};

八、ArkTS 调用层 — 完整功能串联

8.1 WebP 导出

testExportWebP(): void {
  try {
    const fontId = this.getLatestFontId();
    // 先用已有方法创建带文字的画布
    const cId = this.createCanvasWithText('你好鸿蒙', fontId, 48, '#E53935', 50, 120, 'zh');

    // 导出为 WebP
    const ctx = getContext(this);
    const webpPath = ctx.filesDir + '/test_export.webp';
    testNapi.canvas.exportWebP(cId, webpPath, 80);

    this.webpInfo += `✅ WebP 已导出\n   路径: ${webpPath}\n`;
  } catch (e) {
    this.webpInfo = '❌ WebP导出失败: ' + (e as Error).message;
  }
}

8.2 二维码生成 + 绘制到画布

testQR(): void {
  try {
    // 第一步:生成二维码像素数据
    const result = testNapi.qr.generate('你好鸿蒙 TypeFun!', 256);
    this.qrInfo += `✅ 二维码已生成: ${result.width}x${result.height}px\n`;

    // 第二步:创建画布,将 QR 像素绘制到画布
    const cId = testNapi.canvas.create(result.width, result.height);
    testNapi.canvas.drawPixels(cId, result.width, result.height, result.data);

    // 第三步:可选叠加文字提示
    const fontId = this.getLatestFontId();
    if (fontId >= 0) {
      this.drawTextLine(cId, 'QR: TypeFun', fontId, 16, '#999999', 10, 20, 'en');
    }

    // 第四步:导出为 PNG 预览
    const qrPath = this.exportCanvasAsPng(cId, 'qr_test.png');
    this.qrPngPath = qrPath;
  } catch (e) {
    this.qrInfo = '❌ 二维码生成失败: ' + (e as Error).message;
  }
}

8.3 特效渲染

testEffect(): void {
  try {
    const fontId = this.getLatestFontId();
    const cId = testNapi.canvas.create(600, 200);

    // 发光特效
    testNapi.canvas.drawEffect(cId, 'glow', {
      fontId: fontId,
      fontSize: 64,
      color: '#FFFFFF',
      x: 50,
      y: 120,
      text: '你好鸿蒙',
      glowColor: '#FFD700',
      glowRadius: 8,
    });

    const effectPath = this.exportCanvasAsPng(cId, 'effect_glow.png');
    this.effectInfo += '✅ 发光特效已渲染并导出\n';
  } catch (e) {
    this.effectInfo = '❌ 特效测试失败: ' + (e as Error).message;
  }
}

8.4 UI 区段

@Builder effectsSection() {
  Column() {
    this.sectionTitle('特效与导出')
    this.subtitle('WebP 动图、二维码与发光特效')
    Row({ space: 12 }) {
      this.outlinePill('WebP 导出', () => this.testExportWebP())
      this.outlinePill('二维码', () => this.testQR())
      this.outlinePill('特效测试', () => this.testEffect())
    }

    if (this.webpInfo) this.dataText(this.webpInfo, 20)
    if (this.qrInfo) this.dataText(this.qrInfo, 20)
    if (this.effectInfo) this.dataText(this.effectInfo, 20)

    // 展示二维码结果
    if (this.qrPngPath) {
      Column() {
        Image('file://' + this.qrPngPath)
          .width(160).height(160)
          .objectFit(ImageFit.Contain)
          .borderRadius(8)
      }
      .padding(20)
      .backgroundColor('#1a2f4a')
      .borderRadius(12)
    }

    // 展示渲染结果
    if (this.pngPath) {
      Image('file://' + this.pngPath)
        .width('100%')
        .aspectRatio(3)
        .objectFit(ImageFit.Contain)
        .borderRadius(8)
    }
  }
}

九、踩坑专区

坑 #1:RGBA↔ARGB32 像素格式 — 二维码渲染黑屏 / WebP 颜色偏蓝

现象

  • 调用 qr.generate() 生成的 RGBA 像素直接写入 Cairo 画布 → 结果全黑
  • Cairo 画布导出 WebP → 颜色偏蓝,半透明区域发暗

根因:Cairo 使用 CAIRO_FORMAT_ARGB32(预乘 BGR-A),而 libqrencode 输出标准 RGBA,WebP 也需要标准 RGBA 输入。三者的内存布局完全不同:

偏移 Cairo ARGB32 QR 输出 / WebP 输入
[0] B(预乘) R
[1] G(预乘) G
[2] R(预乘) B
[3] A A

修复

  1. QR → Cairo:新增 canvas.drawPixels,内部做 RGBA→预乘ARGB32 转换后写入临时 Cairo 表面,再 cairo_paint 合成到目标画布
  2. Cairo → WebP:在 exportWebPexportAnimWebP 中,取出 Cairo 像素后做反预乘 + 通道重排,转回标准 RGBA 再传给 WebPPictureImportRGBA
// RGBA → Cairo ARGB32 (drawPixels 中)
double af = a / 255.0;
dst[dstIdx + 0] = static_cast<uint8_t>(b * af + 0.5);  // B pre-multiplied
dst[dstIdx + 1] = static_cast<uint8_t>(g * af + 0.5);  // G pre-multiplied
dst[dstIdx + 2] = static_cast<uint8_t>(r * af + 0.5);  // R pre-multiplied
dst[dstIdx + 3] = a;

// Cairo ARGB32 → RGBA (exportWebP 中,反预乘)
uint8_t a = src[3];
if (a > 0) {
    dst[0] = (src[2] * 255 + a / 2) / a;  // R (反预乘)
    dst[1] = (src[1] * 255 + a / 2) / a;  // G
    dst[2] = (src[0] * 255 + a / 2) / a;  // B
} else {
    dst[0] = dst[1] = dst[2] = 0;
}
dst[3] = a;

ArkTS 侧对比

  testQR(): void {
    const result = testNapi.qr.generate('你好鸿蒙 TypeFun!', 256);
-   // 之前没有 drawPixels,二维码像素无处渲染 → 黑屏
+   const cId = testNapi.canvas.create(result.width, result.height);
+   testNapi.canvas.drawPixels(cId, result.width, result.height, result.data);
  }

坑 #2:libwebp.so 运行时找不到

现象:编译通过,但运行时 dlopen: library "libwebp.so" not found

根因.so 动态库需要放入 entry/libs/arm64-v8a/ 目录,仅 CMake 链接不够。CMake 的 target_link_libraries 只解决编译时符号解析,运行时加载器需要能找到 .so 文件。

修复

# 确认 .so 文件存在
ls entry/libs/arm64-v8a/
# libcairo.so  libopencc.so  libwebp.so  libwebpmux.so  libexpat.so  ...

如果缺少,从 lycium_plusplus 产物中复制:

cp thirdparty/libwebp/arm64-v8a/lib/libwebp.so entry/libs/arm64-v8a/
cp thirdparty/libwebp/arm64-v8a/lib/libwebpmux.so entry/libs/arm64-v8a/

同时确认 module.json5 中未声明对 .so 的限制。

坑 #3:WebPPictureImportRGBA 编码失败

现象WebPEncode 返回 false,picture.error_codeVP8_ENC_ERROR_PARAMETER

根因picture.use_argb 未设置为 1,或 WebPConfigPreset 的 quality 参数为 0。

修复

picture.use_argb = 1;  // 必须设置,否则低质量模式下会走有损 YUV 路径导致错误

if (quality < 1) quality = 1;    // quality 不能为 0
if (quality > 100) quality = 100;

十、像素格式转换总结

Cairo ARGB32 与 WebP RGBA 之间的转换是周期5 最核心也最容易出错的部分。整理如下:

                    ┌──────────────────────┐
                    │   Cairo ARGB32 画布    │
                    │  预乘 BGR-A 格式       │
                    └──────┬───────┬────────┘
                           │       │
              exportWebP   │       │  drawPixels
              (反预乘+重排) │       │  (预乘+重排)
                           │       │
                           ▼       │
                 ┌─────────────┐   │
                 │ 标准 RGBA   │   │
                 │ (WebP/Q R)  │   │
                 └─────────────┘   │
                           ▲       │
          WebPPictureImportRGBA    │
                           │       │
                    ┌──────┴───────┴────────┐
                    │   libwebp / libqrencode │
                    │   输入输出均为标准 RGBA   │
                    └────────────────────────┘
转换方向 函数 公式
RGBA → ARGB32(drawPixels 预乘 + R↔B 互换 B_out = B × (A/255), R_out = R × (A/255), A_out = A
ARGB32 → RGBA(exportWebP 反预乘 + R↔B 互换 R_out = (R_premul × 255 + A/2) / A, A_out = A

十一、通用集成模板

CMake 模板(周期5 库)

# ─── 宏 ───
set(THIRDPARTY_ROOT ${NATIVERENDER_ROOT_PATH}/thirdparty)
macro(setup_lib LIB_NAME)
    set(${LIB_NAME}_ROOT ${THIRDPARTY_ROOT}/${LIB_NAME}/${OHOS_ARCH})
endmacro()

setup_lib(libwebp)
setup_lib(libqrencode)

# ─── 头文件 ───
include_directories(
    ${libwebp_ROOT}/include
    ${libqrencode_ROOT}/include
)

# ─── 链接 ───
target_link_libraries(entry PUBLIC
    libace_napi.z.so
    ${libwebp_ROOT}/lib/libwebp.so       # WebP 编码
    ${libwebp_ROOT}/lib/libwebpmux.so    # WebP 动画
    ${libqrencode_ROOT}/lib/libqrencode.a # QR 编码
)

NAPI 桥接模板(WebP 导出)

#include "webp/encode.h"
#include "webp/mux.h"

static napi_value YourExportWebP(napi_env env, napi_callback_info info)
{
    // 1. 参数解析 (canvasId, path, quality?)
    // 2. 从 Cairo surface 获取像素 → ARGB32→RGBA 转换
    // 3. WebPConfigPreset + WebPPictureInit + WebPPictureImportRGBA
    // 4. WebPEncode → WebPMemoryWriter → 写文件
    // 5. 清理: WebPMemoryWriterClear + WebPPictureFree
}

NAPI 桥接模板(二维码)

#include "qrencode.h"

static napi_value YourQRGenerate(napi_env env, napi_callback_info info)
{
    // 1. 参数解析 (text, size?)
    // 2. QRcode_encodeString → QRcode*
    // 3. 遍历 modules → 构建 RGBA 像素数组
    // 4. 构建返回值 { width, height, data: number[] }
    // 5. QRcode_free
}

十二、总结

在鸿蒙PC应用中引入 libwebp 和 libqrencode,核心挑战不在编译链接(lycium_plusplus 已解决交叉编译),而在 NAPI 桥接像素格式转换

  1. Cairo ARGB32 与标准 RGBA 的格式差异是最大隐蔽陷阱:QR 生成 → Cairo 绘制需要 RGBA→预乘ARGB32(drawPixels),Cairo → WebP 导出需要反预乘ARGB32→RGBA(exportWebP)。两个方向的转换公式不同,一旦搞反就会黑屏或颜色偏蓝。

  2. libwebp 动画编码器需要 libwebpmux.so:仅链接 libwebp.so 只能做静图编码,动画必须额外链接 libwebpmux.so

  3. libqrencode 输出的是像素模块而非图像:需要自行将 QR 模块(0/1 矩阵)扩展为 RGBA 像素数组,再通过 drawPixels 绘制到 Cairo 画布。

  4. .so 动态库需放入 entry/libs/arm64-v8a/:CMake 链接只解决编译时符号,运行时加载器需要在应用包内找到 .so 文件。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐