本示例为开发者展示常用的水印添加能力,包括两种方式给页面添加水印、保存图片添加水印、拍照图片添加水印和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.5static readonly DIVIDER_WIDTH = '93%'static readonly DIVIDER_DRAWER_WIDTH = '90%'static readonly CARD_TITLE_HEIGHT = 20static readonly CARD_TEXT_HEIGHT = 48static readonly INDEX_TITLE_HEIGHT = 112static readonly FULL_WIDTH = '100%'static readonly FULL_HEIGHT = '100%'static readonly FONT_SIZE_UNCHECKED = 18static readonly FONT_SIZE_CHECKED = 24static readonly CONTENT_HEIGHT = 300static readonly LIST_HEIGHT = 48static readonly LIST_CARD_WIDTH = 272static readonly LIST_CARD_HEIGHT = 344static readonly LIST_CONTENT_HEIGHT = '110%'static readonly BACKGROUND_TAB_HEIGHT = 40static readonly INDEX_BUTTON_HEIGHT = 60static readonly BACKGROUND_TAB_WIDTH = 96static readonly DRAWER_WIDTH = 264static readonly SUB_TAB_WIDTH = 56static readonly SUB_SLIDE_TAB_WIDTH = 56static readonly SUB_TAB_BOT_HEIGHT = 25static readonly SUB_TAB_HEIGHT = 30static readonly SUB_LIST_WIDTH = '85%'static readonly SIDE_TAB_WIDTH = '27.8%'static readonly SIDE_CONTEND_WIDTH = '72.2%'static readonly TAB_INDEX_ZERO = 0static readonly TAB_INDEX_ONE = 1static readonly TAB_INDEX_TWO = 2static readonly TAB_INDEX_THREE = 3static readonly TAB_INDEX_FOUR = 4static readonly TAB_INDEX_FIVE = 5static readonly IMAGE_SIZE_TAB = 22static readonly IMAGE_SIZE_MIDDLE = 56static readonly MORE_IMAGE_WIDTH = 20static readonly MORE_IMAGE_HEIGHT = 15static readonly DRAWER_IMAGE_HEIGHT_WIDTH = 24static readonly LIST_IMAGE_HEIGHT_WIDTH = 40static readonly IMAGE_OFFSET = -15static readonly ICON_Offset = -3static readonly ANIMATION_DURATION = 300static readonly MARGIN_SIXTEEN = 16static readonly MARGIN_BUTTON_TOP = 48static readonly TRANSLATE_TOP = -40static readonly TRANSLATE_BOTTOM = 40static readonly BORDER_RADIUS_DRAWER = 16static readonly BORDER_RADIUS_INDEX_LIST = 18static readonly BORDER_RADIUS_DRAWER_CONTENT = 20static readonly BORDER_RADIUS_BACK_TAB = 21static readonly STROKE_WIDTH = 2static readonly SLICE_INDEX_ZERO = 0static readonly SLICE_INDEX_SIX = 6static readonly FONT_WEIGHT_TAB = 600static 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 = 1500static 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: ResourceStrchild: Array<ChildRoute>
}export interface ChildRoute {text: ResourceStrto: 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": "添加水印"}]
}
- 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"}]
}
- 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.fillColorthis.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) : 0const 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() 绘制水印}
}
- 使用overlay属性添加水印
// entry/src/main/ets/pages/WatermarkOverlayPage.etsimport { NavBar } from '../component/NavBar'
import { Watermark } from '../component/Watermark'@Entry
@ComponentV2
struct OverlayPage {@BuilderwatermarkBuilder() {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 {// 从路由参数中获取拍照后返回的图片 URIprivate 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)}
}
- 页面NavBar
// entry/src/main/ets/component/NavBar.etsimport { router } from '@kit.ArkUI'@ComponentV2
export struct NavBar {@Param title: ResourceStr = (router.getParams() as Record<string, ResourceStr>).title@Param isWhiteIcon: boolean = falsebuild() {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 })}
}
- 图片处理与保存工具类
// 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.CREATEfd = (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.PixelMapwidth: numberheight: 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.heightconst 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.filesDirreturn `${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}// 页面即将出现时加载 PDFaboutToAppear(): void {const filePath = this.savePdfToSandbox()this.controller.loadDocument(filePath)}// 构造水印信息(文字水印)getWatermarkInfo() {const watermarkInfo: pdfService.TextWatermarkInfo = new pdfService.TextWatermarkInfo()watermarkInfo.watermarkType = pdfService.WatermarkType.WATERMARK_TEXTwatermarkInfo.content = 'This is Watermark'watermarkInfo.textSize = 32watermarkInfo.textColor = 200watermarkInfo.opacity = 0.3watermarkInfo.rotation = 45return 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}// 加载并显示指定路径的 PDFasync showInPdfView(filePath: string) {this.hasWatermark = truethis.controller.releaseDocument() // 必须先释放再加载,避免崩溃await this.controller.loadDocument(filePath)this.controller.setPageFit(pdfService.PageFit.FIT_WIDTH) // 适配宽度}// 弹出文件选择器保存带水印的 PDFasync 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%')}
}
案例代码下载