本示例为开发者展示常用的水印添加能力,包括两种方式给页面添加水印、保存图片添加水印、拍照图片添加水印和pdf文件添加水印。
案例效果截图
| 首页 | 页面水印 | 图片水印 | pdf水印 |
|---|---|---|---|
案例运用到的知识点
- 核心知识点
- 页面添加水印: 封装Canvas绘制水印组件,使用Stack层叠布局或overlay浮层属性,将水印组件与页面融合。
- 保存图片添加水印: 获取图片数据,createPixelMap,使用OffScreenContext在指定位置绘制水印,最后保存带水印图片。
- 拍照图片添加水印: 打开相机,获取存储fileUri,然后存入沙箱,获取图片数据,createPixelMap,绘制水印,最后保存带水印图片。
- pdf文件添加水印: 使用PdfView预览组件预览pdf,使用pdfService服务加载pdf、添加水印、保存pdf。
- 其他知识点
- 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 // 应用静态资源目录
公共文件与资源
本案例涉及到的常量类和工具类代码如下:
- 通用常量类
// 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
}
本案例涉及到的资源文件如下:
- 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浮层属性,将水印组件与页面融合。
- 使用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,绘制水印,最后保存带水印图片。
- 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%')
}
}
✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接