|
|
@@ -2,8 +2,8 @@
|
|
|
* @node image
|
|
|
* @name Image
|
|
|
* @category media
|
|
|
- * @version 1.0.0
|
|
|
- * @description Extract image information, metadata, and EXIF data using ImageMagick
|
|
|
+ * @version 1.1.0
|
|
|
+ * @description Image manipulation and metadata extraction using ImageMagick
|
|
|
* @icon image
|
|
|
*/
|
|
|
|
|
|
@@ -63,6 +63,116 @@ const configSchema = {
|
|
|
description: 'Extract EXIF metadata (camera info, GPS, date taken, etc.)',
|
|
|
showWhen: { field: 'operation', value: 'info' }
|
|
|
},
|
|
|
+ resizeMode: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Resize Mode',
|
|
|
+ enum: ['dimensions', 'percentage'],
|
|
|
+ enumLabels: ['Dimensions (pixels)', 'Percentage (%)'],
|
|
|
+ default: 'dimensions',
|
|
|
+ description: 'How to specify the new size',
|
|
|
+ showWhen: { field: 'operation', value: 'resize' }
|
|
|
+ },
|
|
|
+ resizeWidth: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Width',
|
|
|
+ minimum: 1,
|
|
|
+ maximum: 20000,
|
|
|
+ description: 'Target width in pixels. Leave empty to auto-calculate from height.',
|
|
|
+ showWhen: { field: 'operation', value: 'resize' }
|
|
|
+ },
|
|
|
+ resizeHeight: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Height',
|
|
|
+ minimum: 1,
|
|
|
+ maximum: 20000,
|
|
|
+ description: 'Target height in pixels. Leave empty to auto-calculate from width.',
|
|
|
+ showWhen: { field: 'operation', value: 'resize' }
|
|
|
+ },
|
|
|
+ resizePercentage: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Scale Percentage',
|
|
|
+ minimum: 1,
|
|
|
+ maximum: 1000,
|
|
|
+ default: 100,
|
|
|
+ description: 'Scale percentage (100 = original size, 50 = half, 200 = double)',
|
|
|
+ showWhen: { field: 'operation', value: 'resize' }
|
|
|
+ },
|
|
|
+ maintainAspectRatio: {
|
|
|
+ type: 'boolean',
|
|
|
+ title: 'Maintain Aspect Ratio',
|
|
|
+ default: true,
|
|
|
+ description: 'Keep original proportions when resizing',
|
|
|
+ showWhen: { field: 'operation', value: 'resize' }
|
|
|
+ },
|
|
|
+ cropX: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'X Offset',
|
|
|
+ minimum: 0,
|
|
|
+ default: 0,
|
|
|
+ description: 'Horizontal offset from left edge in pixels',
|
|
|
+ showWhen: { field: 'operation', value: 'crop' }
|
|
|
+ },
|
|
|
+ cropY: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Y Offset',
|
|
|
+ minimum: 0,
|
|
|
+ default: 0,
|
|
|
+ description: 'Vertical offset from top edge in pixels',
|
|
|
+ showWhen: { field: 'operation', value: 'crop' }
|
|
|
+ },
|
|
|
+ cropWidth: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Crop Width',
|
|
|
+ minimum: 1,
|
|
|
+ description: 'Width of the crop region in pixels',
|
|
|
+ showWhen: { field: 'operation', value: 'crop' }
|
|
|
+ },
|
|
|
+ cropHeight: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Crop Height',
|
|
|
+ minimum: 1,
|
|
|
+ description: 'Height of the crop region in pixels',
|
|
|
+ showWhen: { field: 'operation', value: 'crop' }
|
|
|
+ },
|
|
|
+ rotateAngle: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Rotation Angle',
|
|
|
+ default: 90,
|
|
|
+ description: 'Rotation angle in degrees (positive = clockwise)',
|
|
|
+ showWhen: { field: 'operation', value: 'rotate' }
|
|
|
+ },
|
|
|
+ rotatePreset: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Quick Rotate',
|
|
|
+ enum: ['custom', '90', '180', '270'],
|
|
|
+ enumLabels: ['Custom Angle', '90 (CW)', '180', '270 (CCW)'],
|
|
|
+ default: 'custom',
|
|
|
+ description: 'Common rotation presets or use custom angle',
|
|
|
+ showWhen: { field: 'operation', value: 'rotate' }
|
|
|
+ },
|
|
|
+ backgroundColor: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Background Color',
|
|
|
+ default: 'transparent',
|
|
|
+ description: 'Fill color for areas exposed by rotation (name, hex, or transparent)',
|
|
|
+ showWhen: { field: 'operation', value: 'rotate' }
|
|
|
+ },
|
|
|
+ outputFormat: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Output Format',
|
|
|
+ enum: ['auto', 'jpeg', 'png', 'webp', 'gif'],
|
|
|
+ enumLabels: ['Same as Input', 'JPEG', 'PNG', 'WebP', 'GIF'],
|
|
|
+ default: 'auto',
|
|
|
+ description: 'Output image format'
|
|
|
+ },
|
|
|
+ outputQuality: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Quality',
|
|
|
+ minimum: 1,
|
|
|
+ maximum: 100,
|
|
|
+ default: 85,
|
|
|
+ description: 'Output quality for lossy formats (JPEG, WebP). 1-100.'
|
|
|
+ },
|
|
|
timeout: {
|
|
|
type: 'number',
|
|
|
title: 'Timeout (ms)',
|
|
|
@@ -205,6 +315,29 @@ const outputSchema = {
|
|
|
meteringMode: { type: 'string' }
|
|
|
}
|
|
|
},
|
|
|
+ file: {
|
|
|
+ type: 'object',
|
|
|
+ description: 'Binary file output (for manipulation operations)',
|
|
|
+ properties: {
|
|
|
+ type: { type: 'string', const: 'binary' },
|
|
|
+ data: { type: 'string', description: 'Base64-encoded image data' },
|
|
|
+ mimeType: { type: 'string', description: 'MIME type of the image' },
|
|
|
+ filename: { type: 'string', description: 'Output filename' },
|
|
|
+ size: { type: 'number', description: 'File size in bytes' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ operation: {
|
|
|
+ type: 'string',
|
|
|
+ description: 'Operation performed'
|
|
|
+ },
|
|
|
+ originalWidth: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Original image width before manipulation'
|
|
|
+ },
|
|
|
+ originalHeight: {
|
|
|
+ type: 'number',
|
|
|
+ description: 'Original image height before manipulation'
|
|
|
+ },
|
|
|
sourceType: {
|
|
|
type: 'string',
|
|
|
description: 'Source type used (base64, filePath, url, storage)'
|
|
|
@@ -637,12 +770,313 @@ function executeInfoOperation(imagePath, includeExif, timeout) {
|
|
|
return info;
|
|
|
}
|
|
|
|
|
|
-async function execute(config, input, context) {
|
|
|
+function getImageDimensions(imagePath, timeout) {
|
|
|
+ var cmd = 'identify -format "%w %h" "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
+ var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ throw new Error('Failed to get image dimensions: ' + (result.stderr || 'Unknown error'));
|
|
|
+ }
|
|
|
+
|
|
|
+ var parts = result.stdout.trim().split(' ');
|
|
|
+ return {
|
|
|
+ width: parseInt(parts[0], 10),
|
|
|
+ height: parseInt(parts[1], 10)
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function getOutputExtension(format, originalPath) {
|
|
|
+ if (format === 'auto') {
|
|
|
+ var ext = originalPath.split('.').pop().toLowerCase();
|
|
|
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp'].indexOf(ext) >= 0) {
|
|
|
+ return ext === 'jpg' ? 'jpeg' : ext;
|
|
|
+ }
|
|
|
+ return 'jpeg';
|
|
|
+ }
|
|
|
+ return format;
|
|
|
+}
|
|
|
+
|
|
|
+function getMimeType(format) {
|
|
|
+ var mimeTypes = {
|
|
|
+ jpeg: 'image/jpeg',
|
|
|
+ jpg: 'image/jpeg',
|
|
|
+ png: 'image/png',
|
|
|
+ gif: 'image/gif',
|
|
|
+ webp: 'image/webp'
|
|
|
+ };
|
|
|
+ return mimeTypes[format] || 'image/jpeg';
|
|
|
+}
|
|
|
+
|
|
|
+function buildOutputPath(format) {
|
|
|
+ var uuid = smartbotic.utils.uuid();
|
|
|
+ return '/tmp/smartbotic-image-out-' + uuid + '.' + format;
|
|
|
+}
|
|
|
+
|
|
|
+function readOutputFile(outputPath) {
|
|
|
+ var readResult = smartbotic.fs.readFile(outputPath, 'base64');
|
|
|
+ if (!readResult.success) {
|
|
|
+ throw new Error('Failed to read output file: ' + (readResult.error || 'Unknown error'));
|
|
|
+ }
|
|
|
+ return readResult.data;
|
|
|
+}
|
|
|
+
|
|
|
+function getOutputFileSize(outputPath) {
|
|
|
+ var stat = smartbotic.fs.stat(outputPath);
|
|
|
+ return stat.success ? stat.size : 0;
|
|
|
+}
|
|
|
+
|
|
|
+function cleanupOutputFile(outputPath) {
|
|
|
+ if (outputPath) {
|
|
|
+ try {
|
|
|
+ smartbotic.fs.unlink(outputPath);
|
|
|
+ } catch (e) {
|
|
|
+ smartbotic.log.warn('Failed to cleanup output file: ' + outputPath);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildQualityArgs(format, quality) {
|
|
|
+ if (format === 'jpeg' || format === 'webp') {
|
|
|
+ return ' -quality ' + quality;
|
|
|
+ }
|
|
|
+ if (format === 'png') {
|
|
|
+ var pngQuality = Math.round((100 - quality) / 10);
|
|
|
+ return ' -quality ' + (pngQuality * 10);
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+function executeResizeOperation(imagePath, config, timeout) {
|
|
|
+ var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+ var originalWidth = dimensions.width;
|
|
|
+ var originalHeight = dimensions.height;
|
|
|
+
|
|
|
+ var resizeMode = config.resizeMode || 'dimensions';
|
|
|
+ var maintainAspectRatio = config.maintainAspectRatio !== false;
|
|
|
+ var targetWidth, targetHeight;
|
|
|
+
|
|
|
+ if (resizeMode === 'percentage') {
|
|
|
+ var percentage = config.resizePercentage || 100;
|
|
|
+ targetWidth = Math.round(originalWidth * percentage / 100);
|
|
|
+ targetHeight = Math.round(originalHeight * percentage / 100);
|
|
|
+ smartbotic.log.info('Resizing by ' + percentage + '% to ' + targetWidth + 'x' + targetHeight);
|
|
|
+ } else {
|
|
|
+ targetWidth = config.resizeWidth;
|
|
|
+ targetHeight = config.resizeHeight;
|
|
|
+
|
|
|
+ if (!targetWidth && !targetHeight) {
|
|
|
+ throw new Error('Resize operation requires at least width or height');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (maintainAspectRatio) {
|
|
|
+ if (targetWidth && !targetHeight) {
|
|
|
+ targetHeight = Math.round(originalHeight * targetWidth / originalWidth);
|
|
|
+ } else if (targetHeight && !targetWidth) {
|
|
|
+ targetWidth = Math.round(originalWidth * targetHeight / originalHeight);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ targetWidth = targetWidth || originalWidth;
|
|
|
+ targetHeight = targetHeight || originalHeight;
|
|
|
+ }
|
|
|
+ smartbotic.log.info('Resizing to ' + targetWidth + 'x' + targetHeight + ' (aspect ratio ' + (maintainAspectRatio ? 'maintained' : 'ignored') + ')');
|
|
|
+ }
|
|
|
+
|
|
|
+ var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
+ var quality = config.outputQuality || 85;
|
|
|
+ var outputPath = buildOutputPath(outputFormat);
|
|
|
+
|
|
|
+ var resizeSpec = targetWidth + 'x' + targetHeight;
|
|
|
+ if (!maintainAspectRatio && resizeMode === 'dimensions') {
|
|
|
+ resizeSpec += '!';
|
|
|
+ }
|
|
|
+
|
|
|
+ var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
+ cmd += ' -resize ' + resizeSpec;
|
|
|
+ cmd += buildQualityArgs(outputFormat, quality);
|
|
|
+ cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
+
|
|
|
+ smartbotic.log.info('Executing: ' + cmd);
|
|
|
+ var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+ throw new Error('Resize operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
+ }
|
|
|
+
|
|
|
+ var base64Data = readOutputFile(outputPath);
|
|
|
+ var fileSize = getOutputFileSize(outputPath);
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+
|
|
|
+ return {
|
|
|
+ operation: 'resize',
|
|
|
+ originalWidth: originalWidth,
|
|
|
+ originalHeight: originalHeight,
|
|
|
+ width: targetWidth,
|
|
|
+ height: targetHeight,
|
|
|
+ format: outputFormat.toUpperCase(),
|
|
|
+ file: {
|
|
|
+ type: 'binary',
|
|
|
+ data: base64Data,
|
|
|
+ mimeType: getMimeType(outputFormat),
|
|
|
+ filename: 'resized.' + outputFormat,
|
|
|
+ size: fileSize
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function executeCropOperation(imagePath, config, timeout) {
|
|
|
+ var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+ var originalWidth = dimensions.width;
|
|
|
+ var originalHeight = dimensions.height;
|
|
|
+
|
|
|
+ var cropX = config.cropX || 0;
|
|
|
+ var cropY = config.cropY || 0;
|
|
|
+ var cropWidth = config.cropWidth;
|
|
|
+ var cropHeight = config.cropHeight;
|
|
|
+
|
|
|
+ if (!cropWidth || !cropHeight) {
|
|
|
+ throw new Error('Crop operation requires both width and height');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (cropX < 0 || cropY < 0) {
|
|
|
+ throw new Error('Crop offset cannot be negative');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (cropX + cropWidth > originalWidth || cropY + cropHeight > originalHeight) {
|
|
|
+ throw new Error('Crop region exceeds image bounds. Image is ' + originalWidth + 'x' + originalHeight + ', crop would extend to ' + (cropX + cropWidth) + 'x' + (cropY + cropHeight));
|
|
|
+ }
|
|
|
+
|
|
|
+ smartbotic.log.info('Cropping ' + cropWidth + 'x' + cropHeight + ' from offset ' + cropX + ',' + cropY);
|
|
|
+
|
|
|
+ var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
+ var quality = config.outputQuality || 85;
|
|
|
+ var outputPath = buildOutputPath(outputFormat);
|
|
|
+
|
|
|
+ var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
+ cmd += ' -crop ' + cropWidth + 'x' + cropHeight + '+' + cropX + '+' + cropY;
|
|
|
+ cmd += ' +repage';
|
|
|
+ cmd += buildQualityArgs(outputFormat, quality);
|
|
|
+ cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
+
|
|
|
+ smartbotic.log.info('Executing: ' + cmd);
|
|
|
+ var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+ throw new Error('Crop operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
+ }
|
|
|
+
|
|
|
+ var base64Data = readOutputFile(outputPath);
|
|
|
+ var fileSize = getOutputFileSize(outputPath);
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+
|
|
|
+ return {
|
|
|
+ operation: 'crop',
|
|
|
+ originalWidth: originalWidth,
|
|
|
+ originalHeight: originalHeight,
|
|
|
+ width: cropWidth,
|
|
|
+ height: cropHeight,
|
|
|
+ cropRegion: {
|
|
|
+ x: cropX,
|
|
|
+ y: cropY,
|
|
|
+ width: cropWidth,
|
|
|
+ height: cropHeight
|
|
|
+ },
|
|
|
+ format: outputFormat.toUpperCase(),
|
|
|
+ file: {
|
|
|
+ type: 'binary',
|
|
|
+ data: base64Data,
|
|
|
+ mimeType: getMimeType(outputFormat),
|
|
|
+ filename: 'cropped.' + outputFormat,
|
|
|
+ size: fileSize
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function executeRotateOperation(imagePath, config, timeout) {
|
|
|
+ var dimensions = getImageDimensions(imagePath, timeout);
|
|
|
+ var originalWidth = dimensions.width;
|
|
|
+ var originalHeight = dimensions.height;
|
|
|
+
|
|
|
+ var rotatePreset = config.rotatePreset || 'custom';
|
|
|
+ var angle;
|
|
|
+
|
|
|
+ if (rotatePreset === 'custom') {
|
|
|
+ angle = config.rotateAngle;
|
|
|
+ if (angle === undefined || angle === null) {
|
|
|
+ angle = 90;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ angle = parseInt(rotatePreset, 10);
|
|
|
+ }
|
|
|
+
|
|
|
+ angle = angle % 360;
|
|
|
+ if (angle < 0) {
|
|
|
+ angle += 360;
|
|
|
+ }
|
|
|
+
|
|
|
+ var backgroundColor = config.backgroundColor || 'transparent';
|
|
|
+ smartbotic.log.info('Rotating ' + angle + ' degrees with background: ' + backgroundColor);
|
|
|
+
|
|
|
+ var outputFormat = getOutputExtension(config.outputFormat || 'auto', imagePath);
|
|
|
+ var quality = config.outputQuality || 85;
|
|
|
+ var outputPath = buildOutputPath(outputFormat);
|
|
|
+
|
|
|
+ var supportsTransparency = (outputFormat === 'png' || outputFormat === 'gif' || outputFormat === 'webp');
|
|
|
+
|
|
|
+ var cmd = 'convert "' + imagePath.replace(/"/g, '\\"') + '"';
|
|
|
+
|
|
|
+ if (backgroundColor === 'transparent' && supportsTransparency) {
|
|
|
+ cmd += ' -background none';
|
|
|
+ } else if (backgroundColor === 'transparent') {
|
|
|
+ cmd += ' -background white';
|
|
|
+ } else {
|
|
|
+ cmd += ' -background "' + backgroundColor.replace(/"/g, '\\"') + '"';
|
|
|
+ }
|
|
|
+
|
|
|
+ cmd += ' -rotate ' + angle;
|
|
|
+ cmd += buildQualityArgs(outputFormat, quality);
|
|
|
+ cmd += ' "' + outputPath.replace(/"/g, '\\"') + '"';
|
|
|
+
|
|
|
+ smartbotic.log.info('Executing: ' + cmd);
|
|
|
+ var result = smartbotic.process.exec(cmd, { timeout: timeout });
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+ throw new Error('Rotate operation failed: ' + (result.stderr || 'Unknown error'));
|
|
|
+ }
|
|
|
+
|
|
|
+ var newDimensions = getImageDimensions(outputPath, timeout);
|
|
|
+ var base64Data = readOutputFile(outputPath);
|
|
|
+ var fileSize = getOutputFileSize(outputPath);
|
|
|
+ cleanupOutputFile(outputPath);
|
|
|
+
|
|
|
+ return {
|
|
|
+ operation: 'rotate',
|
|
|
+ originalWidth: originalWidth,
|
|
|
+ originalHeight: originalHeight,
|
|
|
+ width: newDimensions.width,
|
|
|
+ height: newDimensions.height,
|
|
|
+ angle: angle,
|
|
|
+ backgroundColor: backgroundColor,
|
|
|
+ format: outputFormat.toUpperCase(),
|
|
|
+ file: {
|
|
|
+ type: 'binary',
|
|
|
+ data: base64Data,
|
|
|
+ mimeType: getMimeType(outputFormat),
|
|
|
+ filename: 'rotated.' + outputFormat,
|
|
|
+ size: fileSize
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input) {
|
|
|
var operation = config.operation || 'info';
|
|
|
var timeout = config.timeout || 30000;
|
|
|
|
|
|
- if (operation !== 'info') {
|
|
|
- throw new Error('Operation "' + operation + '" is not yet implemented. Only "info" is available in this version.');
|
|
|
+ var supportedOperations = ['info', 'resize', 'crop', 'rotate'];
|
|
|
+ if (supportedOperations.indexOf(operation) < 0) {
|
|
|
+ throw new Error('Operation "' + operation + '" is not yet implemented. Supported: ' + supportedOperations.join(', '));
|
|
|
}
|
|
|
|
|
|
var imageInput = resolveImageInput(config, input);
|
|
|
@@ -658,7 +1092,17 @@ async function execute(config, input, context) {
|
|
|
fileInfo = prepareImageFile(imageInput, timeout);
|
|
|
smartbotic.log.info('Processing image: ' + fileInfo.path);
|
|
|
|
|
|
- var result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
|
|
|
+ var result;
|
|
|
+
|
|
|
+ if (operation === 'info') {
|
|
|
+ result = executeInfoOperation(fileInfo.path, config.includeExif !== false, timeout);
|
|
|
+ } else if (operation === 'resize') {
|
|
|
+ result = executeResizeOperation(fileInfo.path, config, timeout);
|
|
|
+ } else if (operation === 'crop') {
|
|
|
+ result = executeCropOperation(fileInfo.path, config, timeout);
|
|
|
+ } else if (operation === 'rotate') {
|
|
|
+ result = executeRotateOperation(fileInfo.path, config, timeout);
|
|
|
+ }
|
|
|
|
|
|
result.sourceType = imageInput.type;
|
|
|
result.sourcePath = fileInfo.path;
|