HarmonyOS 应用开发高级案例(三):添加水印

37 阅读11分钟

本示例为开发者展示常用的水印添加能力,包括两种方式给页面添加水印、保存图片添加水印、拍照图片添加水印和pdf文件添加水印。

案例效果截图

首页页面水印图片水印pdf水印

案例运用到的知识点

  1. 核心知识点
  • 页面添加水印: 封装Canvas绘制水印组件,使用Stack层叠布局或overlay浮层属性,将水印组件与页面融合。
  • 保存图片添加水印: 获取图片数据,createPixelMap,使用OffScreenContext在指定位置绘制水印,最后保存带水印图片。
  • 拍照图片添加水印: 打开相机,获取存储fileUri,然后存入沙箱,获取图片数据,createPixelMap,绘制水印,最后保存带水印图片。
  • pdf文件添加水印: 使用PdfView预览组件预览pdf,使用pdfService服务加载pdf、添加水印、保存pdf。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param
  • 自定义组件和组件生命周期
  • 内置组件:Stack/Scroll/Flex/Column/Row/Text/Image/Button
  • 提示框:promptAction
  • 图形绘制:Canvas
  • UI范式渲染控制:ForEach/if
  • 日志管理类的编写
  • 常量与资源分类的访问
  • MVVM模式

代码结构

├──entry/src/main/ets/
│  ├──component
│  │  ├──NavBar.ets                     // 顶部导航条
│  │  └──Watermark.ets                  // 页面水印组件
│  ├──constants
│  │  ├──Utils.ets                      // 工具类
│  │  └──Constants.ets                  // 公共常量类
│  ├──entryability
│  │  └──EntryAbility.ets               // 程序入口类
│  └──pages            
│     ├──CameraPage.ets                 // 拍照添加水印
│     ├──Index.ets                      // 首页
│     ├──SaveImagePage.ets              // 保存图片添加水印
│     ├──WatermarkPdfPage.ets           // pdf文件添加水印
│     ├──WatermarkStackPage.ets         // 使用Stack添加页面背景水印
│     └──WatermarkOverlay.ets           // 使用overlay添加页面背景水印
└──entry/src/main/resources             // 应用静态资源目录

公共文件与资源

本案例涉及到的常量类和工具类代码如下:

  1. 通用常量类
// entry/src/main/ets/constants/Constants.ets
export class Constants {
  static readonly INDEX_CONTENT_WIDTH = '91.1%'
  static readonly DIVIDER_HEIGHT = 0.5
  static readonly DIVIDER_WIDTH = '93%'
  static readonly DIVIDER_DRAWER_WIDTH = '90%'
  static readonly CARD_TITLE_HEIGHT = 20
  static readonly CARD_TEXT_HEIGHT = 48
  static readonly INDEX_TITLE_HEIGHT = 112
  static readonly FULL_WIDTH = '100%'
  static readonly FULL_HEIGHT = '100%'
  static readonly FONT_SIZE_UNCHECKED = 18
  static readonly FONT_SIZE_CHECKED = 24
  static readonly CONTENT_HEIGHT = 300
  static readonly LIST_HEIGHT = 48
  static readonly LIST_CARD_WIDTH = 272
  static readonly LIST_CARD_HEIGHT = 344
  static readonly LIST_CONTENT_HEIGHT = '110%'
  static readonly BACKGROUND_TAB_HEIGHT = 40
  static readonly INDEX_BUTTON_HEIGHT = 60
  static readonly BACKGROUND_TAB_WIDTH = 96
  static readonly DRAWER_WIDTH = 264
  static readonly SUB_TAB_WIDTH = 56
  static readonly SUB_SLIDE_TAB_WIDTH = 56
  static readonly SUB_TAB_BOT_HEIGHT = 25
  static readonly SUB_TAB_HEIGHT = 30
  static readonly SUB_LIST_WIDTH = '85%'
  static readonly SIDE_TAB_WIDTH = '27.8%'
  static readonly SIDE_CONTEND_WIDTH = '72.2%'
  static readonly TAB_INDEX_ZERO = 0
  static readonly TAB_INDEX_ONE = 1
  static readonly TAB_INDEX_TWO = 2
  static readonly TAB_INDEX_THREE = 3
  static readonly TAB_INDEX_FOUR = 4
  static readonly TAB_INDEX_FIVE = 5
  static readonly IMAGE_SIZE_TAB = 22
  static readonly IMAGE_SIZE_MIDDLE = 56
  static readonly MORE_IMAGE_WIDTH = 20
  static readonly MORE_IMAGE_HEIGHT = 15
  static readonly DRAWER_IMAGE_HEIGHT_WIDTH = 24
  static readonly LIST_IMAGE_HEIGHT_WIDTH = 40
  static readonly IMAGE_OFFSET = -15
  static readonly ICON_Offset = -3
  static readonly ANIMATION_DURATION = 300
  static readonly MARGIN_SIXTEEN = 16
  static readonly MARGIN_BUTTON_TOP = 48
  static readonly TRANSLATE_TOP = -40
  static readonly TRANSLATE_BOTTOM = 40
  static readonly BORDER_RADIUS_DRAWER = 16
  static readonly BORDER_RADIUS_INDEX_LIST = 18
  static readonly BORDER_RADIUS_DRAWER_CONTENT = 20
  static readonly BORDER_RADIUS_BACK_TAB = 21
  static readonly STROKE_WIDTH = 2
  static readonly SLICE_INDEX_ZERO = 0
  static readonly SLICE_INDEX_SIX = 6
  static readonly FONT_WEIGHT_TAB = 600
  static readonly STRING_WATERMARK_TEXT = '水印水印水印'
  static readonly FILL_STYLE_WATERMARK = '#10000000'
  static readonly FONT_WATERMARK = '16vp'
  static readonly TEXT_ALIGN_WATERMARK = 'center'
  static readonly BASELINE_WATERMARK = 'middle'
  static readonly TOAST_DURATION = 1500
  static readonly TOP_TAB_DATA = ['文学', '交友', '直播', '视频', '盛典', '潮玩']
  static readonly ROUTES: Route[] = [
    {
      title: $r('app.string.page_bg'),
      child: [
        { text: $r('app.string.use_stack'), to: 'WatermarkStackPage' },
        { text: $r('app.string.use_overlay'), to: 'WatermarkOverlayPage' }
      ]
    },
    {
      title: $r('app.string.photo'),
      child: [
        { text: $r('app.string.save_photo'), to: 'SaveImagePage' },
        { text: $r('app.string.take_camera'), to: 'CameraPage' }
      ]
    },
    {
      title: $r('app.string.file'),
      child: [
        { text: $r('app.string.pdf_watermark'), to: 'WatermarkPdfPage' }
      ]
    }
  ]
}

export interface Route {
  title: ResourceStr
  child: Array<ChildRoute>
}

export interface ChildRoute {
  text: ResourceStr
  to: string
}

本案例涉及到的资源文件如下:

  1. string.json
// entry/src/main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "module description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "Watermark"
    },
    {
      "name": "title",
      "value": "水印添加能力"
    },
    {
      "name": "open_camera",
      "value": "打开相机"
    },
    {
      "name": "image_reason",
      "value": "生成水印照片需要写入图片权限"
    },
    {
      "name": "message_save_success",
      "value": "图片保存成功"
    },
    {
      "name": "pdf_save_success",
      "value": "pdf下载成功"
    },
    {
      "name": "watermark_text",
      "value": "水印文字"
    },
    {
      "name": "watermark_screen_text",
      "value": "水印水印水印"
    },
    {
      "name": "page_bg",
      "value": "页面背景"
    },
    {
      "name": "photo",
      "value": "图片"
    },
    {
      "name": "camera",
      "value": "拍照"
    },
    {
      "name": "file",
      "value": "文件"
    },
    {
      "name": "use_stack",
      "value": "使用Stack组件添加添加水印"
    },
    {
      "name": "use_overlay",
      "value": "使用overlay属性添加水印"
    },
    {
      "name": "save_photo",
      "value": "保存图片添加水印"
    },
    {
      "name": "take_camera",
      "value": "拍照图片添加水印"
    },
    {
      "name": "pdf_watermark",
      "value": "PDF添加水印"
    },
    {
      "name": "button_text_add_watermark",
      "value": "添加水印"
    }
  ]
}

2. float.json

// entry/src/main/resources/base/element/float.json
{
  "float": [
    {
      "name": "mainPage_baseTab_size",
      "value": "24vp"
    },
    {
      "name": "mainPage_baseTab_top",
      "value": "4vp"
    },
    {
      "name": "mainPage_barHeight",
      "value": "52vp"
    },
    {
      "name": "rudder_barHeight",
      "value": "90vp"
    },
    {
      "name": "tab_text_font_size",
      "value": "10fp"
    },
    {
      "name": "content_font_size",
      "value": "30fp"
    },
    {
      "name": "title_font_size",
      "value": "30fp"
    },
    {
      "name": "text_size",
      "value": "16fp"
    },
    {
      "name": "double_text_size",
      "value": "18fp"
    },
    {
      "name": "current_text_size",
      "value": "20fp"
    },
    {
      "name": "back_text_size",
      "value": "14fp"
    },
    {
      "name": "text_line_height",
      "value": "22vp"
    },
    {
      "name": "divider_width",
      "value": "48vp"
    },
    {
      "name": "opacity_1",
      "value": "1"
    },
    {
      "name": "opacity_0",
      "value": "0"
    },
    {
      "name": "list_friction",
      "value": "0.6"
    },
    {
      "name": "size_text",
      "value": "16vp"
    },
    {
      "name": "margin_drawer_list",
      "value": "4vp"
    },
    {
      "name": "margin_tab_text",
      "value": "6vp"
    },
    {
      "name": "margin_under_tab",
      "value": "7vp"
    },
    {
      "name": "margin_eight",
      "value": "8vp"
    },
    {
      "name": "margin_index_top",
      "value": "14vp"
    },
    {
      "name": "margin_sixteen",
      "value": "16vp"
    },
    {
      "name": "margin_list",
      "value": "17vp"
    },
    {
      "name": "margin_index_bottom",
      "value": "18vp"
    },
    {
      "name": "margin_slide_top",
      "value": "40vp"
    },
    {
      "name": "margin_button_bottom",
      "value": "48vp"
    },
    {
      "name": "margin_side_tab_top",
      "value": "74vp"
    },
    {
      "name": "margin_sidebar_content",
      "value": "284vp"
    },
    {
      "name": "padding_double_tab_left",
      "value": "4vp"
    },
    {
      "name": "padding_rudder_tab",
      "value": "11vp"
    },
    {
      "name": "padding_bottom_tab",
      "value": "12vp"
    },
    {
      "name": "padding_drawer_row",
      "value": "13vp"
    },
    {
      "name": "padding_index_top",
      "value": "84vp"
    },
    {
      "name": "drawer_padding",
      "value": "104vp"
    },
    {
      "name": "navbar_back_width",
      "value": "24"
    },
    {
      "name": "navbar_back_height",
      "value": "24"
    },
    {
      "name": "navbar_back_opacity",
      "value": "0.9"
    },
    {
      "name": "navbar_position_x",
      "value": "24"
    },
    {
      "name": "navbar_position_y",
      "value": "12"
    },
    {
      "name": "navbar_height",
      "value": "45"
    },
    {
      "name": "navbar_title_size",
      "value": "18"
    },
    {
      "name": "empty_img_width",
      "value": "110"
    },
    {
      "name": "empty_img_height",
      "value": "88"
    },
    {
      "name": "save_image_margin_bottom",
      "value": "20"
    },
    {
      "name": "index_item_padding_left",
      "value": "12"
    },
    {
      "name": "index_arrow_width",
      "value": "24"
    },
    {
      "name": "index_arrow_height",
      "value": "24"
    },
    {
      "name": "index_item_margin_right",
      "value": "12"
    }
  ]
}

3. color.json

// entry/src/main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "current_color",
      "value": "#3388ff"
    },
    {
      "name": "tab_color",
      "value": "#F3F4F5"
    },
    {
      "name": "text_color",
      "value": "#E6000000"
    },
    {
      "name": "checked_color",
      "value": "#0A59F7"
    },
    {
      "name": "back_color",
      "value": "#0D000000"
    },
    {
      "name": "current_list_color",
      "value": "#1A0A59F7"
    },
    {
      "name": "list_background_color",
      "value": "#E9EAEC"
    },
    {
      "name": "content_background_color",
      "value": "#c1c2c4"
    },
    {
      "name": "side_background_color",
      "value": "#F1F3F5"
    },
    {
      "name": "side_selected_color",
      "value": "#182431"
    },
    {
      "name": "side_text_color",
      "value": "#99182431"
    },
    {
      "name": "side_content_color",
      "value": "#FFFFFF"
    },
    {
      "name": "index_background_color",
      "value": "#f0f3f7"
    },
    {
      "name": "index_divider_color",
      "value": "#0D000000"
    },
    {
      "name": "index_text_color",
      "value": "#99000000"
    }
  ]
}

其他资源请到源码中获取。

水印添加能力首页

构建水印添加能力首页布局,实现页面背景、图片和文件添加水印的列表和路由。

// entry/src/main/ets/pages/Index.ets

// 引入路由功能模块
import { router } from '@kit.ArkUI'
// 引入常量定义,包括路由、子路由、常量值等
import { ChildRoute, Constants, Route } from '../constants/Constants'
// 引入相机模块及相机选择器
import { camera, cameraPicker as picker } from '@kit.CameraKit'

@Entry
@ComponentV2
struct Index {
  // 路由数组,用于渲染主界面菜单
  private routes: Route[] = Constants.ROUTES
  // 用于判断分割线是否是最后一个子项
  private one: number = 1
  // 本地状态:文件 URI(例如拍照后的图片路径)
  @Local fileUri: string = ''

  /**
   * 打开相机拍照,拍照后跳转到 CameraPage 页面
   * @param title 页面标题(来自菜单项)
   */
  async openCamera(title: ResourceStr) {
    // 定义相机配置,使用后置摄像头
    const pickerProfile: picker.PickerProfile = {
      cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
    }

    // 弹出拍照界面,只支持拍照(不支持录像)
    const pickerResult: picker.PickerResult 
      = await picker.pick(getContext(this),
      [picker.PickerMediaType.PHOTO], pickerProfile)

    // 记录拍照后的文件路径
    this.fileUri = pickerResult.resultUri

    // 如果文件路径有效,则跳转到相应页面,并传递参数
    if (this.fileUri) {
      router.pushUrl({
        url: 'pages/CameraPage',
        params: {
          fileUri: this.fileUri,
          title
        }
      })
    }
  }

  build() {
    Column() {
      Row() {
        Text($r('app.string.title')) // 读取资源字符串作为标题
          .fontWeight(FontWeight.Bold)
          .fontSize($r('app.float.title_font_size'))
          .width(Constants.FULL_WIDTH)
          .fontColor($r('app.color.text_color'))
      }
      .width(Constants.INDEX_CONTENT_WIDTH)
      .height(Constants.INDEX_TITLE_HEIGHT)

      // 主体内容区域,包含多个一级路由模块
      Column() {
        ForEach(this.routes, (item: Route) => { // 遍历一级菜单项
          Row() {
            Text(item.title)
              .width(Constants.INDEX_CONTENT_WIDTH)
              .fontSize($r('app.float.double_text_size'))
              .fontColor($r('app.color.index_text_color'))
          }
          .height(Constants.CARD_TITLE_HEIGHT)

          Column() {
            // 遍历子菜单
            ForEach(item.child, (itemChild: ChildRoute, index: number) => { 
              Column() {
                Row() {
                  // 子菜单文字
                  Text(itemChild.text)
                    .height(Constants.CARD_TEXT_HEIGHT)
                    .fontWeight(FontWeight.Medium)
                    .padding({ left: $r('app.float.index_item_padding_left') })
                    .fontSize($r('app.float.text_size'))

                  // 空占位符,用于弹性布局
                  Column()
                    .layoutWeight(1)

                  // 右侧箭头图标
                  Image($r('app.media.ic_public_arrow_right'))
                    .width($r('app.float.index_arrow_width'))
                    .height($r('app.float.index_arrow_height'))
                    .margin({ right: $r('app.float.index_item_margin_right') })
                }
                .justifyContent(FlexAlign.Start)
                .alignItems(VerticalAlign.Center)

                // 非最后一个子项,显示底部分割线
                Stack() {
                  if (item.child.length - this.one !== index) {
                    Row()
                      .height(Constants.DIVIDER_HEIGHT)
                      .backgroundColor($r('app.color.index_divider_color'))
                      .width(Constants.DIVIDER_WIDTH)
                  }
                }
              }
              .onClick(() => {
                // 如果点击的是打开相机的项目,调用 openCamera 方法
                if (itemChild.to === 'CameraPage') {
                  this.openCamera(itemChild.text)
                } else {
                  // 否则直接跳转到目标页面,并传递标题参数
                  router.pushUrl({
                    url: 'pages/' + itemChild.to,
                    params: {
                      title: itemChild.text
                    }
                  })
                }
              })
              .width(Constants.INDEX_CONTENT_WIDTH)
              .height(Constants.CARD_TEXT_HEIGHT)
            }, (item: ChildRoute, index: number) 
              => JSON.stringify(item) + index)
          }
          .margin({
            top: $r('app.float.margin_index_top'),
            bottom: $r('app.float.margin_index_bottom')
          })
          .borderRadius(Constants.BORDER_RADIUS_INDEX_LIST)
          .backgroundColor(Color.White)
        }, (item: Route, index: number) => JSON.stringify(item) + index)
      }
      .width(Constants.FULL_WIDTH)
    }
    // 页面整体样式设置
    .padding({ top: $r('app.float.padding_index_top') })
    .translate({ y: Constants.TRANSLATE_TOP })
    .backgroundColor($r('app.color.side_background_color'))
    .width(Constants.FULL_WIDTH)
    .height(Constants.LIST_CONTENT_HEIGHT)
    .alignItems(HorizontalAlign.Center)
  }
}

页面添加水印

封装Canvas绘制水印组件,使用Stack层叠布局或overlay浮层属性,将水印组件与页面融合。

  1. 使用Stack组件添加添加水印
  • Stack结构页面
// entry/src/main/ets/pages/WatermarkStackPage.ets

// 导入自定义导航栏组件
import { NavBar } from '../component/NavBar'
// 导入自定义水印组件
import { Watermark } from '../component/Watermark'

@Entry
@ComponentV2
struct CanvasPage {
  build() {
    // 外层垂直布局,内容居中对齐
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center }) {
      // 页面顶部导航栏组件
      NavBar()

      // 中间内容使用 Stack 叠层布局(用于图像叠加水印)
      Stack({ alignContent: Alignment.Center }) {
        Column() {
          // 显示占位图(empty 图片资源)
          Image($r('app.media.empty'))
            .width(110)
            .height(88)
        }

        // 水印组件叠加在图片上,设置旋转角度为 20°
        Watermark({ rotationAngle: 20 })
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%') 
  }
}

在 main_pages.json 里添加 WatermarkStackPage 的路径源:

// entry/src/resource/base/profile/main_pages.json
{
  "src": [
    "pages/Index",
    "pages/WatermarkStackPage",
    ...
  ]
}

本案例其他页面均需要添加路径源,届时不再赘述。

  • Canvas水印绘制组件
// 导入工具方法:用于获取多语言水印文本资源
import { getResourceString } from "../constants/Utils"

@ComponentV2
export struct Watermark {
  // 创建 2D 渲染上下文设置,启用抗锯齿
  private settings: RenderingContextSettings = new RenderingContextSettings(true)

  // 创建 Canvas 2D 渲染上下文对象,用于绘图
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)

  // 以下为可配置参数(可通过使用组件时传入)

  @Param watermarkWidth: number = 120 // 单个水印区域的宽度
  @Param watermarkHeight: number = 120 // 单个水印区域的高度
  @Param watermarkText: string 
    = this.getWatermarkText() // 水印文字,默认读取资源字符串
  @Param rotationAngle: number = -30 // 水印旋转角度(单位:度),默认 -30°
  @Param fillColor: string | number | CanvasGradient | CanvasPattern 
    = '#10000000' // 填充颜色(含透明度)
  @Param font: string = '16vp' // 字体大小(单位为 vp)

  // 水印绘制逻辑
  draw() {
    // 设置填充样式与字体
    this.context.fillStyle = this.fillColor
    this.context.font = this.font

    // 计算要铺满画布所需的列数和行数
    const colCount = Math.ceil(this.context.width / this.watermarkWidth)
    const rowCount = Math.ceil(this.context.height / this.watermarkHeight)

    // 外层循环控制列
    for (let col = 0; col <= colCount; col++) {
      let row = 0

      // 内层循环控制行
      for (; row <= rowCount; row++) {
        // 将角度转换为弧度
        const angle = this.rotationAngle * Math.PI / 180

        // 旋转坐标系
        this.context.rotate(angle)

        // 根据旋转方向调整文字绘制的位置
        const positionX = this.rotationAngle > 0 
          ? this.watermarkHeight * Math.tan(angle) : 0
        const positionY = this.rotationAngle > 0 
          ? 0 : this.watermarkWidth * Math.tan(-angle)

        // 绘制水印文字
        this.context.fillText(this.watermarkText, positionX, positionY)

        // 恢复旋转角度
        this.context.rotate(-angle)

        // 向下平移到下一行水印位置
        this.context.translate(0, this.watermarkHeight)
      }

      // 回退 Y 轴平移,准备进入下一列
      this.context.translate(0, -this.watermarkHeight * row)
      // 向右平移到下一列水印位置
      this.context.translate(this.watermarkWidth, 0)
    }
  }

  // 从资源中获取水印文字
  getWatermarkText() {
    return getResourceString(
      $r('app.string.watermark_screen_text'), 
      getContext(this)
    )
  }

  // 渲染 Canvas,并在准备完成后触发绘制
  build() {
    Canvas(this.context)
      .width('100%') // 画布宽度撑满容器
      .height('100%') // 画布高度撑满容器
      .hitTestBehavior(HitTestMode.Transparent) // 点击事件透传,不阻挡下层组件
      .onReady(() => this.draw()) // 在画布准备好后调用 draw() 绘制水印
  }
}

2. 使用overlay属性添加水印

// entry/src/main/ets/pages/WatermarkOverlayPage.ets

import { NavBar } from '../component/NavBar'
import { Watermark } from '../component/Watermark'

@Entry
@ComponentV2
struct OverlayPage {
  @Builder
  watermarkBuilder() {
    Column() {
      Watermark()
    }
  }

  build() {
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center }) {
      NavBar()
      Column() {
        Image($r('app.media.empty'))
          .width(110)
          .height(88)
      }
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
      // 通过给Column添加Overlay实现水印添加
      .overlay(this.watermarkBuilder())
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

NavBar和Watermark共享已有的组件。

保存图片添加水印

获取图片数据,createPixelMap,使用OffScreenContext在指定位置绘制水印,最后保存带水印图片。

// entry/src/main/ets/pages/SaveImagePage.ets

// 导入系统和自定义模块
import { promptAction } from '@kit.ArkUI' // 用于显示提示信息(如 Toast)
import { image } from '@kit.ImageKit' // 提供图像处理能力
import { NavBar } from '../component/NavBar' // 导航栏组件
import {
  addWatermark,              // 添加水印的工具函数
  getResourceString,         // 资源字符串获取工具
  ImagePixelMap,             // 自定义图像像素数据结构
  imageSource2PixelMap,      // 将 ImageSource 转换为 PixelMap 的工具函数
  saveToFile                 // 保存图像到文件的方法
} from '../constants/Utils'
import { Constants } from '../constants/Constants' // 常量定义
import { hilog } from '@kit.PerformanceAnalysisKit' // 日志记录工具

const TAG = 'SaveImagePage' // 日志标签

@Entry
@ComponentV2
struct SaveImagePage {
  // 用于存储已添加水印后的图像数据
  @Local addedWatermarkPixelMap: image.PixelMap | null = null

  // 显示“保存成功”的 Toast 提示
  showSuccess() {
    promptAction.showToast({
      message: $r('app.string.message_save_success'),
      duration: Constants.TOAST_DURATION
    })
  }

  // 获取水印文本,支持多语言
  getWatermarkText() {
    return getResourceString($r('app.string.watermark_text'), getContext(this))
  }

  // 从资源文件中读取图像并转换为 ImagePixelMap(包含 PixelMap、宽高)
  async getImagePixelMap(resource: Resource): Promise<ImagePixelMap> {
    const data: Uint8Array 
      = await getContext(this).resourceManager.getMediaContent(resource)
    const arrayBuffer: ArrayBuffer 
      = data.buffer.slice(data.byteOffset, data.byteLength + data.byteOffset)
    const imageSource: image.ImageSource = image.createImageSource(arrayBuffer)
    return await imageSource2PixelMap(imageSource)
  }

  // 页面构建方法
  build() {
    Column() {
      NavBar() // 自定义导航栏

      // 显示图像:如果已添加水印则显示处理后的图像,否则显示原图
      Image(this.addedWatermarkPixelMap || $r('app.media.img1'))
        .width('100%')

      Row() {
        // 若尚未添加水印,显示“添加水印”按钮
        if (!this.addedWatermarkPixelMap) {
          Button($r('app.string.button_text_add_watermark'))
            .height(40)
            .width('100%')
            .onClick(async () => {
              // 加载图像资源并添加水印
              const imagePixelMap 
                = await this.getImagePixelMap($r('app.media.img1'))
              this.addedWatermarkPixelMap 
                = addWatermark(imagePixelMap, this.getWatermarkText())
            })
        } else {
          // 已添加水印后显示“保存”按钮
          SaveButton()
            .height(40)
            .width('100%')
            .onClick(async (
              event: ClickEvent, result: SaveButtonOnClickResult) => {
              if (result === SaveButtonOnClickResult.SUCCESS) {
                try {
                  // 保存带水印图像
                  await saveToFile(
                    this.addedWatermarkPixelMap!, 
                    getContext(this)
                  )
                  this.showSuccess()
                } catch (err) {
                  hilog.error(0x0000, TAG, 'createAsset failed, error:', err)
                }
              } else {
                hilog.error(0x0000, TAG, 
                            'SaveButtonOnClickResult createAsset failed')
              }
            })
        }
      }
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

拍照图片添加水印

打开相机,获取存储fileUri,然后存入沙箱,获取图片数据,createPixelMap,绘制水印,最后保存带水印图片。

  1. CameraPage页面
// entry/src/main/ets/pages/CameraPage.ets

// 引入 ArkUI 的提示动作和路由功能模块
import { promptAction, router } from '@kit.ArkUI'
// 引入图像处理模块
import { image } from '@kit.ImageKit'
// 引入文件操作模块
import { fileIo } from '@kit.CoreFileKit'
// 引入自定义工具函数:添加水印、读取资源字符串、转换像素图、保存文件等
import { 
  addWatermark, getResourceString, ImagePixelMap, 
  imageSource2PixelMap, saveToFile 
} from '../constants/Utils'
// 自定义顶部导航栏组件
import { NavBar } from '../component/NavBar'
// 引入全局常量配置
import { Constants } from '../constants/Constants'
// 引入系统日志工具,用于性能分析与调试
import { hilog } from '@kit.PerformanceAnalysisKit'

// 日志打印标签
const TAG = 'CameraPage'

@Entry
@ComponentV2
struct CameraPage {
  // 从路由参数中获取拍照后返回的图片 URI
  private fileUri: string = (router.getParams() 
                             as Record<string, string>).fileUri

  // 响应式状态:添加水印后的图片像素图(初始为 null)
  @Local addedWatermarkPixelMap: image.PixelMap | null = null

  /**
   * 显示保存成功的提示信息
   */
  showSuccess() {
    promptAction.showToast({
      message: $r('app.string.message_save_success'), // 获取资源中的提示文本
      duration: Constants.TOAST_DURATION               // 显示时长常量
    })
  }

  /**
   * 获取水印文本(例如设备信息、拍摄时间等)
   */
  getWatermarkText() {
    return getResourceString($r('app.string.watermark_text'), getContext(this))
  }

  /**
   * 从本地图片文件 URI 生成像素图
   * @param fileUri 本地文件路径
   * @returns 图片像素图对象(PixelMap)
   */
  async getImagePixelMap(fileUri: string): Promise<ImagePixelMap> {
    // 打开文件,获取文件描述符
    const file = fileIo.openSync(fileUri)
    // 创建图像源对象
    const imageSource: image.ImageSource = image.createImageSource(file.fd)
    // 关闭文件
    fileIo.closeSync(file)
    // 将图像源转换为像素图
    return await imageSource2PixelMap(imageSource)
  }

  build() {
    Column() {
      NavBar() // 页面顶部导航栏(自定义组件)

      // 显示图片区域,支持滚动
      Scroll() {
        // 优先显示水印图,否则显示原图
        Image(this.addedWatermarkPixelMap || this.fileUri) 
          .width('100%')
      }
      .layoutWeight(1)
      .margin({ bottom: 10 })

      // 底部按钮区域
      Row() {
        if (!this.addedWatermarkPixelMap) {
          // 如果尚未添加水印,显示“添加水印”按钮
          Button($r('app.string.button_text_add_watermark'))
            .height(40)
            .width('100%')
            .onClick(async () => {
              // 加载原始图片
              const imagePixelMap = await this.getImagePixelMap(this.fileUri)
              // 调用工具方法添加水印,并更新状态
              this.addedWatermarkPixelMap 
                = addWatermark(imagePixelMap, this.getWatermarkText())
            })
        } else {
          // 如果已有水印图,显示“下载”按钮(使用自定义组件 SaveButton)
          SaveButton()
            .height(40)
            .width('100%')
            .onClick(async (
              event: ClickEvent, 
              result: SaveButtonOnClickResult
            ) => {
              // 判断保存操作是否成功
              if (result === SaveButtonOnClickResult.SUCCESS) {
                try {
                  // 尝试保存图片到相册或文件系统
                  await saveToFile(
                    this.addedWatermarkPixelMap!, 
                    getContext(this)
                  )
                  // 显示成功提示
                  this.showSuccess()
                } catch (err) {
                  // 保存失败,打印错误日志
                  hilog.error(0x0000, TAG, 'createAsset failed, error:', err)
                }
              } else {
                // 保存操作被取消或失败,打印错误日志
                hilog.error(0x0000, TAG, 
                            'SaveButtonOnClickResult createAsset failed')
              }
            })
        }
      }
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.SpaceBetween)
  }
}

2. 页面NavBar

// entry/src/main/ets/component/NavBar.ets

import { router } from '@kit.ArkUI'

@ComponentV2
export struct NavBar {
  @Param title: ResourceStr = (router.getParams() 
                               as Record<string, ResourceStr>).title
  @Param isWhiteIcon: boolean = false

  build() {
    Row() {
      Button() {
        Image($r('app.media.back'))
          .width(20)
          .height(20)
          .fillColor(this.isWhiteIcon ? Color.White : Color.Black)
          .opacity(0.9)
      }
      .width(40)
      .height(40)
      .backgroundColor('rgba(0, 0, 0, 0.05)')
      .margin({ right: 8 })
      .onClick(() => {
        router.back()
      })
      Text(this.title)
        .fontSize(20)
        .fontColor(this.isWhiteIcon ? Color.White : Color.Black)
        .opacity(0.9)
        .fontWeight(FontWeight.Bold)
    }
    .height($r('app.float.navbar_height'))
    .width('100%')
    .borderWidth({ bottom: 0 })
    .justifyContent(FlexAlign.Start)
    .padding({ left: 16 })
  }
}

3. 图片处理与保存工具类

// entry/src/main/ets/pages/WatermarkPdfPage.ets

// 导入图像处理模块
import { image } from '@kit.ImageKit'
// 导入文件系统操作模块
import { fileIo } from '@kit.CoreFileKit'
// 导入日志模块,用于调试输出
import { hilog } from '@kit.PerformanceAnalysisKit'
// 导入相册访问模块,用于保存图片到图库
import { photoAccessHelper } from '@kit.MediaLibraryKit'
// 导入显示模块,用于获取屏幕宽度
import { display } from '@kit.ArkUI'
// 导入上下文类型,用于获取资源管理器等
import { Context } from '@kit.AbilityKit'

// 日志标签常量
const TAG = 'Utils'

// 文件描述符,用于打开和关闭文件句柄
let fd: number | null = null

/**
 * 将 PixelMap 图像保存为 PNG 文件并写入图库
 * @param pixelMap 图像像素数据
 * @param context 当前上下文,用于访问系统接口
 */
export async function saveToFile(
  pixelMap: image.PixelMap, 
  context: Context
): Promise<void> {
  try {
    // 获取相册写入权限助手
    const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context)
    // 创建一个新的图片文件路径(自动分配在图库)
    const filePath = await phAccessHelper.createAsset(
      photoAccessHelper.PhotoType.IMAGE, 'png')
    
    // 将 PixelMap 图像打包为 PNG 二进制数据
    const imagePacker = image.createImagePacker()
    const imageBuffer = await imagePacker.packToData(pixelMap, {
      format: 'image/png',
      quality: 100
    })

    // 打开文件用于写入(可读写 + 创建新文件)
    const mode = fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE
    fd = (await fileIo.open(filePath, mode)).fd

    // 清空原内容(保险操作)
    await fileIo.truncate(fd)
    // 将图像数据写入文件
    await fileIo.write(fd, imageBuffer)
  } catch (err) {
    // 打印错误日志
    hilog.error(0x0000, TAG, 'saveToFile error:', JSON.stringify(err) ?? '')
  } finally {
    // 关闭文件描述符(不论成功或失败都执行)
    if (fd) {
      fileIo.close(fd)
    }
  }
}

// 定义图像像素结构,包括宽高
export interface ImagePixelMap {
  pixelMap: image.PixelMap
  width: number
  height: number
}

/**
 * 从 imageSource 对象创建图像像素图(PixelMap)
 * @param imageSource 图像源对象,来自本地文件或资源
 * @returns 图像像素图以及其尺寸
 */
export async function imageSource2PixelMap(
  imageSource: image.ImageSource): Promise<ImagePixelMap> {
  // 获取图像信息,提取宽高
  const imageInfo: image.ImageInfo = await imageSource.getImageInfo()
  const height = imageInfo.size.height
  const width = imageInfo.size.width

  // 配置解码参数:支持编辑,并设置期望尺寸
  const options: image.DecodingOptions = {
    editable: true,
    desiredSize: { height, width }
  }

  // 解码并生成像素图
  const pixelMap: PixelMap = await imageSource.createPixelMap(options)

  // 返回结果对象
  const result: ImagePixelMap = { pixelMap, width, height }
  return result
}

/**
 * 在图像像素图上绘制水印文本
 * @param imagePixelMap 原图像像素图
 * @param text 水印内容,默认 'watermark'
 * @param drawWatermark 可选自定义绘图回调(提供更灵活的水印样式)
 * @returns 添加水印后的图像像素图
 */
export function addWatermark(
  imagePixelMap: ImagePixelMap,
  text: string = 'watermark',
  drawWatermark?: (OffscreenContext: OffscreenCanvasRenderingContext2D) => void
): image.PixelMap {
  // 将像素单位转换为可视单位(vp)
  const height = px2vp(imagePixelMap.height)
  const width = px2vp(imagePixelMap.width)

  // 创建离屏画布(不会直接显示)
  const offScreenCanvas = new OffscreenCanvas(width, height)
  const offScreenContext = offScreenCanvas.getContext('2d')

  // 将原图绘制到画布上
  offScreenContext.drawImage(imagePixelMap.pixelMap, 0, 0, width, height)

  // 如果传入了自定义水印绘制方法,则调用
  if (drawWatermark) {
    drawWatermark(offScreenContext)
  } else {
    // 默认水印绘制逻辑
    const imageScale = width / px2vp(display.getDefaultDisplaySync().width)
    offScreenContext.textAlign = 'right'
    offScreenContext.fillStyle = '#A2FFFFFF' // 半透明白色
    offScreenContext.font = 12 * imageScale + 'vp' // 动态字体大小
    const padding = 5 * imageScale // 与边缘保持距离
    offScreenContext.fillText(text, width - padding, height - padding)
  }

  // 返回绘制完成后的 PixelMap 对象
  return offScreenContext.getPixelMap(0, 0, width, height)
}

/**
 * 同步获取资源字符串
 * @param resource 资源对象(如 $r('app.string.xxx'))
 * @param context 当前上下文
 * @returns 字符串内容(若失败则返回空串)
 */
export function getResourceString(
  resource: Resource, context: Context): string {
  let result: string = ''
  try {
    // 尝试通过资源 ID 同步读取字符串内容
    result = context.resourceManager.getStringSync(resource.id)
  } catch (e) {
    // 打印失败日志
    hilog.error(0x0000, TAG, 
      `[getResourceString]getStringSync failed, error:${JSON.stringify(e)}.`)
  }
  return result
}

pdf文件添加水印

使用PdfView预览组件预览pdf,使用pdfService服务加载pdf、添加水印、保存pdf。

// 导入 PDF、文件选择器、组件和日志工具模块
import { pdfService, PdfView, pdfViewManager } from '@kit.PDFKit'
import { fileIo, picker } from '@kit.CoreFileKit'
import { NavBar } from '../component/NavBar'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { promptAction } from '@kit.ArkUI'
import { Constants } from '../constants/Constants'

const TAG = 'WatermarkPdfPage' // 日志标签

@Entry
@ComponentV2
struct WatermarkPdfPage {
  // 创建 PDF 控制器对象,用于管理 PDF 加载、显示、释放等操作
  private controller: pdfViewManager.PdfController 
  = new pdfViewManager.PdfController()

  // 标志 PDF 是否已经加了水印,用于切换按钮
  @Local hasWatermark: boolean = false

  // 显示保存成功的提示
  showSuccess() {
    promptAction.showToast({
      message: $r('app.string.pdf_save_success'),
      duration: Constants.TOAST_DURATION
    })
  }

  // 获取沙箱路径
  getSandboxPath(path: string) {
    const context = getContext()
    const sandboxDir = context.filesDir
    return `${sandboxDir}/${path}`
  }

  // 获取原始 PDF 文件的沙箱路径
  getPdfSandboxPath(): string {
    return this.getSandboxPath('input.pdf')
  }

  // 获取已加水印的 PDF 文件的沙箱路径
  getAddedWatermarkPdfSandboxPath(): string {
    return this.getSandboxPath('output.pdf')
  }

  // 将内置 PDF 文件复制到沙箱中,返回沙箱路径
  savePdfToSandbox(): string {
    const filePath = this.getPdfSandboxPath()
    fileIo.accessSync(filePath) // 确保路径存在
    const content: Uint8Array 
      = getContext().resourceManager.getRawFileContentSync('watermark.pdf')
    const file 
      = fileIo.openSync(
        filePath, fileIo.OpenMode.WRITE_ONLY 
        | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC)
    fileIo.writeSync(file.fd, content.buffer)
    fileIo.closeSync(file.fd)
    return filePath
  }

  // 页面即将出现时加载 PDF
  aboutToAppear(): void {
    const filePath = this.savePdfToSandbox()
    this.controller.loadDocument(filePath)
  }

  // 构造水印信息(文字水印)
  getWatermarkInfo() {
    const watermarkInfo: pdfService.TextWatermarkInfo 
      = new pdfService.TextWatermarkInfo()
    watermarkInfo.watermarkType = pdfService.WatermarkType.WATERMARK_TEXT
    watermarkInfo.content = 'This is Watermark'
    watermarkInfo.textSize = 32
    watermarkInfo.textColor = 200
    watermarkInfo.opacity = 0.3
    watermarkInfo.rotation = 45
    return watermarkInfo
  }

  // 向 PDF 添加水印并保存为新文件,然后更新视图
  addWatermark() {
    const filePath = this.getPdfSandboxPath()
    let pdfDocument: pdfService.PdfDocument = new pdfService.PdfDocument()
    pdfDocument.loadDocument(filePath)

    // 从第 0 页开始添加水印,直到最后一页
    pdfDocument.addWatermark(
      this.getWatermarkInfo(), 0, pdfDocument.getPageCount(), true, true)

    const watermarkFilePath = this.getAddedWatermarkPdfSandboxPath()
    pdfDocument.saveDocument(watermarkFilePath)

    this.showInPdfView(watermarkFilePath) // 显示带水印的 PDF
  }

  // 加载并显示指定路径的 PDF
  async showInPdfView(filePath: string) {
    this.hasWatermark = true
    this.controller.releaseDocument() // 必须先释放再加载,避免崩溃
    await this.controller.loadDocument(filePath)
    this.controller.setPageFit(pdfService.PageFit.FIT_WIDTH) // 适配宽度
  }

  // 弹出文件选择器保存带水印的 PDF
  async savePdf() {
    const documentSaveOptions = new picker.DocumentSaveOptions()
    documentSaveOptions.newFileNames = ['watermark.pdf']
    const documentPicker = new picker.DocumentViewPicker(getContext(this))
    const saveResult = await documentPicker.save(documentSaveOptions)
    this.copyFileSync(this.getAddedWatermarkPdfSandboxPath(), saveResult[0])
    this.showSuccess()
  }

  // 同步复制文件:将 PDF 从沙箱复制到用户指定位置
  copyFileSync(srcPath: string, destPath: string) {
    const srcFile = fileIo.openSync(srcPath, fileIo.OpenMode.READ_WRITE)
    const destFile = fileIo.openSync(destPath, fileIo.OpenMode.READ_WRITE)
    fileIo.copyFileSync(srcFile.fd, destFile.fd)
    fileIo.closeSync(srcFile)
    fileIo.closeSync(destFile)
  }

  // 页面 UI 构建逻辑
  build() {
    Column() {
      NavBar()

      // 主视图区,显示 PDF 内容并展示控制按钮
      Stack({ alignContent: Alignment.Bottom }) {
        PdfView({
          controller: this.controller,
          pageFit: pdfService.PageFit.FIT_WIDTH
        })
          .id('pdfview_app_view')
          .layoutWeight(1)

        // 底部按钮:添加水印或保存
        Row() {
          if (!this.hasWatermark) {
            Button($r('app.string.button_text_add_watermark'))
              .height(40)
              .width('100%')
              .onClick(() => this.addWatermark())
          } else {
            SaveButton()
              .height(40)
              .width('100%')
              .onClick(async (
                event: ClickEvent, result: SaveButtonOnClickResult) => {
                if (result === SaveButtonOnClickResult.SUCCESS) {
                  try {
                    this.savePdf()
                  } catch (err) {
                    hilog.error(0x0000, TAG, 'createAsset failed, error:', err)
                  }
                } else {
                  hilog.error(0x0000, TAG, 
                              'SaveButtonOnClickResult createAsset failed')
                }
              })
          }
        }
        .padding({ left: 16, right: 16, bottom: 16 })
      }
      .layoutWeight(1)
    }
    .height('100%')
    .width('100%')
  }
}

✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接