img-cropping.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. /**
  2. * fun 标签:img-cropping — 按正方形区域居中裁剪图片并保存
  3. *
  4. * 入参(inVars 顺序):
  5. * - imagePath:源图片路径(相对当前流程目录或绝对路径)
  6. * - savePath:输出路径(相对流程目录或绝对路径)
  7. * - squareSpec:边长规则 [scale, 轴]:
  8. * - [0.8, "w"] / "0.8,w" / "[0.8,w]":边长 = 图片宽度 × 0.8
  9. * - [1, "h"] / "1,h":边长 = 图片高度 × 1
  10. * 轴:w / width / 宽;h / height / 高
  11. *
  12. * 若 scale×参照边 大于 min(宽,高),会夹紧到 min(宽,高)。
  13. */
  14. const path = require('path')
  15. const fs = require('fs')
  16. const { spawnSync } = require('child_process')
  17. const { getPythonExeFromConfig } = require('../../../../python-exe-from-config.js')
  18. const tagName = 'img-cropping'
  19. const configPath = process.env.STATIC_ROOT
  20. ? path.join(path.dirname(path.resolve(process.env.STATIC_ROOT)), 'config.js')
  21. : path.join(__dirname, '..', '..', '..', '..', '..', 'config.js')
  22. const config = fs.existsSync(configPath) ? require(configPath) : {}
  23. const projectRoot = (config.projectRoot && fs.existsSync(config.projectRoot))
  24. ? config.projectRoot
  25. : path.dirname(path.resolve(configPath))
  26. const squareCropScriptPath = path.join(projectRoot, 'python', 'scripts', 'img-crop-square-center.py')
  27. function buildAbsolutePath (p, folderPath) {
  28. if (p == null || p === '') return null
  29. const s = typeof p === 'string' ? p.trim() : String(p)
  30. if (!s) return null
  31. if (path.isAbsolute(s) || /^[A-Za-z]:/.test(s)) return path.normalize(s)
  32. return folderPath ? path.join(folderPath, s) : path.resolve(projectRoot, s)
  33. }
  34. /** @returns {{ scale: number, axis: 'w'|'h' } | { error: string }} */
  35. function parseSquareSpec (raw) {
  36. if (raw == null) return { error: 'squareSpec 为空' }
  37. if (Array.isArray(raw)) {
  38. if (raw.length < 2) return { error: 'squareSpec 数组须为 [scale, 轴],如 [0.8, "w"]' }
  39. const scale = Number(raw[0])
  40. const axis = normalizeAxis(raw[1])
  41. if (Number.isNaN(scale) || scale <= 0) return { error: 'squareSpec 中 scale 须为大于 0 的数字' }
  42. if (!axis) return { error: 'squareSpec 中轴须为 w 或 h(宽/高)' }
  43. return { scale, axis }
  44. }
  45. const str = typeof raw === 'string' ? raw.trim() : String(raw).trim()
  46. if (!str) return { error: 'squareSpec 为空字符串' }
  47. try {
  48. const j = JSON.parse(str)
  49. if (Array.isArray(j) && j.length >= 2) return parseSquareSpec(j)
  50. } catch (_) { /* 非 JSON */ }
  51. const inner = str.startsWith('[') && str.endsWith(']') ? str.slice(1, -1).trim() : str
  52. const parts = inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, ''))
  53. if (parts.length < 2) return { error: 'squareSpec 格式须为 [scale,轴] 或 "scale,轴",如 [0.8,w] 或 1,h' }
  54. const scale = Number(parts[0])
  55. const axis = normalizeAxis(parts[1])
  56. if (Number.isNaN(scale) || scale <= 0) return { error: 'scale 须为大于 0 的数字' }
  57. if (!axis) return { error: '轴须为 w 或 h' }
  58. return { scale, axis }
  59. }
  60. function normalizeAxis (v) {
  61. if (v == null) return null
  62. const a = String(v).trim().toLowerCase()
  63. if (a === 'w' || a === 'width' || a === '宽') return 'w'
  64. if (a === 'h' || a === 'height' || a === '高') return 'h'
  65. return null
  66. }
  67. /**
  68. * @param {{ imagePath: string, squareSpec: string|any[], savePath: string, folderPath?: string }} input
  69. */
  70. async function executeImgCropping ({ imagePath, squareSpec, savePath, folderPath }) {
  71. if (imagePath == null || String(imagePath).trim() === '') {
  72. return { success: false, error: 'img-cropping 缺少 imagePath(inVars[0])' }
  73. }
  74. if (savePath == null || String(savePath).trim() === '') {
  75. return { success: false, error: 'img-cropping 缺少 savePath(inVars[1])' }
  76. }
  77. if (squareSpec === undefined || squareSpec === null || squareSpec === '') {
  78. return { success: false, error: 'img-cropping 缺少 squareSpec(inVars[2],如 [0.8,"w"])' }
  79. }
  80. const spec = parseSquareSpec(squareSpec)
  81. if (spec.error) return { success: false, error: `img-cropping squareSpec 无效: ${spec.error}` }
  82. const absIn = buildAbsolutePath(String(imagePath).trim(), folderPath)
  83. const absOut = buildAbsolutePath(String(savePath).trim(), folderPath)
  84. if (!absIn || !absOut) return { success: false, error: 'img-cropping 路径无效' }
  85. if (!fs.existsSync(absIn)) return { success: false, error: `img-cropping 源文件不存在: ${absIn}` }
  86. if (!fs.existsSync(squareCropScriptPath)) {
  87. return { success: false, error: `脚本不存在: ${squareCropScriptPath}` }
  88. }
  89. const pythonExe = getPythonExeFromConfig(config)
  90. const r = spawnSync(
  91. pythonExe,
  92. [squareCropScriptPath, absIn, absOut, String(spec.scale), spec.axis],
  93. {
  94. encoding: 'utf-8',
  95. timeout: 60000,
  96. cwd: projectRoot
  97. }
  98. )
  99. const combined = ((r.stdout || '') + '\n' + (r.stderr || '')).trim()
  100. if (r.status !== 0) {
  101. return { success: false, error: combined || 'img-cropping 裁剪失败' }
  102. }
  103. let meta = {}
  104. const lines = (r.stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
  105. const jsonLine = lines.filter((l) => l.startsWith('{')).pop()
  106. if (jsonLine) {
  107. try {
  108. meta = JSON.parse(jsonLine)
  109. } catch (_) {}
  110. }
  111. if (!meta.success) {
  112. return { success: false, error: meta.error || combined || 'img-cropping 未返回成功' }
  113. }
  114. return {
  115. success: true,
  116. path: absOut,
  117. value: absOut,
  118. result: absOut,
  119. crop: { x: meta.x, y: meta.y, side: meta.side, sourceWidth: meta.width, sourceHeight: meta.height }
  120. }
  121. }
  122. module.exports = { tagName, executeImgCropping, parseSquareSpec }