From e46cabcd50a73f22a8fe89be089d29e96a2df7d4 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 26 Aug 2026 13:34:41 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9E=95[Add]=20Vendor=20secure-metadata?= =?UTF-8?q?=20v0.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/vendor/secure-metadata/LICENSE | 21 + assets/vendor/secure-metadata/README.md | 19 + assets/vendor/secure-metadata/package.json | 74 + .../secure-metadata-0.1.0.browser.js | 3008 +++++++++++++++++ 4 files changed, 3122 insertions(+) create mode 100644 assets/vendor/secure-metadata/LICENSE create mode 100644 assets/vendor/secure-metadata/README.md create mode 100644 assets/vendor/secure-metadata/package.json create mode 100644 assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js diff --git a/assets/vendor/secure-metadata/LICENSE b/assets/vendor/secure-metadata/LICENSE new file mode 100644 index 0000000..4eae717 --- /dev/null +++ b/assets/vendor/secure-metadata/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Secure Tools Project contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/vendor/secure-metadata/README.md b/assets/vendor/secure-metadata/README.md new file mode 100644 index 0000000..0158ad3 --- /dev/null +++ b/assets/vendor/secure-metadata/README.md @@ -0,0 +1,19 @@ +# secure-metadata + +Manually pinned browser artifact for local image metadata inspection, cleaning, and verification. + +- Library: `secure-metadata` +- Version: `0.1.0` +- Repository: `SecureToolsProject/Secure_Metadata` +- Release tag: `v0.1.0` +- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` +- Artifact: `secure-metadata-0.1.0.browser.js` +- SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- License: MIT; see `LICENSE` +- Runtime dependencies: 0 +- Integration: manually pinned, same-origin +- Upgrade policy: explicit reviewed replacement only + +The browser artifact was downloaded from the GitHub `v0.1.0` Release and checked locally against both the published `SHA256SUMS` manifest and the approved hash above. Its bytes are unchanged: it was not rebuilt, minified, reformatted, concatenated, or stripped. `LICENSE` and `package.json` were copied from the immutable `v0.1.0` tag. + +Secure Tools imports this file only through `tools/image/metadata/metadata.js`. Production pages do not load secure-metadata from npm, a CDN, GitHub, or another runtime origin. Updates require a new explicit provenance and hash review. diff --git a/assets/vendor/secure-metadata/package.json b/assets/vendor/secure-metadata/package.json new file mode 100644 index 0000000..4709432 --- /dev/null +++ b/assets/vendor/secure-metadata/package.json @@ -0,0 +1,74 @@ +{ + "name": "secure-metadata", + "version": "0.1.0", + "description": "Deterministic, security-conscious metadata tooling for binary image formats.", + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/index.d.ts", + "import": "./dist/browser/secure-metadata.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup && tsup --config tsup.browser.config.ts", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "fuzz:smoke": "npm run build --silent && node scripts/fuzz.mjs --seed 20260825 --runs 250 --max-bytes 512", + "fuzz": "npm run build --silent && node scripts/fuzz.mjs", + "typecheck": "tsc --noEmit", + "browser:smoke": "node scripts/browser-smoke.mjs", + "package:audit": "node scripts/release/audit-package.mjs", + "license:audit": "node scripts/release/audit-licenses.mjs", + "version:check": "node scripts/release/check-version.mjs", + "release:build": "node scripts/release/build-artifacts.mjs", + "release:repro": "node scripts/release/check-reproducibility.mjs", + "release:verify": "node scripts/release/verify-hashes.mjs", + "release:check": "node scripts/release/check-rc.mjs" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.13.3", + "eslint": "^10.9.0", + "fast-check": "^4.9.0", + "playwright": "^1.62.1", + "prettier": "^3.9.6", + "tsup": "^8.5.1", + "typescript": "5.9.3", + "typescript-eslint": "^8.67.0", + "vitest": "^4.1.11" + }, + "overrides": { + "esbuild": "0.28.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SecureToolsProject/Secure_Metadata.git" + }, + "homepage": "https://github.com/SecureToolsProject/Secure_Metadata#readme", + "bugs": { + "url": "https://github.com/SecureToolsProject/Secure_Metadata/issues" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js b/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js new file mode 100644 index 0000000..915bdd9 --- /dev/null +++ b/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js @@ -0,0 +1,3008 @@ +// src/core/errors.ts +var SecureMetadataError = class extends Error { + constructor(message, code, options) { + super(message, options); + this.code = code; + } + code; + name = "SecureMetadataError"; +}; +var BinaryBoundsError = class extends SecureMetadataError { + constructor(code, inputLength, offset, requestedLength) { + super( + `Invalid binary range: offset ${String(offset)}, length ${String(requestedLength)}, input length ${String(inputLength)}.`, + code + ); + this.inputLength = inputLength; + this.offset = offset; + this.requestedLength = requestedLength; + } + inputLength; + offset; + requestedLength; + name = "BinaryBoundsError"; +}; +var InvalidParseLimitError = class extends SecureMetadataError { + constructor(limitName, value) { + super( + `Parse limit ${limitName} must be a non-negative safe integer; received ${String(value)}.`, + "INVALID_LIMIT" + ); + this.limitName = limitName; + this.value = value; + } + limitName; + value; + name = "InvalidParseLimitError"; +}; +var InputLimitExceededError = class extends SecureMetadataError { + constructor(inputLength, maximumLength) { + super( + `Input length ${String(inputLength)} exceeds maxInputBytes ${String(maximumLength)}.`, + "INPUT_LIMIT_EXCEEDED" + ); + this.inputLength = inputLength; + this.maximumLength = maximumLength; + } + inputLength; + maximumLength; + name = "InputLimitExceededError"; +}; +var UnsupportedFormatError = class extends SecureMetadataError { + constructor(operation, format) { + super( + `${operation} does not support ${format} input.`, + "UNSUPPORTED_FORMAT" + ); + this.operation = operation; + this.format = format; + } + operation; + format; + name = "UnsupportedFormatError"; +}; +var IncompleteJpegError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete JPEG ending at EOI.`, + "INCOMPLETE_JPEG" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompleteJpegError"; +}; +var IncompleteWebPError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete WebP RIFF container.`, + "INCOMPLETE_WEBP" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompleteWebPError"; +}; +var IncompletePngError = class extends SecureMetadataError { + constructor(operation, diagnostics) { + super( + `${operation} requires a structurally complete PNG ending at IEND.`, + "INCOMPLETE_PNG" + ); + this.operation = operation; + this.diagnostics = diagnostics; + } + operation; + diagnostics; + name = "IncompletePngError"; +}; + +// src/core/binary/bounds.ts +function hasValidRange(inputLength, offset, length) { + return Number.isSafeInteger(offset) && Number.isSafeInteger(length) && offset >= 0 && length >= 0 && offset <= inputLength && length <= inputLength - offset; +} +function assertValidRange(inputLength, offset, length) { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new BinaryBoundsError("INVALID_OFFSET", inputLength, offset, length); + } + if (!Number.isSafeInteger(length) || length < 0) { + throw new BinaryBoundsError("INVALID_LENGTH", inputLength, offset, length); + } + if (offset > inputLength || length > inputLength - offset) { + throw new BinaryBoundsError("OUT_OF_BOUNDS", inputLength, offset, length); + } +} + +// src/core/binary/byte-reader.ts +var ByteReader = class { + length; + #bytes; + #view; + constructor(bytes) { + this.#bytes = bytes; + this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.length = bytes.byteLength; + } + has(offset, length = 1) { + return hasValidRange(this.length, offset, length); + } + u8(offset) { + assertValidRange(this.length, offset, 1); + return this.#view.getUint8(offset); + } + u16LE(offset) { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, true); + } + u16BE(offset) { + assertValidRange(this.length, offset, 2); + return this.#view.getUint16(offset, false); + } + u32LE(offset) { + assertValidRange(this.length, offset, 4); + return this.#view.getUint32(offset, true); + } + u32BE(offset) { + assertValidRange(this.length, offset, 4); + return this.#view.getUint32(offset, false); + } + /** Returns a bounded view, not a copy, after validating the complete range. */ + slice(offset, length) { + assertValidRange(this.length, offset, length); + return this.#bytes.subarray(offset, offset + length); + } + matches(offset, signature) { + if (!this.has(offset, signature.length)) { + return false; + } + for (let index = 0; index < signature.length; index += 1) { + if (this.#bytes[offset + index] !== signature[index]) { + return false; + } + } + return true; + } +}; + +// src/core/binary/input.ts +function toUint8Array(input) { + return input instanceof Uint8Array ? input : new Uint8Array(input); +} + +// src/core/detect-format.ts +var PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]; +var JPEG_SIGNATURE = [255, 216]; +var RIFF_SIGNATURE = [82, 73, 70, 70]; +var WEBP_SIGNATURE = [87, 69, 66, 80]; +function detectFormat(reader) { + if (reader.matches(0, PNG_SIGNATURE)) { + return "png"; + } + if (reader.matches(0, JPEG_SIGNATURE)) { + return "jpeg"; + } + if (reader.matches(0, RIFF_SIGNATURE) && reader.matches(8, WEBP_SIGNATURE)) { + return "webp"; + } + return "unknown"; +} + +// src/core/limits.ts +var DEFAULT_PARSE_LIMITS = Object.freeze({ + maxInputBytes: 100 * 1024 * 1024, + maxSegments: 4096, + maxChunks: 4096, + maxMetadataEntries: 1e4, + maxIfdDepth: 16, + maxIfdEntries: 4096, + maxStringBytes: 4 * 1024 * 1024, + maxDecompressedBytes: 16 * 1024 * 1024, + maxDiagnostics: 256 +}); +function resolveParseLimit(name, configured) { + const value = configured ?? DEFAULT_PARSE_LIMITS[name]; + if (!Number.isSafeInteger(value) || value < 0) { + throw new InvalidParseLimitError(name, value); + } + return value; +} + +// src/exif/metadata.ts +function metadataEntriesFromTiff(result, context) { + return result.entries.map((entry) => ({ + id: `${context.idPrefix}-${String(entry.entryOffset)}-${entry.tag.toString(16)}`, + namespace: entry.namespace, + name: entry.name, + category: entry.category, + privacy: entry.privacy, + ...entry.value === void 0 ? {} : { value: entry.value }, + source: { + format: context.format, + container: "tiff-ifd", + offset: context.baseOffset + entry.entryOffset, + length: 12, + tiffPath: entry.path, + tiffTag: entry.tag, + tiffType: entry.type, + tiffCount: entry.count + } + })); +} +function relocateTiffDiagnostics(diagnostics, baseOffset) { + return diagnostics.map( + (item) => item.offset === void 0 ? item : { ...item, offset: baseOffset + item.offset } + ); +} + +// src/exif/field-types.ts +var TIFF_FIELD_TYPE = { + BYTE: 1, + ASCII: 2, + SHORT: 3, + LONG: 4, + RATIONAL: 5, + UNDEFINED: 7, + SLONG: 9, + SRATIONAL: 10 +}; +var FIELD_TYPE_SIZES = { + [TIFF_FIELD_TYPE.BYTE]: 1, + [TIFF_FIELD_TYPE.ASCII]: 1, + [TIFF_FIELD_TYPE.SHORT]: 2, + [TIFF_FIELD_TYPE.LONG]: 4, + [TIFF_FIELD_TYPE.RATIONAL]: 8, + [TIFF_FIELD_TYPE.UNDEFINED]: 1, + [TIFF_FIELD_TYPE.SLONG]: 4, + [TIFF_FIELD_TYPE.SRATIONAL]: 8 +}; +function tiffFieldTypeSize(type) { + return FIELD_TYPE_SIZES[type]; +} + +// src/exif/decode-value.ts +var MAX_DECODED_COMPONENTS = 1024; +function scalarOrArray(values) { + if (values.length === 1) { + const value = values[0]; + if (value !== void 0) { + return value; + } + } + return values; +} +function ascii(reader, offset, count) { + let result = ""; + for (let index = 0; index < count; index += 1) { + const byte = reader.u8(offset + index); + if (byte === 0) { + break; + } + result += byte <= 127 ? String.fromCharCode(byte) : "?"; + } + return result; +} +function unsignedValues(reader, offset, count, width) { + const values = []; + for (let index = 0; index < count; index += 1) { + const componentOffset = offset + index * width; + values.push( + width === 1 ? reader.u8(componentOffset) : width === 2 ? reader.u16(componentOffset) : reader.u32(componentOffset) + ); + } + return scalarOrArray(values); +} +function signedLongValues(reader, offset, count) { + const values = []; + for (let index = 0; index < count; index += 1) { + values.push(reader.i32(offset + index * 4)); + } + return scalarOrArray(values); +} +function rationalValues(reader, offset, count, signed) { + const diagnostics = []; + const values = []; + for (let index = 0; index < count; index += 1) { + const componentOffset = offset + index * 8; + const numerator = signed ? reader.i32(componentOffset) : reader.u32(componentOffset); + const denominator = signed ? reader.i32(componentOffset + 4) : reader.u32(componentOffset + 4); + values.push({ numerator, denominator }); + if (denominator === 0) { + diagnostics.push({ + severity: "error", + code: "TIFF_INVALID_RATIONAL", + message: "TIFF rational value has a zero denominator.", + offset: componentOffset + 4 + }); + } + } + return { value: scalarOrArray(values), diagnostics }; +} +function decodeTiffValue(reader, type, count, valueOffset, definition) { + if (count > MAX_DECODED_COMPONENTS && type !== TIFF_FIELD_TYPE.ASCII) { + return { + diagnostics: [ + { + severity: "error", + code: "TIFF_INVALID_VALUE_RANGE", + message: `TIFF value has too many components to decode (${String(count)}).`, + offset: valueOffset + } + ] + }; + } + if (definition.special === "exif-version") { + return { value: ascii(reader, valueOffset, count), diagnostics: [] }; + } + if (definition.special === "gps-version") { + const components = []; + for (let index = 0; index < count; index += 1) { + components.push(reader.u8(valueOffset + index)); + } + return { value: components.join("."), diagnostics: [] }; + } + switch (type) { + case TIFF_FIELD_TYPE.ASCII: + return { value: ascii(reader, valueOffset, count), diagnostics: [] }; + case TIFF_FIELD_TYPE.BYTE: + case TIFF_FIELD_TYPE.UNDEFINED: + return { + value: unsignedValues(reader, valueOffset, count, 1), + diagnostics: [] + }; + case TIFF_FIELD_TYPE.SHORT: + return { + value: unsignedValues(reader, valueOffset, count, 2), + diagnostics: [] + }; + case TIFF_FIELD_TYPE.LONG: + return { + value: unsignedValues(reader, valueOffset, count, 4), + diagnostics: [] + }; + case TIFF_FIELD_TYPE.SLONG: + return { + value: signedLongValues(reader, valueOffset, count), + diagnostics: [] + }; + case TIFF_FIELD_TYPE.RATIONAL: + return rationalValues(reader, valueOffset, count, false); + case TIFF_FIELD_TYPE.SRATIONAL: + return rationalValues(reader, valueOffset, count, true); + default: + return { diagnostics: [] }; + } +} + +// src/exif/tags.ts +var TIFF_TAG = { + IMAGE_DESCRIPTION: 270, + MAKE: 271, + MODEL: 272, + ORIENTATION: 274, + SOFTWARE: 305, + DATE_TIME: 306, + ARTIST: 315, + COPYRIGHT: 33432, + EXIF_IFD_POINTER: 34665, + GPS_IFD_POINTER: 34853, + EXPOSURE_TIME: 33434, + F_NUMBER: 33437, + ISO_SPEED: 34855, + EXIF_VERSION: 36864, + DATE_TIME_ORIGINAL: 36867, + DATE_TIME_DIGITIZED: 36868, + FOCAL_LENGTH: 37386, + MAKER_NOTE: 37500, + PIXEL_X_DIMENSION: 40962, + PIXEL_Y_DIMENSION: 40963, + FOCAL_LENGTH_35MM: 41989 +}; +var IFD0_TAGS = { + [TIFF_TAG.IMAGE_DESCRIPTION]: { + name: "ImageDescription", + namespace: "exif", + category: "description", + privacy: "potentially-sensitive" + }, + [TIFF_TAG.MAKE]: { + name: "Make", + namespace: "exif", + category: "device", + privacy: "potentially-sensitive" + }, + [TIFF_TAG.MODEL]: { + name: "Model", + namespace: "exif", + category: "device", + privacy: "potentially-sensitive" + }, + [TIFF_TAG.ORIENTATION]: { + name: "Orientation", + namespace: "exif", + category: "technical", + privacy: "non-sensitive" + }, + [TIFF_TAG.SOFTWARE]: { + name: "Software", + namespace: "exif", + category: "software", + privacy: "potentially-sensitive" + }, + [TIFF_TAG.DATE_TIME]: { + name: "DateTime", + namespace: "exif", + category: "timestamp", + privacy: "potentially-sensitive" + }, + [TIFF_TAG.ARTIST]: { + name: "Artist", + namespace: "exif", + category: "identity", + privacy: "sensitive" + }, + [TIFF_TAG.COPYRIGHT]: { + name: "Copyright", + namespace: "exif", + category: "rights", + privacy: "potentially-sensitive" + } +}; +var EXIF_TAGS = { + [TIFF_TAG.EXPOSURE_TIME]: technical("ExposureTime"), + [TIFF_TAG.F_NUMBER]: technical("FNumber"), + [TIFF_TAG.ISO_SPEED]: technical("PhotographicSensitivity"), + [TIFF_TAG.EXIF_VERSION]: { + ...technical("ExifVersion"), + special: "exif-version" + }, + [TIFF_TAG.DATE_TIME_ORIGINAL]: timestamp("DateTimeOriginal"), + [TIFF_TAG.DATE_TIME_DIGITIZED]: timestamp("DateTimeDigitized"), + [TIFF_TAG.FOCAL_LENGTH]: technical("FocalLength"), + [TIFF_TAG.PIXEL_X_DIMENSION]: technical("PixelXDimension"), + [TIFF_TAG.PIXEL_Y_DIMENSION]: technical("PixelYDimension"), + [TIFF_TAG.FOCAL_LENGTH_35MM]: technical("FocalLengthIn35mmFilm"), + [TIFF_TAG.MAKER_NOTE]: { + name: "MakerNote", + namespace: "exif", + category: "unknown", + privacy: "potentially-sensitive" + } +}; +var GPS_TAGS = { + 0: { + name: "GPSVersionID", + namespace: "gps", + category: "technical", + privacy: "non-sensitive", + special: "gps-version" + }, + 1: location("GPSLatitudeRef"), + 2: location("GPSLatitude"), + 3: location("GPSLongitudeRef"), + 4: location("GPSLongitude"), + 5: location("GPSAltitudeRef"), + 6: location("GPSAltitude"), + 7: { + name: "GPSTimeStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive" + }, + 29: { + name: "GPSDateStamp", + namespace: "gps", + category: "timestamp", + privacy: "potentially-sensitive" + } +}; +function technical(name) { + return { + name, + namespace: "exif", + category: "technical", + privacy: "non-sensitive" + }; +} +function timestamp(name) { + return { + name, + namespace: "exif", + category: "timestamp", + privacy: "potentially-sensitive" + }; +} +function location(name) { + return { + name, + namespace: "gps", + category: "location", + privacy: "sensitive" + }; +} +function tiffTagDefinition(kind, tag) { + const definition = kind === "gps" ? GPS_TAGS[tag] : kind === "exif" ? EXIF_TAGS[tag] : IFD0_TAGS[tag]; + return definition ?? { + name: `Tag0x${tag.toString(16).toUpperCase().padStart(4, "0")}`, + namespace: kind === "gps" ? "gps" : "exif", + category: "unknown", + privacy: "unknown" + }; +} + +// src/exif/tiff-reader.ts +var TiffReader = class { + length; + #reader; + #littleEndian; + constructor(bytes, byteOrder2) { + this.#reader = new ByteReader(bytes); + this.#littleEndian = byteOrder2 === "little"; + this.length = bytes.byteLength; + } + has(offset, length = 1) { + return this.#reader.has(offset, length); + } + u8(offset) { + return this.#reader.u8(offset); + } + u16(offset) { + return this.#littleEndian ? this.#reader.u16LE(offset) : this.#reader.u16BE(offset); + } + u32(offset) { + return this.#littleEndian ? this.#reader.u32LE(offset) : this.#reader.u32BE(offset); + } + i32(offset) { + const value = this.u32(offset); + return value >= 2147483648 ? value - 4294967296 : value; + } +}; + +// src/exif/tiff.ts +function checkedMultiply(left, right) { + return left <= Math.floor(Number.MAX_SAFE_INTEGER / right) ? left * right : void 0; +} +function emit(state, code, message, offset, severity = "error") { + if (state.diagnostics.length < state.maxDiagnostics) { + state.diagnostics.push( + offset === void 0 ? { severity, code, message } : { severity, code, message, offset } + ); + } + if (severity === "error") { + state.complete = false; + } +} +function initialFailure(code, message, maxDiagnostics, offset) { + const diagnostic4 = offset === void 0 ? { severity: "error", code, message } : { severity: "error", code, message, offset }; + return { + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [diagnostic4] + }; +} +function byteOrder(bytes) { + if (bytes.matches(0, [73, 73])) { + return "little"; + } + if (bytes.matches(0, [77, 77])) { + return "big"; + } + return void 0; +} +function queueTarget(reader, state, pending, target, kind, path, depth, sourceOffset, maxTargets) { + if (target === 0) { + return; + } + if (!reader.has(target, 2)) { + emit( + state, + "TIFF_INVALID_POINTER", + `TIFF ${path} pointer targets an invalid IFD offset ${String(target)}.`, + sourceOffset + ); + return; + } + if (pending.length >= maxTargets) { + if (!state.traversalLimitReported) { + emit( + state, + "TIFF_TRAVERSAL_LIMIT_EXCEEDED", + `TIFF traversal exceeds maxMetadataEntries ${String(maxTargets)}.`, + sourceOffset + ); + state.traversalLimitReported = true; + } + return; + } + pending.push({ offset: target, kind, path, depth }); +} +function parseTiff(bytes, limits) { + const maxDiagnostics = limits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics; + const raw = new ByteReader(bytes); + if (!raw.has(0, 8)) { + return initialFailure( + "TIFF_TRUNCATED_HEADER", + "TIFF header requires at least eight bytes.", + maxDiagnostics, + 0 + ); + } + const order = byteOrder(raw); + if (order === void 0) { + return initialFailure( + "TIFF_INVALID_BYTE_ORDER", + "TIFF byte order must be II or MM.", + maxDiagnostics, + 0 + ); + } + const reader = new TiffReader(bytes, order); + if (reader.u16(2) !== 42) { + return { + byteOrder: order, + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [ + { + severity: "error", + code: "TIFF_INVALID_MAGIC", + message: "TIFF magic value is not 42.", + offset: 2 + } + ] + }; + } + const firstIfdOffset = reader.u32(4); + if (firstIfdOffset === 0) { + return { + byteOrder: order, + complete: true, + entries: [], + diagnostics: [] + }; + } + if (!reader.has(firstIfdOffset, 2)) { + return { + byteOrder: order, + complete: false, + entries: [], + diagnostics: maxDiagnostics === 0 ? [] : [ + { + severity: "error", + code: "TIFF_INVALID_FIRST_IFD_OFFSET", + message: "TIFF first IFD offset is outside the TIFF payload.", + offset: 4 + } + ] + }; + } + const state = { + entries: [], + diagnostics: [], + complete: true, + processedEntries: 0, + traversalLimitReported: false, + maxDiagnostics + }; + const pending = [ + { offset: firstIfdOffset, kind: "ifd0", path: "IFD0", depth: 1 } + ]; + const visited = /* @__PURE__ */ new Set(); + let queueIndex = 0; + while (queueIndex < pending.length) { + const current = pending[queueIndex]; + queueIndex += 1; + if (current === void 0) { + break; + } + if (visited.has(current.offset)) { + emit( + state, + "TIFF_CYCLIC_IFD", + `TIFF IFD offset ${String(current.offset)} was already visited.`, + current.offset + ); + continue; + } + if (current.depth > limits.maxIfdDepth) { + emit( + state, + "TIFF_IFD_DEPTH_LIMIT_EXCEEDED", + `TIFF IFD depth exceeds maxIfdDepth ${String(limits.maxIfdDepth)}.`, + current.offset + ); + continue; + } + visited.add(current.offset); + const entryCount = reader.u16(current.offset); + if (entryCount > limits.maxIfdEntries) { + emit( + state, + "TIFF_IFD_ENTRY_LIMIT_EXCEEDED", + `TIFF IFD declares ${String(entryCount)} entries, exceeding maxIfdEntries ${String(limits.maxIfdEntries)}.`, + current.offset + ); + continue; + } + const entriesByteLength = checkedMultiply(entryCount, 12); + if (entriesByteLength === void 0) { + emit( + state, + "TIFF_TRUNCATED_IFD", + "TIFF IFD table size exceeds safe integer arithmetic.", + current.offset + ); + continue; + } + const tableLength = 2 + entriesByteLength + 4; + if (!reader.has(current.offset, tableLength)) { + emit( + state, + "TIFF_TRUNCATED_IFD", + "TIFF IFD table or next-IFD pointer is truncated.", + current.offset + ); + continue; + } + const exifTargets = []; + const gpsTargets = []; + const entriesOffset = current.offset + 2; + for (let index = 0; index < entryCount; index += 1) { + if (state.processedEntries >= limits.maxMetadataEntries) { + if (!state.traversalLimitReported) { + emit( + state, + "TIFF_TRAVERSAL_LIMIT_EXCEEDED", + `TIFF traversal exceeds maxMetadataEntries ${String(limits.maxMetadataEntries)}.`, + entriesOffset + index * 12 + ); + state.traversalLimitReported = true; + } + break; + } + state.processedEntries += 1; + const entryOffset = entriesOffset + index * 12; + const tag = reader.u16(entryOffset); + const type = reader.u16(entryOffset + 2); + const count = reader.u32(entryOffset + 4); + const valueFieldOffset = entryOffset + 8; + const definition = tiffTagDefinition(current.kind, tag); + const typeSize = tiffFieldTypeSize(type); + if (typeSize === void 0) { + emit( + state, + "TIFF_UNSUPPORTED_FIELD_TYPE", + `${definition.name} uses unsupported TIFF field type ${String(type)}.`, + entryOffset + 2 + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset: valueFieldOffset, + valueLength: 0 + }); + continue; + } + const valueByteLength = checkedMultiply(count, typeSize); + if (valueByteLength === void 0 || valueByteLength > limits.maxStringBytes) { + emit( + state, + "TIFF_INVALID_VALUE_RANGE", + `${definition.name} value size is outside configured decoding limits.`, + entryOffset + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset: valueFieldOffset, + valueLength: valueByteLength ?? 0 + }); + continue; + } + const valueOffset = valueByteLength <= 4 ? valueFieldOffset : reader.u32(valueFieldOffset); + if (!reader.has(valueOffset, valueByteLength)) { + emit( + state, + "TIFF_INVALID_VALUE_OFFSET", + `${definition.name} value range is outside the TIFF payload.`, + valueFieldOffset + ); + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset, + valueLength: valueByteLength + }); + continue; + } + if (tag === TIFF_TAG.EXIF_IFD_POINTER || tag === TIFF_TAG.GPS_IFD_POINTER) { + if (type !== TIFF_FIELD_TYPE.LONG || count !== 1) { + emit( + state, + "TIFF_INVALID_POINTER", + `${definition.name} must be LONG with count 1.`, + entryOffset + ); + continue; + } + const target = reader.u32(valueOffset); + const collection = tag === TIFF_TAG.EXIF_IFD_POINTER ? exifTargets : gpsTargets; + collection.push({ target, sourceOffset: valueFieldOffset }); + continue; + } + const isOpaque = definition.name === "MakerNote" || definition.name.startsWith("Tag0x"); + const decoded = isOpaque ? { diagnostics: [] } : decodeTiffValue(reader, type, count, valueOffset, definition); + for (const item of decoded.diagnostics) { + if (state.diagnostics.length < state.maxDiagnostics) { + state.diagnostics.push(item); + } + if (item.severity === "error") { + state.complete = false; + } + } + state.entries.push({ + tag, + type, + count, + name: definition.name, + namespace: definition.namespace, + category: definition.category, + privacy: definition.privacy, + ...decoded.value === void 0 ? {} : { value: decoded.value }, + path: `${current.path}/${definition.name}`, + entryOffset, + valueOffset, + valueLength: valueByteLength + }); + } + const nextPointerOffset = entriesOffset + entriesByteLength; + const nextTarget = reader.u32(nextPointerOffset); + for (const target of exifTargets) { + queueTarget( + reader, + state, + pending, + target.target, + "exif", + `${current.path}/ExifIFD`, + current.depth + 1, + target.sourceOffset, + limits.maxMetadataEntries + ); + } + for (const target of gpsTargets) { + queueTarget( + reader, + state, + pending, + target.target, + "gps", + `${current.path}/GPSIFD`, + current.depth + 1, + target.sourceOffset, + limits.maxMetadataEntries + ); + } + queueTarget( + reader, + state, + pending, + nextTarget, + "next", + current.kind === "ifd0" ? "IFD1" : `${current.path}/NextIFD`, + current.depth + 1, + nextPointerOffset, + limits.maxMetadataEntries + ); + } + return { + byteOrder: order, + complete: state.complete, + entries: state.entries, + diagnostics: state.diagnostics, + ...state.traversalLimitReported ? { entryLimitExceeded: true } : {} + }; +} + +// src/jpeg/markers.ts +var JPEG_MARKER = { + TEM: 1, + SOF0: 192, + SOF1: 193, + SOF2: 194, + DHT: 196, + SOI: 216, + EOI: 217, + SOS: 218, + DQT: 219, + DRI: 221, + COM: 254 +}; +var MARKER_NAMES = { + [JPEG_MARKER.TEM]: "TEM", + [JPEG_MARKER.SOF0]: "SOF0", + [JPEG_MARKER.SOF1]: "SOF1", + [JPEG_MARKER.SOF2]: "SOF2", + [JPEG_MARKER.DHT]: "DHT", + [JPEG_MARKER.SOI]: "SOI", + [JPEG_MARKER.EOI]: "EOI", + [JPEG_MARKER.SOS]: "SOS", + [JPEG_MARKER.DQT]: "DQT", + [JPEG_MARKER.DRI]: "DRI", + [JPEG_MARKER.COM]: "COM" +}; +function isApplicationMarker(marker) { + return marker >= 224 && marker <= 239; +} +function isRestartMarker(marker) { + return marker >= 208 && marker <= 215; +} +function isStandaloneMarker(marker) { + return marker === JPEG_MARKER.TEM || marker === JPEG_MARKER.SOI || marker === JPEG_MARKER.EOI || isRestartMarker(marker); +} +function isValidMarkerCode(marker) { + return marker === JPEG_MARKER.TEM || marker >= 192 && marker <= 254; +} +function markerName(marker) { + if (isApplicationMarker(marker)) { + return `APP${String(marker - 224)}`; + } + if (isRestartMarker(marker)) { + return `RST${String(marker - 208)}`; + } + return MARKER_NAMES[marker] ?? `UNKNOWN_${marker.toString(16).toUpperCase()}`; +} + +// src/jpeg/metadata.ts +function source(segment) { + return { + format: "jpeg", + container: "jpeg-segment", + offset: segment.offset, + length: segment.length, + jpegMarker: segment.marker + }; +} +function inspectJpegMetadata(reader, result, tiffLimits, maxMetadataEntries) { + const entries = []; + const diagnostics = []; + let attemptedExifDecode = false; + let entryLimitExceeded = false; + const add = (entry) => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; + for (const segment of result.segments) { + if (segment.marker === JPEG_MARKER.COM) { + add({ + id: `jpeg-comment-${String(segment.offset)}`, + namespace: "jpeg-comment", + name: "JPEG comment", + category: "description", + privacy: "potentially-sensitive", + source: source(segment) + }); + continue; + } + switch (segment.metadataKind) { + case "exif": { + if (!add({ + id: `jpeg-exif-${String(segment.offset)}`, + namespace: "exif", + name: "EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }) || segment.payloadOffset === void 0 || segment.payloadLength === void 0) { + break; + } + attemptedExifDecode = true; + const tiffOffset = segment.payloadOffset + 6; + const tiffLength = segment.payloadLength - 6; + const tiff = parseTiff(reader.slice(tiffOffset, tiffLength), { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length + }); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "jpeg", + baseOffset: tiffOffset, + idPrefix: `jpeg-tiff-${String(segment.offset)}` + }) + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, tiffOffset) + ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; + break; + } + case "xmp": + add({ + id: `jpeg-xmp-${String(segment.offset)}`, + namespace: "xmp", + name: segment.metadataSubtype === "extended-xmp" ? "Extended XMP container" : "XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }); + break; + case "icc": + add({ + id: `jpeg-icc-${String(segment.offset)}`, + namespace: "icc", + name: "ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source(segment) + }); + break; + case "iptc": + add({ + id: `jpeg-iptc-${String(segment.offset)}`, + namespace: "iptc", + name: "Photoshop/IPTC container", + category: "unknown", + privacy: "potentially-sensitive", + source: source(segment) + }); + break; + } + } + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded + }; +} + +// src/jpeg/classify.ts +var JFIF_SIGNATURE = [74, 70, 73, 70, 0]; +var JFXX_SIGNATURE = [74, 70, 88, 88, 0]; +var EXIF_SIGNATURE = [69, 120, 105, 102, 0, 0]; +var XMP_SIGNATURE = [ + 104, + 116, + 116, + 112, + 58, + 47, + 47, + 110, + 115, + 46, + 97, + 100, + 111, + 98, + 101, + 46, + 99, + 111, + 109, + 47, + 120, + 97, + 112, + 47, + 49, + 46, + 48, + 47, + 0 +]; +var EXTENDED_XMP_SIGNATURE = [ + 104, + 116, + 116, + 112, + 58, + 47, + 47, + 110, + 115, + 46, + 97, + 100, + 111, + 98, + 101, + 46, + 99, + 111, + 109, + 47, + 120, + 109, + 112, + 47, + 101, + 120, + 116, + 101, + 110, + 115, + 105, + 111, + 110, + 47, + 0 +]; +var ICC_SIGNATURE = [ + 73, + 67, + 67, + 95, + 80, + 82, + 79, + 70, + 73, + 76, + 69, + 0 +]; +var PHOTOSHOP_SIGNATURE = [ + 80, + 104, + 111, + 116, + 111, + 115, + 104, + 111, + 112, + 32, + 51, + 46, + 48, + 0 +]; +var ADOBE_SIGNATURE = [65, 100, 111, 98, 101]; +function matchesPayload(reader, payloadOffset, payloadLength, signature) { + return signature.length <= payloadLength && reader.matches(payloadOffset, signature); +} +function classifySegmentKind(marker) { + if (marker >= 224 && marker <= 239) { + return "application"; + } + if (marker === JPEG_MARKER.COM) { + return "comment"; + } + if (marker === JPEG_MARKER.SOS) { + return "scan"; + } + if (marker === JPEG_MARKER.TEM || marker === JPEG_MARKER.SOI || marker === JPEG_MARKER.EOI || marker >= 208 && marker <= 215) { + return "standalone"; + } + if (marker >= 192 && marker <= 223) { + return "image-structure"; + } + return "unknown"; +} +function classifyApplicationSegment(reader, marker, payloadOffset, payloadLength) { + if (marker === 224) { + if (matchesPayload(reader, payloadOffset, payloadLength, JFIF_SIGNATURE)) { + return { metadataKind: "jfif", metadataSubtype: "jfif" }; + } + if (matchesPayload(reader, payloadOffset, payloadLength, JFXX_SIGNATURE)) { + return { metadataKind: "jfif", metadataSubtype: "jfxx" }; + } + } + if (marker === 225) { + if (matchesPayload(reader, payloadOffset, payloadLength, EXIF_SIGNATURE)) { + return { metadataKind: "exif" }; + } + if (matchesPayload(reader, payloadOffset, payloadLength, XMP_SIGNATURE)) { + return { metadataKind: "xmp", metadataSubtype: "standard-xmp" }; + } + if (matchesPayload( + reader, + payloadOffset, + payloadLength, + EXTENDED_XMP_SIGNATURE + )) { + return { metadataKind: "xmp", metadataSubtype: "extended-xmp" }; + } + } + if (marker === 226 && matchesPayload(reader, payloadOffset, payloadLength, ICC_SIGNATURE)) { + return { metadataKind: "icc" }; + } + if (marker === 237 && matchesPayload(reader, payloadOffset, payloadLength, PHOTOSHOP_SIGNATURE)) { + return { metadataKind: "iptc", metadataSubtype: "photoshop" }; + } + if (marker === 238 && matchesPayload(reader, payloadOffset, payloadLength, ADOBE_SIGNATURE)) { + return { metadataKind: "adobe" }; + } + return { metadataKind: "unknown" }; +} + +// src/jpeg/parser.ts +function addDiagnostic(state, ...items) { + const remaining = state.maxDiagnostics - state.diagnostics.length; + if (remaining > 0) { + state.diagnostics.push(...items.slice(0, remaining)); + } +} +function diagnostic(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function readMarker(reader, offset) { + if (reader.u8(offset) !== 255) { + return diagnostic( + "error", + "JPEG_INVALID_MARKER", + "Expected a JPEG marker prefix.", + offset + ); + } + let cursor = offset; + while (reader.has(cursor) && reader.u8(cursor) === 255) { + cursor += 1; + } + if (!reader.has(cursor)) { + return diagnostic( + "error", + "JPEG_TRUNCATED_MARKER", + "JPEG input ends within marker fill bytes.", + offset + ); + } + const marker = reader.u8(cursor); + if (marker === 0 || !isValidMarkerCode(marker)) { + return diagnostic( + "error", + "JPEG_INVALID_MARKER", + `Invalid JPEG marker code 0x${marker.toString(16).padStart(2, "0")}.`, + cursor + ); + } + return { + marker, + markerOffset: cursor - 1, + rangeOffset: offset, + afterMarker: cursor + 1 + }; +} +function addSegment(state, segment, maxSegments) { + if (state.segments.length >= maxSegments) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_SEGMENT_LIMIT_EXCEEDED", + `JPEG marker count exceeds maxSegments ${String(maxSegments)}.`, + segment.offset + ) + ); + return false; + } + state.segments.push(segment); + return true; +} +function incompleteResult(state, sawSoi) { + return { + segments: state.segments, + complete: false, + sawSoi, + sawEoi: false, + diagnostics: state.diagnostics + }; +} +function skipScanData(reader, scanOffset, state, maxSegments) { + let cursor = scanOffset; + while (reader.has(cursor)) { + if (reader.u8(cursor) !== 255) { + cursor += 1; + continue; + } + const fillStart = cursor; + cursor += 1; + while (reader.has(cursor) && reader.u8(cursor) === 255) { + cursor += 1; + } + if (!reader.has(cursor)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SCAN", + "JPEG entropy-coded scan ends within marker fill bytes.", + fillStart + ) + ); + return void 0; + } + const marker = reader.u8(cursor); + if (marker === 0) { + cursor += 1; + continue; + } + if (isRestartMarker(marker)) { + const markerOffset = cursor - 1; + if (!addSegment( + state, + { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: 2, + rangeOffset: fillStart, + rangeLength: cursor + 1 - fillStart, + kind: "standalone" + }, + maxSegments + )) { + return void 0; + } + cursor += 1; + continue; + } + return cursor - 1; + } + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SCAN", + "JPEG entropy-coded scan reaches EOF before a terminating marker.", + scanOffset + ) + ); + return void 0; +} +function parseJpeg(reader, maxSegments, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const state = { segments: [], diagnostics: [], maxDiagnostics }; + if (!reader.matches(0, [255, JPEG_MARKER.SOI])) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_SOI", + "JPEG input does not begin with the SOI marker.", + 0 + ) + ); + return incompleteResult(state, false); + } + if (!addSegment( + state, + { + marker: JPEG_MARKER.SOI, + markerName: "SOI", + offset: 0, + length: 2, + rangeOffset: 0, + rangeLength: 2, + kind: "standalone" + }, + maxSegments + )) { + return incompleteResult(state, true); + } + let offset = 2; + while (reader.has(offset)) { + const markerResult = readMarker(reader, offset); + if ("severity" in markerResult) { + addDiagnostic(state, markerResult); + return incompleteResult(state, true); + } + const { marker, markerOffset, rangeOffset, afterMarker } = markerResult; + if (marker === JPEG_MARKER.SOI) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_MARKER", + "Unexpected SOI marker inside JPEG container.", + markerOffset + ) + ); + return incompleteResult(state, true); + } + if (isStandaloneMarker(marker)) { + if (!addSegment( + state, + { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: 2, + rangeOffset, + rangeLength: afterMarker - rangeOffset, + kind: "standalone" + }, + maxSegments + )) { + return incompleteResult(state, true); + } + offset = afterMarker; + if (marker === JPEG_MARKER.EOI) { + if (offset < reader.length) { + addDiagnostic( + state, + diagnostic( + "warning", + "JPEG_TRAILING_DATA", + `JPEG contains ${String(reader.length - offset)} trailing byte(s) after EOI.`, + offset + ) + ); + } + return { + segments: state.segments, + complete: true, + sawSoi: true, + sawEoi: true, + diagnostics: state.diagnostics + }; + } + continue; + } + if (!reader.has(afterMarker, 2)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SEGMENT_LENGTH", + `${markerName(marker)} is missing its two-byte segment length.`, + afterMarker + ) + ); + return incompleteResult(state, true); + } + const declaredLength = reader.u16BE(afterMarker); + if (declaredLength < 2) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_INVALID_SEGMENT_LENGTH", + `${markerName(marker)} declares invalid length ${String(declaredLength)}.`, + afterMarker + ) + ); + return incompleteResult(state, true); + } + if (!reader.has(afterMarker, declaredLength)) { + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_TRUNCATED_SEGMENT", + `${markerName(marker)} extends beyond the JPEG input.`, + markerOffset + ) + ); + return incompleteResult(state, true); + } + const payloadOffset = afterMarker + 2; + const payloadLength = declaredLength - 2; + const segmentEnd = afterMarker + declaredLength; + const classification = isApplicationMarker(marker) ? classifyApplicationSegment(reader, marker, payloadOffset, payloadLength) : void 0; + const segment = { + marker, + markerName: markerName(marker), + offset: markerOffset, + length: declaredLength + 2, + rangeOffset, + rangeLength: segmentEnd - rangeOffset, + payloadOffset, + payloadLength, + kind: classifySegmentKind(marker), + ...classification ?? {} + }; + if (!addSegment(state, segment, maxSegments)) { + return incompleteResult(state, true); + } + offset = segmentEnd; + if (marker === JPEG_MARKER.SOS) { + const nextMarkerOffset = skipScanData( + reader, + segmentEnd, + state, + maxSegments + ); + if (nextMarkerOffset === void 0) { + return incompleteResult(state, true); + } + offset = nextMarkerOffset; + } + } + addDiagnostic( + state, + diagnostic( + "error", + "JPEG_MISSING_EOI", + "JPEG input ends before an EOI marker.", + reader.length + ) + ); + return incompleteResult(state, true); +} + +// src/png/metadata.ts +function source2(chunk) { + return { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + }; +} +function inspectPngMetadata(reader, result, tiffLimits, maxMetadataEntries) { + const entries = []; + const diagnostics = []; + let attemptedExifDecode = false; + let entryLimitExceeded = false; + const add = (entry) => { + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + return false; + } + entries.push(entry); + return true; + }; + for (const chunk of result.chunks) { + switch (chunk.metadataKind) { + case "exif": { + if (!add({ + id: `png-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "PNG EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source2(chunk) + })) { + break; + } + attemptedExifDecode = true; + const tiff = parseTiff( + reader.slice(chunk.dataOffset, chunk.dataLength), + { + ...tiffLimits, + maxMetadataEntries: maxMetadataEntries - entries.length, + maxDiagnostics: (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length + } + ); + entries.push( + ...metadataEntriesFromTiff(tiff, { + format: "png", + baseOffset: chunk.dataOffset, + idPrefix: `png-tiff-${String(chunk.offset)}` + }) + ); + diagnostics.push( + ...relocateTiffDiagnostics(tiff.diagnostics, chunk.dataOffset) + ); + entryLimitExceeded ||= tiff.entryLimitExceeded === true; + break; + } + case "xmp": + add({ + id: `png-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "PNG XMP iTXt container", + category: "unknown", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "text": + add({ + id: `png-text-${String(chunk.offset)}`, + namespace: "png-text", + name: chunk.keyword === void 0 ? `${chunk.fourCC} metadata` : `${chunk.fourCC} metadata (${chunk.keyword})`, + category: "description", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "timestamp": + add({ + id: `png-time-${String(chunk.offset)}`, + namespace: "png-time", + name: "PNG modification time", + category: "timestamp", + privacy: "potentially-sensitive", + source: source2(chunk) + }); + break; + case "icc": + add({ + id: `png-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "PNG ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source2(chunk) + }); + break; + } + } + return { + entries, + diagnostics, + attemptedExifDecode, + entryLimitExceeded + }; +} + +// src/png/crc32.ts +function pngCrc32(bytes) { + let crc = 4294967295; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0); + } + } + return (crc ^ 4294967295) >>> 0; +} + +// src/png/parser.ts +var PNG_SIGNATURE2 = [137, 80, 78, 71, 13, 10, 26, 10]; +var XMP_KEYWORD = "XML:com.adobe.xmp"; +function addDiagnostic2(diagnostics, maximum, ...items) { + const remaining = maximum - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } +} +function diagnostic2(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function failure(diagnostics, chunks = [], containerLength = 0) { + return { + chunks, + complete: false, + sawIend: false, + containerLength, + diagnostics + }; +} +function fourCC(reader, offset) { + return String.fromCharCode( + reader.u8(offset), + reader.u8(offset + 1), + reader.u8(offset + 2), + reader.u8(offset + 3) + ); +} +function isAsciiLetter(value) { + return value >= 65 && value <= 90 || value >= 97 && value <= 122; +} +function classifyChunk(fourCC3, ancillary) { + switch (fourCC3) { + case "IDAT": + return { kind: "image" }; + case "IHDR": + case "PLTE": + case "IEND": + return { kind: "critical" }; + case "eXIf": + return { kind: "metadata", metadataKind: "exif" }; + case "iCCP": + return { kind: "metadata", metadataKind: "icc" }; + case "tIME": + return { kind: "metadata", metadataKind: "timestamp" }; + case "tEXt": + case "zTXt": + case "iTXt": + return { kind: "metadata", metadataKind: "text" }; + case "gAMA": + case "cHRM": + case "sRGB": + case "sBIT": + case "pHYs": + return { kind: "color" }; + case "acTL": + case "fcTL": + case "fdAT": + return { kind: "animation" }; + default: + return { kind: ancillary ? "unknown" : "critical" }; + } +} +function readKeyword(reader, dataOffset, dataLength, maxStringBytes, diagnostics, maxDiagnostics, fourCC3) { + const keywordLimit = Math.min(maxStringBytes, 79); + const scanLength = Math.min(dataLength, keywordLimit + 1); + for (let index = 0; index < scanLength; index += 1) { + if (reader.u8(dataOffset + index) !== 0) { + continue; + } + if (index === 0) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + `${fourCC3} has an empty text keyword.`, + dataOffset + ) + ); + return void 0; + } + const characters = []; + for (let keywordIndex = 0; keywordIndex < index; keywordIndex += 1) { + characters.push(reader.u8(dataOffset + keywordIndex)); + } + return { + value: String.fromCharCode(...characters), + afterKeyword: dataOffset + index + 1 + }; + } + addDiagnostic2( + diagnostics, + maxDiagnostics, + dataLength > keywordLimit && keywordLimit === maxStringBytes ? diagnostic2( + "warning", + "PNG_TEXT_LIMIT_EXCEEDED", + `${fourCC3} keyword exceeds maxStringBytes ${String(maxStringBytes)}.`, + dataOffset + ) : diagnostic2( + "warning", + "PNG_INVALID_TEXT", + `${fourCC3} text keyword is not NUL-terminated.`, + dataOffset + ) + ); + return void 0; +} +function parsePng(reader, maxChunks, maxStringBytes, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const diagnostics = []; + const chunks = []; + if (!reader.matches(0, PNG_SIGNATURE2)) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_SIGNATURE", + "PNG input does not contain the complete eight-byte signature.", + 0 + ) + ); + return failure(diagnostics); + } + let offset = 8; + while (offset < reader.length) { + if (chunks.length >= maxChunks) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_CHUNK_LIMIT_EXCEEDED", + `PNG chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + const remaining = reader.length - offset; + if (remaining < 4) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_LENGTH", + "PNG input ends within a chunk length field.", + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (remaining < 8) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_TYPE", + "PNG input ends within a chunk type field.", + offset + 4 + ) + ); + return failure(diagnostics, chunks, offset); + } + const dataLength = reader.u32BE(offset); + const typeOffset = offset + 4; + for (let index = 0; index < 4; index += 1) { + if (!isAsciiLetter(reader.u8(typeOffset + index))) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_CHUNK_TYPE", + "PNG chunk types must contain four ASCII letters.", + typeOffset + ) + ); + return failure(diagnostics, chunks, offset); + } + } + const type = fourCC(reader, typeOffset); + const dataOffset = offset + 8; + const available = reader.length - dataOffset; + if (dataLength > available) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} data extends beyond the supplied input.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (available - dataLength < 4) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_MISSING_CRC", + `${type} is missing its complete CRC field.`, + dataOffset + dataLength + ) + ); + return failure(diagnostics, chunks, offset); + } + const crcOffset = dataOffset + dataLength; + const totalLength = 12 + dataLength; + if (!Number.isSafeInteger(totalLength) || totalLength > remaining) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_TRUNCATED_CHUNK_DATA", + `${type} physical chunk range is invalid.`, + offset + ) + ); + return failure(diagnostics, chunks, offset); + } + const ancillary = (reader.u8(typeOffset) & 32) !== 0; + const classification = classifyChunk(type, ancillary); + let keyword; + let textCompressed; + if (type === "tEXt" || type === "zTXt" || type === "iTXt") { + const parsedKeyword = readKeyword( + reader, + dataOffset, + dataLength, + maxStringBytes, + diagnostics, + maxDiagnostics, + type + ); + keyword = parsedKeyword?.value; + if (type === "zTXt") { + textCompressed = true; + if (parsedKeyword !== void 0 && parsedKeyword.afterKeyword >= dataOffset + dataLength) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "zTXt is missing its compression method byte.", + parsedKeyword.afterKeyword + ) + ); + } + } else if (type === "iTXt" && parsedKeyword !== void 0) { + if (dataOffset + dataLength - parsedKeyword.afterKeyword < 2) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "iTXt is missing compression flag or method bytes.", + parsedKeyword.afterKeyword + ) + ); + } else { + const flag = reader.u8(parsedKeyword.afterKeyword); + textCompressed = flag === 1; + if (flag > 1) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_TEXT", + "iTXt compression flag must be zero or one.", + parsedKeyword.afterKeyword + ) + ); + } + } + } + } + const expectedCrc = reader.u32BE(crcOffset); + const actualCrc = pngCrc32(reader.slice(typeOffset, 4 + dataLength)); + const crcValid = expectedCrc === actualCrc; + if (!crcValid) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_INVALID_CRC", + `${type} CRC does not match its type and data.`, + crcOffset + ) + ); + } + const chunk = { + fourCC: type, + offset, + dataOffset, + dataLength, + totalLength, + ancillary, + ...classification, + ...type === "iTXt" && keyword === XMP_KEYWORD ? { metadataKind: "xmp" } : {}, + ...keyword === void 0 ? {} : { keyword }, + ...textCompressed === void 0 ? {} : { textCompressed }, + crcValid + }; + chunks.push(chunk); + offset += totalLength; + if (type === "IEND") { + if (dataLength !== 0) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_INVALID_IEND", + "IEND must have an empty data field.", + chunk.dataOffset + ) + ); + return failure(diagnostics, chunks, offset); + } + if (offset < reader.length) { + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "warning", + "PNG_TRAILING_DATA", + `PNG contains ${String(reader.length - offset)} trailing byte(s) after IEND.`, + offset + ) + ); + } + return { + chunks, + complete: true, + sawIend: true, + containerLength: offset, + diagnostics + }; + } + } + addDiagnostic2( + diagnostics, + maxDiagnostics, + diagnostic2( + "error", + "PNG_MISSING_IEND", + "PNG input ends before an IEND chunk.", + reader.length + ) + ); + return failure(diagnostics, chunks, reader.length); +} + +// src/webp/metadata.ts +function inspectWebPMetadata(result, maxMetadataEntries) { + const entries = []; + let entryLimitExceeded = false; + for (const chunk of result.chunks) { + if (chunk.metadataKind === void 0) { + continue; + } + if (entries.length >= maxMetadataEntries) { + entryLimitExceeded = true; + continue; + } + const source3 = { + format: "webp", + container: "webp-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + }; + switch (chunk.metadataKind) { + case "exif": + entries.push({ + id: `webp-exif-${String(chunk.offset)}`, + namespace: "exif", + name: "WebP EXIF container", + category: "unknown", + privacy: "potentially-sensitive", + source: source3 + }); + break; + case "xmp": + entries.push({ + id: `webp-xmp-${String(chunk.offset)}`, + namespace: "xmp", + name: "WebP XMP container", + category: "unknown", + privacy: "potentially-sensitive", + source: source3 + }); + break; + case "icc": + entries.push({ + id: `webp-icc-${String(chunk.offset)}`, + namespace: "icc", + name: "WebP ICC profile container", + category: "color", + privacy: "non-sensitive", + source: source3 + }); + break; + } + } + return { entries, entryLimitExceeded }; +} + +// src/webp/chunks.ts +var WEBP_VP8X_FLAG = Object.freeze({ + icc: 32, + alpha: 16, + exif: 8, + xmp: 4, + animation: 2 +}); +var WEBP_VP8X_METADATA_MASK = WEBP_VP8X_FLAG.icc | WEBP_VP8X_FLAG.exif | WEBP_VP8X_FLAG.xmp; +function classifyWebPChunk(fourCC3) { + switch (fourCC3) { + case "VP8 ": + case "VP8L": + return { kind: "image" }; + case "ALPH": + return { kind: "alpha" }; + case "VP8X": + return { kind: "extended" }; + case "ANIM": + case "ANMF": + return { kind: "animation" }; + case "EXIF": + return { kind: "metadata", metadataKind: "exif" }; + case "XMP ": + return { kind: "metadata", metadataKind: "xmp" }; + case "ICCP": + return { kind: "metadata", metadataKind: "icc" }; + default: + return { kind: "unknown" }; + } +} + +// src/webp/parser.ts +var RIFF = [82, 73, 70, 70]; +var WEBP = [87, 69, 66, 80]; +function diagnostic3(severity, code, message, offset) { + return offset === void 0 ? { severity, code, message } : { severity, code, message, offset }; +} +function failure2(diagnostics, chunks = [], containerLength = 0) { + return { chunks, complete: false, containerLength, diagnostics }; +} +function fourCC2(reader, offset) { + return String.fromCharCode( + reader.u8(offset), + reader.u8(offset + 1), + reader.u8(offset + 2), + reader.u8(offset + 3) + ); +} +function parseWebP(reader, maxChunks, maxDiagnostics = DEFAULT_PARSE_LIMITS.maxDiagnostics) { + const diagnostics = []; + const chunks = []; + let hasStructuralError = false; + const addDiagnostic3 = (...items) => { + hasStructuralError ||= items.some(({ severity }) => severity === "error"); + const remaining = maxDiagnostics - diagnostics.length; + if (remaining > 0) { + diagnostics.push(...items.slice(0, remaining)); + } + }; + if (!reader.has(0, 12) || !reader.matches(0, RIFF) || !reader.matches(8, WEBP)) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_RIFF_HEADER", + "WebP input requires a 12-byte RIFF....WEBP header.", + 0 + ) + ); + return failure2(diagnostics); + } + const declaredRiffSize = reader.u32LE(4); + const containerLength = declaredRiffSize + 8; + if (declaredRiffSize < 4 || !Number.isSafeInteger(containerLength) || containerLength < 12) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_RIFF_SIZE", + "WebP RIFF size does not include the WEBP form type.", + 4 + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength > reader.length) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_RIFF", + "WebP RIFF size extends beyond the supplied input.", + 4 + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength < reader.length) { + addDiagnostic3( + diagnostic3( + "warning", + "WEBP_TRAILING_DATA", + `WebP contains ${String(reader.length - containerLength)} trailing byte(s) after the RIFF container.`, + containerLength + ) + ); + } + let offset = 12; + let vp8xCount = 0; + while (offset < containerLength) { + if (chunks.length >= maxChunks) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_CHUNK_LIMIT_EXCEEDED", + `WebP chunk count exceeds maxChunks ${String(maxChunks)}.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + if (containerLength - offset < 8) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK_HEADER", + "WebP RIFF ends within a chunk header.", + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const type = fourCC2(reader, offset); + const payloadLength = reader.u32LE(offset + 4); + const payloadOffset = offset + 8; + const payloadEnd = payloadOffset + payloadLength; + if (!Number.isSafeInteger(payloadEnd) || payloadEnd > containerLength) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} payload extends beyond the RIFF boundary.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const padding = payloadLength % 2; + if (padding === 1 && payloadEnd === containerLength) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_PADDING", + `${type} has an odd payload without its required padding byte.`, + payloadEnd + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const totalLength = 8 + payloadLength + padding; + if (!Number.isSafeInteger(totalLength) || totalLength > containerLength - offset) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_TRUNCATED_CHUNK", + `${type} physical chunk range exceeds the RIFF boundary.`, + offset + ) + ); + return failure2(diagnostics, chunks, containerLength); + } + const classification = classifyWebPChunk(type); + let vp8xFlags; + if (type === "VP8X") { + vp8xCount += 1; + if (vp8xCount > 1) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_DUPLICATE_VP8X", + "WebP contains more than one VP8X chunk.", + offset + ) + ); + } + if (payloadLength !== 10) { + addDiagnostic3( + diagnostic3( + "error", + "WEBP_INVALID_VP8X", + "VP8X payload must be exactly 10 bytes.", + offset + ) + ); + } else { + vp8xFlags = reader.u8(payloadOffset); + } + } + chunks.push({ + fourCC: type, + offset, + payloadOffset, + payloadLength, + totalLength, + ...classification, + ...vp8xFlags === void 0 ? {} : { vp8xFlags } + }); + offset += totalLength; + } + const vp8x = chunks.find(({ fourCC: type }) => type === "VP8X"); + if (!hasStructuralError && vp8x?.vp8xFlags !== void 0) { + const observedFlags = (chunks.some(({ metadataKind }) => metadataKind === "icc") ? WEBP_VP8X_FLAG.icc : 0) | (chunks.some(({ metadataKind }) => metadataKind === "exif") ? WEBP_VP8X_FLAG.exif : 0) | (chunks.some(({ metadataKind }) => metadataKind === "xmp") ? WEBP_VP8X_FLAG.xmp : 0); + if ((vp8x.vp8xFlags & WEBP_VP8X_METADATA_MASK) !== observedFlags) { + addDiagnostic3( + diagnostic3( + "warning", + "WEBP_INCONSISTENT_FEATURE_FLAGS", + "VP8X metadata flags do not match observed metadata chunks.", + vp8x.payloadOffset + ) + ); + } + } + return { + chunks, + complete: !hasStructuralError, + containerLength, + diagnostics + }; +} + +// src/inspect.ts +function resolveTiffLimits(limits, enabled) { + return { + maxIfdEntries: enabled ? resolveParseLimit("maxIfdEntries", limits?.maxIfdEntries) : DEFAULT_PARSE_LIMITS.maxIfdEntries, + maxIfdDepth: enabled ? resolveParseLimit("maxIfdDepth", limits?.maxIfdDepth) : DEFAULT_PARSE_LIMITS.maxIfdDepth, + maxMetadataEntries: enabled ? resolveParseLimit("maxMetadataEntries", limits?.maxMetadataEntries) : DEFAULT_PARSE_LIMITS.maxMetadataEntries, + maxStringBytes: enabled ? resolveParseLimit("maxStringBytes", limits?.maxStringBytes) : DEFAULT_PARSE_LIMITS.maxStringBytes, + maxDiagnostics: enabled ? resolveParseLimit("maxDiagnostics", limits?.maxDiagnostics) : DEFAULT_PARSE_LIMITS.maxDiagnostics + }; +} +function boundedDiagnostics(diagnostics, entryLimitExceeded, maxDiagnostics) { + const withEntryLimit = entryLimitExceeded ? [ + { + severity: "warning", + code: "METADATA_ENTRY_LIMIT_EXCEEDED", + message: "Metadata report exceeds the configured maxMetadataEntries limit." + }, + ...diagnostics + ] : diagnostics; + return withEntryLimit.slice(0, maxDiagnostics); +} +function inspectMetadata(input, options) { + const bytes = toUint8Array(input); + const maxInputBytes = resolveParseLimit( + "maxInputBytes", + options?.limits?.maxInputBytes + ); + if (bytes.byteLength > maxInputBytes) { + throw new InputLimitExceededError(bytes.byteLength, maxInputBytes); + } + const reader = new ByteReader(bytes); + const format = detectFormat(reader); + if (format === "jpeg") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const jpeg = parseJpeg( + reader, + resolveParseLimit("maxSegments", options?.limits?.maxSegments), + maxDiagnostics + ); + const hasExif = jpeg.segments.some( + ({ metadataKind }) => metadataKind === "exif" + ); + const metadata = inspectJpegMetadata( + reader, + jpeg, + resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: !jpeg.complete ? "container-partial" : metadata.attemptedExifDecode ? "metadata-partial" : "container-inspected", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + [...jpeg.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + if (format === "webp") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const webp = parseWebP( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + maxDiagnostics + ); + const metadata = inspectWebPMetadata(webp, maxMetadataEntries); + return { + format, + size: bytes.byteLength, + inspectionStatus: webp.complete ? "container-inspected" : "container-partial", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + webp.diagnostics, + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + if (format === "png") { + const maxMetadataEntries = resolveParseLimit( + "maxMetadataEntries", + options?.limits?.maxMetadataEntries + ); + const maxDiagnostics = resolveParseLimit( + "maxDiagnostics", + options?.limits?.maxDiagnostics + ); + const png = parsePng( + reader, + resolveParseLimit("maxChunks", options?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", options?.limits?.maxStringBytes), + maxDiagnostics + ); + const hasExif = png.chunks.some( + ({ metadataKind }) => metadataKind === "exif" + ); + const metadata = inspectPngMetadata( + reader, + png, + resolveTiffLimits(options?.limits, hasExif), + maxMetadataEntries + ); + return { + format, + size: bytes.byteLength, + inspectionStatus: !png.complete ? "container-partial" : metadata.attemptedExifDecode ? "metadata-partial" : "container-inspected", + entries: metadata.entries, + ...metadata.entryLimitExceeded ? { metadataTruncated: true } : {}, + diagnostics: boundedDiagnostics( + [...png.diagnostics, ...metadata.diagnostics], + metadata.entryLimitExceeded, + maxDiagnostics + ) + }; + } + return { + format, + size: bytes.byteLength, + inspectionStatus: "format-only", + entries: [], + diagnostics: [] + }; +} + +// src/policy/normalize.ts +var DEFAULT_CLEANING_POLICY = Object.freeze({ + removeExif: true, + removeXmp: true, + removeIptc: true, + removeComments: true, + removeTextMetadata: true, + removeTimestamps: true, + preserveIcc: true +}); +function normalizeCleaningPolicy(policy) { + return Object.freeze({ + removeExif: policy?.removeExif ?? DEFAULT_CLEANING_POLICY.removeExif, + removeXmp: policy?.removeXmp ?? DEFAULT_CLEANING_POLICY.removeXmp, + removeIptc: policy?.removeIptc ?? DEFAULT_CLEANING_POLICY.removeIptc, + removeComments: policy?.removeComments ?? DEFAULT_CLEANING_POLICY.removeComments, + removeTextMetadata: policy?.removeTextMetadata ?? DEFAULT_CLEANING_POLICY.removeTextMetadata, + removeTimestamps: policy?.removeTimestamps ?? DEFAULT_CLEANING_POLICY.removeTimestamps, + preserveIcc: policy?.preserveIcc ?? policy?.preserveColorProfiles ?? DEFAULT_CLEANING_POLICY.preserveIcc + }); +} + +// src/png/clean.ts +var DEFAULT_PNG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove(chunk, policy) { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "text": + return policy.removeTextMetadata; + case "timestamp": + return policy.removeTimestamps; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor(chunk, action) { + let namespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + switch (chunk.metadataKind) { + case "exif": + namespace = "exif"; + name = "PNG EXIF container"; + break; + case "xmp": + namespace = "xmp"; + name = "PNG XMP iTXt container"; + break; + case "text": + namespace = "png-text"; + name = `${chunk.fourCC} metadata`; + break; + case "timestamp": + namespace = "png-time"; + name = "PNG modification time"; + break; + case "icc": + namespace = "icc"; + name = "PNG ICC profile container"; + break; + } + return { + namespace, + action, + name, + source: { + format: "png", + container: "png-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + } + }; +} +function outputError(message) { + return new SecureMetadataError(message, "CLEAN_OUTPUT_SIZE_INVALID"); +} +function cleanPng(bytes, policy) { + const parsed = parsePng( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxStringBytes", policy?.limits?.maxStringBytes), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!parsed.complete) { + throw new IncompletePngError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = parsed.chunks.filter( + (chunk) => shouldRemove(chunk, resolved) + ); + const retained = parsed.chunks.filter( + (chunk) => !shouldRemove(chunk, resolved) + ); + let containerLength = 8; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if (!Number.isSafeInteger(containerLength) || containerLength > parsed.containerLength) { + throw outputError("PNG cleaner output container size is invalid."); + } + } + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 8 || outputLength > bytes.byteLength) { + throw outputError("PNG cleaner output size is invalid."); + } + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 8)); + let outputOffset = 8; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset + ); + outputOffset += chunk.totalLength; + } + output.set(bytes.subarray(parsed.containerLength), outputOffset); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "png", + report, + removed: removals.map((chunk) => changeFor(chunk, "removed")), + preserved: retained.filter( + ({ kind, metadataKind }) => kind === "unknown" || metadataKind !== void 0 + ).map((chunk) => changeFor(chunk, "preserved")), + diagnostics: report.diagnostics + }; +} + +// src/webp/clean.ts +var DEFAULT_WEBP_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove2(chunk, policy) { + switch (chunk.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor2(chunk, action) { + let namespace = "unknown"; + let name = `Unknown ${chunk.fourCC} chunk`; + if (chunk.metadataKind !== void 0) { + namespace = chunk.metadataKind; + name = chunk.metadataKind === "icc" ? "WebP ICC profile container" : `WebP ${chunk.metadataKind.toUpperCase()} container`; + } + return { + namespace, + action, + name, + source: { + format: "webp", + container: "webp-chunk", + offset: chunk.offset, + length: chunk.totalLength, + chunkType: chunk.fourCC + } + }; +} +function outputError2(message) { + return new SecureMetadataError(message, "CLEAN_OUTPUT_SIZE_INVALID"); +} +function cleanWebP(bytes, policy) { + const parsed = parseWebP( + new ByteReader(bytes), + resolveParseLimit("maxChunks", policy?.limits?.maxChunks), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!parsed.complete) { + throw new IncompleteWebPError( + "cleanMetadata", + parsed.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = parsed.chunks.filter( + (chunk) => shouldRemove2(chunk, resolved) + ); + const retained = parsed.chunks.filter( + (chunk) => !shouldRemove2(chunk, resolved) + ); + let containerLength = 12; + for (const chunk of retained) { + containerLength += chunk.totalLength; + if (!Number.isSafeInteger(containerLength) || containerLength > parsed.containerLength) { + throw outputError2("WebP cleaner output RIFF size is invalid."); + } + } + const trailingLength = bytes.byteLength - parsed.containerLength; + const outputLength = containerLength + trailingLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 12 || outputLength > bytes.byteLength) { + throw outputError2("WebP cleaner output size is invalid."); + } + const hasIcc = retained.some(({ metadataKind }) => metadataKind === "icc"); + const hasExif = retained.some(({ metadataKind }) => metadataKind === "exif"); + const hasXmp = retained.some(({ metadataKind }) => metadataKind === "xmp"); + const metadataFlags = (hasIcc ? WEBP_VP8X_FLAG.icc : 0) | (hasExif ? WEBP_VP8X_FLAG.exif : 0) | (hasXmp ? WEBP_VP8X_FLAG.xmp : 0); + const output = new Uint8Array(outputLength); + output.set(bytes.subarray(0, 12)); + new DataView(output.buffer).setUint32(4, containerLength - 8, true); + let outputOffset = 12; + for (const chunk of retained) { + output.set( + bytes.subarray(chunk.offset, chunk.offset + chunk.totalLength), + outputOffset + ); + if (chunk.vp8xFlags !== void 0) { + output[outputOffset + 8] = chunk.vp8xFlags & ~WEBP_VP8X_METADATA_MASK | metadataFlags; + } + outputOffset += chunk.totalLength; + } + output.set(bytes.subarray(parsed.containerLength), outputOffset); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompleteWebPError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "webp", + report, + removed: removals.map((chunk) => changeFor2(chunk, "removed")), + preserved: retained.filter( + ({ kind, metadataKind }) => kind === "unknown" || metadataKind !== void 0 + ).map((chunk) => changeFor2(chunk, "preserved")), + diagnostics: report.diagnostics + }; +} + +// src/policy/clean.ts +var DEFAULT_JPEG_CLEANING_POLICY = DEFAULT_CLEANING_POLICY; +function shouldRemove3(segment, policy) { + if (segment.kind === "comment") { + return policy.removeComments; + } + switch (segment.metadataKind) { + case "exif": + return policy.removeExif; + case "xmp": + return policy.removeXmp; + case "iptc": + return policy.removeIptc; + case "icc": + return !policy.preserveIcc; + default: + return false; + } +} +function changeFor3(segment, action) { + let namespace = "container"; + let name = segment.markerName; + if (segment.kind === "comment") { + namespace = "jpeg-comment"; + name = "JPEG comment"; + } else { + switch (segment.metadataKind) { + case "exif": + namespace = "exif"; + name = "EXIF container"; + break; + case "xmp": + namespace = "xmp"; + name = segment.metadataSubtype === "extended-xmp" ? "Extended XMP container" : "XMP container"; + break; + case "iptc": + namespace = "iptc"; + name = "Photoshop/IPTC container"; + break; + case "icc": + namespace = "icc"; + name = "ICC profile container"; + break; + case "jfif": + name = segment.metadataSubtype === "jfxx" ? "JFXX application segment" : "JFIF application segment"; + break; + case "adobe": + name = "Adobe application segment"; + break; + case "unknown": + namespace = "unknown"; + name = `Unknown ${segment.markerName} application segment`; + break; + } + } + return { + namespace, + action, + name, + source: { + format: "jpeg", + container: "jpeg-segment", + offset: segment.offset, + length: segment.length, + jpegMarker: segment.marker + } + }; +} +function copyWithoutSegments(input, removals) { + const retained = []; + let inputOffset = 0; + let outputLength = 0; + for (const segment of removals) { + const end = segment.rangeOffset + segment.rangeLength; + if (!Number.isSafeInteger(segment.rangeOffset) || !Number.isSafeInteger(segment.rangeLength) || segment.rangeLength <= 0 || !Number.isSafeInteger(end) || segment.rangeOffset < inputOffset || end > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner produced an invalid removal range.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + const length = segment.rangeOffset - inputOffset; + retained.push({ offset: inputOffset, length }); + outputLength += length; + if (!Number.isSafeInteger(outputLength) || outputLength > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner output size is invalid.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + inputOffset = end; + } + const tailLength = input.byteLength - inputOffset; + retained.push({ offset: inputOffset, length: tailLength }); + outputLength += tailLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 0 || outputLength > input.byteLength) { + throw new SecureMetadataError( + "JPEG cleaner output size is invalid.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + const output = new Uint8Array(outputLength); + let outputOffset = 0; + for (const range of retained) { + output.set( + input.subarray(range.offset, range.offset + range.length), + outputOffset + ); + outputOffset += range.length; + } + return output; +} +function cleanMetadata(input, policy) { + const bytes = toUint8Array(input); + const maxInputBytes = resolveParseLimit( + "maxInputBytes", + policy?.limits?.maxInputBytes + ); + if (bytes.byteLength > maxInputBytes) { + throw new InputLimitExceededError(bytes.byteLength, maxInputBytes); + } + const reader = new ByteReader(bytes); + const format = detectFormat(reader); + if (format === "png") { + return cleanPng(bytes, policy); + } + if (format === "webp") { + return cleanWebP(bytes, policy); + } + if (format !== "jpeg") { + throw new UnsupportedFormatError("cleanMetadata", format); + } + const jpeg = parseJpeg( + reader, + resolveParseLimit("maxSegments", policy?.limits?.maxSegments), + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ); + if (!jpeg.complete) { + throw new IncompleteJpegError( + "cleanMetadata", + jpeg.diagnostics.slice( + 0, + resolveParseLimit("maxDiagnostics", policy?.limits?.maxDiagnostics) + ) + ); + } + const resolved = normalizeCleaningPolicy(policy); + const removals = jpeg.segments.filter( + (segment) => shouldRemove3(segment, resolved) + ); + const removed = removals.map((segment) => changeFor3(segment, "removed")); + const preserved = jpeg.segments.filter( + (segment) => (segment.kind === "application" || segment.kind === "comment") && !shouldRemove3(segment, resolved) + ).map((segment) => changeFor3(segment, "preserved")); + const output = copyWithoutSegments(bytes, removals); + const report = inspectMetadata( + output, + policy?.limits === void 0 ? void 0 : { limits: policy.limits } + ); + if (report.inspectionStatus === "container-partial") { + throw new IncompleteJpegError("cleanMetadata", report.diagnostics); + } + return { + output, + format: "jpeg", + report, + removed, + preserved, + diagnostics: report.diagnostics + }; +} + +// src/verify/verify.ts +var DEFAULT_JPEG_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + iptc: "absent", + comments: "absent", + icc: "ignore" +}); +var DEFAULT_WEBP_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + icc: "ignore" +}); +var DEFAULT_PNG_VERIFICATION_POLICY = Object.freeze({ + exif: "absent", + xmp: "absent", + textMetadata: "absent", + timestamps: "absent", + icc: "ignore" +}); +function verifyMetadata(input, expectation) { + const report = inspectMetadata( + input, + expectation?.limits === void 0 ? void 0 : { limits: expectation.limits } + ); + if (report.format === "jpeg") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteJpegError("verifyMetadata", report.diagnostics); + } + } else if (report.format === "webp") { + if (report.inspectionStatus === "container-partial") { + throw new IncompleteWebPError("verifyMetadata", report.diagnostics); + } + } else if (report.format === "png") { + if (report.inspectionStatus === "container-partial") { + throw new IncompletePngError("verifyMetadata", report.diagnostics); + } + } else { + throw new UnsupportedFormatError("verifyMetadata", report.format); + } + if (report.metadataTruncated === true) { + return { + valid: false, + checks: [], + report, + diagnostics: report.diagnostics + }; + } + const privacyDefault = expectation?.requireNoPrivacyRelevantMetadata === false ? "ignore" : "absent"; + let expected; + if (report.format === "jpeg") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + iptc: expectation?.iptc ?? privacyDefault, + "jpeg-comment": expectation?.comments ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } else if (report.format === "webp") { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } else { + expected = { + exif: expectation?.exif ?? privacyDefault, + xmp: expectation?.xmp ?? privacyDefault, + "png-text": expectation?.textMetadata ?? privacyDefault, + "png-time": expectation?.timestamps ?? privacyDefault, + icc: expectation?.icc ?? "ignore" + }; + } + const checks = []; + for (const [namespace, wanted] of Object.entries(expected)) { + if (wanted === "ignore") { + continue; + } + const present = report.entries.some( + (entry) => entry.namespace === namespace + ); + const actual = present ? "present" : "absent"; + checks.push({ + namespace, + expected: wanted, + actual, + passed: actual === wanted + }); + } + return { + valid: checks.every(({ passed }) => passed), + checks, + report, + diagnostics: report.diagnostics + }; +} +export { + BinaryBoundsError, + DEFAULT_CLEANING_POLICY, + DEFAULT_JPEG_CLEANING_POLICY, + DEFAULT_JPEG_VERIFICATION_POLICY, + DEFAULT_PARSE_LIMITS, + DEFAULT_PNG_CLEANING_POLICY, + DEFAULT_PNG_VERIFICATION_POLICY, + DEFAULT_WEBP_CLEANING_POLICY, + DEFAULT_WEBP_VERIFICATION_POLICY, + IncompleteJpegError, + IncompletePngError, + IncompleteWebPError, + InputLimitExceededError, + InvalidParseLimitError, + SecureMetadataError, + UnsupportedFormatError, + cleanMetadata, + inspectMetadata, + verifyMetadata +}; +//# sourceMappingURL=secure-metadata.js.map \ No newline at end of file From 029880156ad9a0e4032cc1c206dafa6d59b61df3 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 26 Aug 2026 13:44:39 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8[Feat]=20Add=20image=20metadata=20?= =?UTF-8?q?processing=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/image/metadata/metadata.js | 101 +++++++++++++++++++++++++++++++ tools/image/metadata/model.js | 73 ++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tools/image/metadata/metadata.js create mode 100644 tools/image/metadata/model.js diff --git a/tools/image/metadata/metadata.js b/tools/image/metadata/metadata.js new file mode 100644 index 0000000..76d691c --- /dev/null +++ b/tools/image/metadata/metadata.js @@ -0,0 +1,101 @@ +import { MAX_FILE_SIZE, validateImageSignature } from "../../shared/image.js"; +import { createImageOutputNames, IMAGE_FORMATS } from "../../shared/image-output.js"; +import { + cleanMetadata, + DEFAULT_CLEANING_POLICY, + inspectMetadata, + verifyMetadata, +} from "../../../assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js"; + +export const PRIVACY_CLEAN_POLICY = DEFAULT_CLEANING_POLICY; + +function metadataError(code, cause) { + const error = new Error(code, cause ? { cause } : undefined); + error.code = code; + return error; +} + +function mapLibraryError(error, fallback) { + const code = String(error?.code || error?.name || "").toUpperCase(); + if (code.includes("INPUT_LIMIT") || code.includes("LIMIT_EXCEEDED")) return metadataError("IMAGE_METADATA_INPUT_LIMIT", error); + if (code.includes("INCOMPLETE_JPEG")) return metadataError("IMAGE_METADATA_INCOMPLETE_JPEG", error); + if (code.includes("INCOMPLETE_PNG")) return metadataError("IMAGE_METADATA_INCOMPLETE_PNG", error); + if (code.includes("INCOMPLETE_WEBP")) return metadataError("IMAGE_METADATA_INCOMPLETE_WEBP", error); + if (code.includes("UNSUPPORTED")) return metadataError("IMAGE_METADATA_UNSUPPORTED", error); + return metadataError(fallback, error); +} + +function assertSupportedReport(report, detectedFormat) { + if (!report || !IMAGE_FORMATS[report.format] || report.format !== detectedFormat) { + throw metadataError("IMAGE_METADATA_UNSUPPORTED"); + } +} + +export async function inspectImageMetadata(file) { + if (!(file instanceof Blob) || file.size < 1) throw metadataError("IMAGE_METADATA_NO_FILE"); + if (file.size > MAX_FILE_SIZE) throw metadataError("IMAGE_FILE_TOO_LARGE"); + let detectedFormat; + try { + detectedFormat = await validateImageSignature(file); + } catch (error) { + throw metadataError(error?.code || "IMAGE_SIGNATURE_INVALID", error); + } + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + const report = inspectMetadata(bytes); + assertSupportedReport(report, detectedFormat); + return Object.freeze({ + file, + bytes, + format: detectedFormat, + mimeType: IMAGE_FORMATS[detectedFormat].mimeType, + report, + cleanable: report.inspectionStatus !== "container-partial" && report.metadataTruncated !== true, + }); + } catch (error) { + if (error?.code?.startsWith?.("IMAGE_")) throw error; + throw mapLibraryError(error, "IMAGE_METADATA_INSPECTION_FAILED"); + } +} + +export function createCleanOutputPlan(source) { + if (!source?.file || !IMAGE_FORMATS[source.format]) throw metadataError("IMAGE_METADATA_NO_FILE"); + return { + filename: createImageOutputNames([source.file], source.format, { suffix: "_clean" }).entries[0], + format: source.format, + mimeType: IMAGE_FORMATS[source.format].mimeType, + extension: `.${IMAGE_FORMATS[source.format].extension}`, + }; +} + +export async function cleanAndVerifyImageMetadata(source) { + if (!source?.bytes || !source.cleanable) throw metadataError("IMAGE_METADATA_NOT_CLEANABLE"); + const plan = createCleanOutputPlan(source); + let cleaned; + try { + cleaned = cleanMetadata(source.bytes, DEFAULT_CLEANING_POLICY); + } catch (error) { + throw mapLibraryError(error, "IMAGE_METADATA_CLEANING_FAILED"); + } + let verification; + try { + verification = verifyMetadata(cleaned.output); + } catch (error) { + throw mapLibraryError(error, "IMAGE_METADATA_VERIFICATION_FAILED"); + } + const checksPassed = Array.isArray(verification?.checks) + && verification.checks.length > 0 + && verification.checks.every((check) => check?.passed === true); + const reportComplete = verification?.report?.inspectionStatus !== "container-partial" + && verification?.report?.metadataTruncated !== true; + if (verification?.valid !== true || !checksPassed || !reportComplete || cleaned?.format !== source.format) { + throw metadataError("IMAGE_METADATA_VERIFICATION_FAILED"); + } + return { + plan, + blob: new Blob([cleaned.output], { type: plan.mimeType }), + bytes: cleaned.output, + cleaned, + verification, + }; +} diff --git a/tools/image/metadata/model.js b/tools/image/metadata/model.js new file mode 100644 index 0000000..b765bd8 --- /dev/null +++ b/tools/image/metadata/model.js @@ -0,0 +1,73 @@ +export const MAX_METADATA_VALUE_LENGTH = 2000; +export const MAX_DIAGNOSTIC_LENGTH = 500; + +const GROUPS = Object.freeze(["location", "device", "author", "time", "descriptive", "rights", "technical", "other"]); + +function boundedText(value, limit) { + const text = String(value ?? "").replaceAll("\0", "�"); + return { text: text.length > limit ? `${text.slice(0, limit)}…` : text, truncated: text.length > limit }; +} + +function rational(value) { + return value && typeof value === "object" && Number.isFinite(value.numerator) && Number.isFinite(value.denominator); +} + +export function formatMetadataValue(value, labels = {}) { + if (value === undefined || value === null) return { text: labels.opaque || "Detected, payload not decoded", opaque: true, truncated: false }; + if (value instanceof Uint8Array) return { text: (labels.bytes || "Binary payload ({count} bytes)").replace("{count}", String(value.byteLength)), opaque: true, truncated: false }; + if (rational(value)) return { text: `${value.numerator}/${value.denominator}`, opaque: false, truncated: false }; + if (Array.isArray(value)) { + const formatted = value.map((item) => rational(item) ? `${item.numerator}/${item.denominator}` : String(item)).join(", "); + return { ...boundedText(formatted, MAX_METADATA_VALUE_LENGTH), opaque: false }; + } + if (typeof value === "boolean") return { text: value ? (labels.true || "True") : (labels.false || "False"), opaque: false, truncated: false }; + if (typeof value === "number") return { text: Number.isFinite(value) ? String(value) : (labels.opaque || "Detected, payload not decoded"), opaque: !Number.isFinite(value), truncated: false }; + return { ...boundedText(value, MAX_METADATA_VALUE_LENGTH), opaque: false }; +} + +function groupFor(entry) { + const namespace = String(entry.namespace || "").toLowerCase(); + const category = String(entry.category || "other").toLowerCase(); + if (namespace === "xmp" || namespace === "iptc") return namespace === "iptc" ? "descriptive" : (GROUPS.includes(category) ? category : "descriptive"); + return GROUPS.includes(category) ? category : "other"; +} + +export function buildInspectionModel(source, labels = {}) { + const entries = Array.isArray(source?.report?.entries) ? source.report.entries : []; + const groups = new Map(GROUPS.map((key) => [key, []])); + entries.forEach((entry) => groups.get(groupFor(entry)).push({ + id: String(entry.id || `${entry.namespace || "metadata"}:${entry.name || entry.source || "entry"}`), + namespace: String(entry.namespace || "other"), + name: String(entry.name || entry.source || entry.id || labels.unknown || "Unknown metadata"), + category: String(entry.category || "other"), + source: String(entry.source || ""), + value: formatMetadataValue(entry.value, labels), + })); + const status = source?.report?.inspectionStatus || "container-partial"; + return { + format: source?.format || source?.report?.format || "", + size: source?.file?.size || source?.report?.size || 0, + status, + coverageKey: `imageMetadata.coverage.${status}`, + successful: status === "container-inspected" || status === "metadata-partial" || status === "metadata-inspected", + cleanable: source?.cleanable === true, + count: entries.length, + groups: [...groups].filter(([, items]) => items.length).map(([key, items]) => ({ key, items })), + diagnostics: (source?.report?.diagnostics || []).map((item) => boundedText(item?.message || item?.code || item, MAX_DIAGNOSTIC_LENGTH).text), + }; +} + +export function buildCleaningModel(result) { + const removed = Array.isArray(result?.cleaned?.removed) ? result.cleaned.removed : []; + const preserved = Array.isArray(result?.cleaned?.preserved) ? result.cleaned.preserved : []; + const checks = Array.isArray(result?.verification?.checks) ? result.verification.checks : []; + return { + filename: result?.plan?.filename || "", + removed, + preserved, + checks, + valid: result?.verification?.valid === true && checks.length > 0 && checks.every((check) => check?.passed === true), + diagnostics: [...(result?.cleaned?.diagnostics || []), ...(result?.verification?.diagnostics || [])] + .map((item) => boundedText(item?.message || item?.code || item, MAX_DIAGNOSTIC_LENGTH).text), + }; +} From 79a2d25a034ddcea0733a53fea4ce44933e5bd2a Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 26 Aug 2026 13:45:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8[Feat]=20Build=20image=20metadata?= =?UTF-8?q?=20inspector=20and=20cleaner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/i18n.js | 6 +- js/locales/image-metadata.js | 33 ++++++++ tools/image/index.html | 10 +++ tools/image/metadata/app.js | 140 ++++++++++++++++++++++++++++++++ tools/image/metadata/index.html | 17 ++++ tools/image/metadata/tool.css | 34 ++++++++ 6 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 js/locales/image-metadata.js create mode 100644 tools/image/metadata/app.js create mode 100644 tools/image/metadata/index.html create mode 100644 tools/image/metadata/tool.css diff --git a/js/i18n.js b/js/i18n.js index 7a27a2c..eb8bc91 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -6,14 +6,18 @@ import { de } from "./locales/de.js"; import { fr } from "./locales/fr.js"; import { imageResizeLocales } from "./locales/image-resize.js"; import { imageCompressorLocales } from "./locales/image-compressor.js"; +import { imageMetadataLocales } from "./locales/image-metadata.js"; const STORAGE_KEY = "secure-tools-language"; const baseTranslations = { en, ko, ja, es, de, fr }; export const translations = Object.fromEntries(Object.entries(baseTranslations).map(([language, catalog]) => [language, { ...catalog, - metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata }, + metadata: { ...catalog.metadata, imageResize: imageResizeLocales[language].metadata, imageCompressor: imageCompressorLocales[language].metadata, imageMetadata: imageMetadataLocales[language].metadata }, + tools: { ...catalog.tools, imageMetadata: imageMetadataLocales[language].toolName }, + categories: { ...catalog.categories, image: { ...catalog.categories.image, metadata: imageMetadataLocales[language].categoryDescription } }, imageResize: imageResizeLocales[language].copy, imageCompressor: imageCompressorLocales[language].copy, + imageMetadata: imageMetadataLocales[language].copy, }])); export function resolveLanguage(value, availableLanguages = Object.keys(translations)) { diff --git a/js/locales/image-metadata.js b/js/locales/image-metadata.js new file mode 100644 index 0000000..a56ec18 --- /dev/null +++ b/js/locales/image-metadata.js @@ -0,0 +1,33 @@ +const shared = { + groups: { location: "Location", device: "Device & camera", author: "Author & creator", time: "Dates & time", descriptive: "Description & keywords", rights: "Rights", technical: "Technical", other: "Other" }, + values: { opaque: "Detected, payload not decoded", bytes: "Binary payload ({count} bytes)", true: "True", false: "False", unknown: "Unknown metadata" }, + coverage: { "container-inspected": "Container inspection completed for the supported structures.", "metadata-inspected": "Metadata inspection completed for the supported structures.", "metadata-partial": "Inspection succeeded, but metadata coverage is partial and non-exhaustive.", "container-partial": "The image container is incomplete. Cleaning is disabled because safe verification is not possible." }, +}; + +export const imageMetadataLocales = { + en: { + metadata: { title: "Image Metadata Inspector & Cleaner — Secure Tools", description: "Inspect and remove JPEG, PNG, and WebP metadata locally in your browser." }, + toolName: "Image Metadata Inspector & Cleaner", categoryDescription: "Inspect privacy-relevant metadata and save a verified cleaned copy.", + copy: { ...shared, eyebrow: "Image privacy tool", title: "Image Metadata Inspector & Cleaner", description: "Inspect metadata in one JPEG, PNG, or WebP image, then create a verified cleaned copy without uploading it.", scope: { title: "Honest inspection scope", body: "The tool reports decoded metadata plus opaque containers it can identify. “Partial” means a successful but non-exhaustive inspection, not that no metadata exists." }, drop: { title: "Add one image", description: "Drop a JPEG, PNG, or WebP image here or use the picker. Choosing another file replaces the current source.", choose: "Choose image", localTitle: "Processed locally.", localBody: "Your image never leaves this device.", privacyLink: "How privacy works" }, source: { title: "Source image", empty: "No image selected yet.", summary: "{name} · {size} · {format} · {count} metadata entries" }, inspector: { title: "Detected metadata", empty: "No metadata entries were decoded or identified in the supported scope.", namespace: "Namespace: {namespace}", source: "Structure: {source}", opaque: "Opaque container", truncated: "Display shortened for safety.", diagnostics: "Parser diagnostics" }, coverage: { "container-inspected": "Container inspection completed for the supported structures.", "metadata-inspected": "Metadata inspection completed for the supported structures.", "metadata-partial": "Inspection succeeded, but this format’s metadata coverage is partial and non-exhaustive.", "container-partial": "The image container is incomplete. Cleaning is disabled because safe verification is not possible." }, clean: { title: "Privacy Clean", description: "Creates a new file using the library’s authoritative privacy policy. Pixels are not decoded or re-encoded; the original stays unchanged.", policy: "Removes supported EXIF, XMP, IPTC, comments, text metadata, and timestamps. ICC color profiles are preserved.", button: "Privacy Clean and save", imageFile: "Cleaned image" }, result: { title: "Verified clean result", summary: "Verified {count} policy checks before saving {name}.", removed: "Removed", preserved: "Preserved", none: "None reported", diagnostics: "Cleaning diagnostics" }, actions: { clear: "Clear image" }, status: { reading: "Inspecting metadata locally…", loaded: "Inspection complete: {count} entries found.", noneFound: "Inspection complete. No supported metadata entries were found.", cleaning: "Cleaning and verifying metadata locally…", saved: "Verified and saved {name}.", downloaded: "Verified and downloaded {name}.", cancelled: "Save cancelled. The source image remains loaded.", cleared: "Source image cleared." }, errors: { oneFile: "Choose exactly one image.", noFile: "Choose a non-empty image.", tooLarge: "The image exceeds the 50 MiB limit.", signature: "The file signature is not a supported JPEG, PNG, or WebP image.", unsupported: "This image metadata structure is unsupported.", incomplete: "The image container is incomplete or damaged, so it cannot be cleaned safely.", limit: "Metadata inspection exceeded a safety limit.", inspection: "Metadata inspection failed. The image may be damaged or unsupported.", notCleanable: "This inspection is incomplete, so cleaning is disabled.", cleaning: "Metadata cleaning failed. No output was saved.", verification: "The cleaned copy did not pass every verification check. No output was saved.", save: "The verified copy could not be saved." } }, + }, + ko: { + metadata: { title: "이미지 메타데이터 검사 및 정리 — Secure Tools", description: "JPEG, PNG, WebP 메타데이터를 브라우저에서 로컬로 검사하고 제거합니다." }, toolName: "이미지 메타데이터 검사 및 정리", categoryDescription: "개인정보 관련 메타데이터를 확인하고 검증된 정리본을 저장합니다.", + copy: { ...shared, eyebrow: "이미지 개인정보 도구", title: "이미지 메타데이터 검사 및 정리", description: "JPEG, PNG 또는 WebP 이미지 한 개의 메타데이터를 확인하고 업로드 없이 검증된 정리본을 만듭니다.", scope: { title: "정직한 검사 범위", body: "해석된 메타데이터와 식별 가능한 불투명 컨테이너를 표시합니다. ‘부분적’은 검사가 성공했지만 모든 항목을 다룬다는 뜻은 아닙니다." }, drop: { title: "이미지 한 개 추가", description: "JPEG, PNG 또는 WebP를 놓거나 선택하세요. 다른 파일을 고르면 현재 원본이 교체됩니다.", choose: "이미지 선택", localTitle: "로컬에서 처리됩니다.", localBody: "이미지는 이 기기를 떠나지 않습니다.", privacyLink: "개인정보 보호 방식" }, source: { title: "원본 이미지", empty: "선택한 이미지가 없습니다.", summary: "{name} · {size} · {format} · 메타데이터 {count}개" }, inspector: { title: "감지된 메타데이터", empty: "지원 범위에서 해석되거나 식별된 메타데이터가 없습니다.", namespace: "네임스페이스: {namespace}", source: "구조: {source}", opaque: "불투명 컨테이너", truncated: "안전을 위해 표시를 줄였습니다.", diagnostics: "파서 진단" }, coverage: { "container-inspected": "지원 구조의 컨테이너 검사가 완료되었습니다.", "metadata-inspected": "지원 구조의 메타데이터 검사가 완료되었습니다.", "metadata-partial": "검사는 성공했지만 이 형식의 메타데이터 범위는 부분적이며 완전하지 않습니다.", "container-partial": "이미지 컨테이너가 불완전합니다. 안전한 검증이 불가능해 정리를 비활성화했습니다." }, clean: { title: "개인정보 정리", description: "라이브러리의 공식 개인정보 정책으로 새 파일을 만듭니다. 픽셀을 디코딩하거나 재인코딩하지 않으며 원본은 유지됩니다.", policy: "지원되는 EXIF, XMP, IPTC, 주석, 텍스트 메타데이터와 타임스탬프를 제거하고 ICC 색상 프로필은 보존합니다.", button: "개인정보 정리 후 저장", imageFile: "정리된 이미지" }, result: { title: "검증된 정리 결과", summary: "{name} 저장 전에 정책 검사 {count}개를 확인했습니다.", removed: "제거됨", preserved: "보존됨", none: "보고된 항목 없음", diagnostics: "정리 진단" }, actions: { clear: "이미지 지우기" }, status: { reading: "로컬에서 메타데이터 검사 중…", loaded: "검사 완료: {count}개 항목을 찾았습니다.", noneFound: "검사 완료. 지원되는 메타데이터가 없습니다.", cleaning: "로컬에서 메타데이터 정리 및 검증 중…", saved: "{name}을 검증하고 저장했습니다.", downloaded: "{name}을 검증하고 다운로드했습니다.", cancelled: "저장을 취소했습니다. 원본 이미지는 유지됩니다.", cleared: "원본 이미지를 지웠습니다." }, errors: { oneFile: "이미지 한 개만 선택하세요.", noFile: "비어 있지 않은 이미지를 선택하세요.", tooLarge: "이미지가 50 MiB 제한을 초과합니다.", signature: "지원되는 JPEG, PNG 또는 WebP 서명이 아닙니다.", unsupported: "지원되지 않는 이미지 메타데이터 구조입니다.", incomplete: "이미지 컨테이너가 불완전하거나 손상되어 안전하게 정리할 수 없습니다.", limit: "메타데이터 검사가 안전 제한을 초과했습니다.", inspection: "메타데이터 검사에 실패했습니다.", notCleanable: "검사가 불완전하여 정리를 사용할 수 없습니다.", cleaning: "메타데이터 정리에 실패했습니다. 출력은 저장되지 않았습니다.", verification: "정리본이 모든 검사를 통과하지 못했습니다. 출력은 저장되지 않았습니다.", save: "검증된 사본을 저장할 수 없습니다." } }, + }, + ja: { + metadata: { title: "画像メタデータ検査・クリーナー — Secure Tools", description: "JPEG、PNG、WebPのメタデータをブラウザー内で検査・削除します。" }, toolName: "画像メタデータ検査・クリーナー", categoryDescription: "プライバシー関連メタデータを確認し、検証済みコピーを保存します。", + copy: { ...shared, eyebrow: "画像プライバシーツール", title: "画像メタデータ検査・クリーナー", description: "JPEG、PNG、WebP画像1件のメタデータを確認し、アップロードせず検証済みコピーを作成します。", scope: { title: "検査範囲", body: "解読したメタデータと識別可能な不透明コンテナを表示します。「部分的」は成功した非網羅的な検査を意味します。" }, drop: { title: "画像を1件追加", description: "JPEG、PNG、WebPをドロップまたは選択します。別のファイルを選ぶと置き換わります。", choose: "画像を選択", localTitle: "ローカル処理。", localBody: "画像は端末外へ送信されません。", privacyLink: "プライバシーの仕組み" }, source: { title: "元画像", empty: "画像が未選択です。", summary: "{name} · {size} · {format} · メタデータ{count}件" }, inspector: { title: "検出したメタデータ", empty: "対応範囲でメタデータは検出されませんでした。", namespace: "名前空間: {namespace}", source: "構造: {source}", opaque: "不透明コンテナ", truncated: "安全のため表示を短縮しました。", diagnostics: "解析診断" }, coverage: shared.coverage, clean: { title: "プライバシークリーン", description: "公式ポリシーで新しいファイルを作成します。ピクセルのデコード・再エンコードはせず、元画像は変更しません。", policy: "対応するEXIF、XMP、IPTC、コメント、テキスト、日時を削除し、ICCカラープロファイルは保持します。", button: "クリーンして保存", imageFile: "クリーン済み画像" }, result: { title: "検証済み結果", summary: "{name}の保存前に{count}件のポリシーチェックを確認しました。", removed: "削除", preserved: "保持", none: "報告なし", diagnostics: "処理診断" }, actions: { clear: "画像をクリア" }, status: { reading: "ローカルで検査中…", loaded: "検査完了: {count}件。", noneFound: "検査完了。対応メタデータはありません。", cleaning: "クリーンと検証を実行中…", saved: "{name}を検証して保存しました。", downloaded: "{name}を検証してダウンロードしました。", cancelled: "保存をキャンセルしました。", cleared: "元画像をクリアしました。" }, errors: { oneFile: "画像を1件だけ選んでください。", noFile: "空でない画像を選んでください。", tooLarge: "50 MiBの上限を超えています。", signature: "対応する画像署名ではありません。", unsupported: "未対応のメタデータ構造です。", incomplete: "画像が不完全なため安全に処理できません。", limit: "安全上限を超えました。", inspection: "検査に失敗しました。", notCleanable: "検査が不完全なため処理できません。", cleaning: "クリーンに失敗しました。保存していません。", verification: "検証に失敗しました。保存していません。", save: "検証済みコピーを保存できません。" } }, + }, + es: { + metadata: { title: "Inspector y limpiador de metadatos — Secure Tools", description: "Inspecciona y elimina metadatos JPEG, PNG y WebP localmente." }, toolName: "Inspector y limpiador de metadatos", categoryDescription: "Revisa metadatos de privacidad y guarda una copia verificada.", + copy: { ...shared, eyebrow: "Herramienta de privacidad", title: "Inspector y limpiador de metadatos", description: "Inspecciona los metadatos de una imagen JPEG, PNG o WebP y crea una copia verificada sin subirla.", scope: { title: "Alcance honesto", body: "Muestra metadatos decodificados y contenedores opacos identificables. «Parcial» indica una inspección correcta, pero no exhaustiva." }, drop: { title: "Añade una imagen", description: "Suelta o elige un JPEG, PNG o WebP. Otro archivo sustituye al actual.", choose: "Elegir imagen", localTitle: "Procesamiento local.", localBody: "La imagen no sale del dispositivo.", privacyLink: "Cómo funciona la privacidad" }, source: { title: "Imagen de origen", empty: "Aún no hay imagen.", summary: "{name} · {size} · {format} · {count} metadatos" }, inspector: { title: "Metadatos detectados", empty: "No se identificaron metadatos en el alcance compatible.", namespace: "Espacio: {namespace}", source: "Estructura: {source}", opaque: "Contenedor opaco", truncated: "Visualización acortada por seguridad.", diagnostics: "Diagnósticos" }, coverage: shared.coverage, clean: { title: "Limpieza de privacidad", description: "Crea otro archivo con la política oficial. No decodifica ni recodifica píxeles; conserva el original.", policy: "Elimina EXIF, XMP, IPTC, comentarios, texto y marcas temporales compatibles. Conserva perfiles ICC.", button: "Limpiar y guardar", imageFile: "Imagen limpia" }, result: { title: "Resultado verificado", summary: "Se verificaron {count} controles antes de guardar {name}.", removed: "Eliminado", preserved: "Conservado", none: "Nada informado", diagnostics: "Diagnósticos de limpieza" }, actions: { clear: "Quitar imagen" }, status: { reading: "Inspeccionando localmente…", loaded: "Inspección completa: {count} entradas.", noneFound: "Inspección completa. No se encontraron metadatos compatibles.", cleaning: "Limpiando y verificando…", saved: "{name} verificado y guardado.", downloaded: "{name} verificado y descargado.", cancelled: "Guardado cancelado.", cleared: "Imagen eliminada." }, errors: { oneFile: "Elige una sola imagen.", noFile: "Elige una imagen no vacía.", tooLarge: "Supera el límite de 50 MiB.", signature: "La firma no es JPEG, PNG ni WebP compatible.", unsupported: "Estructura no compatible.", incomplete: "El contenedor está incompleto y no puede limpiarse con seguridad.", limit: "Se superó un límite de seguridad.", inspection: "Falló la inspección.", notCleanable: "La inspección está incompleta.", cleaning: "Falló la limpieza. No se guardó nada.", verification: "La copia no superó la verificación. No se guardó nada.", save: "No se pudo guardar la copia verificada." } }, + }, + de: { + metadata: { title: "Bildmetadaten-Prüfer & Bereiniger — Secure Tools", description: "JPEG-, PNG- und WebP-Metadaten lokal prüfen und entfernen." }, toolName: "Bildmetadaten-Prüfer & Bereiniger", categoryDescription: "Datenschutzrelevante Metadaten prüfen und eine verifizierte Kopie speichern.", + copy: { ...shared, eyebrow: "Bild-Datenschutzwerkzeug", title: "Bildmetadaten-Prüfer & Bereiniger", description: "Metadaten eines JPEG-, PNG- oder WebP-Bildes prüfen und ohne Upload eine verifizierte Kopie erstellen.", scope: { title: "Ehrlicher Prüfumfang", body: "Zeigt dekodierte Metadaten und erkennbare undurchsichtige Container. „Teilweise“ bedeutet erfolgreich, aber nicht vollständig." }, drop: { title: "Ein Bild hinzufügen", description: "JPEG, PNG oder WebP ablegen oder auswählen. Eine neue Datei ersetzt die aktuelle.", choose: "Bild auswählen", localTitle: "Lokal verarbeitet.", localBody: "Das Bild verlässt dieses Gerät nicht.", privacyLink: "So funktioniert Datenschutz" }, source: { title: "Quellbild", empty: "Noch kein Bild ausgewählt.", summary: "{name} · {size} · {format} · {count} Metadaten" }, inspector: { title: "Erkannte Metadaten", empty: "Im unterstützten Umfang wurden keine Metadaten erkannt.", namespace: "Namensraum: {namespace}", source: "Struktur: {source}", opaque: "Undurchsichtiger Container", truncated: "Anzeige aus Sicherheitsgründen gekürzt.", diagnostics: "Parser-Diagnose" }, coverage: shared.coverage, clean: { title: "Datenschutzbereinigung", description: "Erstellt eine neue Datei nach offizieller Richtlinie. Pixel werden nicht dekodiert oder neu kodiert; das Original bleibt erhalten.", policy: "Entfernt unterstützte EXIF-, XMP-, IPTC-, Kommentar-, Text- und Zeitdaten. ICC-Farbprofile bleiben erhalten.", button: "Bereinigen und speichern", imageFile: "Bereinigtes Bild" }, result: { title: "Verifiziertes Ergebnis", summary: "Vor dem Speichern von {name} wurden {count} Richtlinienprüfungen bestätigt.", removed: "Entfernt", preserved: "Erhalten", none: "Keine Meldung", diagnostics: "Bereinigungsdiagnose" }, actions: { clear: "Bild entfernen" }, status: { reading: "Metadaten werden lokal geprüft…", loaded: "Prüfung abgeschlossen: {count} Einträge.", noneFound: "Prüfung abgeschlossen. Keine unterstützten Metadaten gefunden.", cleaning: "Bereinigung und Prüfung laufen…", saved: "{name} verifiziert und gespeichert.", downloaded: "{name} verifiziert und heruntergeladen.", cancelled: "Speichern abgebrochen.", cleared: "Quellbild entfernt." }, errors: { oneFile: "Genau ein Bild auswählen.", noFile: "Ein nicht leeres Bild auswählen.", tooLarge: "Das Bild überschreitet 50 MiB.", signature: "Keine unterstützte JPEG-, PNG- oder WebP-Signatur.", unsupported: "Nicht unterstützte Struktur.", incomplete: "Der Container ist unvollständig und kann nicht sicher bereinigt werden.", limit: "Sicherheitslimit überschritten.", inspection: "Prüfung fehlgeschlagen.", notCleanable: "Die Prüfung ist unvollständig.", cleaning: "Bereinigung fehlgeschlagen. Nichts gespeichert.", verification: "Verifizierung fehlgeschlagen. Nichts gespeichert.", save: "Die verifizierte Kopie konnte nicht gespeichert werden." } }, + }, + fr: { + metadata: { title: "Inspecteur et nettoyeur de métadonnées — Secure Tools", description: "Inspectez et supprimez localement les métadonnées JPEG, PNG et WebP." }, toolName: "Inspecteur et nettoyeur de métadonnées", categoryDescription: "Examinez les métadonnées privées et enregistrez une copie vérifiée.", + copy: { ...shared, eyebrow: "Outil de confidentialité", title: "Inspecteur et nettoyeur de métadonnées", description: "Inspectez les métadonnées d’une image JPEG, PNG ou WebP et créez une copie vérifiée sans transfert.", scope: { title: "Périmètre honnête", body: "Affiche les métadonnées décodées et les conteneurs opaques identifiables. « Partiel » signifie réussi mais non exhaustif." }, drop: { title: "Ajouter une image", description: "Déposez ou choisissez un JPEG, PNG ou WebP. Un nouveau fichier remplace l’actuel.", choose: "Choisir une image", localTitle: "Traitement local.", localBody: "L’image ne quitte pas cet appareil.", privacyLink: "Fonctionnement de la confidentialité" }, source: { title: "Image source", empty: "Aucune image sélectionnée.", summary: "{name} · {size} · {format} · {count} métadonnées" }, inspector: { title: "Métadonnées détectées", empty: "Aucune métadonnée identifiée dans le périmètre pris en charge.", namespace: "Espace : {namespace}", source: "Structure : {source}", opaque: "Conteneur opaque", truncated: "Affichage raccourci par sécurité.", diagnostics: "Diagnostics" }, coverage: shared.coverage, clean: { title: "Nettoyage de confidentialité", description: "Crée un fichier selon la politique officielle. Aucun pixel n’est décodé ni réencodé ; l’original reste intact.", policy: "Supprime EXIF, XMP, IPTC, commentaires, textes et horodatages pris en charge. Conserve les profils ICC.", button: "Nettoyer et enregistrer", imageFile: "Image nettoyée" }, result: { title: "Résultat vérifié", summary: "{count} contrôles vérifiés avant l’enregistrement de {name}.", removed: "Supprimé", preserved: "Conservé", none: "Aucun élément signalé", diagnostics: "Diagnostics du nettoyage" }, actions: { clear: "Retirer l’image" }, status: { reading: "Inspection locale…", loaded: "Inspection terminée : {count} entrées.", noneFound: "Inspection terminée. Aucune métadonnée prise en charge.", cleaning: "Nettoyage et vérification…", saved: "{name} vérifié et enregistré.", downloaded: "{name} vérifié et téléchargé.", cancelled: "Enregistrement annulé.", cleared: "Image source retirée." }, errors: { oneFile: "Choisissez une seule image.", noFile: "Choisissez une image non vide.", tooLarge: "L’image dépasse 50 Mio.", signature: "Signature JPEG, PNG ou WebP non prise en charge.", unsupported: "Structure non prise en charge.", incomplete: "Le conteneur est incomplet et ne peut pas être nettoyé sûrement.", limit: "Limite de sécurité dépassée.", inspection: "Échec de l’inspection.", notCleanable: "L’inspection est incomplète.", cleaning: "Échec du nettoyage. Aucun fichier enregistré.", verification: "La vérification a échoué. Aucun fichier enregistré.", save: "Impossible d’enregistrer la copie vérifiée." } }, + }, +}; diff --git a/tools/image/index.html b/tools/image/index.html index 0bd6a3f..08f8988 100644 --- a/tools/image/index.html +++ b/tools/image/index.html @@ -105,6 +105,16 @@

Image Compressor

+
  • + +
    +Available + +
    +

    Image Metadata Inspector & Cleaner

    +

    Inspect privacy-relevant metadata and save a verified cleaned copy.

    +
    +
  • Available tools process file contents in browser memory. Planned items are clearly marked.

    diff --git a/tools/image/metadata/app.js b/tools/image/metadata/app.js new file mode 100644 index 0000000..d6b5dca --- /dev/null +++ b/tools/image/metadata/app.js @@ -0,0 +1,140 @@ +import { t } from "../../../js/i18n.js"; +import { formatBytes } from "../../shared/file.js"; +import { downloadBlob, requestSaveHandle, writeBlobToHandle } from "../../shared/save.js"; +import { buildCleaningModel, buildInspectionModel } from "./model.js"; +import { cleanAndVerifyImageMetadata, createCleanOutputPlan, inspectImageMetadata } from "./metadata.js"; + +const elements = Object.fromEntries([ + "file-input", "drop-zone", "source-empty", "source-summary", "clear-source", "inspection", "coverage", + "metadata-empty", "metadata-groups", "inspection-diagnostics", "inspection-diagnostic-list", "clean-image", + "clean-result", "result-summary", "removed-list", "preserved-list", "result-diagnostics", + "result-diagnostic-list", "tool-status", +].map((id) => [id.replaceAll("-", "_"), document.querySelector(`#${id}`)])); + +const state = { source: null, inspection: null, result: null, busy: false, status: null }; +const message = (key, values = {}) => Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), t(key)); + +function setStatus(key, values = {}, tone = "neutral") { + state.status = key ? { key, values, tone } : null; + elements.tool_status.textContent = key ? message(key, values) : ""; + elements.tool_status.dataset.tone = tone; +} + +function valueLabels() { + return { opaque: t("imageMetadata.values.opaque"), bytes: t("imageMetadata.values.bytes"), true: t("imageMetadata.values.true"), false: t("imageMetadata.values.false"), unknown: t("imageMetadata.values.unknown") }; +} + +function appendItems(list, values) { + list.replaceChildren(); + const items = values.length ? values : [t("imageMetadata.result.none")]; + items.forEach((value) => { const item = document.createElement("li"); item.textContent = typeof value === "string" ? value : String(value?.name || value?.namespace || value?.id || value); list.append(item); }); +} + +function renderInspection() { + elements.metadata_groups.replaceChildren(); + if (!state.inspection) return; + elements.coverage.textContent = t(state.inspection.coverageKey); + elements.metadata_empty.hidden = state.inspection.count !== 0; + state.inspection.groups.forEach((group) => { + const section = document.createElement("section"); section.className = "metadata-group"; + const heading = document.createElement("h4"); heading.textContent = t(`imageMetadata.groups.${group.key}`); section.append(heading); + const list = document.createElement("dl"); + group.items.forEach((entry) => { + const wrapper = document.createElement("div"); wrapper.className = "metadata-entry"; + const term = document.createElement("dt"); term.textContent = entry.name; + const detail = document.createElement("dd"); + const value = document.createElement("span"); value.className = "metadata-value"; value.textContent = entry.value.text; detail.append(value); + const context = document.createElement("span"); context.className = "metadata-context"; context.textContent = message("imageMetadata.inspector.namespace", { namespace: entry.namespace }); detail.append(context); + if (entry.source) { const source = document.createElement("span"); source.className = "metadata-context"; source.textContent = message("imageMetadata.inspector.source", { source: entry.source }); detail.append(source); } + if (entry.value.opaque) { const opaque = document.createElement("span"); opaque.className = "metadata-flag"; opaque.textContent = t("imageMetadata.inspector.opaque"); detail.append(opaque); } + if (entry.value.truncated) { const note = document.createElement("span"); note.className = "metadata-context"; note.textContent = t("imageMetadata.inspector.truncated"); detail.append(note); } + wrapper.append(term, detail); list.append(wrapper); + }); + section.append(list); elements.metadata_groups.append(section); + }); + appendItems(elements.inspection_diagnostic_list, state.inspection.diagnostics); + elements.inspection_diagnostics.hidden = state.inspection.diagnostics.length === 0; +} + +function renderResult() { + elements.clean_result.hidden = !state.result; + if (!state.result) return; + elements.result_summary.textContent = message("imageMetadata.result.summary", { name: state.result.filename, count: state.result.checks.length }); + appendItems(elements.removed_list, state.result.removed); + appendItems(elements.preserved_list, state.result.preserved); + appendItems(elements.result_diagnostic_list, state.result.diagnostics); + elements.result_diagnostics.hidden = state.result.diagnostics.length === 0; +} + +function render() { + const hasSource = Boolean(state.source); + elements.source_empty.hidden = hasSource; + elements.source_summary.hidden = !hasSource; + if (hasSource) elements.source_summary.textContent = message("imageMetadata.source.summary", { name: state.source.file.name, size: formatBytes(state.source.file.size), format: state.source.format.toUpperCase(), count: state.inspection.count }); + elements.inspection.hidden = !hasSource; + elements.file_input.disabled = state.busy; + elements.clear_source.disabled = state.busy || !hasSource; + elements.clean_image.disabled = state.busy || !hasSource || !state.inspection?.cleanable; + renderInspection(); renderResult(); +} + +function errorKey(error) { + const code = error?.code; + if (code === "IMAGE_METADATA_NO_FILE") return "imageMetadata.errors.noFile"; + if (code === "IMAGE_FILE_TOO_LARGE") return "imageMetadata.errors.tooLarge"; + if (code === "IMAGE_SIGNATURE_INVALID" || code === "UNSUPPORTED_IMAGE") return "imageMetadata.errors.signature"; + if (code === "IMAGE_METADATA_UNSUPPORTED") return "imageMetadata.errors.unsupported"; + if (code?.startsWith("IMAGE_METADATA_INCOMPLETE_")) return "imageMetadata.errors.incomplete"; + if (code === "IMAGE_METADATA_INPUT_LIMIT") return "imageMetadata.errors.limit"; + if (code === "IMAGE_METADATA_NOT_CLEANABLE") return "imageMetadata.errors.notCleanable"; + if (code === "IMAGE_METADATA_CLEANING_FAILED") return "imageMetadata.errors.cleaning"; + if (code === "IMAGE_METADATA_VERIFICATION_FAILED") return "imageMetadata.errors.verification"; + return "imageMetadata.errors.inspection"; +} + +async function addSource(files) { + if (state.busy || !files.length) return; + if (files.length !== 1) { setStatus("imageMetadata.errors.oneFile", {}, "error"); return; } + state.busy = true; state.source = null; state.inspection = null; state.result = null; render(); setStatus("imageMetadata.status.reading"); + try { + state.source = await inspectImageMetadata(files[0]); + state.inspection = buildInspectionModel(state.source, valueLabels()); + setStatus(state.inspection.count ? "imageMetadata.status.loaded" : "imageMetadata.status.noneFound", { count: state.inspection.count }, state.inspection.successful ? "success" : "warning"); + } catch (error) { + console.error("Image metadata inspection failed", error?.code || error?.name || "error"); setStatus(errorKey(error), {}, "error"); + } finally { state.busy = false; elements.file_input.value = ""; render(); } +} + +function clearSource() { + if (state.busy) return; + state.source = null; state.inspection = null; state.result = null; setStatus("imageMetadata.status.cleared"); render(); +} + +async function cleanAndSave() { + if (!state.source || state.busy) return; + const plan = createCleanOutputPlan(state.source); + let handle = null; + if ("showSaveFilePicker" in window) { + try { handle = await requestSaveHandle(window, { suggestedName: plan.filename, description: t("imageMetadata.clean.imageFile"), mimeType: plan.mimeType, extension: plan.extension }); } + catch (error) { if (error?.name === "AbortError") setStatus("imageMetadata.status.cancelled"); else setStatus("imageMetadata.errors.save", {}, "error"); return; } + } + state.busy = true; state.result = null; render(); setStatus("imageMetadata.status.cleaning"); + try { + const result = await cleanAndVerifyImageMetadata(state.source); + if (handle) await writeBlobToHandle(handle, result.blob); else downloadBlob(result.blob, result.plan.filename); + state.result = buildCleaningModel(result); + setStatus(handle ? "imageMetadata.status.saved" : "imageMetadata.status.downloaded", { name: result.plan.filename }, "success"); + } catch (error) { + console.error("Image metadata cleaning failed", error?.code || error?.name || "error"); + setStatus(errorKey(error), {}, "error"); + } finally { state.busy = false; render(); } +} + +elements.file_input.addEventListener("change", (event) => addSource([...event.target.files])); +elements.clear_source.addEventListener("click", clearSource); +elements.clean_image.addEventListener("click", cleanAndSave); +for (const type of ["dragenter", "dragover"]) elements.drop_zone.addEventListener(type, (event) => { event.preventDefault(); if (!state.busy) elements.drop_zone.dataset.dragging = "true"; }); +for (const type of ["dragleave", "drop"]) elements.drop_zone.addEventListener(type, (event) => { event.preventDefault(); delete elements.drop_zone.dataset.dragging; }); +elements.drop_zone.addEventListener("drop", (event) => { if (!state.busy) addSource([...event.dataTransfer.files]); }); +document.addEventListener("securetools:languagechange", () => { if (state.source) state.inspection = buildInspectionModel(state.source, valueLabels()); render(); if (state.status) setStatus(state.status.key, state.status.values, state.status.tone); }); +render(); diff --git a/tools/image/metadata/index.html b/tools/image/metadata/index.html new file mode 100644 index 0000000..501afc7 --- /dev/null +++ b/tools/image/metadata/index.html @@ -0,0 +1,17 @@ + + + +Image Metadata Inspector & Cleaner — Secure Tools + + + + +

    Image privacy tool

    Image Metadata Inspector & Cleaner

    Inspect metadata in one image, then create a verified cleaned copy without uploading it.

    + +

    Add one image

    Drop a JPEG, PNG, or WebP here or use the picker. Choosing another file replaces the current source.

    Processed locally. Your image never leaves this device. How privacy works

    + + + +

    + + diff --git a/tools/image/metadata/tool.css b/tools/image/metadata/tool.css new file mode 100644 index 0000000..81d76fd --- /dev/null +++ b/tools/image/metadata/tool.css @@ -0,0 +1,34 @@ +.scope-note, .metadata-workspace, .clean-result { margin-top: var(--space-5); padding: var(--space-5); } +.scope-note { border-left: .25rem solid var(--accent); } +.scope-note h2, .scope-note p, .metadata-toolbar h2, .metadata-toolbar p, .inspector-heading h3, .clean-result h2, .clean-result p { margin: 0; } +.scope-note p, .metadata-toolbar p, .coverage-note, .metadata-context { color: var(--text-secondary); } +.metadata-toolbar { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); } +.coverage-note { padding: var(--space-3); border-radius: var(--radius-sm); background: var(--bg-subtle); } +.inspector-heading { margin-top: var(--space-5); } +.metadata-groups { display: grid; gap: var(--space-4); margin-top: var(--space-4); } +.metadata-group { min-width: 0; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.metadata-group h4 { margin: 0 0 var(--space-3); } +.metadata-group dl { display: grid; gap: var(--space-3); margin: 0; } +.metadata-entry { display: grid; grid-template-columns: minmax(9rem, .4fr) minmax(0, 1fr); gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border); } +.metadata-entry:first-child { padding-top: 0; border-top: 0; } +.metadata-entry dt { font-weight: var(--font-weight-bold); overflow-wrap: anywhere; } +.metadata-entry dd { min-width: 0; margin: 0; } +.metadata-value { display: block; overflow-wrap: anywhere; white-space: pre-wrap; } +.metadata-context, .metadata-flag { display: block; margin-top: var(--space-1); font-size: .8rem; overflow-wrap: anywhere; } +.metadata-flag { width: fit-content; padding: .1rem .45rem; border-radius: 999px; color: var(--text-primary); background: color-mix(in srgb, var(--accent) 14%, transparent); } +details { margin-top: var(--space-4); } +details li { overflow-wrap: anywhere; } +.tool-output .output-scope { margin-top: var(--space-2); font-size: .85rem; } +.result-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-4); } +.result-grid > div { min-width: 0; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-subtle); } +.result-grid h3 { margin-top: 0; } +.result-grid li { overflow-wrap: anywhere; } +[hidden] { display: none !important; } +@media (prefers-reduced-motion: reduce) { .metadata-entry { transition: none; } } +@media (max-width: 38rem) { + .scope-note, .metadata-workspace, .clean-result { padding: var(--space-4); } + .metadata-toolbar { flex-direction: column; } + .metadata-toolbar .button { width: 100%; } + .metadata-entry { grid-template-columns: 1fr; gap: var(--space-1); } + .result-grid { grid-template-columns: 1fr; } +} From 4e4e8a0b4533f47ac6ff2846eeb430210fb0dde0 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 26 Aug 2026 14:03:32 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B[Fix]=20Correct=20metadata=20ca?= =?UTF-8?q?tegories=20and=20locale=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/locales/de.js | 4 +++- js/locales/en.js | 4 ++-- js/locales/es.js | 4 +++- js/locales/fr.js | 4 +++- js/locales/image-metadata.js | 31 ++++++++++++++++++++++++++++++- js/locales/ja.js | 4 +++- js/locales/ko.js | 4 ++-- tools/image/metadata/model.js | 32 +++++++++++++++++++++++++++----- 8 files changed, 73 insertions(+), 14 deletions(-) diff --git a/js/locales/de.js b/js/locales/de.js index 351e134..e7e6896 100644 --- a/js/locales/de.js +++ b/js/locales/de.js @@ -122,6 +122,7 @@ export const de = { "imageConverter": "Bildkonverter", "imageCompressor": "Bildkompressor", "imageResizer": "Bild-Resizer", + "imageMetadata": "Bildmetadaten-Prüfer & Bereiniger", "metadataInspector": "Metadaten-Inspektor", "metadataCleaner": "Metadaten-Reiniger", "available": "Verfügbar", @@ -153,7 +154,8 @@ export const de = { "description": "Bereiten Sie alltägliche Bilder privat in Ihrem Browser vor.", "convert": "Konvertieren Sie gängige Bildformate lokal.", "compress": "Reduzieren Sie die Dateigröße mit klaren Qualitätskontrollen.", - "resize": "Bilder auf exakte Pixelabmessungen skalieren." + "resize": "Bilder auf exakte Pixelabmessungen skalieren.", + "metadata": "Datenschutzrelevante Metadaten prüfen und eine verifizierte Kopie speichern." }, "privacy": { "title": "Datenschutz-Tools", diff --git a/js/locales/en.js b/js/locales/en.js index 5c39a47..392edfd 100644 --- a/js/locales/en.js +++ b/js/locales/en.js @@ -36,7 +36,7 @@ export const en = { categoryDescriptions: { pdf: "Create, combine, and organize documents.", image: "Convert and prepare everyday images.", privacy: "Inspect and clean hidden file details.", scan: "Turn scans into useful documents.", media: "Work with audio and video locally." }, browseCategory: "Browse category →", imagesToPdf: "Images to PDF", mergePdf: "Merge PDF", splitPdf: "Split PDF", organizePdf: "Organize PDF", pdfToImages: "PDF to Images", pdfMetadata: "PDF Metadata", imageConverter: "Image Converter", - imageCompressor: "Image Compressor", imageResizer: "Image Resizer", metadataInspector: "Metadata Inspector", metadataCleaner: "Metadata Cleaner", + imageCompressor: "Image Compressor", imageResizer: "Image Resizer", imageMetadata: "Image Metadata Inspector & Cleaner", metadataInspector: "Metadata Inspector", metadataCleaner: "Metadata Cleaner", available: "Available", comingSoon: "Coming soon", }, categories: { @@ -54,7 +54,7 @@ export const en = { }, image: { title: "Image tools", description: "Prepare everyday images privately in your browser.", - convert: "Convert common image formats locally.", compress: "Reduce file size with clear quality controls.", resize: "Resize images to exact pixel dimensions.", + convert: "Convert common image formats locally.", compress: "Reduce file size with clear quality controls.", resize: "Resize images to exact pixel dimensions.", metadata: "Inspect privacy-relevant metadata and save a verified cleaned copy.", }, privacy: { title: "Privacy tools", description: "Inspect and remove hidden file details without sharing the source file.", diff --git a/js/locales/es.js b/js/locales/es.js index 3d50a9e..16ed51d 100644 --- a/js/locales/es.js +++ b/js/locales/es.js @@ -122,6 +122,7 @@ export const es = { "imageConverter": "Convertidor de imágenes", "imageCompressor": "Compresor de imagen", "imageResizer": "Cambio de tamaño de imagen", + "imageMetadata": "Inspector y limpiador de metadatos", "metadataInspector": "Inspector de metadatos", "metadataCleaner": "Limpiador de metadatos", "available": "Disponible", @@ -153,7 +154,8 @@ export const es = { "description": "Prepare imágenes cotidianas de forma privada en su navegador.", "convert": "Convierte formatos de imagen comunes localmente.", "compress": "Reduzca el tamaño del archivo con controles de calidad claros.", - "resize": "Cambia el tamaño de las imágenes a las dimensiones exactas en píxeles." + "resize": "Cambia el tamaño de las imágenes a las dimensiones exactas en píxeles.", + "metadata": "Revisa metadatos de privacidad y guarda una copia verificada." }, "privacy": { "title": "Herramientas de privacidad", diff --git a/js/locales/fr.js b/js/locales/fr.js index 3e9334b..e8b8c4d 100644 --- a/js/locales/fr.js +++ b/js/locales/fr.js @@ -122,6 +122,7 @@ export const fr = { "imageConverter": "Convertisseur d'images", "imageCompressor": "Compresseur d'images", "imageResizer": "Redimensionneur d'image", + "imageMetadata": "Inspecteur et nettoyeur de métadonnées", "metadataInspector": "Inspecteur de métadonnées", "metadataCleaner": "Nettoyeur de métadonnées", "available": "Disponible", @@ -153,7 +154,8 @@ export const fr = { "description": "Préparez des images de tous les jours en privé dans votre navigateur.", "convert": "Convertissez localement les formats d'image courants.", "compress": "Réduisez la taille des fichiers avec des contrôles de qualité clairs.", - "resize": "Redimensionnez les images aux dimensions exactes en pixels." + "resize": "Redimensionnez les images aux dimensions exactes en pixels.", + "metadata": "Examinez les métadonnées privées et enregistrez une copie vérifiée." }, "privacy": { "title": "Outils de confidentialité", diff --git a/js/locales/image-metadata.js b/js/locales/image-metadata.js index a56ec18..e8b9776 100644 --- a/js/locales/image-metadata.js +++ b/js/locales/image-metadata.js @@ -1,5 +1,5 @@ const shared = { - groups: { location: "Location", device: "Device & camera", author: "Author & creator", time: "Dates & time", descriptive: "Description & keywords", rights: "Rights", technical: "Technical", other: "Other" }, + groups: { location: "Location", device: "Device & camera", time: "Dates & time", technical: "Image & EXIF", software: "Software", author: "Author & identity", rights: "Copyright & rights", descriptive: "Description & text", xmp: "XMP", iptc: "IPTC", color: "Color profile", rendering: "Rendering", other: "Other & unknown" }, values: { opaque: "Detected, payload not decoded", bytes: "Binary payload ({count} bytes)", true: "True", false: "False", unknown: "Unknown metadata" }, coverage: { "container-inspected": "Container inspection completed for the supported structures.", "metadata-inspected": "Metadata inspection completed for the supported structures.", "metadata-partial": "Inspection succeeded, but metadata coverage is partial and non-exhaustive.", "container-partial": "The image container is incomplete. Cleaning is disabled because safe verification is not possible." }, }; @@ -31,3 +31,32 @@ export const imageMetadataLocales = { copy: { ...shared, eyebrow: "Outil de confidentialité", title: "Inspecteur et nettoyeur de métadonnées", description: "Inspectez les métadonnées d’une image JPEG, PNG ou WebP et créez une copie vérifiée sans transfert.", scope: { title: "Périmètre honnête", body: "Affiche les métadonnées décodées et les conteneurs opaques identifiables. « Partiel » signifie réussi mais non exhaustif." }, drop: { title: "Ajouter une image", description: "Déposez ou choisissez un JPEG, PNG ou WebP. Un nouveau fichier remplace l’actuel.", choose: "Choisir une image", localTitle: "Traitement local.", localBody: "L’image ne quitte pas cet appareil.", privacyLink: "Fonctionnement de la confidentialité" }, source: { title: "Image source", empty: "Aucune image sélectionnée.", summary: "{name} · {size} · {format} · {count} métadonnées" }, inspector: { title: "Métadonnées détectées", empty: "Aucune métadonnée identifiée dans le périmètre pris en charge.", namespace: "Espace : {namespace}", source: "Structure : {source}", opaque: "Conteneur opaque", truncated: "Affichage raccourci par sécurité.", diagnostics: "Diagnostics" }, coverage: shared.coverage, clean: { title: "Nettoyage de confidentialité", description: "Crée un fichier selon la politique officielle. Aucun pixel n’est décodé ni réencodé ; l’original reste intact.", policy: "Supprime EXIF, XMP, IPTC, commentaires, textes et horodatages pris en charge. Conserve les profils ICC.", button: "Nettoyer et enregistrer", imageFile: "Image nettoyée" }, result: { title: "Résultat vérifié", summary: "{count} contrôles vérifiés avant l’enregistrement de {name}.", removed: "Supprimé", preserved: "Conservé", none: "Aucun élément signalé", diagnostics: "Diagnostics du nettoyage" }, actions: { clear: "Retirer l’image" }, status: { reading: "Inspection locale…", loaded: "Inspection terminée : {count} entrées.", noneFound: "Inspection terminée. Aucune métadonnée prise en charge.", cleaning: "Nettoyage et vérification…", saved: "{name} vérifié et enregistré.", downloaded: "{name} vérifié et téléchargé.", cancelled: "Enregistrement annulé.", cleared: "Image source retirée." }, errors: { oneFile: "Choisissez une seule image.", noFile: "Choisissez une image non vide.", tooLarge: "L’image dépasse 50 Mio.", signature: "Signature JPEG, PNG ou WebP non prise en charge.", unsupported: "Structure non prise en charge.", incomplete: "Le conteneur est incomplet et ne peut pas être nettoyé sûrement.", limit: "Limite de sécurité dépassée.", inspection: "Échec de l’inspection.", notCleanable: "L’inspection est incomplète.", cleaning: "Échec du nettoyage. Aucun fichier enregistré.", verification: "La vérification a échoué. Aucun fichier enregistré.", save: "Impossible d’enregistrer la copie vérifiée." } }, }, }; + +const localizedPresentation = { + en: { + groups: shared.groups, values: shared.values, + coverage: { "format-only": "The image format was recognized, but detailed metadata inspection is unavailable.", "container-inspected": "Supported metadata containers were checked. Some payloads may remain opaque.", "container-partial": "Container inspection could not be completed safely. Cleaning is disabled.", "metadata-partial": "Supported metadata fields were decoded, but the inspection is successful and non-exhaustive.", "metadata-inspected": "Supported metadata inspection completed under the library’s current semantics; this is not proof of complete privacy." }, + }, + ko: { + groups: { location: "위치", device: "카메라 및 기기", time: "날짜 및 시간", technical: "이미지 및 EXIF", software: "소프트웨어", author: "작성자 및 식별 정보", rights: "저작권 및 권리", descriptive: "설명 및 텍스트", xmp: "XMP", iptc: "IPTC", color: "색상 프로필", rendering: "렌더링", other: "기타 및 알 수 없음" }, + values: { opaque: "감지됨, 페이로드는 해석되지 않음", bytes: "바이너리 페이로드 ({count}바이트)", true: "참", false: "거짓", unknown: "알 수 없는 메타데이터" }, + coverage: { "format-only": "이미지 형식은 인식했지만 자세한 메타데이터 검사는 지원되지 않습니다.", "container-inspected": "지원되는 메타데이터 컨테이너를 확인했습니다. 일부 페이로드는 해석되지 않을 수 있습니다.", "container-partial": "컨테이너 검사를 안전하게 완료하지 못해 정리를 비활성화했습니다.", "metadata-partial": "지원되는 메타데이터 필드를 해석했지만 성공한 검사는 완전하지 않습니다.", "metadata-inspected": "라이브러리의 현재 의미 범위에서 검사를 완료했으며 완전한 개인정보 보호를 보증하지 않습니다." }, + }, + ja: { + groups: { location: "位置", device: "カメラ・端末", time: "日時", technical: "画像・EXIF", software: "ソフトウェア", author: "作成者・識別情報", rights: "著作権・権利", descriptive: "説明・テキスト", xmp: "XMP", iptc: "IPTC", color: "カラープロファイル", rendering: "レンダリング", other: "その他・不明" }, values: { opaque: "検出済み(内容は未解読)", bytes: "バイナリデータ({count}バイト)", true: "真", false: "偽", unknown: "不明なメタデータ" }, + coverage: { "format-only": "形式は認識されましたが、詳細なメタデータ検査は利用できません。", "container-inspected": "対応するコンテナを確認しました。一部の内容は不透明な場合があります。", "container-partial": "コンテナ検査を安全に完了できないため、クリーン機能を無効にしました。", "metadata-partial": "対応フィールドを解読しましたが、検査は成功した非網羅的なものです。", "metadata-inspected": "ライブラリの現在の意味範囲で検査を完了しました。完全なプライバシーの証明ではありません。" }, + }, + es: { + groups: { location: "Ubicación", device: "Cámara y dispositivo", time: "Fecha y hora", technical: "Imagen y EXIF", software: "Software", author: "Autor e identidad", rights: "Derechos", descriptive: "Descripción y texto", xmp: "XMP", iptc: "IPTC", color: "Perfil de color", rendering: "Renderizado", other: "Otros o desconocidos" }, values: { opaque: "Detectado; contenido no decodificado", bytes: "Contenido binario ({count} bytes)", true: "Verdadero", false: "Falso", unknown: "Metadato desconocido" }, + coverage: { "format-only": "Se reconoció el formato, pero no hay inspección detallada.", "container-inspected": "Se revisaron los contenedores compatibles. Algún contenido puede seguir opaco.", "container-partial": "No se pudo completar la inspección con seguridad; la limpieza está desactivada.", "metadata-partial": "Se decodificaron campos compatibles, pero la inspección correcta no es exhaustiva.", "metadata-inspected": "La inspección terminó según la semántica actual; no prueba privacidad completa." }, + }, + de: { + groups: { location: "Standort", device: "Kamera und Gerät", time: "Datum und Zeit", technical: "Bild und EXIF", software: "Software", author: "Autor und Identität", rights: "Urheberrecht und Rechte", descriptive: "Beschreibung und Text", xmp: "XMP", iptc: "IPTC", color: "Farbprofil", rendering: "Darstellung", other: "Sonstige und unbekannte" }, values: { opaque: "Erkannt, Nutzdaten nicht dekodiert", bytes: "Binäre Nutzdaten ({count} Bytes)", true: "Wahr", false: "Falsch", unknown: "Unbekannte Metadaten" }, + coverage: { "format-only": "Das Format wurde erkannt, eine Detailprüfung ist jedoch nicht verfügbar.", "container-inspected": "Unterstützte Container wurden geprüft. Einige Nutzdaten können undurchsichtig bleiben.", "container-partial": "Die Prüfung konnte nicht sicher abgeschlossen werden; Bereinigung ist deaktiviert.", "metadata-partial": "Unterstützte Felder wurden dekodiert, die erfolgreiche Prüfung ist aber nicht vollständig.", "metadata-inspected": "Prüfung nach aktueller Semantik abgeschlossen; dies beweist keinen vollständigen Datenschutz." }, + }, + fr: { + groups: { location: "Localisation", device: "Appareil photo", time: "Date et heure", technical: "Image et EXIF", software: "Logiciel", author: "Auteur et identité", rights: "Droits", descriptive: "Description et texte", xmp: "XMP", iptc: "IPTC", color: "Profil colorimétrique", rendering: "Rendu", other: "Autres et inconnues" }, values: { opaque: "Détecté, contenu non décodé", bytes: "Contenu binaire ({count} octets)", true: "Vrai", false: "Faux", unknown: "Métadonnée inconnue" }, + coverage: { "format-only": "Le format est reconnu, mais l’inspection détaillée n’est pas disponible.", "container-inspected": "Les conteneurs pris en charge ont été vérifiés. Certains contenus peuvent rester opaques.", "container-partial": "L’inspection n’a pas pu être terminée sûrement ; le nettoyage est désactivé.", "metadata-partial": "Les champs pris en charge ont été décodés, mais l’inspection réussie n’est pas exhaustive.", "metadata-inspected": "Inspection terminée selon la sémantique actuelle ; elle ne prouve pas une confidentialité complète." }, + }, +}; +for (const [language, presentation] of Object.entries(localizedPresentation)) Object.assign(imageMetadataLocales[language].copy, presentation); diff --git a/js/locales/ja.js b/js/locales/ja.js index 04ecda6..5f53e58 100644 --- a/js/locales/ja.js +++ b/js/locales/ja.js @@ -122,6 +122,7 @@ export const ja = { "imageConverter": "画像変換", "imageCompressor": "画像圧縮", "imageResizer": "画像サイズ変更", + "imageMetadata": "画像メタデータ検査・クリーナー", "metadataInspector": "メタデータ確認", "metadataCleaner": "メタデータ削除", "available": "利用可能", @@ -153,7 +154,8 @@ export const ja = { "description": "画像ファイルをブラウザ上でプライベートに処理します", "convert": "一般的な画像形式を端末内で変換します", "compress": "画質を調整しながらファイルサイズを小さくします", - "resize": "画像を指定したピクセルサイズに変更します" + "resize": "画像を指定したピクセルサイズに変更します", + "metadata": "プライバシー関連メタデータを確認し、検証済みコピーを保存します" }, "privacy": { "title": "プライバシーツール", diff --git a/js/locales/ko.js b/js/locales/ko.js index 5ff80b3..4992bc1 100644 --- a/js/locales/ko.js +++ b/js/locales/ko.js @@ -31,7 +31,7 @@ export const ko = { categories: { pdf: "PDF", image: "이미지", privacy: "개인정보 보호", scan: "스캔 및 OCR", media: "미디어" }, categoryDescriptions: { pdf: "문서를 만들고 합치고 정리합니다", image: "이미지를 변환하고 다듬습니다", privacy: "숨겨진 파일 정보를 확인하고 정리합니다", scan: "스캔을 활용 가능한 문서로 바꿉니다", media: "오디오와 비디오를 기기에서 처리합니다" }, browseCategory: "도구 보기 →", - imagesToPdf: "이미지를 PDF로", mergePdf: "PDF 합치기", splitPdf: "PDF 나누기", organizePdf: "PDF 정리", pdfToImages: "PDF를 이미지로", pdfMetadata: "PDF 메타데이터", imageConverter: "이미지 변환기", imageCompressor: "이미지 압축기", imageResizer: "이미지 크기 조절", metadataInspector: "메타데이터 확인", metadataCleaner: "메타데이터 제거", + imagesToPdf: "이미지를 PDF로", mergePdf: "PDF 합치기", splitPdf: "PDF 나누기", organizePdf: "PDF 정리", pdfToImages: "PDF를 이미지로", pdfMetadata: "PDF 메타데이터", imageConverter: "이미지 변환기", imageCompressor: "이미지 압축기", imageResizer: "이미지 크기 조절", imageMetadata: "이미지 메타데이터 검사 및 정리", metadataInspector: "메타데이터 확인", metadataCleaner: "메타데이터 제거", available: "사용 가능", comingSoon: "준비 중", }, categories: { @@ -49,7 +49,7 @@ export const ko = { }, image: { title: "이미지 도구", description: "일상 이미지 파일을 브라우저에서 비공개로 준비하세요.", - convert: "일반 이미지 형식을 로컬에서 변환합니다.", compress: "명확한 품질 설정으로 파일 크기를 줄입니다.", resize: "이미지를 정확한 픽셀 크기로 조절합니다.", + convert: "일반 이미지 형식을 로컬에서 변환합니다.", compress: "명확한 품질 설정으로 파일 크기를 줄입니다.", resize: "이미지를 정확한 픽셀 크기로 조절합니다.", metadata: "개인정보 관련 메타데이터를 확인하고 검증된 정리본을 저장합니다.", }, privacy: { title: "개인정보 보호 도구", description: "원본 파일을 공유하지 않고 숨겨진 정보를 확인하고 제거하세요.", diff --git a/tools/image/metadata/model.js b/tools/image/metadata/model.js index b765bd8..8d92b1c 100644 --- a/tools/image/metadata/model.js +++ b/tools/image/metadata/model.js @@ -1,13 +1,35 @@ export const MAX_METADATA_VALUE_LENGTH = 2000; export const MAX_DIAGNOSTIC_LENGTH = 500; -const GROUPS = Object.freeze(["location", "device", "author", "time", "descriptive", "rights", "technical", "other"]); +const GROUPS = Object.freeze(["location", "device", "time", "technical", "software", "author", "rights", "descriptive", "xmp", "iptc", "color", "rendering", "other"]); +const CATEGORY_GROUP = Object.freeze({ + location: "location", + device: "device", + timestamp: "time", + technical: "technical", + software: "software", + identity: "author", + rights: "rights", + description: "descriptive", + color: "color", + rendering: "rendering", +}); function boundedText(value, limit) { const text = String(value ?? "").replaceAll("\0", "�"); return { text: text.length > limit ? `${text.slice(0, limit)}…` : text, truncated: text.length > limit }; } +function diagnosticText(item) { + if (!item || typeof item !== "object") return boundedText(item, MAX_DIAGNOSTIC_LENGTH).text; + const parts = []; + if (item.severity) parts.push(String(item.severity).toUpperCase()); + if (item.code) parts.push(String(item.code)); + if (Number.isSafeInteger(item.offset)) parts.push(`byte ${item.offset}`); + if (item.message) parts.push(String(item.message)); + return boundedText(parts.join(" · ") || "Diagnostic", MAX_DIAGNOSTIC_LENGTH).text; +} + function rational(value) { return value && typeof value === "object" && Number.isFinite(value.numerator) && Number.isFinite(value.denominator); } @@ -28,8 +50,8 @@ export function formatMetadataValue(value, labels = {}) { function groupFor(entry) { const namespace = String(entry.namespace || "").toLowerCase(); const category = String(entry.category || "other").toLowerCase(); - if (namespace === "xmp" || namespace === "iptc") return namespace === "iptc" ? "descriptive" : (GROUPS.includes(category) ? category : "descriptive"); - return GROUPS.includes(category) ? category : "other"; + if (namespace === "xmp" || namespace === "iptc") return namespace; + return CATEGORY_GROUP[category] || "other"; } export function buildInspectionModel(source, labels = {}) { @@ -53,7 +75,7 @@ export function buildInspectionModel(source, labels = {}) { cleanable: source?.cleanable === true, count: entries.length, groups: [...groups].filter(([, items]) => items.length).map(([key, items]) => ({ key, items })), - diagnostics: (source?.report?.diagnostics || []).map((item) => boundedText(item?.message || item?.code || item, MAX_DIAGNOSTIC_LENGTH).text), + diagnostics: (source?.report?.diagnostics || []).map(diagnosticText), }; } @@ -68,6 +90,6 @@ export function buildCleaningModel(result) { checks, valid: result?.verification?.valid === true && checks.length > 0 && checks.every((check) => check?.passed === true), diagnostics: [...(result?.cleaned?.diagnostics || []), ...(result?.verification?.diagnostics || [])] - .map((item) => boundedText(item?.message || item?.code || item, MAX_DIAGNOSTIC_LENGTH).text), + .map(diagnosticText), }; } From 79361ffba80a1ad7bcb52e934273e7ebf3600468 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Wed, 26 Aug 2026 14:03:42 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85[Test]=20Cover=20image=20metadata?= =?UTF-8?q?=20integration=20and=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/i18n-quality.test.mjs | 5 +- tests/image-compressor.test.mjs | 2 +- tests/image-converter.test.mjs | 2 +- tests/image-metadata.test.mjs | 162 ++++++++++++++++++++++++++++++++ tests/image-resize.test.mjs | 4 +- tests/release-gate.test.mjs | 15 +++ tests/run-all.mjs | 1 + 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/image-metadata.test.mjs diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 9b06ae6..d5cba2a 100644 --- a/tests/i18n-quality.test.mjs +++ b/tests/i18n-quality.test.mjs @@ -45,7 +45,7 @@ function placeholders(value) { function testCatalogParityAndQuality() { assert.deepEqual([...Object.keys(translations)], [...languageNames.keys()]); const english = flatten(translations.en); - assert.equal(english.size, 632); + assert.equal(english.size, 712); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); @@ -77,7 +77,7 @@ function testResolutionDetectionAndPersistence() { function testSelectorsAndDocumentTranslation() { const pages = listFiles(root, (file) => file.endsWith(".html") && fs.readFileSync(file, "utf8").includes("data-language-select")); - assert.equal(pages.length, 18, "Every production page with the shared header must expose the language selector"); + assert.equal(pages.length, 19, "Every production page with the shared header must expose the language selector"); for (const file of pages) { const html = fs.readFileSync(file, "utf8"); const select = html.match(/]*data-language-select[^>]*>([\s\S]*?)<\/select>/)?.[1]; @@ -101,6 +101,7 @@ function testDynamicToolsAndMetadata() { "tools/pdf/organize/app.js", "tools/pdf/to-images/app.js", "tools/pdf/metadata/app.js", + "tools/image/metadata/app.js", ]; for (const relative of dynamicApps) { const source = fs.readFileSync(path.join(root, relative), "utf8"); diff --git a/tests/image-compressor.test.mjs b/tests/image-compressor.test.mjs index 2260d25..80d52db 100644 --- a/tests/image-compressor.test.mjs +++ b/tests/image-compressor.test.mjs @@ -50,7 +50,7 @@ await assert.rejects(compressImages({ files: Array.from({ length: 5 }, (_, i) => assert.equal(MAX_JOB_PIXELS, 200_000_000); assert.equal(limitClosed, 5); const html = read("tools/image/compress/index.html"); const app = read("tools/image/compress/app.js"); const logic = read("tools/image/compress/compressor.js"); const css = read("tools/image/compress/tool.css"); const category = read("tools/image/index.html"); -assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); assert.doesNotMatch(category, /metadataInspector|metadataCleaner|categories\.plannedNote/); +assert.match(category, /href="\.\/converter\/"/); assert.match(category, /href="\.\/resize\/"/); assert.match(category, /href="\.\/compress\/"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /id="output-format"[\s\S]*value="original"[\s\S]*value="jpeg"[\s\S]*value="png"[\s\S]*value="webp"/); assert.match(html, /id="quality"[^>]*min="0\.5"[^>]*max="1"[^>]*value="0\.8"/); assert.match(html, /id="compression-results"[^>]*hidden[^>]*aria-labelledby="results-title"/); assert.match(html, /role="status" aria-live="polite"/); assert.match(html, /connect-src 'none'/); assert.match(app, /showQuality = elements\.format\.value !== "png"/); assert.match(app, /invalidateResults\(true\)/); assert.match(app, /finally \{ state\.busy = false; elements\.progress\.hidden = true/); assert.match(app, /URL\.revokeObjectURL/); assert.match(logic, /decoded\?\.close\(\)/); assert.match(read("tools/shared/image.js"), /imageOrientation: "from-image"/); assert.match(app, /const metrics = \{ results: result\.results, aggregate: result\.aggregate \}/, "Result UI state must not retain generated output blobs"); diff --git a/tests/image-converter.test.mjs b/tests/image-converter.test.mjs index e25b19f..9dbc977 100644 --- a/tests/image-converter.test.mjs +++ b/tests/image-converter.test.mjs @@ -172,7 +172,7 @@ const organizer = read("tools/pdf/organize/app.js"); assert.match(category, /href="\.\/converter\/"[\s\S]*status--available/); assert.match(category, /href="\.\/resize\/"[\s\S]*status--available/); assert.match(category, /href="\.\/compress\/"[\s\S]*status--available/); -assert.equal((category.match(/class="category-tool surface"/g) || []).length, 3); +assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); assert.doesNotMatch(category, /categories\.plannedNote/); assert.match(html, /type="file"[^>]*multiple[^>]*aria-describedby="drop-description"/); assert.match(html, /