diff --git a/package-lock.json b/package-lock.json index cfbd389e..f58cef86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "html-entities": "^2.3.3", "html-minifier-terser": "^7.2.0", "htmlparser2": "^10.0.0", - "image-size": "^2.0.2", "jszip": "^3.7.1", "lodash": "^4.17.21", "lru-cache": "^10.4.3", "mime-types": "^2.1.35", "nanoid": "^3.1.25", + "probe-image-size": "^7.2.3", "xmlbuilder2": "2.1.2" }, "devDependencies": { @@ -7833,6 +7833,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -7842,18 +7854,6 @@ "node": ">= 4" } }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -11074,8 +11074,7 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "node_modules/lodash.mergewith": { "version": "4.6.2", @@ -11410,6 +11409,32 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -12007,6 +12032,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/probe-image-size": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.3.0.tgz", + "integrity": "sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -12608,6 +12654,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -13068,6 +13129,30 @@ "node": ">= 0.4" } }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index f043ca69..8c9c5683 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,8 @@ "demomacro", "29217321", "FultonG", - "odex21" + "odex21", + "elefevre" ], "license": "MIT", "bugs": { @@ -178,12 +179,12 @@ "html-entities": "^2.3.3", "html-minifier-terser": "^7.2.0", "htmlparser2": "^10.0.0", - "image-size": "^2.0.2", "jszip": "^3.7.1", "lodash": "^4.17.21", "lru-cache": "^10.4.3", "mime-types": "^2.1.35", "nanoid": "^3.1.25", + "probe-image-size": "^7.2.3", "xmlbuilder2": "2.1.2" }, "overrides": { diff --git a/src/helpers/xml-builder.js b/src/helpers/xml-builder.js index 4e87e07f..7a194e93 100644 --- a/src/helpers/xml-builder.js +++ b/src/helpers/xml-builder.js @@ -7,9 +7,8 @@ import { fragment } from 'xmlbuilder2'; import colorNames from 'color-name'; import { cloneDeep } from 'lodash'; -import sizeOf from 'image-size'; import { isVNode, isVText } from '../vdom/index'; -import { parseDataUrl, downloadAndCacheImage, buildImage } from '../utils/image'; +import { parseDataUrl, downloadAndCacheImage, buildImage, measureImage } from '../utils/image'; import namespaces from '../namespaces'; import { @@ -1139,7 +1138,7 @@ const buildRun = async (vNode, attributes, docxDocumentInstance) => { const imageBuffer = Buffer.from(response.fileContent, 'base64'); - // Validate buffer before calling sizeOf + // Validate buffer before measuring if (!imageBuffer || imageBuffer.length === 0) { console.warn(`[BUILDRUN] Empty image buffer for: ${imageSource}`); return runFragment; @@ -1154,7 +1153,7 @@ const buildRun = async (vNode, attributes, docxDocumentInstance) => { let imageProperties; try { - imageProperties = sizeOf(imageBuffer); + imageProperties = measureImage(imageBuffer); if (!imageProperties || !imageProperties.width || !imageProperties.height) { console.warn(`[BUILDRUN] Invalid image properties for: ${imageSource}`); return runFragment; @@ -1714,7 +1713,7 @@ const processImageSource = async (docxDocumentInstance, vNode, imageSource, logC const imageBuffer = Buffer.from(decodeURIComponent(base64String), 'base64'); - // Validate buffer before calling sizeOf + // Validate buffer before measuring if (!imageBuffer || imageBuffer.length === 0) { console.warn(`[${logContext}] Empty image buffer for: ${imageSource}`); return null; @@ -1722,7 +1721,7 @@ const processImageSource = async (docxDocumentInstance, vNode, imageSource, logC let imageProperties; try { - imageProperties = sizeOf(imageBuffer); + imageProperties = measureImage(imageBuffer); if (!imageProperties || !imageProperties.width || !imageProperties.height) { console.warn(`[${logContext}] Invalid image properties for: ${imageSource}`); return null; diff --git a/src/utils/image.js b/src/utils/image.js index c4051ca3..aaaece60 100644 --- a/src/utils/image.js +++ b/src/utils/image.js @@ -1,6 +1,8 @@ import axios from 'axios'; import mimeTypes from 'mime-types'; -import sizeOf from 'image-size'; +// `probe-image-size/sync` (not the package root) keeps `needle` — and with it +// http/https/zlib/iconv-lite — out of the bundle; we only ever probe Buffers. +import sizeOf from 'probe-image-size/sync'; import { isValidUrl } from './url'; import * as xmlBuilder from '../helpers/xml-builder'; @@ -141,6 +143,34 @@ function convertSVGUnitToPixels(value, unit) { return Math.round(value * factor); } +/** + * Measures an image buffer and returns its dimensions in pixels. + * + * `probe-image-size` reports the raw declared number alongside the unit it was + * written in (`wUnits`/`hUnits`), so an SVG declared `width="72pt"` comes back as + * `72` rather than the 96 pixels it actually occupies. `image-size` used to do that + * conversion internally and every caller still treats the result as pixels, so + * normalize here instead of at each call site. + * + * Raster formats always report `px`, so they pass through untouched. + * + * @param {Buffer} imageBuffer - The raw image bytes + * @returns {Object|null} Measurement with `width`/`height` in pixels, or null if unrecognised + */ +export function measureImage(imageBuffer) { + const measured = sizeOf(imageBuffer); + + if (!measured) { + return null; + } + + return { + ...measured, + width: convertSVGUnitToPixels(measured.width, measured.wUnits || 'px'), + height: convertSVGUnitToPixels(measured.height, measured.hUnits || 'px'), + }; +} + /** * Parses SVG dimensions from SVG string, supporting various formats. * Handles: integers, decimals, units (px, cm, mm, in, pt, pc, em, rem, %), and viewBox fallback. @@ -483,7 +513,7 @@ export const buildImage = async ( internalRelationship ); - // Add validation before calling sizeOf + // Add validation before measuring if (!imageBuffer || imageBuffer.length === 0) { // eslint-disable-next-line no-console console.error(`[ERROR] buildImage: Empty image buffer for ${vNode.properties.src}`); @@ -502,8 +532,8 @@ export const buildImage = async ( let imageProperties; - // For SVG files, use dimensions from vNode properties instead of sizeOf - // (sizeOf doesn't work on SVG XML content) + // For SVG files, prefer the dimensions declared on the vNode over the measured + // intrinsic size — the author's width/height attributes are the intent if (response.isSVG) { imageProperties = { width: vNode.properties.width || 100, @@ -511,7 +541,7 @@ export const buildImage = async ( }; } else { try { - imageProperties = sizeOf(imageBuffer); + imageProperties = measureImage(imageBuffer); if (!imageProperties || !imageProperties.width || !imageProperties.height) { // eslint-disable-next-line no-console console.error( @@ -523,7 +553,7 @@ export const buildImage = async ( } catch (sizeError) { // eslint-disable-next-line no-console console.error( - `[ERROR] buildImage: sizeOf failed for ${vNode.properties.src}:`, + `[ERROR] buildImage: failed to measure ${vNode.properties.src}:`, sizeError.message ); return null; diff --git a/tests/image-measurement.test.js b/tests/image-measurement.test.js new file mode 100644 index 00000000..2b1574cb --- /dev/null +++ b/tests/image-measurement.test.js @@ -0,0 +1,105 @@ +import { measureImage } from '../src/utils/image'; + +/** + * Locks the pixel dimensions `measureImage` reports. + * + * The underlying prober (`probe-image-size`) returns the raw declared number plus the + * unit it was written in, so an SVG sized in points comes back as points. Every caller + * treats the result as pixels and multiplies it by 9525 to get EMUs, so a raw value + * silently renders the image at the wrong size — a `72pt` SVG shrinks by 25%. + * + * The expected values below were measured with `image-size@2.0.2`, the library this + * replaced, so this suite is a parity check against the previous rendering rather than + * a restatement of the current implementation. + */ +describe('measureImage', () => { + // A minimal 1x1 PNG — the same fixture the image pipeline tests use. + const onePixelPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 'base64' + ); + + const buildSvg = (width, height) => + Buffer.from( + `` + ); + + describe('SVG dimensions are normalised to pixels', () => { + // Expected pixel dimensions for a 72 x 36 SVG declared in each unit, at 96 DPI: + // 1pt = 4/3px, 1pc = 16px, 1in = 96px, 1cm = 37.795px, em/rem assume a 16px root. + const unitCases = [ + { unit: 'no unit', suffix: '', width: 72, height: 36 }, + { unit: 'px', suffix: 'px', width: 72, height: 36 }, + { unit: 'pt', suffix: 'pt', width: 96, height: 48 }, + { unit: 'pc', suffix: 'pc', width: 1152, height: 576 }, + { unit: 'cm', suffix: 'cm', width: 2721, height: 1361 }, + { unit: 'mm', suffix: 'mm', width: 272, height: 136 }, + { unit: 'in', suffix: 'in', width: 6912, height: 3456 }, + { unit: 'em', suffix: 'em', width: 1152, height: 576 }, + { unit: 'rem', suffix: 'rem', width: 1152, height: 576 }, + ]; + + it.each(unitCases)( + 'converts a 72 x 36 SVG in $unit to $width x $height px', + ({ suffix, width, height }) => { + const measured = measureImage(buildSvg(`72${suffix}`, `36${suffix}`)); + + expect(measured).not.toBeNull(); + expect(measured.width).toBe(width); + expect(measured.height).toBe(height); + } + ); + + it('treats a percentage width as pixels rather than scaling by it', () => { + // A percentage has no meaning without a parent box, so it passes through + // unconverted — matching how the SVG attribute parser already handles it. + const measured = measureImage(buildSvg('72%', '36%')); + + expect(measured.width).toBe(72); + expect(measured.height).toBe(36); + }); + + it('falls back to the viewBox when no width or height is declared', () => { + const measured = measureImage( + Buffer.from('') + ); + + expect(measured.width).toBe(300); + expect(measured.height).toBe(150); + }); + }); + + describe('raster formats pass through untouched', () => { + it('reports a 1x1 PNG as 1px x 1px', () => { + const measured = measureImage(onePixelPng); + + expect(measured.width).toBe(1); + expect(measured.height).toBe(1); + expect(measured.wUnits).toBe('px'); + }); + + it('preserves the fields callers read alongside the dimensions', () => { + const measured = measureImage(onePixelPng); + + expect(measured.type).toBe('png'); + expect(measured.mime).toBe('image/png'); + }); + }); + + describe('unrecognised input', () => { + // The previous library threw here; this one returns null. Both are handled by the + // falsy-dimension guard at every call site, but the null path is the live one now. + it.each([ + ['an empty buffer', Buffer.alloc(0)], + ['random bytes', Buffer.from('this is definitely not an image')], + ['an HTML error page', Buffer.from('404')], + // Cut before the IHDR chunk carries the dimensions — a longer prefix is + // genuinely measurable, since a PNG declares its size in the first 24 bytes. + ['a PNG truncated before its IHDR', onePixelPng.slice(0, 20)], + ])('returns null for %s instead of throwing', (_label, buffer) => { + expect(() => measureImage(buffer)).not.toThrow(); + expect(measureImage(buffer)).toBeNull(); + }); + }); +});