
1.超级多的裁剪模式,固定格式裁剪,自由裁剪
身份证,银行卡,a4纸,1寸,2寸,不传默认自由裁剪
javascript
const cropPresets = {
idCard: { widthMM: 85.6, heightMM: 54, lockCropRatio: true, lockedCropBox: true },
bankCard: { widthMM: 85.6, heightMM: 53.98, lockCropRatio: true, lockedCropBox: true },
householdRegister: { widthMM: 143, heightMM: 105, lockCropRatio: true, lockedCropBox: true },
a4: { widthMM: 210, heightMM: 297, lockCropRatio: true, lockedCropBox: true },
a4HalfShort: { widthMM: 210, heightMM: 140, lockCropRatio: true, lockedCropBox: true },
idPhoto1: { widthMM: 25, heightMM: 35, lockCropRatio: true, lockedCropBox: true },
idPhoto2: { widthMM: 35, heightMM: 49, lockCropRatio: true, lockedCropBox: true }
};
2.水印自己随意定义
水印采用数组对象格式,随便定义
javascript
const watermarkList = ref([
{ key: '', value: '一个项目' },
{ key: '用 户:', value: '' },
{ key: '品 牌:', value: '海尔' },
{ key: '施工内容:', value: '安装热风机' },
{ key: '拍摄时间:', type: 'time' },
{ key: '拍摄地点:', type: 'location' }
]);
const watermarkLocation = ref('');
javascript
<template>
<view class="print-generator">
<canvas v-if="exportCanvasVisible" :canvas-id="exportCanvasId" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" class="hidden-canvas"></canvas>
<view v-if="cropVisible" class="crop-mask">
<view class="crop-header">
<text class="crop-title">{{ cropTitle }}</text>
</view>
<view
class="crop-stage"
:style="{ width: stageWidth + 'px', height: stageHeight + 'px' }"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view v-if="cropImagePath" class="crop-image-wrap" :style="imageWrapStyle">
<image class="crop-image" :src="cropImagePath" mode="aspectFill" :style="imageStyle"></image>
</view>
<view v-if="watermarkPreviewLines.length" class="watermark-preview" :style="watermarkPreviewStyle">
<text v-for="(line, index) in watermarkPreviewLines" :key="index" class="watermark-preview-line">{{ line }}</text>
</view>
<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-top" :style="shadowTopStyle"></view>
<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-bottom" :style="shadowBottomStyle"></view>
<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-left" :style="shadowLeftStyle"></view>
<view v-if="!isWatermarkOnly" class="crop-shadow crop-shadow-right" :style="shadowRightStyle"></view>
<view
v-if="!isWatermarkOnly"
class="crop-box"
:style="cropBoxStyle"
@touchstart.stop="onCropBoxTouchStart"
@touchmove.stop.prevent="onCropBoxTouchMove"
@touchend.stop="onTouchEnd"
@touchcancel.stop="onTouchEnd"
>
<view class="grid-line grid-line-v grid-line-v1"></view>
<view class="grid-line grid-line-v grid-line-v2"></view>
<view class="grid-line grid-line-h grid-line-h1"></view>
<view class="grid-line grid-line-h grid-line-h2"></view>
<template v-if="!isCropBoxLocked">
<view
v-for="handle in resizeHandles"
:key="handle"
:class="['resize-handle', 'resize-handle-' + handle]"
@touchstart.stop="onResizeTouchStart(handle, $event)"
@touchmove.stop.prevent="onResizeTouchMove"
@touchend.stop="onTouchEnd"
@touchcancel.stop="onTouchEnd"
></view>
</template>
</view>
</view>
<view v-if="!isWatermarkOnly" class="crop-tools">
<button class="tool-btn" @click="rotateCropImage">
<text class="tool-icon">↻</text>
<text class="tool-text">{{ labels.rotate }}</text>
</button>
<button class="tool-btn" :class="{ disabled: !canRestore }" @click="restoreCropImage">
<text class="tool-text">{{ labels.restore }}</text>
</button>
</view>
<view class="crop-actions">
<button class="crop-btn crop-btn-cancel" @click="cancelCrop">{{ labels.cancel }}</button>
<button class="crop-btn crop-btn-confirm" @click="confirmCrop">{{ labels.confirm }}</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, nextTick, getCurrentInstance } from 'vue';
const canvasInstance = getCurrentInstance();
const labels = {
title: '\u8c03\u6574\u88c1\u526a',
watermarkTitle: '\u786e\u8ba4\u6c34\u5370',
rotate: '\u65cb\u8f6c',
restore: '\u8fd8\u539f',
cancel: '\u53d6\u6d88',
confirm: '\u786e\u8ba4'
};
const resizeHandles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
const cropPresets = {
idCard: { widthMM: 85.6, heightMM: 54, lockCropRatio: true, lockedCropBox: true },
bankCard: { widthMM: 85.6, heightMM: 53.98, lockCropRatio: true, lockedCropBox: true },
householdRegister: { widthMM: 143, heightMM: 105, lockCropRatio: true, lockedCropBox: true },
a4: { widthMM: 210, heightMM: 297, lockCropRatio: true, lockedCropBox: true },
a4HalfShort: { widthMM: 210, heightMM: 140, lockCropRatio: true, lockedCropBox: true },
idPhoto1: { widthMM: 25, heightMM: 35, lockCropRatio: true, lockedCropBox: true },
idPhoto2: { widthMM: 35, heightMM: 49, lockCropRatio: true, lockedCropBox: true }
};
const canvasWidth = ref(0);
const canvasHeight = ref(0);
const exportCanvasVisible = ref(false);
const exportCanvasId = ref('posterCanvas');
const previewUrl = ref('');
const cropVisible = ref(false);
const cropImagePath = ref('');
const cropOptions = ref(null);
const cropResolver = ref(null);
const cropRejecter = ref(null);
const imageInfo = ref(null);
const stageWidth = ref(0);
const stageHeight = ref(0);
const cropBoxWidth = ref(0);
const cropBoxHeight = ref(0);
const cropBoxX = ref(0);
const cropBoxY = ref(0);
const cropBoxMin = ref(0);
const cropBoxMax = ref(0);
const imageBaseWidth = ref(0);
const imageBaseHeight = ref(0);
const imageX = ref(0);
const imageY = ref(0);
const imageScale = ref(1);
const rotationAngle = ref(0);
const touchStart = ref(null);
const originalImagePath = ref('');
const originalImageInfo = ref(null);
const canRestore = computed(() => rotationAngle.value !== 0 || cropImagePath.value !== originalImagePath.value);
const isCropBoxLocked = computed(() => !!(cropOptions.value && cropOptions.value.lockedCropBox));
const isWatermarkOnly = computed(() => !!(cropOptions.value && cropOptions.value.watermarkOnly));
const cropTitle = computed(() => (isWatermarkOnly.value ? labels.watermarkTitle : labels.title));
const formatDateTime = (date = new Date()) => {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const normalizeWatermark = (watermark) => {
if (!watermark) return null;
if (Array.isArray(watermark)) return { enabled: true, items: watermark };
if (watermark === true) return { enabled: true };
if (typeof watermark === 'string') return { enabled: true, location: watermark };
return {
enabled: true,
...watermark
};
};
const getWatermarkLines = (watermark) => {
const config = normalizeWatermark(watermark);
if (!config) return [];
if (Array.isArray(config.lines) && config.lines.length) return config.lines.filter(Boolean);
if (Array.isArray(config.items) && config.items.length) {
return config.items
.map((item) => {
if (!item) return '';
if (typeof item === 'string') return item;
const key = item.key || item.label || item.name || '';
let value = item.value;
if (value === undefined || value === null || value === '') {
if (item.type === 'time') value = config.time || config.datetime || formatDateTime();
if (item.type === 'location') value = config.location || '\u672a\u77e5\u5730\u70b9';
}
return `${key}${value || ''}`;
})
.filter(Boolean);
}
const lines = [];
if (config.text) lines.push(config.text);
if (config.showTime !== false) lines.push(config.time || config.datetime || formatDateTime());
if (config.showLocation !== false) lines.push(config.location || '\u672a\u77e5\u5730\u70b9');
if (config.remark) lines.push(config.remark);
return lines;
};
const watermarkPreviewLines = computed(() => getWatermarkLines(cropOptions.value && cropOptions.value.watermark));
const watermarkPreviewStyle = computed(() => ({
left: imageX.value + 12 + 'px',
top: imageY.value + imageBaseHeight.value * imageScale.value - Math.max(watermarkPreviewLines.value.length, 1) * 22 - 14 + 'px'
}));
const getImageInfo = (src) => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: resolve,
fail: (err) => reject(new Error('getImageInfo failed: ' + JSON.stringify(err)))
});
});
};
const waitNextTick = () => {
return new Promise((resolve) => nextTick(resolve));
};
const clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max);
};
const calcCenterCropRect = (imageWidth, imageHeight, targetWidth, targetHeight) => {
const imageRatio = imageWidth / imageHeight;
const targetRatio = targetWidth / targetHeight;
let sx = 0;
let sy = 0;
let sw = imageWidth;
let sh = imageHeight;
if (imageRatio > targetRatio) {
sw = imageHeight * targetRatio;
sx = (imageWidth - sw) / 2;
} else if (imageRatio < targetRatio) {
sh = imageWidth / targetRatio;
sy = (imageHeight - sh) / 2;
}
return { sx, sy, sw, sh };
};
const getDisplaySize = (info, rotation = rotationAngle.value) => {
const rotated = rotation % 180 !== 0;
return {
width: rotated ? info.height : info.width,
height: rotated ? info.width : info.height
};
};
const getCropBoxLeft = () => cropBoxX.value;
const getCropBoxTop = () => cropBoxY.value;
const constrainCropBoxPosition = () => {
cropBoxX.value = clamp(cropBoxX.value, 0, Math.max(stageWidth.value - cropBoxWidth.value, 0));
cropBoxY.value = clamp(cropBoxY.value, 0, Math.max(stageHeight.value - cropBoxHeight.value, 0));
};
const constrainImagePosition = () => {
const scaledWidth = imageBaseWidth.value * imageScale.value;
const scaledHeight = imageBaseHeight.value * imageScale.value;
const cropLeft = getCropBoxLeft();
const cropTop = getCropBoxTop();
const minX = cropLeft + cropBoxWidth.value - scaledWidth;
const maxX = cropLeft;
const minY = cropTop + cropBoxHeight.value - scaledHeight;
const maxY = cropTop;
imageX.value = minX > maxX ? (stageWidth.value - scaledWidth) / 2 : clamp(imageX.value, minX, maxX);
imageY.value = minY > maxY ? (stageHeight.value - scaledHeight) / 2 : clamp(imageY.value, minY, maxY);
};
const ensureImageCoversCropBox = () => {
const minScale = Math.max(cropBoxWidth.value / imageBaseWidth.value, cropBoxHeight.value / imageBaseHeight.value, 1);
if (imageScale.value < minScale) {
imageScale.value = minScale;
}
};
const applyFreeCropBoxRect = (nextX, nextY, nextWidth, nextHeight) => {
const minSize = cropBoxMin.value;
let width = clamp(nextWidth, minSize, stageWidth.value);
let height = clamp(nextHeight, minSize, stageHeight.value);
let x = nextX;
let y = nextY;
if (x < 0) {
width += x;
x = 0;
}
if (y < 0) {
height += y;
y = 0;
}
if (x + width > stageWidth.value) {
width = stageWidth.value - x;
}
if (y + height > stageHeight.value) {
height = stageHeight.value - y;
}
cropBoxWidth.value = Math.max(width, minSize);
cropBoxHeight.value = Math.max(height, minSize);
cropBoxX.value = x;
cropBoxY.value = y;
constrainCropBoxPosition();
ensureImageCoversCropBox();
constrainImagePosition();
};
const applyLockedCropBoxRect = (nextX, nextY, nextWidth) => {
const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
const minWidth = cropBoxMin.value;
const maxWidth = Math.min(cropBoxMax.value, stageWidth.value, stageHeight.value * targetRatio);
let width = clamp(nextWidth, minWidth, maxWidth);
let height = width / targetRatio;
if (height > stageHeight.value) {
height = stageHeight.value;
width = height * targetRatio;
}
cropBoxWidth.value = width;
cropBoxHeight.value = height;
cropBoxX.value = nextX;
cropBoxY.value = nextY;
constrainCropBoxPosition();
ensureImageCoversCropBox();
constrainImagePosition();
};
const resizeCropBoxFromCenter = (nextWidth, anchorX, anchorY) => {
const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
const width = nextWidth;
const height = width / targetRatio;
applyLockedCropBoxRect(anchorX - width / 2, anchorY - height / 2, width);
};
const resizeCropBoxFromEdge = (handle, nextWidth, start) => {
const targetRatio = cropOptions.value.widthMM / cropOptions.value.heightMM;
const oldRight = start.cropBoxX + start.cropBoxWidth;
const oldBottom = start.cropBoxY + start.cropBoxHeight;
const width = nextWidth;
const height = width / targetRatio;
let nextX = start.cropBoxX;
let nextY = start.cropBoxY;
if (handle.includes('w')) {
nextX = oldRight - width;
}
if (handle.includes('n')) {
nextY = oldBottom - height;
}
if (handle === 'n' || handle === 's') {
nextX = start.cropBoxX + (start.cropBoxWidth - width) / 2;
}
if (handle === 'e' || handle === 'w') {
nextY = start.cropBoxY + (start.cropBoxHeight - height) / 2;
}
applyLockedCropBoxRect(nextX, nextY, width);
};
const resizeCropBoxFree = (handle, dx, dy, start) => {
const oldRight = start.cropBoxX + start.cropBoxWidth;
const oldBottom = start.cropBoxY + start.cropBoxHeight;
let nextX = start.cropBoxX;
let nextY = start.cropBoxY;
let nextWidth = start.cropBoxWidth;
let nextHeight = start.cropBoxHeight;
if (handle.includes('e')) {
nextWidth = start.cropBoxWidth + dx;
}
if (handle.includes('w')) {
nextX = start.cropBoxX + dx;
nextWidth = oldRight - nextX;
}
if (handle.includes('s')) {
nextHeight = start.cropBoxHeight + dy;
}
if (handle.includes('n')) {
nextY = start.cropBoxY + dy;
nextHeight = oldBottom - nextY;
}
applyFreeCropBoxRect(nextX, nextY, nextWidth, nextHeight);
};
const initCropState = async (options, info) => {
const systemInfo = uni.getSystemInfoSync();
const windowWidth = systemInfo.windowWidth || 375;
const windowHeight = systemInfo.windowHeight || 667;
const displaySize = getDisplaySize(info);
const targetRatio = options.watermarkOnly ? displaySize.width / displaySize.height : options.widthMM / options.heightMM;
const maxStageWidth = Math.min(windowWidth - 12, 460);
const maxStageHeight = Math.min(windowHeight * 0.66, 600);
stageWidth.value = maxStageWidth;
stageHeight.value = maxStageHeight;
let defaultBoxWidth = Math.min(maxStageWidth * 0.9, maxStageHeight * 0.86 * targetRatio);
let defaultBoxHeight = defaultBoxWidth / targetRatio;
if (defaultBoxHeight > maxStageHeight * 0.86) {
defaultBoxHeight = maxStageHeight * 0.86;
defaultBoxWidth = defaultBoxHeight * targetRatio;
}
cropBoxMin.value = Math.max(120, Math.min(defaultBoxWidth, defaultBoxHeight) * 0.55);
cropBoxMax.value = Math.min(maxStageWidth - 32, (maxStageHeight - 32) * targetRatio);
cropBoxWidth.value = defaultBoxWidth;
cropBoxHeight.value = defaultBoxHeight;
cropBoxX.value = (stageWidth.value - cropBoxWidth.value) / 2;
cropBoxY.value = (stageHeight.value - cropBoxHeight.value) / 2;
const imageRatio = displaySize.width / displaySize.height;
const boxRatio = cropBoxWidth.value / cropBoxHeight.value;
if (imageRatio > boxRatio) {
imageBaseHeight.value = cropBoxHeight.value;
imageBaseWidth.value = imageBaseHeight.value * imageRatio;
} else {
imageBaseWidth.value = cropBoxWidth.value;
imageBaseHeight.value = imageBaseWidth.value / imageRatio;
}
imageScale.value = 1;
imageX.value = getCropBoxLeft() + (cropBoxWidth.value - imageBaseWidth.value) / 2;
imageY.value = getCropBoxTop() + (cropBoxHeight.value - imageBaseHeight.value) / 2;
constrainImagePosition();
await waitNextTick();
};
const imageWrapStyle = computed(() => ({
width: imageBaseWidth.value + 'px',
height: imageBaseHeight.value + 'px',
transform: `translate(${imageX.value}px, ${imageY.value}px) scale(${imageScale.value})`,
transformOrigin: '0 0'
}));
const imageStyle = computed(() => {
const rotated = rotationAngle.value % 180 !== 0;
const width = rotated ? imageBaseHeight.value : imageBaseWidth.value;
const height = rotated ? imageBaseWidth.value : imageBaseHeight.value;
return {
width: width + 'px',
height: height + 'px',
transform: `translate(${(imageBaseWidth.value - width) / 2}px, ${(imageBaseHeight.value - height) / 2}px) rotate(${rotationAngle.value}deg)`,
transformOrigin: 'center center'
};
});
const cropBoxStyle = computed(() => ({
left: getCropBoxLeft() + 'px',
top: getCropBoxTop() + 'px',
width: cropBoxWidth.value + 'px',
height: cropBoxHeight.value + 'px'
}));
const shadowTopStyle = computed(() => ({
left: 0,
top: 0,
width: stageWidth.value + 'px',
height: getCropBoxTop() + 'px'
}));
const shadowBottomStyle = computed(() => ({
left: 0,
top: getCropBoxTop() + cropBoxHeight.value + 'px',
width: stageWidth.value + 'px',
height: stageHeight.value - getCropBoxTop() - cropBoxHeight.value + 'px'
}));
const shadowLeftStyle = computed(() => ({
left: 0,
top: getCropBoxTop() + 'px',
width: getCropBoxLeft() + 'px',
height: cropBoxHeight.value + 'px'
}));
const shadowRightStyle = computed(() => ({
left: getCropBoxLeft() + cropBoxWidth.value + 'px',
top: getCropBoxTop() + 'px',
width: stageWidth.value - getCropBoxLeft() - cropBoxWidth.value + 'px',
height: cropBoxHeight.value + 'px'
}));
const onTouchStart = (event) => {
if (isWatermarkOnly.value) return;
if (event.touches && event.touches.length >= 2) {
const first = event.touches[0];
const second = event.touches[1];
touchStart.value = {
type: 'pinch',
distance: Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY),
scale: imageScale.value
};
return;
}
const touch = event.touches && event.touches[0];
if (!touch) return;
touchStart.value = {
x: touch.clientX,
y: touch.clientY,
imageX: imageX.value,
imageY: imageY.value
};
};
const onTouchMove = (event) => {
if (isWatermarkOnly.value) return;
if (event.touches && event.touches.length >= 2 && touchStart.value && touchStart.value.type === 'pinch') {
const first = event.touches[0];
const second = event.touches[1];
const distance = Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
imageScale.value = clamp(touchStart.value.scale * (distance / touchStart.value.distance), 1, cropOptions.value.maxImageScale || 4);
ensureImageCoversCropBox();
constrainImagePosition();
return;
}
const touch = event.touches && event.touches[0];
if (!touch || !touchStart.value || touchStart.value.type) return;
imageX.value = touchStart.value.imageX + touch.clientX - touchStart.value.x;
imageY.value = touchStart.value.imageY + touch.clientY - touchStart.value.y;
constrainImagePosition();
};
const onTouchEnd = () => {
touchStart.value = null;
};
const onCropBoxTouchStart = (event) => {
if (isWatermarkOnly.value) return;
if (isCropBoxLocked.value) {
onTouchStart(event);
return;
}
const touch = event.touches && event.touches[0];
if (!touch) return;
touchStart.value = {
type: 'cropBox',
x: touch.clientX,
y: touch.clientY,
cropBoxX: cropBoxX.value,
cropBoxY: cropBoxY.value
};
};
const onCropBoxTouchMove = (event) => {
if (isWatermarkOnly.value) return;
if (isCropBoxLocked.value) {
onTouchMove(event);
return;
}
const touch = event.touches && event.touches[0];
if (!touch || !touchStart.value || touchStart.value.type !== 'cropBox') return;
cropBoxX.value = touchStart.value.cropBoxX + touch.clientX - touchStart.value.x;
cropBoxY.value = touchStart.value.cropBoxY + touch.clientY - touchStart.value.y;
constrainCropBoxPosition();
ensureImageCoversCropBox();
constrainImagePosition();
};
const onResizeTouchStart = (handle, event) => {
if (isWatermarkOnly.value) return;
if (isCropBoxLocked.value) return;
const touch = event.touches && event.touches[0];
if (!touch) return;
touchStart.value = {
type: 'resize',
handle,
x: touch.clientX,
y: touch.clientY,
cropBoxX: cropBoxX.value,
cropBoxY: cropBoxY.value,
cropBoxWidth: cropBoxWidth.value,
cropBoxHeight: cropBoxHeight.value
};
};
const onResizeTouchMove = (event) => {
if (isCropBoxLocked.value) return;
const touch = event.touches && event.touches[0];
const start = touchStart.value;
if (!touch || !start || start.type !== 'resize') return;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
const centerX = start.cropBoxX + start.cropBoxWidth / 2;
const centerY = start.cropBoxY + start.cropBoxHeight / 2;
let width = start.cropBoxWidth;
const handle = start.handle;
if (!cropOptions.value.lockCropRatio) {
resizeCropBoxFree(handle, dx, dy, start);
return;
}
if (handle.includes('e')) width = start.cropBoxWidth + dx * 2;
if (handle.includes('w')) width = start.cropBoxWidth - dx * 2;
if (handle === 'n') width = (start.cropBoxHeight - dy * 2) * (cropOptions.value.widthMM / cropOptions.value.heightMM);
if (handle === 's') width = (start.cropBoxHeight + dy * 2) * (cropOptions.value.widthMM / cropOptions.value.heightMM);
if (handle === 'ne' || handle === 'sw') width = start.cropBoxWidth + (dx - dy) * (handle === 'ne' ? 1 : -1);
if (handle === 'nw' || handle === 'se') width = start.cropBoxWidth + (dx + dy) * (handle === 'se' ? 1 : -1);
if (cropOptions.value.resizeFromCenter) {
resizeCropBoxFromCenter(width, centerX, centerY);
} else {
resizeCropBoxFromEdge(handle, width, start);
}
};
const openCropper = async (options, info) => {
cropImagePath.value = info.path || options.imagePath;
originalImagePath.value = cropImagePath.value;
originalImageInfo.value = info;
rotationAngle.value = 0;
cropOptions.value = options;
imageInfo.value = info;
cropVisible.value = true;
await initCropState(options, info);
return new Promise((resolve, reject) => {
cropResolver.value = resolve;
cropRejecter.value = reject;
});
};
const restoreCropImage = async () => {
if (!canRestore.value || !originalImageInfo.value) return;
cropImagePath.value = originalImagePath.value;
imageInfo.value = originalImageInfo.value;
rotationAngle.value = 0;
await initCropState(cropOptions.value, originalImageInfo.value);
};
const rotateCropImage = async () => {
rotationAngle.value = (rotationAngle.value + 90) % 360;
await initCropState(cropOptions.value, imageInfo.value);
};
const getManualCropRect = () => {
const info = imageInfo.value;
const displaySize = getDisplaySize(info);
if (isWatermarkOnly.value) {
return {
sx: 0,
sy: 0,
sw: displaySize.width,
sh: displaySize.height
};
}
const scaledWidth = imageBaseWidth.value * imageScale.value;
const scaledHeight = imageBaseHeight.value * imageScale.value;
const cropLeft = getCropBoxLeft();
const cropTop = getCropBoxTop();
const rect = {
sx: ((cropLeft - imageX.value) / scaledWidth) * displaySize.width,
sy: ((cropTop - imageY.value) / scaledHeight) * displaySize.height,
sw: (cropBoxWidth.value / scaledWidth) * displaySize.width,
sh: (cropBoxHeight.value / scaledHeight) * displaySize.height
};
rect.sx = clamp(rect.sx, 0, displaySize.width - 1);
rect.sy = clamp(rect.sy, 0, displaySize.height - 1);
rect.sw = clamp(rect.sw, 1, displaySize.width - rect.sx);
rect.sh = clamp(rect.sh, 1, displaySize.height - rect.sy);
return rect;
};
const closeCropper = () => {
cropVisible.value = false;
cropResolver.value = null;
cropRejecter.value = null;
touchStart.value = null;
};
const cancelCrop = () => {
if (cropRejecter.value) {
cropRejecter.value(new Error('crop canceled'));
}
closeCropper();
};
const confirmCrop = () => {
if (cropResolver.value) {
const displaySize = getDisplaySize(imageInfo.value);
cropResolver.value({
rect: getManualCropRect(),
outputWidth: isWatermarkOnly.value ? Math.round(displaySize.width) : Math.round(cropBoxWidth.value),
outputHeight: isWatermarkOnly.value ? Math.round(displaySize.height) : Math.round(cropBoxHeight.value),
imagePath: cropImagePath.value,
imageInfo: imageInfo.value,
rotation: rotationAngle.value
});
}
closeCropper();
};
const drawImageWithCrop = (ctx, imagePath, info, cropRect, rotation, outputWidth, outputHeight) => {
if (!rotation) {
ctx.drawImage(imagePath, cropRect.sx, cropRect.sy, cropRect.sw, cropRect.sh, 0, 0, outputWidth, outputHeight);
return;
}
const displaySize = getDisplaySize(info, rotation);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, outputWidth, outputHeight);
ctx.clip();
ctx.translate(-cropRect.sx * (outputWidth / cropRect.sw), -cropRect.sy * (outputHeight / cropRect.sh));
ctx.scale(outputWidth / cropRect.sw, outputHeight / cropRect.sh);
ctx.translate(displaySize.width / 2, displaySize.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
ctx.drawImage(imagePath, -info.width / 2, -info.height / 2, info.width, info.height);
ctx.restore();
};
const drawWatermark = (ctx, outputWidth, outputHeight, watermark) => {
const lines = getWatermarkLines(watermark);
if (!lines.length) return;
const size = watermark.size || Math.max(14, Math.round(outputWidth / 36));
const padding = watermark.padding || Math.round(size * 0.9);
const lineHeight = watermark.lineHeight || Math.round(size * 1.45);
const x = padding;
const y = outputHeight - padding - (lines.length - 1) * lineHeight;
ctx.save();
ctx.setFontSize(size);
ctx.setTextAlign('left');
ctx.setTextBaseline('middle');
lines.forEach((line, index) => {
const lineY = y + index * lineHeight;
ctx.setFillStyle(watermark.shadowColor || 'rgba(0, 0, 0, 0.55)');
ctx.fillText(line, x + 1, lineY + 1);
ctx.setFillStyle(watermark.color || '#FFFFFF');
ctx.fillText(line, x, lineY);
});
ctx.restore();
};
const drawToTempFile = async (options, info, cropRect, drawImagePath, rotation = 0, cropResult = null) => {
const { widthMM, heightMM, dpi, watermark } = options;
const rawPxWidth = Math.round((widthMM / 25.4) * dpi);
const rawPxHeight = Math.round((heightMM / 25.4) * dpi);
const cropOutputWidth = cropResult && cropResult.outputWidth ? cropResult.outputWidth : Math.round(cropRect.sw);
const cropOutputHeight = cropResult && cropResult.outputHeight ? cropResult.outputHeight : Math.round(cropRect.sh);
const cropQualityScale = options.outputMode === 'print' ? 1 : options.cropQualityScale || options.outputPixelRatio || 2;
const baseOutputWidth = options.outputMode === 'print' ? rawPxWidth : Math.round(cropOutputWidth * cropQualityScale);
const baseOutputHeight = options.outputMode === 'print' ? rawPxHeight : Math.round(cropOutputHeight * cropQualityScale);
const outputScale = Math.min((options.maxOutputSize || 1200) / Math.max(baseOutputWidth, baseOutputHeight), 1);
const pxWidth = Math.round(baseOutputWidth * outputScale);
const pxHeight = Math.round(baseOutputHeight * outputScale);
const canvasId = `posterCanvas_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
exportCanvasId.value = canvasId;
canvasWidth.value = pxWidth;
canvasHeight.value = pxHeight;
exportCanvasVisible.value = true;
await waitNextTick();
await new Promise((resolve) => setTimeout(resolve, 50));
return new Promise((resolve, reject) => {
let settled = false;
const destroyCanvas = () => {
exportCanvasVisible.value = false;
canvasWidth.value = 0;
canvasHeight.value = 0;
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
destroyCanvas();
reject(new Error('canvas export timeout'));
}, options.exportTimeout || 8000);
const ctx = uni.createCanvasContext(canvasId, canvasInstance);
ctx.setFillStyle('#FFFFFF');
ctx.fillRect(0, 0, pxWidth, pxHeight);
drawImageWithCrop(ctx, drawImagePath || info.path || options.imagePath, info, cropRect, rotation, pxWidth, pxHeight);
drawWatermark(ctx, pxWidth, pxHeight, watermark);
const exportCanvas = () => {
uni.canvasToTempFilePath(
{
canvasId,
destWidth: pxWidth,
destHeight: pxHeight,
fileType: 'jpg',
quality: 1,
success: (res) => {
if (settled) return;
settled = true;
clearTimeout(timer);
previewUrl.value = res.tempFilePath;
destroyCanvas();
resolve(res.tempFilePath);
},
fail: (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
destroyCanvas();
reject(new Error('canvasToTempFilePath failed: ' + JSON.stringify(err)));
}
},
canvasInstance
);
};
ctx.draw(false, () => {
setTimeout(exportCanvas, options.exportDelay || 100);
});
});
};
const generate = async (options) => {
const preset = options && options.preset ? (typeof options.preset === 'string' ? cropPresets[options.preset] : options.preset) : null;
const config = {
widthMM: 25,
heightMM: 35,
dpi: 300,
watermark: null,
manualCrop: true,
outputMode: 'crop',
resizeFromCenter: false,
lockCropRatio: false,
maxImageScale: 4,
maxOutputSize: 2400,
cropQualityScale: 2,
exportDelay: 100,
...(preset || {}),
...options
};
config.watermark = normalizeWatermark(config.watermark);
if (config.watermark) {
config.watermarkOnly = config.watermarkOnly !== false;
}
if (!config.imagePath) {
throw new Error('imagePath is required');
}
uni.hideLoading();
const info = await getImageInfo(config.imagePath);
const targetPxWidth = Math.round((config.widthMM / 25.4) * config.dpi);
const targetPxHeight = Math.round((config.heightMM / 25.4) * config.dpi);
const cropResult = config.manualCrop
? await openCropper(config, info)
: {
rect: calcCenterCropRect(info.width, info.height, targetPxWidth, targetPxHeight),
outputWidth: targetPxWidth,
outputHeight: targetPxHeight,
imagePath: info.path || config.imagePath,
imageInfo: info,
rotation: 0
};
if (config.showLoading !== false) {
uni.showLoading({ title: '生成中...' });
}
try {
return await drawToTempFile(config, cropResult.imageInfo, cropResult.rect, cropResult.imagePath, cropResult.rotation, cropResult);
} finally {
if (config.showLoading !== false) {
uni.hideLoading();
}
}
};
const chooseImage = (options = {}) => {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sourceType: ['album', 'camera'],
...options,
success: (res) => {
const imagePath = res.tempFilePaths && res.tempFilePaths[0];
if (!imagePath) {
reject(new Error('chooseImage returned empty path'));
return;
}
resolve(imagePath);
},
fail: reject
});
});
};
const chooseAndGenerate = async (options = {}) => {
const { chooseOptions, ...generateOptions } = options;
const imagePath = generateOptions.imagePath || await chooseImage(chooseOptions);
return generate({
...generateOptions,
imagePath
});
};
defineExpose({
generate,
chooseAndGenerate
});
</script>
<style scoped>
.print-generator {
position: relative;
width: 1px;
height: 1px;
overflow: hidden;
}
.hidden-canvas {
position: fixed;
left: -10000px;
top: -10000px;
z-index: -1;
opacity: 0;
pointer-events: none;
}
.crop-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 9999;
background: #000000;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 0 0 28rpx;
}
.crop-header {
width: 100%;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.crop-title {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
.crop-stage {
position: relative;
overflow: hidden;
background: #000000;
touch-action: none;
margin-top: 140rpx;
}
.crop-image-wrap {
position: absolute;
left: 0;
top: 0;
will-change: transform;
overflow: visible;
}
.crop-image {
position: absolute;
left: 0;
top: 0;
will-change: transform;
}
.watermark-preview {
position: absolute;
z-index: 4;
display: flex;
flex-direction: column;
pointer-events: none;
}
.watermark-preview-line {
font-size: 14px;
line-height: 22px;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75);
}
.crop-shadow {
position: absolute;
background: rgba(0, 0, 0, 0.62);
z-index: 2;
}
.crop-box {
position: absolute;
z-index: 3;
box-sizing: border-box;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.grid-line {
position: absolute;
background: rgba(255, 255, 255, 0.22);
}
.grid-line-v {
top: 0;
bottom: 0;
width: 1px;
}
.grid-line-v1 {
left: 33.333%;
}
.grid-line-v2 {
left: 66.666%;
}
.grid-line-h {
left: 0;
right: 0;
height: 1px;
}
.grid-line-h1 {
top: 33.333%;
}
.grid-line-h2 {
top: 66.666%;
}
.resize-handle {
position: absolute;
z-index: 4;
width: 44rpx;
height: 44rpx;
box-sizing: border-box;
}
.resize-handle::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 30rpx;
height: 30rpx;
margin-left: -15rpx;
margin-top: -15rpx;
border-color: #ffffff;
border-style: solid;
box-sizing: border-box;
}
.resize-handle-nw {
left: -24rpx;
top: -24rpx;
}
.resize-handle-nw::after {
border-width: 8rpx 0 0 8rpx;
}
.resize-handle-n {
left: 50%;
top: -24rpx;
margin-left: -22rpx;
}
.resize-handle-n::after {
width: 40rpx;
height: 8rpx;
margin-left: -20rpx;
margin-top: -4rpx;
border-width: 0;
background: #ffffff;
}
.resize-handle-ne {
right: -24rpx;
top: -24rpx;
}
.resize-handle-ne::after {
border-width: 8rpx 8rpx 0 0;
}
.resize-handle-e {
right: -24rpx;
top: 50%;
margin-top: -22rpx;
}
.resize-handle-e::after {
width: 8rpx;
height: 40rpx;
margin-left: -4rpx;
margin-top: -20rpx;
border-width: 0;
background: #ffffff;
}
.resize-handle-se {
right: -24rpx;
bottom: -24rpx;
}
.resize-handle-se::after {
border-width: 0 8rpx 8rpx 0;
}
.resize-handle-s {
left: 50%;
bottom: -24rpx;
margin-left: -22rpx;
}
.resize-handle-s::after {
width: 40rpx;
height: 8rpx;
margin-left: -20rpx;
margin-top: -4rpx;
border-width: 0;
background: #ffffff;
}
.resize-handle-sw {
left: -24rpx;
bottom: -24rpx;
}
.resize-handle-sw::after {
border-width: 0 0 8rpx 8rpx;
}
.resize-handle-w {
left: -24rpx;
top: 50%;
margin-top: -22rpx;
}
.resize-handle-w::after {
width: 8rpx;
height: 40rpx;
margin-left: -4rpx;
margin-top: -20rpx;
border-width: 0;
background: #ffffff;
}
.crop-tools {
width: 100%;
height: 190rpx;
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 0 56rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
}
.tool-btn {
min-width: 120rpx;
height: 96rpx;
padding: 0;
margin: 0;
background: transparent;
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 26rpx;
line-height: 1.2;
}
.tool-btn::after,
.crop-btn::after {
border: 0;
}
.tool-btn.disabled {
color: rgba(255, 255, 255, 0.28);
}
.tool-icon {
font-size: 54rpx;
line-height: 1;
}
.tool-text {
margin-top: 10rpx;
}
.crop-actions {
width: 100%;
height: 132rpx;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 0 48rpx;
}
.crop-btn {
width: 160rpx;
height: 92rpx;
line-height: 92rpx;
border-radius: 0;
font-size: 34rpx;
padding: 0;
margin: 0;
background: transparent;
color: #ffffff;
text-align: center;
}
.crop-btn-cancel {
text-align: left;
}
.crop-btn-confirm {
text-align: right;
}
</style>