image-size.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * 零依赖图片尺寸/类型探测(分组要用「尺寸」信号,不值得为此引第三方库)。
  14. * 支持 PNG / JPEG / GIF / WebP(VP8/VP8L/VP8X)/ BMP。读不出返回 null。
  15. */
  16. import fs from 'node:fs';
  17. const SIGS = [
  18. { ext: 'png', mime: 'image/png', test: (b) => b.length > 8 && b.readUInt32BE(0) === 0x89504e47 },
  19. { ext: 'jpg', mime: 'image/jpeg', test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 },
  20. { ext: 'gif', mime: 'image/gif', test: (b) => b.length > 6 && b.slice(0, 3).toString('ascii') === 'GIF' },
  21. { ext: 'webp', mime: 'image/webp', test: (b) => b.length > 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WEBP' },
  22. { ext: 'bmp', mime: 'image/bmp', test: (b) => b.length > 2 && b[0] === 0x42 && b[1] === 0x4d },
  23. ];
  24. function detectFormat(buffer) {
  25. for (const sig of SIGS) {
  26. try {
  27. if (sig.test(buffer)) return sig;
  28. } catch { /* 太短,继续 */ }
  29. }
  30. return null;
  31. }
  32. function pngSize(buffer) {
  33. if (buffer.length < 24) return null;
  34. return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
  35. }
  36. function gifSize(buffer) {
  37. if (buffer.length < 10) return null;
  38. return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
  39. }
  40. function bmpSize(buffer) {
  41. if (buffer.length < 26) return null;
  42. return { width: Math.abs(buffer.readInt32LE(18)), height: Math.abs(buffer.readInt32LE(22)) };
  43. }
  44. function jpegSize(buffer) {
  45. let offset = 2;
  46. while (offset + 9 < buffer.length) {
  47. if (buffer[offset] !== 0xff) { offset += 1; continue; }
  48. const marker = buffer[offset + 1];
  49. // SOF0..SOF15,排除 DHT(c4) / JPG(c8) / DAC(cc)
  50. if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
  51. return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
  52. }
  53. const segmentLength = buffer.readUInt16BE(offset + 2);
  54. if (segmentLength <= 0) return null;
  55. offset += 2 + segmentLength;
  56. }
  57. return null;
  58. }
  59. function webpSize(buffer) {
  60. const chunk = buffer.slice(12, 16).toString('ascii');
  61. if (chunk === 'VP8 ' && buffer.length > 30) {
  62. return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
  63. }
  64. if (chunk === 'VP8L' && buffer.length > 25) {
  65. const bits = buffer.readUInt32LE(21);
  66. return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
  67. }
  68. if (chunk === 'VP8X' && buffer.length > 30) {
  69. const width = 1 + (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16));
  70. const height = 1 + (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16));
  71. return { width, height };
  72. }
  73. return null;
  74. }
  75. /**
  76. * 读取图片信息。
  77. * @returns {{ok:boolean, ext:string, mime:string, width:number|null, height:number|null, byteSize:number, error:string|null}}
  78. */
  79. export function readImageInfo(filePath) {
  80. const base = { ok: false, ext: '', mime: '', width: null, height: null, byteSize: 0, error: null };
  81. let fd;
  82. try {
  83. const stat = fs.statSync(filePath);
  84. base.byteSize = stat.size;
  85. const head = Buffer.alloc(Math.min(64 * 1024, Math.max(16, stat.size)));
  86. fd = fs.openSync(filePath, 'r');
  87. fs.readSync(fd, head, 0, head.length, 0);
  88. const format = detectFormat(head);
  89. if (!format) return { ...base, error: 'UNKNOWN_IMAGE_FORMAT' };
  90. let size = null;
  91. if (format.ext === 'png') size = pngSize(head);
  92. else if (format.ext === 'gif') size = gifSize(head);
  93. else if (format.ext === 'bmp') size = bmpSize(head);
  94. else if (format.ext === 'jpg') size = jpegSize(head);
  95. else if (format.ext === 'webp') size = webpSize(head);
  96. return {
  97. ...base,
  98. ok: true,
  99. ext: format.ext,
  100. mime: format.mime,
  101. width: size ? size.width : null,
  102. height: size ? size.height : null,
  103. mtimeMs: stat.mtimeMs,
  104. error: size ? null : 'SIZE_NOT_FOUND',
  105. };
  106. } catch (error) {
  107. return { ...base, error: `READ_FAILED: ${error.message}` };
  108. } finally {
  109. if (fd !== undefined) {
  110. try { fs.closeSync(fd); } catch { /* ignore */ }
  111. }
  112. }
  113. }
  114. export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.heic']);
  115. export const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.avi', '.webm', '.mkv', '.wmv']);
  116. export const DOC_EXTENSIONS = new Set(['.docx', '.docm']);
  117. export const DECK_EXTENSIONS = new Set(['.pptx', '.pptm']);
  118. export const TEXT_EXTENSIONS = new Set(['.md', '.txt', '.markdown', '.csv']);