From 7501783886d395ac934f2c4c31df18abf435a47e Mon Sep 17 00:00:00 2001 From: maruson08 Date: Thu, 27 Aug 2026 13:49:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9E=95[Add]=20Vendor=20secure-metadata?= =?UTF-8?q?=20v0.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/vendor/secure-metadata/README.md | 28 ++- assets/vendor/secure-metadata/package.json | 2 +- ...er.js => secure-metadata-0.1.1.browser.js} | 215 ++++++++++++++---- tests/image-metadata.test.mjs | 2 +- tests/release-gate.test.mjs | 14 +- tools/image/metadata/metadata.js | 2 +- 6 files changed, 203 insertions(+), 60 deletions(-) rename assets/vendor/secure-metadata/{secure-metadata-0.1.0.browser.js => secure-metadata-0.1.1.browser.js} (92%) diff --git a/assets/vendor/secure-metadata/README.md b/assets/vendor/secure-metadata/README.md index 0158ad3..17487c9 100644 --- a/assets/vendor/secure-metadata/README.md +++ b/assets/vendor/secure-metadata/README.md @@ -1,19 +1,23 @@ # secure-metadata -Manually pinned browser artifact for local image metadata inspection, cleaning, and verification. +This directory contains the approved same-origin browser runtime used by Image Metadata Inspector & Cleaner. + +## Provenance - 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` +- Version: `0.1.1` +- Source repository: `SecureToolsProject/Secure_Metadata` +- Release tag: `v0.1.1` +- Release commit: `cdcd138e48d30618b6d76f7c6538cd43ad660b53` +- Browser artifact: `secure-metadata-0.1.1.browser.js` +- Browser artifact SHA-256: `4bfcc9e0e484db12192e46f076c19cf69cd36c496c7cfbb5a71c1057cbcccba1` +- Package artifact: `secure-metadata-0.1.1.tgz` +- Package artifact SHA-256: `4ecaedeeac12ddda1821afb93f0b9f9adc2323b38c7f865ad9b026550fd4305d` +- License: MIT - 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. +## Integrity and runtime use + +The browser and package artifacts were downloaded from the immutable GitHub `v0.1.1` Release and checked locally against the published `SHA256SUMS` manifest and GitHub asset digests. The browser bytes are unchanged and match the browser build inside the published package: they were not rebuilt, minified, reformatted, concatenated, or stripped. `LICENSE` and `package.json` were taken from that published package. -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. +Secure Tools imports the browser artifact 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 index 4709432..854ead1 100644 --- a/assets/vendor/secure-metadata/package.json +++ b/assets/vendor/secure-metadata/package.json @@ -1,6 +1,6 @@ { "name": "secure-metadata", - "version": "0.1.0", + "version": "0.1.1", "description": "Deterministic, security-conscious metadata tooling for binary image formats.", "license": "MIT", "type": "module", diff --git a/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js b/assets/vendor/secure-metadata/secure-metadata-0.1.1.browser.js similarity index 92% rename from assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js rename to assets/vendor/secure-metadata/secure-metadata-0.1.1.browser.js index 915bdd9..1541776 100644 --- a/assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js +++ b/assets/vendor/secure-metadata/secure-metadata-0.1.1.browser.js @@ -423,7 +423,7 @@ var IFD0_TAGS = { [TIFF_TAG.ORIENTATION]: { name: "Orientation", namespace: "exif", - category: "technical", + category: "rendering", privacy: "non-sensitive" }, [TIFF_TAG.SOFTWARE]: { @@ -928,6 +928,74 @@ function parseTiff(bytes, limits) { }; } +// src/exif/orientation.ts +var EXIF_SIGNATURE = Uint8Array.of(69, 120, 105, 102, 0, 0); +var TIFF_LENGTH = 26; +function matches(left, right) { + return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]); +} +function parseLimits(limits) { + return { + maxIfdEntries: resolveParseLimit("maxIfdEntries", limits?.maxIfdEntries), + maxIfdDepth: resolveParseLimit("maxIfdDepth", limits?.maxIfdDepth), + maxMetadataEntries: resolveParseLimit( + "maxMetadataEntries", + limits?.maxMetadataEntries + ), + maxStringBytes: resolveParseLimit("maxStringBytes", limits?.maxStringBytes), + maxDiagnostics: resolveParseLimit("maxDiagnostics", limits?.maxDiagnostics) + }; +} +function orientationFromTiff(result) { + if (!result.complete || result.entryLimitExceeded === true || result.byteOrder === void 0) { + return void 0; + } + const orientations = result.entries.filter( + (entry) => entry.tag === TIFF_TAG.ORIENTATION && entry.path === "IFD0/Orientation" + ); + const orientation = orientations[0]; + if (orientations.length !== 1 || orientation === void 0 || orientation.type !== TIFF_FIELD_TYPE.SHORT || orientation.count !== 1 || typeof orientation.value !== "number" || orientation.value < 1 || orientation.value > 8) { + return void 0; + } + return { value: orientation.value, byteOrder: result.byteOrder }; +} +function minimalOrientationExifPayload(orientation) { + const output = new Uint8Array(EXIF_SIGNATURE.byteLength + TIFF_LENGTH); + output.set(EXIF_SIGNATURE); + const tiffOffset = EXIF_SIGNATURE.byteLength; + const view = new DataView( + output.buffer, + output.byteOffset + tiffOffset, + TIFF_LENGTH + ); + const little = orientation.byteOrder === "little"; + output.set(little ? [73, 73] : [77, 77], tiffOffset); + view.setUint16(2, 42, little); + view.setUint32(4, 8, little); + view.setUint16(8, 1, little); + view.setUint16(10, TIFF_TAG.ORIENTATION, little); + view.setUint16(12, TIFF_FIELD_TYPE.SHORT, little); + view.setUint32(14, 1, little); + view.setUint16(18, orientation.value, little); + view.setUint32(22, 0, little); + return output; +} +function preservedOrientationExifPayload(payload, limits) { + if (payload.byteLength < EXIF_SIGNATURE.byteLength || !EXIF_SIGNATURE.every((value, index) => payload[index] === value)) { + return void 0; + } + const tiff = parseTiff( + payload.subarray(EXIF_SIGNATURE.byteLength), + parseLimits(limits) + ); + const orientation = orientationFromTiff(tiff); + return orientation === void 0 ? void 0 : minimalOrientationExifPayload(orientation); +} +function isMinimalOrientationExifPayload(payload, result) { + const orientation = orientationFromTiff(result); + return orientation !== void 0 && matches(payload, minimalOrientationExifPayload(orientation)); +} + // src/jpeg/markers.ts var JPEG_MARKER = { TEM: 1, @@ -1014,6 +1082,7 @@ function inspectJpegMetadata(reader, result, tiffLimits, maxMetadataEntries) { } switch (segment.metadataKind) { case "exif": { + const containerIndex = entries.length; if (!add({ id: `jpeg-exif-${String(segment.offset)}`, namespace: "exif", @@ -1032,6 +1101,20 @@ function inspectJpegMetadata(reader, result, tiffLimits, maxMetadataEntries) { maxMetadataEntries: maxMetadataEntries - entries.length, maxDiagnostics: (tiffLimits.maxDiagnostics ?? DEFAULT_PARSE_LIMITS.maxDiagnostics) - diagnostics.length }); + const payload = reader.slice( + segment.payloadOffset, + segment.payloadLength + ); + if (isMinimalOrientationExifPayload(payload, tiff)) { + entries[containerIndex] = { + id: `jpeg-exif-${String(segment.offset)}`, + namespace: "exif", + name: "EXIF Orientation container", + category: "rendering", + privacy: "non-sensitive", + source: source(segment) + }; + } entries.push( ...metadataEntriesFromTiff(tiff, { format: "jpeg", @@ -1088,7 +1171,7 @@ function inspectJpegMetadata(reader, result, tiffLimits, maxMetadataEntries) { // 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 EXIF_SIGNATURE2 = [69, 120, 105, 102, 0, 0]; var XMP_SIGNATURE = [ 104, 116, @@ -1219,7 +1302,7 @@ function classifyApplicationSegment(reader, marker, payloadOffset, payloadLength } } if (marker === 225) { - if (matchesPayload(reader, payloadOffset, payloadLength, EXIF_SIGNATURE)) { + if (matchesPayload(reader, payloadOffset, payloadLength, EXIF_SIGNATURE2)) { return { metadataKind: "exif" }; } if (matchesPayload(reader, payloadOffset, payloadLength, XMP_SIGNATURE)) { @@ -2785,22 +2868,35 @@ function changeFor3(segment, action) { } }; } -function copyWithoutSegments(input, removals) { - const retained = []; +function jpegApp1Segment(payload) { + const declaredLength = payload.byteLength + 2; + if (declaredLength > 65535) { + throw new SecureMetadataError( + "Preserved EXIF Orientation exceeds the JPEG APP1 size limit.", + "CLEAN_OUTPUT_SIZE_INVALID" + ); + } + const output = new Uint8Array(payload.byteLength + 4); + output.set([255, 225, declaredLength >>> 8, declaredLength & 255]); + output.set(payload, 4); + return output; +} +function bytesEqual(left, right) { + return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]); +} +function copyWithSegmentEdits(input, edits) { let inputOffset = 0; - let outputLength = 0; - for (const segment of removals) { + let outputLength = input.byteLength; + for (const { segment, replacement } of edits) { 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.", + "JPEG cleaner produced an invalid edit range.", "CLEAN_OUTPUT_SIZE_INVALID" ); } - const length = segment.rangeOffset - inputOffset; - retained.push({ offset: inputOffset, length }); - outputLength += length; - if (!Number.isSafeInteger(outputLength) || outputLength > input.byteLength) { + outputLength += (replacement?.byteLength ?? 0) - segment.rangeLength; + if (!Number.isSafeInteger(outputLength) || outputLength < 0 || outputLength > input.byteLength) { throw new SecureMetadataError( "JPEG cleaner output size is invalid.", "CLEAN_OUTPUT_SIZE_INVALID" @@ -2808,24 +2904,20 @@ function copyWithoutSegments(input, removals) { } 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); + inputOffset = 0; let outputOffset = 0; - for (const range of retained) { - output.set( - input.subarray(range.offset, range.offset + range.length), - outputOffset - ); - outputOffset += range.length; + for (const { segment, replacement } of edits) { + const retainedLength = segment.rangeOffset - inputOffset; + output.set(input.subarray(inputOffset, segment.rangeOffset), outputOffset); + outputOffset += retainedLength; + if (replacement !== void 0) { + output.set(replacement, outputOffset); + outputOffset += replacement.byteLength; + } + inputOffset = segment.rangeOffset + segment.rangeLength; } + output.set(input.subarray(inputOffset), outputOffset); return output; } function cleanMetadata(input, policy) { @@ -2863,14 +2955,52 @@ function cleanMetadata(input, policy) { ); } 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 orientationCandidates = resolved.removeExif ? jpeg.segments.flatMap((segment) => { + if (segment.metadataKind !== "exif" || segment.payloadOffset === void 0 || segment.payloadLength === void 0) { + return []; + } + const payload = reader.slice( + segment.payloadOffset, + segment.payloadLength + ); + const replacement = preservedOrientationExifPayload( + payload, + policy?.limits + ); + return replacement === void 0 ? [] : [{ segment, replacement }]; + }) : []; + const orientationCandidate = orientationCandidates.length === 1 ? orientationCandidates[0] : void 0; + const edits = []; + const orientationPreserved = []; + for (const segment of jpeg.segments) { + if (!shouldRemove3(segment, resolved)) { + continue; + } + if (orientationCandidate !== void 0 && segment === orientationCandidate.segment) { + const replacement = jpegApp1Segment(orientationCandidate.replacement); + orientationPreserved.push({ + ...changeFor3(segment, "preserved"), + name: "EXIF Orientation" + }); + const original = bytes.subarray( + segment.rangeOffset, + segment.rangeOffset + segment.rangeLength + ); + if (!bytesEqual(original, replacement)) { + edits.push({ segment, replacement }); + } + continue; + } + edits.push({ segment }); + } + const removed = edits.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")), + ...orientationPreserved + ]; + const output = copyWithSegmentEdits(bytes, edits); const report = inspectMetadata( output, policy?.limits === void 0 ? void 0 : { limits: policy.limits } @@ -2908,6 +3038,17 @@ var DEFAULT_PNG_VERIFICATION_POLICY = Object.freeze({ timestamps: "absent", icc: "ignore" }); +function namespaceIsPresent(report, namespace) { + if (report.format !== "jpeg" || namespace !== "exif") { + return report.entries.some((entry) => entry.namespace === namespace); + } + const exifEntries = report.entries.filter( + (entry) => entry.namespace === "exif" || entry.namespace === "gps" + ); + return exifEntries.length !== 0 && !(exifEntries.length === 2 && exifEntries.some( + (entry) => entry.name === "EXIF Orientation container" + ) && exifEntries.some((entry) => entry.name === "Orientation")); +} function verifyMetadata(input, expectation) { const report = inspectMetadata( input, @@ -2966,9 +3107,7 @@ function verifyMetadata(input, expectation) { if (wanted === "ignore") { continue; } - const present = report.entries.some( - (entry) => entry.namespace === namespace - ); + const present = namespaceIsPresent(report, namespace); const actual = present ? "present" : "absent"; checks.push({ namespace, diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs index 286f993..04ad265 100644 --- a/tests/image-metadata.test.mjs +++ b/tests/image-metadata.test.mjs @@ -187,5 +187,5 @@ assert.match(adapter, /cleanMetadata\(source\.bytes, policy\)/); assert.match(adapter, /verifyMetadata\(cleaned\.output, expectation\)/); assert.match(adapter, /verification\.checks\.length > 0/); const applicationFiles = ["tools/image/metadata/app.js", "tools/image/metadata/model.js", "tools/image/metadata/metadata.js"]; -assert.deepEqual(applicationFiles.filter((relative) => read(relative).includes("secure-metadata-0.1.0.browser.js")), ["tools/image/metadata/metadata.js"]); +assert.deepEqual(applicationFiles.filter((relative) => read(relative).includes("secure-metadata-0.1.1.browser.js")), ["tools/image/metadata/metadata.js"]); console.log("Image Metadata inspection, honest coverage, cleaning, verification, output, UI, privacy, and regression contracts passed."); diff --git a/tests/release-gate.test.mjs b/tests/release-gate.test.mjs index 5f64ea5..fc6c4b7 100644 --- a/tests/release-gate.test.mjs +++ b/tests/release-gate.test.mjs @@ -54,11 +54,11 @@ const vendors = { }, "secure-metadata": { name: "secure-metadata", - version: "0.1.0", + version: "0.1.1", license: "MIT", - files: ["LICENSE", "README.md", "package.json", "secure-metadata-0.1.0.browser.js"], + files: ["LICENSE", "README.md", "package.json", "secure-metadata-0.1.1.browser.js"], runtimes: { - "secure-metadata-0.1.0.browser.js": "8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28", + "secure-metadata-0.1.1.browser.js": "4bfcc9e0e484db12192e46f076c19cf69cd36c496c7cfbb5a71c1057cbcccba1", }, }, @@ -79,11 +79,11 @@ for (const [directory, expected] of Object.entries(vendors)) { } assert.match(read("assets/vendor/jszip/README.md"), /License choice: MIT/); const secureMetadataProvenance = read("assets/vendor/secure-metadata/README.md"); -assert.match(secureMetadataProvenance, /Release tag: `v0\.1\.0`/); -assert.match(secureMetadataProvenance, /Release commit: `352258ec413a838dfe8b9146370505f125b5ae10`/); -assert.match(secureMetadataProvenance, /SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28`/); +assert.match(secureMetadataProvenance, /Release tag: `v0\.1\.1`/); +assert.match(secureMetadataProvenance, /Release commit: `cdcd138e48d30618b6d76f7c6538cd43ad660b53`/); +assert.match(secureMetadataProvenance, /SHA-256: `4bfcc9e0e484db12192e46f076c19cf69cd36c496c7cfbb5a71c1057cbcccba1`/); assert.match(secureMetadataProvenance, /Runtime dependencies: 0/); -assert.match(read("assets/vendor/secure-metadata/secure-metadata-0.1.0.browser.js"), /maxInputBytes:\s*100 \* 1024 \* 1024/); +assert.match(read("assets/vendor/secure-metadata/secure-metadata-0.1.1.browser.js"), /maxInputBytes:\s*100 \* 1024 \* 1024/); const renderer = read("tools/shared/pdf-renderer.js"); assert.match(renderer, /new URL\("\.\.\/\.\.\/assets\/vendor\/pdfjs\/pdf\.worker\.min\.mjs", import\.meta\.url\)/); diff --git a/tools/image/metadata/metadata.js b/tools/image/metadata/metadata.js index 32f80b1..e07229d 100644 --- a/tools/image/metadata/metadata.js +++ b/tools/image/metadata/metadata.js @@ -1,6 +1,6 @@ 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"; +import { cleanMetadata, DEFAULT_CLEANING_POLICY, inspectMetadata, verifyMetadata } from "../../../assets/vendor/secure-metadata/secure-metadata-0.1.1.browser.js"; export const PRIVACY_CLEAN_POLICY = DEFAULT_CLEANING_POLICY; export const CLEANING_POLICY_KEYS = Object.freeze(["removeExif", "removeXmp", "removeIptc", "removeComments", "removeTextMetadata", "removeTimestamps", "preserveIcc"]); From 5845bded3259a837ff7bbe555282a0cf41b46e80 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Thu, 27 Aug 2026 13:51:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85[Test]=20Cover=20EXIF=20orientatio?= =?UTF-8?q?n=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/image-metadata.test.mjs | 64 +++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs index 04ad265..8d4d13c 100644 --- a/tests/image-metadata.test.mjs +++ b/tests/image-metadata.test.mjs @@ -66,6 +66,52 @@ function metadataJpeg() { ); } +function orientationExifPayload(orientation) { + const tiff = new Uint8Array(160); const view = new DataView(tiff.buffer); const little = true; + tiff.set([0x49, 0x49]); view.setUint16(2, 42, little); view.setUint32(4, 8, little); + const entries = [ + { tag: 0x0112, type: 3, count: 1, value: orientation }, + { tag: 0x010f, type: 2, count: 12, offset: 80 }, + { tag: 0x0131, type: 2, count: 14, offset: 96 }, + { tag: 0x8825, type: 4, count: 1, value: 140 }, + ]; + view.setUint16(8, entries.length, little); + entries.forEach((entry, index) => { + const offset = 10 + index * 12; + view.setUint16(offset, entry.tag, little); view.setUint16(offset + 2, entry.type, little); view.setUint32(offset + 4, entry.count, little); + if (entry.offset !== undefined) view.setUint32(offset + 8, entry.offset, little); + else if (entry.type === 3) view.setUint16(offset + 8, entry.value, little); + else view.setUint32(offset + 8, entry.value, little); + }); + view.setUint32(58, 0, little); + tiff.set(new TextEncoder().encode("PrivateMake\0"), 80); + tiff.set(new TextEncoder().encode("PrivateEditor\0"), 96); + view.setUint16(140, 1, little); view.setUint16(142, 1, little); view.setUint16(144, 2, little); view.setUint32(146, 2, little); + tiff.set([0x4e, 0], 150); view.setUint32(154, 0, little); + return concatenate(new TextEncoder().encode("Exif\0\0"), tiff.slice(0, 158)); +} + +const ORIENTATION_SCAN = Uint8Array.of(0x11, 0xff, 0x00, 0x22, 0xff, 0xd0, 0x33); +function orientationMetadataJpeg(orientation) { + const encoder = new TextEncoder(); + return concatenate( + Uint8Array.of(0xff, 0xd8), + jpegSegment(0xe1, orientationExifPayload(orientation)), + jpegSegment(0xe2, concatenate(encoder.encode("ICC_PROFILE\0"), Uint8Array.of(1, 1, 9, 8, 7))), + jpegSegment(0xfe, encoder.encode("private comment")), + jpegSegment(0xda, Uint8Array.of(1)), + ORIENTATION_SCAN, + Uint8Array.of(0xff, 0xd9), + ); +} + +function jpegScanTail(bytes) { + for (let index = 0; index < bytes.length - 1; index += 1) { + if (bytes[index] === 0xff && bytes[index + 1] === 0xda) return bytes.slice(index); + } + throw new Error("JPEG scan marker not found"); +} + function webpChunk(type, data) { const output = new Uint8Array(8 + data.length + (data.length % 2)); output.set(new TextEncoder().encode(type)); new DataView(output.buffer).setUint32(4, data.length, true); output.set(data, 8); return output; @@ -152,6 +198,24 @@ for (const [bytes, name, format, mimeType] of [ assert.ok(output.cleaned.preserved.some((change) => change.namespace === "icc")); } +for (const orientation of [3, 6, 8]) { + const bytes = orientationMetadataJpeg(orientation); const snapshot = bytes.slice(); const scanTail = jpegScanTail(bytes); + const inspected = await inspectImageMetadata(new File([bytes], `orientation-${orientation}.jpg`, { type: "image/jpeg" })); + assert.equal(inspected.report.entries.find((entry) => entry.name === "Orientation")?.value, orientation, `Orientation ${orientation} is decoded before cleaning`); + assert.ok(inspected.report.entries.some((entry) => entry.namespace === "gps"), "GPS metadata is present in the regression fixture"); + const output = await cleanAndVerifyImageMetadata(inspected); + assert.equal(output.verification.valid, true); assert.ok(output.verification.checks.every((check) => check.passed)); + assert.ok(output.cleaned.preserved.some((change) => change.namespace === "exif" && change.name === "EXIF Orientation")); + assert.ok(output.cleaned.removed.some((change) => change.namespace === "exif"), "Privacy-related EXIF is removed"); + assert.ok(output.cleaned.removed.some((change) => change.namespace === "jpeg-comment"), "JPEG comments are removed"); + const after = await inspectImageMetadata(new File([output.bytes], output.plan.filename, { type: output.plan.mimeType })); + assert.equal(after.report.entries.find((entry) => entry.name === "Orientation")?.value, orientation, `Orientation ${orientation} survives Privacy Clean`); + assert.equal(after.report.entries.some((entry) => entry.namespace === "gps"), false, "GPS metadata is removed"); + assert.equal(after.report.entries.some((entry) => ["Make", "Software"].includes(entry.name)), false, "Private EXIF fields are removed"); + assert.deepEqual(jpegScanTail(output.bytes), scanTail, "JPEG scan payload and trailing image bytes remain unchanged"); + assert.deepEqual(bytes, snapshot, "Orientation cleaning does not mutate source bytes"); +} + class OversizeBlob extends Blob { get size() { return 50 * 1024 * 1024 + 1; } get name() { return "large.png"; } } await assert.rejects(inspectImageMetadata(new OversizeBlob([Uint8Array.of(0x89)])), (error) => error.code === "IMAGE_FILE_TOO_LARGE"); await assert.rejects(cleanAndVerifyImageMetadata({ ...source, format: "jpeg" }), (error) => error.code === "IMAGE_METADATA_VERIFICATION_FAILED", "Format mismatch fails closed after verification"); From ba9d62d559c41fe3957e540444778a8dd5a71f46 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Thu, 27 Aug 2026 13:56:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B[Fix]=20Polish=20metadata=20act?= =?UTF-8?q?ions=20and=20summaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/locales/metadata-ux.js | 48 ++++++++++++++++----------------- tests/i18n-quality.test.mjs | 2 +- tests/image-metadata.test.mjs | 14 ++++++++-- tests/pdf-metadata.test.mjs | 4 ++- tests/ux-consistency.test.mjs | 2 +- tools/image/metadata/app.js | 6 +++-- tools/image/metadata/index.html | 4 +-- tools/image/metadata/model.js | 18 +++++++++++++ tools/image/metadata/tool.css | 8 +++++- tools/pdf/metadata/app.js | 6 +++-- tools/pdf/metadata/index.html | 6 ++--- tools/pdf/metadata/tool.css | 10 ++++++- 12 files changed, 88 insertions(+), 40 deletions(-) diff --git a/js/locales/metadata-ux.js b/js/locales/metadata-ux.js index 7ff5319..bc56393 100644 --- a/js/locales/metadata-ux.js +++ b/js/locales/metadata-ux.js @@ -2,13 +2,13 @@ export const metadataUxLocales = { en: { image: { source: { selectedLabel: "Selected source image", meta: "{format} · {size}", remove: "Remove source image" }, - inspector: { decodedTitle: "Decoded metadata", summary: "{groups} decoded groups · {additional} additional structures", noDecoded: "No supported metadata values were decoded.", additional: "{count} additional metadata structure(s) were detected but not fully decoded.", details: "View details", detailTitle: "All detected metadata", diagnosticsTitle: "Parser diagnostics" }, - clean: { customize: "Customize", customizeDescription: "Choose only metadata classes supported by the current format and verification layer.", customButton: "Clean with custom policy and save" }, - policy: { legend: "Custom cleaning policy", removeExif: "Remove EXIF", removeXmp: "Remove XMP", removeIptc: "Remove IPTC", removeComments: "Remove JPEG comments", removeTextMetadata: "Remove PNG text metadata", removeTimestamps: "Remove PNG timestamps", preserveIcc: "Preserve ICC color profile" }, + inspector: { decodedTitle: "Decoded metadata", summary: "{groups} decoded groups · {additional} additional structures", noDecoded: "No supported metadata values were decoded.", additional: "{count} additional metadata structure(s) were detected but not fully decoded.", additionalDecoded: "{count} additional decoded field(s) are available in details.", details: "View details", detailTitle: "All detected metadata", diagnosticsTitle: "Parser diagnostics" }, + clean: { policy: "Removes supported privacy metadata while preserving valid rendering orientation and ICC profiles.", customize: "Customize", customizeDescription: "Choose only metadata classes supported by the current format and verification layer.", customButton: "Clean with custom policy and save" }, + policy: { legend: "Custom cleaning policy", removeExif: "Remove privacy-related EXIF (keep valid orientation)", removeXmp: "Remove XMP", removeIptc: "Remove IPTC", removeComments: "Remove JPEG comments", removeTextMetadata: "Remove PNG text metadata", removeTimestamps: "Remove PNG timestamps", preserveIcc: "Preserve ICC color profile" }, }, pdf: { source: { selectedLabel: "Selected source PDF", meta: "PDF · {size}", remove: "Remove source PDF" }, - inspector: { decodedTitle: "Decoded document metadata", summary: "{count} supported decoded field(s)", noDecoded: "No supported document-info values were found.", details: "View details", detailsHelp: "The detailed view includes every supported document-info field. It does not inspect XMP or hidden PDF structures." }, + inspector: { decodedTitle: "Decoded document metadata", summary: "{count} supported decoded field(s)", noDecoded: "No supported document-info values were found.", additionalDecoded: "{count} additional decoded field(s) are available in details.", details: "View details", detailsHelp: "The detailed view includes every supported document-info field. It does not inspect XMP or hidden PDF structures." }, actions: { privacyClean: "Privacy Clean and save PDF", customize: "Customize", customClean: "Clean selected fields and save" }, custom: { description: "Select standard document-info fields to remove. This does not edit values or expand the supported PDF scope.", legend: "Fields to remove" }, errors: { verification: "The requested metadata fields remained after verification, so no file was saved." }, @@ -17,13 +17,13 @@ export const metadataUxLocales = { ko: { image: { source: { selectedLabel: "선택한 원본 이미지", meta: "{format} · {size}", remove: "원본 이미지 제거" }, - inspector: { decodedTitle: "해석된 메타데이터", summary: "해석된 그룹 {groups}개 · 추가 구조 {additional}개", noDecoded: "지원되는 메타데이터 값을 해석하지 못했습니다.", additional: "추가 메타데이터 구조 {count}개를 감지했지만 완전히 해석하지는 못했습니다.", details: "세부 정보 보기", detailTitle: "감지된 모든 메타데이터", diagnosticsTitle: "파서 진단" }, - clean: { customize: "사용자 지정", customizeDescription: "현재 형식과 검증 계층이 지원하는 메타데이터 종류만 선택합니다.", customButton: "사용자 지정 정책으로 정리하고 저장" }, - policy: { legend: "사용자 지정 정리 정책", removeExif: "EXIF 제거", removeXmp: "XMP 제거", removeIptc: "IPTC 제거", removeComments: "JPEG 주석 제거", removeTextMetadata: "PNG 텍스트 메타데이터 제거", removeTimestamps: "PNG 타임스탬프 제거", preserveIcc: "ICC 색상 프로필 유지" }, + inspector: { decodedTitle: "해석된 메타데이터", summary: "해석된 그룹 {groups}개 · 추가 구조 {additional}개", noDecoded: "지원되는 메타데이터 값을 해석하지 못했습니다.", additional: "추가 메타데이터 구조 {count}개를 감지했지만 완전히 해석하지는 못했습니다.", additionalDecoded: "추가로 해석된 필드 {count}개는 세부 정보에서 확인할 수 있습니다.", details: "세부 정보 보기", detailTitle: "감지된 모든 메타데이터", diagnosticsTitle: "파서 진단" }, + clean: { policy: "지원되는 개인정보 메타데이터를 제거하고 유효한 표시 방향과 ICC 프로필은 유지합니다.", customize: "사용자 지정", customizeDescription: "현재 형식과 검증 계층이 지원하는 메타데이터 종류만 선택합니다.", customButton: "사용자 지정 정책으로 정리하고 저장" }, + policy: { legend: "사용자 지정 정리 정책", removeExif: "개인정보 관련 EXIF 제거(유효한 방향 유지)", removeXmp: "XMP 제거", removeIptc: "IPTC 제거", removeComments: "JPEG 주석 제거", removeTextMetadata: "PNG 텍스트 메타데이터 제거", removeTimestamps: "PNG 타임스탬프 제거", preserveIcc: "ICC 색상 프로필 유지" }, }, pdf: { source: { selectedLabel: "선택한 원본 PDF", meta: "PDF · {size}", remove: "원본 PDF 제거" }, - inspector: { decodedTitle: "해석된 문서 메타데이터", summary: "지원되는 해석 필드 {count}개", noDecoded: "지원되는 문서 정보 값을 찾지 못했습니다.", details: "세부 정보 보기", detailsHelp: "세부 보기에는 지원되는 모든 문서 정보 필드가 표시됩니다. XMP나 숨겨진 PDF 구조는 검사하지 않습니다." }, + inspector: { decodedTitle: "해석된 문서 메타데이터", summary: "지원되는 해석 필드 {count}개", noDecoded: "지원되는 문서 정보 값을 찾지 못했습니다.", additionalDecoded: "추가로 해석된 필드 {count}개는 세부 정보에서 확인할 수 있습니다.", details: "세부 정보 보기", detailsHelp: "세부 보기에는 지원되는 모든 문서 정보 필드가 표시됩니다. XMP나 숨겨진 PDF 구조는 검사하지 않습니다." }, actions: { privacyClean: "개인정보 정리 후 PDF 저장", customize: "사용자 지정", customClean: "선택한 필드 정리 후 저장" }, custom: { description: "제거할 표준 문서 정보 필드를 선택합니다. 값을 편집하거나 지원 PDF 범위를 넓히지 않습니다.", legend: "제거할 필드" }, errors: { verification: "검증 후에도 요청한 메타데이터 필드가 남아 있어 파일을 저장하지 않았습니다." }, @@ -32,13 +32,13 @@ export const metadataUxLocales = { ja: { image: { source: { selectedLabel: "選択した元画像", meta: "{format} · {size}", remove: "元画像を削除" }, - inspector: { decodedTitle: "デコード済みメタデータ", summary: "デコード済みグループ {groups} 件 · 追加構造 {additional} 件", noDecoded: "対応するメタデータ値はデコードされませんでした。", additional: "追加のメタデータ構造を {count} 件検出しましたが、完全にはデコードされていません。", details: "詳細を表示", detailTitle: "検出されたすべてのメタデータ", diagnosticsTitle: "パーサー診断" }, - clean: { customize: "カスタマイズ", customizeDescription: "現在の形式と検証レイヤーが対応するメタデータ分類だけを選択します。", customButton: "カスタムポリシーで消去して保存" }, - policy: { legend: "カスタム消去ポリシー", removeExif: "EXIF を削除", removeXmp: "XMP を削除", removeIptc: "IPTC を削除", removeComments: "JPEG コメントを削除", removeTextMetadata: "PNG テキストメタデータを削除", removeTimestamps: "PNG タイムスタンプを削除", preserveIcc: "ICC カラープロファイルを保持" }, + inspector: { decodedTitle: "デコード済みメタデータ", summary: "デコード済みグループ {groups} 件 · 追加構造 {additional} 件", noDecoded: "対応するメタデータ値はデコードされませんでした。", additional: "追加のメタデータ構造を {count} 件検出しましたが、完全にはデコードされていません。", additionalDecoded: "追加のデコード済みフィールド {count} 件は詳細で確認できます。", details: "詳細を表示", detailTitle: "検出されたすべてのメタデータ", diagnosticsTitle: "パーサー診断" }, + clean: { policy: "対応するプライバシーメタデータを削除し、有効な表示方向と ICC プロファイルを保持します。", customize: "カスタマイズ", customizeDescription: "現在の形式と検証レイヤーが対応するメタデータ分類だけを選択します。", customButton: "カスタムポリシーで消去して保存" }, + policy: { legend: "カスタム消去ポリシー", removeExif: "プライバシー関連 EXIF を削除(有効な方向は保持)", removeXmp: "XMP を削除", removeIptc: "IPTC を削除", removeComments: "JPEG コメントを削除", removeTextMetadata: "PNG テキストメタデータを削除", removeTimestamps: "PNG タイムスタンプを削除", preserveIcc: "ICC カラープロファイルを保持" }, }, pdf: { source: { selectedLabel: "選択した元PDF", meta: "PDF · {size}", remove: "元PDFを削除" }, - inspector: { decodedTitle: "デコード済み文書メタデータ", summary: "対応するデコード済みフィールド {count} 件", noDecoded: "対応する文書情報の値は見つかりませんでした。", details: "詳細を表示", detailsHelp: "詳細表示には対応するすべての文書情報フィールドが含まれます。XMP や隠し PDF 構造は検査しません。" }, + inspector: { decodedTitle: "デコード済み文書メタデータ", summary: "対応するデコード済みフィールド {count} 件", noDecoded: "対応する文書情報の値は見つかりませんでした。", additionalDecoded: "追加のデコード済みフィールド {count} 件は詳細で確認できます。", details: "詳細を表示", detailsHelp: "詳細表示には対応するすべての文書情報フィールドが含まれます。XMP や隠し PDF 構造は検査しません。" }, actions: { privacyClean: "プライバシー消去して PDF を保存", customize: "カスタマイズ", customClean: "選択したフィールドを消去して保存" }, custom: { description: "削除する標準文書情報フィールドを選択します。値の編集や PDF 対応範囲の拡張は行いません。", legend: "削除するフィールド" }, errors: { verification: "検証後も指定したメタデータ項目が残っていたため、ファイルは保存されませんでした。" }, @@ -47,13 +47,13 @@ export const metadataUxLocales = { es: { image: { source: { selectedLabel: "Imagen de origen seleccionada", meta: "{format} · {size}", remove: "Quitar imagen de origen" }, - inspector: { decodedTitle: "Metadatos decodificados", summary: "{groups} grupos decodificados · {additional} estructuras adicionales", noDecoded: "No se decodificaron valores de metadatos compatibles.", additional: "Se detectaron {count} estructuras de metadatos adicionales, pero no se decodificaron por completo.", details: "Ver detalles", detailTitle: "Todos los metadatos detectados", diagnosticsTitle: "Diagnósticos del analizador" }, - clean: { customize: "Personalizar", customizeDescription: "Elige solo clases de metadatos compatibles con el formato actual y la verificación.", customButton: "Limpiar con política personalizada y guardar" }, - policy: { legend: "Política de limpieza personalizada", removeExif: "Eliminar EXIF", removeXmp: "Eliminar XMP", removeIptc: "Eliminar IPTC", removeComments: "Eliminar comentarios JPEG", removeTextMetadata: "Eliminar metadatos de texto PNG", removeTimestamps: "Eliminar marcas de tiempo PNG", preserveIcc: "Conservar el perfil de color ICC" }, + inspector: { decodedTitle: "Metadatos decodificados", summary: "{groups} grupos decodificados · {additional} estructuras adicionales", noDecoded: "No se decodificaron valores de metadatos compatibles.", additional: "Se detectaron {count} estructuras de metadatos adicionales, pero no se decodificaron por completo.", additionalDecoded: "Hay {count} campos decodificados adicionales disponibles en los detalles.", details: "Ver detalles", detailTitle: "Todos los metadatos detectados", diagnosticsTitle: "Diagnósticos del analizador" }, + clean: { policy: "Elimina metadatos de privacidad compatibles y conserva la orientación de visualización válida y los perfiles ICC.", customize: "Personalizar", customizeDescription: "Elige solo clases de metadatos compatibles con el formato actual y la verificación.", customButton: "Limpiar con política personalizada y guardar" }, + policy: { legend: "Política de limpieza personalizada", removeExif: "Eliminar EXIF privado (conservar orientación válida)", removeXmp: "Eliminar XMP", removeIptc: "Eliminar IPTC", removeComments: "Eliminar comentarios JPEG", removeTextMetadata: "Eliminar metadatos de texto PNG", removeTimestamps: "Eliminar marcas de tiempo PNG", preserveIcc: "Conservar el perfil de color ICC" }, }, pdf: { source: { selectedLabel: "PDF de origen seleccionado", meta: "PDF · {size}", remove: "Quitar PDF de origen" }, - inspector: { decodedTitle: "Metadatos del documento decodificados", summary: "{count} campos compatibles decodificados", noDecoded: "No se encontraron valores de información de documento compatibles.", details: "Ver detalles", detailsHelp: "La vista detallada incluye todos los campos de información de documento compatibles. No inspecciona XMP ni estructuras PDF ocultas." }, + inspector: { decodedTitle: "Metadatos del documento decodificados", summary: "{count} campos compatibles decodificados", noDecoded: "No se encontraron valores de información de documento compatibles.", additionalDecoded: "Hay {count} campos decodificados adicionales disponibles en los detalles.", details: "Ver detalles", detailsHelp: "La vista detallada incluye todos los campos de información de documento compatibles. No inspecciona XMP ni estructuras PDF ocultas." }, actions: { privacyClean: "Limpieza de privacidad y guardar PDF", customize: "Personalizar", customClean: "Limpiar campos seleccionados y guardar" }, custom: { description: "Selecciona campos estándar de información de documento para eliminarlos. No edita valores ni amplía el alcance PDF compatible.", legend: "Campos que se eliminarán" }, errors: { verification: "Los campos de metadatos solicitados permanecieron tras la verificación, por lo que no se guardó ningún archivo." }, @@ -62,13 +62,13 @@ export const metadataUxLocales = { de: { image: { source: { selectedLabel: "Ausgewähltes Quellbild", meta: "{format} · {size}", remove: "Quellbild entfernen" }, - inspector: { decodedTitle: "Dekodierte Metadaten", summary: "{groups} dekodierte Gruppen · {additional} zusätzliche Strukturen", noDecoded: "Keine unterstützten Metadatenwerte wurden dekodiert.", additional: "{count} zusätzliche Metadatenstrukturen wurden erkannt, aber nicht vollständig dekodiert.", details: "Details anzeigen", detailTitle: "Alle erkannten Metadaten", diagnosticsTitle: "Parserdiagnose" }, - clean: { customize: "Anpassen", customizeDescription: "Wählen Sie nur Metadatenklassen, die das aktuelle Format und die Verifikation unterstützen.", customButton: "Mit eigener Richtlinie bereinigen und speichern" }, - policy: { legend: "Eigene Bereinigungsrichtlinie", removeExif: "EXIF entfernen", removeXmp: "XMP entfernen", removeIptc: "IPTC entfernen", removeComments: "JPEG-Kommentare entfernen", removeTextMetadata: "PNG-Textmetadaten entfernen", removeTimestamps: "PNG-Zeitstempel entfernen", preserveIcc: "ICC-Farbprofil beibehalten" }, + inspector: { decodedTitle: "Dekodierte Metadaten", summary: "{groups} dekodierte Gruppen · {additional} zusätzliche Strukturen", noDecoded: "Keine unterstützten Metadatenwerte wurden dekodiert.", additional: "{count} zusätzliche Metadatenstrukturen wurden erkannt, aber nicht vollständig dekodiert.", additionalDecoded: "{count} weitere dekodierte Felder sind in den Details verfügbar.", details: "Details anzeigen", detailTitle: "Alle erkannten Metadaten", diagnosticsTitle: "Parserdiagnose" }, + clean: { policy: "Entfernt unterstützte Datenschutzmetadaten und erhält gültige Anzeigeausrichtung und ICC-Profile.", customize: "Anpassen", customizeDescription: "Wählen Sie nur Metadatenklassen, die das aktuelle Format und die Verifikation unterstützen.", customButton: "Mit eigener Richtlinie bereinigen und speichern" }, + policy: { legend: "Eigene Bereinigungsrichtlinie", removeExif: "Datenschutzrelevante EXIF entfernen (Ausrichtung erhalten)", removeXmp: "XMP entfernen", removeIptc: "IPTC entfernen", removeComments: "JPEG-Kommentare entfernen", removeTextMetadata: "PNG-Textmetadaten entfernen", removeTimestamps: "PNG-Zeitstempel entfernen", preserveIcc: "ICC-Farbprofil beibehalten" }, }, pdf: { source: { selectedLabel: "Ausgewählte Quell-PDF", meta: "PDF · {size}", remove: "Quell-PDF entfernen" }, - inspector: { decodedTitle: "Dekodierte Dokumentmetadaten", summary: "{count} unterstützte dekodierte Felder", noDecoded: "Keine unterstützten Dokumentinfo-Werte gefunden.", details: "Details anzeigen", detailsHelp: "Die Detailansicht enthält alle unterstützten Dokumentinfo-Felder. XMP und verborgene PDF-Strukturen werden nicht untersucht." }, + inspector: { decodedTitle: "Dekodierte Dokumentmetadaten", summary: "{count} unterstützte dekodierte Felder", noDecoded: "Keine unterstützten Dokumentinfo-Werte gefunden.", additionalDecoded: "{count} weitere dekodierte Felder sind in den Details verfügbar.", details: "Details anzeigen", detailsHelp: "Die Detailansicht enthält alle unterstützten Dokumentinfo-Felder. XMP und verborgene PDF-Strukturen werden nicht untersucht." }, actions: { privacyClean: "Datenschutzbereinigung und PDF speichern", customize: "Anpassen", customClean: "Ausgewählte Felder bereinigen und speichern" }, custom: { description: "Wählen Sie zu entfernende Standard-Dokumentinfo-Felder. Werte werden nicht bearbeitet und der PDF-Prüfumfang wird nicht erweitert.", legend: "Zu entfernende Felder" }, errors: { verification: "Die angeforderten Metadatenfelder waren nach der Prüfung noch vorhanden; daher wurde keine Datei gespeichert." }, @@ -77,13 +77,13 @@ export const metadataUxLocales = { fr: { image: { source: { selectedLabel: "Image source sélectionnée", meta: "{format} · {size}", remove: "Retirer l’image source" }, - inspector: { decodedTitle: "Métadonnées décodées", summary: "{groups} groupes décodés · {additional} structures supplémentaires", noDecoded: "Aucune valeur de métadonnée prise en charge n’a été décodée.", additional: "{count} structures de métadonnées supplémentaires ont été détectées sans être entièrement décodées.", details: "Afficher les détails", detailTitle: "Toutes les métadonnées détectées", diagnosticsTitle: "Diagnostics de l’analyseur" }, - clean: { customize: "Personnaliser", customizeDescription: "Choisissez uniquement les classes prises en charge par le format actuel et la vérification.", customButton: "Nettoyer avec la règle personnalisée et enregistrer" }, - policy: { legend: "Règle de nettoyage personnalisée", removeExif: "Supprimer EXIF", removeXmp: "Supprimer XMP", removeIptc: "Supprimer IPTC", removeComments: "Supprimer les commentaires JPEG", removeTextMetadata: "Supprimer les métadonnées texte PNG", removeTimestamps: "Supprimer les horodatages PNG", preserveIcc: "Conserver le profil colorimétrique ICC" }, + inspector: { decodedTitle: "Métadonnées décodées", summary: "{groups} groupes décodés · {additional} structures supplémentaires", noDecoded: "Aucune valeur de métadonnée prise en charge n’a été décodée.", additional: "{count} structures de métadonnées supplémentaires ont été détectées sans être entièrement décodées.", additionalDecoded: "{count} champs décodés supplémentaires sont disponibles dans les détails.", details: "Afficher les détails", detailTitle: "Toutes les métadonnées détectées", diagnosticsTitle: "Diagnostics de l’analyseur" }, + clean: { policy: "Supprime les métadonnées de confidentialité prises en charge tout en conservant une orientation d’affichage valide et les profils ICC.", customize: "Personnaliser", customizeDescription: "Choisissez uniquement les classes prises en charge par le format actuel et la vérification.", customButton: "Nettoyer avec la règle personnalisée et enregistrer" }, + policy: { legend: "Règle de nettoyage personnalisée", removeExif: "Supprimer les EXIF privés (garder l’orientation valide)", removeXmp: "Supprimer XMP", removeIptc: "Supprimer IPTC", removeComments: "Supprimer les commentaires JPEG", removeTextMetadata: "Supprimer les métadonnées texte PNG", removeTimestamps: "Supprimer les horodatages PNG", preserveIcc: "Conserver le profil colorimétrique ICC" }, }, pdf: { source: { selectedLabel: "PDF source sélectionné", meta: "PDF · {size}", remove: "Retirer le PDF source" }, - inspector: { decodedTitle: "Métadonnées du document décodées", summary: "{count} champs pris en charge décodés", noDecoded: "Aucune valeur d’information de document prise en charge n’a été trouvée.", details: "Afficher les détails", detailsHelp: "La vue détaillée contient tous les champs d’information de document pris en charge. Elle n’inspecte pas XMP ni les structures PDF masquées." }, + inspector: { decodedTitle: "Métadonnées du document décodées", summary: "{count} champs pris en charge décodés", noDecoded: "Aucune valeur d’information de document prise en charge n’a été trouvée.", additionalDecoded: "{count} champs décodés supplémentaires sont disponibles dans les détails.", details: "Afficher les détails", detailsHelp: "La vue détaillée contient tous les champs d’information de document pris en charge. Elle n’inspecte pas XMP ni les structures PDF masquées." }, actions: { privacyClean: "Nettoyage de confidentialité et enregistrer le PDF", customize: "Personnaliser", customClean: "Nettoyer les champs sélectionnés et enregistrer" }, custom: { description: "Sélectionnez les champs d’information de document standard à supprimer. Les valeurs ne sont pas modifiées et la portée PDF n’est pas élargie.", legend: "Champs à supprimer" }, errors: { verification: "Les champs de métadonnées demandés subsistaient après vérification ; aucun fichier n’a donc été enregistré." }, diff --git a/tests/i18n-quality.test.mjs b/tests/i18n-quality.test.mjs index 96c6a23..09f59ef 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, 759); + assert.equal(english.size, 761); for (const [language, catalog] of Object.entries(translations)) { const flattened = flatten(catalog); diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs index 8d4d13c..d9814ff 100644 --- a/tests/image-metadata.test.mjs +++ b/tests/image-metadata.test.mjs @@ -146,8 +146,16 @@ assert.equal(partialModel.groups[0].key, "xmp", "XMP remains a distinct namespac const categorized = buildInspectionModel({ cleanable: true, report: { inspectionStatus: "metadata-partial", entries: [{ id: "a", namespace: "exif", name: "Software", category: "software", value: "Camera App" }, { id: "b", namespace: "iptc", name: "Caption", category: "description", value: "Decoded caption" }, { id: "c", namespace: "unknown", name: "Tag", category: "not-known" }], diagnostics: [{ severity: "warning", code: "TEST_DIAGNOSTIC", offset: 42, message: "bounded detail" }] } }, labels); assert.deepEqual(categorized.groups.map(({ key }) => key), ["software", "iptc", "other"]); assert.deepEqual(categorized.decodedGroups.map(({ key }) => key), ["software", "iptc"]); -assert.equal(categorized.decodedCount, 2); assert.equal(categorized.decodedGroupCount, 2); assert.equal(categorized.additionalCount, 1); +assert.equal(categorized.decodedCount, 2); assert.equal(categorized.decodedGroupCount, 2); assert.equal(categorized.additionalCount, 1); assert.equal(categorized.additionalDecodedCount, 0); assert.match(categorized.diagnostics[0], /WARNING · TEST_DIAGNOSTIC · byte 42 · bounded detail/); +const manyDecoded = buildInspectionModel({ cleanable: true, report: { inspectionStatus: "metadata-inspected", entries: [ + { namespace: "exif", name: "Technical", category: "technical", value: "t" }, { namespace: "exif", name: "Device", category: "device", value: "d" }, + { namespace: "exif", name: "Captured", category: "timestamp", value: "c" }, { namespace: "gps", name: "Location", category: "location", value: "l" }, + { namespace: "exif", name: "Software", category: "software", value: "s" }, { namespace: "iptc", name: "Author", category: "identity", value: "a" }, + { namespace: "iptc", name: "Description", category: "description", value: "x" }, { namespace: "exif", name: "Rights", category: "rights", value: "r" }, +] } }, labels); +assert.equal(manyDecoded.summaryGroups.reduce((count, group) => count + group.items.length, 0), 6); +assert.equal(manyDecoded.additionalDecodedCount, 2); assert.equal(manyDecoded.summaryGroups[0].key, "device"); assert.equal(buildInspectionModel({ report: { inspectionStatus: "format-only", entries: [], diagnostics: [] } }).coverageKey, "imageMetadata.coverage.format-only"); assert.equal(buildInspectionModel({ report: { inspectionStatus: "container-inspected", entries: [], diagnostics: [] } }).count, 0, "No supported metadata is distinct from a claim that no metadata exists"); const incompleteModel = buildInspectionModel({ cleanable: false, report: { inspectionStatus: "container-partial", entries: [], diagnostics: [] } }, labels); @@ -234,10 +242,12 @@ assert.match(app, /URL\.revokeObjectURL\(state\.previewUrl\)/); assert.match(app, /pagehide[^;]+releasePreview/); assert.match(app, /releasePreview\(\); resetPolicy\(\); state\.source = null; state\.inspection = null; state\.result = null/); assert.match(html, /id="inspection-details" class="inspection-details">]*data-i18n="imageMetadata\.inspector\.details"/); -assert.match(html, /id="metadata-groups" class="metadata-groups metadata-groups--primary"[\s\S]*id="additional-notice"[\s\S]*id="metadata-detail-groups"/); +assert.match(html, /class="metadata-overview"[\s\S]*class="[^"]*metadata-action-panel[^"]*"[\s\S]*id="metadata-groups" class="metadata-groups metadata-groups--primary"[\s\S]*id="decoded-overflow-notice"[\s\S]*id="additional-notice"[\s\S]*id="metadata-detail-groups"/); assert.match(html, /id="customize-cleaning" class="clean-customization"[^>]*hidden[\s\S]*
/); assert.equal((html.match(/data-policy-key=/g) || []).length, 7); assert.match(app, /FORMAT_POLICY_KEYS[\s\S]*jpeg:[^\n]*removeIptc[^\n]*removeComments[\s\S]*png:[^\n]*removeTextMetadata[^\n]*removeTimestamps[\s\S]*webp:/); +assert.match(app, /renderGroups\(elements\.metadata_groups, state\.inspection\.summaryGroups, false\)/); assert.match(app, /additionalDecodedCount/); +assert.ok(html.indexOf("metadata-action-panel") < html.indexOf('id="inspection"'), "Primary actions precede arbitrary metadata content in DOM order"); assert.match(html, /connect-src 'none'/); assert.match(html, /role="status" aria-live="polite"/); assert.match(category, /href="\.\/metadata\/"/); assert.equal((category.match(/class="category-tool surface"/g) || []).length, 4); const requestIndex = app.indexOf("await requestSaveHandle"); const cleanIndex = app.indexOf("await cleanAndVerifyImageMetadata"); const writeIndex = app.indexOf("await writeBlobToHandle"); diff --git a/tests/pdf-metadata.test.mjs b/tests/pdf-metadata.test.mjs index c976642..90c0e25 100644 --- a/tests/pdf-metadata.test.mjs +++ b/tests/pdf-metadata.test.mjs @@ -175,13 +175,15 @@ function testUiArchitectureAndScope() { assert.match(css, /overflow-wrap:\s*anywhere/); assert.match(css, /\.metadata-comparison\[hidden\]/); assert.doesNotMatch(html, /id="remove-all"/); - assert.match(html, /id="metadata-summary-list" class="metadata-summary-list"[\s\S]*id="inspection-details" class="inspection-details"/); + assert.match(html, /class="metadata-overview"[\s\S]*class="[^"]*metadata-action-panel[^"]*"[\s\S]*id="metadata-summary-list" class="metadata-summary-list"[\s\S]*id="decoded-overflow-notice"[\s\S]*id="inspection-details" class="inspection-details"/); assert.match(html, /id="clean-pdf"[^>]*data-i18n="pdfMetadata\.actions\.privacyClean"/); assert.match(html, /id="customize-cleaning" class="clean-customization"[^>]*hidden[\s\S]*
[\s\S]*id="custom-policy-options"/); assert.match(html, /id="clean-custom"[^>]*data-i18n="pdfMetadata\.actions\.customClean"/); assert.match(app, /elements\.clean\.addEventListener\("click", \(\) => cleanAndSave\(state\.fields\.filter\(\(field\) => field\.present\)\.map\(\(field\) => field\.key\)\)\)/); assert.match(app, /elements\.cleanCustom\.addEventListener\("click", \(\) => cleanAndSave\(selectedMetadataKeys\(state\.fields\)\)\)/); assert.match(app, /state\.fields = selectAllPresentMetadata\(source\.fields\)/); + assert.match(app, /present\.slice\(0, 4\)/); assert.match(app, /pdfMetadata\.inspector\.additionalDecoded/); + assert.ok(html.indexOf("metadata-action-panel") < html.indexOf('id="inspector"'), "Primary actions precede decoded PDF content in DOM order"); assert.match(css, /#custom-policy-options[^}]*grid-template-columns:\s*repeat\(2/); assert.doesNotMatch(css, /\.metadata-table\s*\{\s*min-width/); assert.match(read("tools/pdf/index.html"), /href="\.\/metadata\/"/); diff --git a/tests/ux-consistency.test.mjs b/tests/ux-consistency.test.mjs index b3df8a4..377a991 100644 --- a/tests/ux-consistency.test.mjs +++ b/tests/ux-consistency.test.mjs @@ -75,7 +75,7 @@ function testQueueSourceAndOutputPatterns() { assert.match(html, /class="source-empty"/); } assert.match(read("tools/pdf/organize/index.html"), /class="organizer-output tool-output surface"/); - assert.match(read("tools/pdf/metadata/index.html"), /class="metadata-output tool-output surface"/); + assert.match(read("tools/pdf/metadata/index.html"), /class="metadata-output tool-output metadata-action-panel"/); } function testSharedChromeAndAccessibility() { diff --git a/tools/image/metadata/app.js b/tools/image/metadata/app.js index c72ad91..cef2df1 100644 --- a/tools/image/metadata/app.js +++ b/tools/image/metadata/app.js @@ -6,7 +6,7 @@ import { cleanAndVerifyImageMetadata, createCleaningPolicy, createCleanOutputPla const elements = Object.fromEntries([ "file-input", "drop-zone", "source-empty", "source-card", "source-thumbnail", "source-name", "source-summary", "clear-source", - "inspection", "coverage", "inspection-summary", "metadata-empty", "metadata-groups", "additional-notice", "inspection-details", + "inspection", "coverage", "inspection-summary", "metadata-empty", "metadata-groups", "decoded-overflow-notice", "additional-notice", "inspection-details", "detail-coverage", "metadata-detail-groups", "inspection-diagnostic-section", "inspection-diagnostic-list", "clean-image", "customize-cleaning", "clean-custom", "clean-result", "result-summary", "removed-list", "preserved-list", "result-diagnostics", "result-diagnostic-list", "tool-status", @@ -78,9 +78,11 @@ function renderInspection() { elements.detail_coverage.textContent = t(state.inspection.coverageKey); elements.inspection_summary.textContent = message("imageMetadata.inspector.summary", { groups: state.inspection.decodedGroupCount, additional: state.inspection.additionalCount }); elements.metadata_empty.hidden = state.inspection.decodedCount !== 0; + elements.decoded_overflow_notice.hidden = state.inspection.additionalDecodedCount === 0; + elements.decoded_overflow_notice.textContent = message("imageMetadata.inspector.additionalDecoded", { count: state.inspection.additionalDecodedCount }); elements.additional_notice.hidden = state.inspection.additionalCount === 0; elements.additional_notice.textContent = message("imageMetadata.inspector.additional", { count: state.inspection.additionalCount }); - renderGroups(elements.metadata_groups, state.inspection.decodedGroups, false); + renderGroups(elements.metadata_groups, state.inspection.summaryGroups, false); renderGroups(elements.metadata_detail_groups, state.inspection.groups, true); appendItems(elements.inspection_diagnostic_list, state.inspection.diagnostics); elements.inspection_diagnostic_section.hidden = state.inspection.diagnostics.length === 0; diff --git a/tools/image/metadata/index.html b/tools/image/metadata/index.html index b803c9f..29dda62 100644 --- a/tools/image/metadata/index.html +++ b/tools/image/metadata/index.html @@ -9,8 +9,8 @@

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/model.js b/tools/image/metadata/model.js index 628b317..9f6d3e3 100644 --- a/tools/image/metadata/model.js +++ b/tools/image/metadata/model.js @@ -2,6 +2,8 @@ export const MAX_METADATA_VALUE_LENGTH = 2000; export const MAX_DIAGNOSTIC_LENGTH = 500; const GROUPS = Object.freeze(["location", "device", "time", "technical", "software", "author", "rights", "descriptive", "xmp", "iptc", "color", "rendering", "other"]); +const SUMMARY_GROUPS = Object.freeze(["device", "time", "location", "software", "author", "descriptive", "rights", "rendering", "technical", "color", "xmp", "iptc", "other"]); +export const MAX_PRIMARY_DECODED_ITEMS = 6; 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", @@ -59,6 +61,18 @@ function groupedEntries(entries, labels) { return [...groups].filter(([, items]) => items.length).map(([key, items]) => ({ key, items })); } +function primaryDecodedGroups(groups, limit = MAX_PRIMARY_DECODED_ITEMS) { + const byKey = new Map(groups.map((group) => [group.key, group])); + const ordered = [...SUMMARY_GROUPS, ...groups.map((group) => group.key).filter((key) => !SUMMARY_GROUPS.includes(key))]; + const primary = []; let remaining = limit; + for (const key of ordered) { + const group = byKey.get(key); if (!group || remaining === 0) continue; + const items = group.items.slice(0, remaining); if (items.length) primary.push({ ...group, items }); + remaining -= items.length; + } + return primary; +} + export function buildInspectionModel(source, labels = {}) { const entries = Array.isArray(source?.report?.entries) ? source.report.entries : []; const groups = groupedEntries(entries, labels); @@ -66,6 +80,8 @@ export function buildInspectionModel(source, labels = {}) { .map((group) => ({ ...group, items: group.items.filter((item) => !item.value.opaque) })) .filter((group) => group.items.length); const decodedCount = decodedGroups.reduce((count, group) => count + group.items.length, 0); + const summaryGroups = primaryDecodedGroups(decodedGroups); + const summaryDecodedCount = summaryGroups.reduce((count, group) => count + group.items.length, 0); const status = source?.report?.inspectionStatus || "container-partial"; return { format: source?.format || source?.report?.format || "", @@ -80,6 +96,8 @@ export function buildInspectionModel(source, labels = {}) { additionalCount: entries.length - decodedCount, groups, decodedGroups, + summaryGroups, + additionalDecodedCount: decodedCount - summaryDecodedCount, diagnostics: (source?.report?.diagnostics || []).map(diagnosticText), }; } diff --git a/tools/image/metadata/tool.css b/tools/image/metadata/tool.css index 408bff3..bf4763c 100644 --- a/tools/image/metadata/tool.css +++ b/tools/image/metadata/tool.css @@ -34,7 +34,7 @@ details li { overflow-wrap: anywhere; } } .metadata-workspace > h2 { margin: 0 0 var(--space-4); } -.metadata-workspace > .source-card { margin-bottom: var(--space-5); } +.metadata-source-panel > .source-card { margin: 0; } .inspector-heading p, .inspection-details > p { color: var(--text-secondary); } .metadata-groups--primary { margin-bottom: var(--space-4); } .inspection-details summary, .clean-customization summary { width: fit-content; cursor: pointer; font-weight: var(--font-weight-bold); } @@ -46,3 +46,9 @@ details li { overflow-wrap: anywhere; } .clean-customization legend { padding-inline: var(--space-2); font-weight: var(--font-weight-bold); } .clean-customization .check-field { margin: 0; } @media (max-width: 38rem) { .clean-customization fieldset { grid-template-columns: 1fr; } .clean-customization .button { width: 100%; } } +.metadata-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, .42fr); gap: var(--space-5); align-items: start; margin-bottom: var(--space-5); } +.metadata-source-panel, .metadata-action-panel { min-width: 0; } +.metadata-action-panel { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-subtle); } +.metadata-action-panel > div > :first-child { margin-top: 0; } +.metadata-action-panel > .button, .metadata-action-panel > .clean-customization > .button { width: 100%; } +@media (max-width: 54rem) { .metadata-overview { grid-template-columns: 1fr; } } diff --git a/tools/pdf/metadata/app.js b/tools/pdf/metadata/app.js index 0b5c7e1..9764838 100644 --- a/tools/pdf/metadata/app.js +++ b/tools/pdf/metadata/app.js @@ -9,7 +9,7 @@ const elements = { input: document.querySelector("#file-input"), dropZone: document.querySelector("#drop-zone"), empty: document.querySelector("#source-empty"), sourceCard: document.querySelector("#source-card"), sourceName: document.querySelector("#source-name"), summary: document.querySelector("#source-summary"), clearSource: document.querySelector("#clear-source"), inspector: document.querySelector("#inspector"), inspectorSummary: document.querySelector("#inspector-summary"), - summaryList: document.querySelector("#metadata-summary-list"), metadataEmpty: document.querySelector("#metadata-empty"), body: document.querySelector("#metadata-body"), + summaryList: document.querySelector("#metadata-summary-list"), decodedOverflowNotice: document.querySelector("#decoded-overflow-notice"), metadataEmpty: document.querySelector("#metadata-empty"), body: document.querySelector("#metadata-body"), inspectionDetails: document.querySelector("#inspection-details"), customizeCleaning: document.querySelector("#customize-cleaning"), customOptions: document.querySelector("#custom-policy-options"), selectAll: document.querySelector("#select-all"), clearSelection: document.querySelector("#clear-selection"), filename: document.querySelector("#filename"), clean: document.querySelector("#clean-pdf"), cleanCustom: document.querySelector("#clean-custom"), @@ -53,7 +53,9 @@ function renderComparison() { function renderSummary() { elements.summaryList.replaceChildren(); const present = state.fields.filter((field) => field.present); - present.forEach((field) => appendDefinition(elements.summaryList, field)); + present.slice(0, 4).forEach((field) => appendDefinition(elements.summaryList, field)); + elements.decodedOverflowNotice.hidden = present.length <= 4; + elements.decodedOverflowNotice.textContent = message("pdfMetadata.inspector.additionalDecoded", { count: Math.max(0, present.length - 4) }); elements.metadataEmpty.hidden = present.length !== 0; elements.inspectorSummary.textContent = message("pdfMetadata.inspector.summary", { count: present.length }); } diff --git a/tools/pdf/metadata/index.html b/tools/pdf/metadata/index.html index 062d4da..7d236c2 100644 --- a/tools/pdf/metadata/index.html +++ b/tools/pdf/metadata/index.html @@ -9,9 +9,9 @@

PDF privacy tool

PDF Metadata Inspector & Cleaner

Review common document metadata and create a cleaned copy without uploading your PDF.

Add one PDF

Drop a PDF here or use the picker. Choosing another file replaces the current source.

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

- - + +
diff --git a/tools/pdf/metadata/tool.css b/tools/pdf/metadata/tool.css index a9b7bbd..61c82af 100644 --- a/tools/pdf/metadata/tool.css +++ b/tools/pdf/metadata/tool.css @@ -60,7 +60,7 @@ } .metadata-workspace > h2 { margin: 0 0 var(--space-4); } -.metadata-workspace > .source-card { margin-bottom: var(--space-5); } +.metadata-source-panel > .source-card { margin: 0; } .metadata-summary-list { display: grid; grid-template-columns: minmax(9rem, .4fr) minmax(0, 1fr); gap: 0 var(--space-4); margin: var(--space-4) 0; } .metadata-summary-list dt, .metadata-summary-list dd { min-width: 0; padding: var(--space-3) 0; border-top: 1px solid var(--border); overflow-wrap: anywhere; } .metadata-summary-list dt { font-weight: var(--font-weight-bold); } @@ -75,3 +75,11 @@ #custom-policy-options .check-field { margin: 0; } .clean-customization .selection-actions { justify-content: flex-start; margin-bottom: var(--space-4); } @media (max-width: 38rem) { .metadata-summary-list { grid-template-columns: 1fr; } .metadata-summary-list dd { padding-top: 0; border-top: 0; } #custom-policy-options { grid-template-columns: 1fr; } .clean-customization .button { width: 100%; } } +.metadata-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(20rem, .45fr); gap: var(--space-5); align-items: start; margin-bottom: var(--space-5); } +.metadata-source-panel, .metadata-action-panel { min-width: 0; } +.metadata-action-panel { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-subtle); } +.metadata-action-panel > div > :first-child { margin-top: 0; } +.metadata-action-panel > .button, .metadata-action-panel > .clean-customization > .button { width: 100%; } +.metadata-action-panel .field { margin: 0; } +.coverage-note { padding: var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); background: var(--bg-subtle); } +@media (max-width: 54rem) { .metadata-overview { grid-template-columns: 1fr; } } From 5117a7be1b1560aef1e8eacd6b23993d4f8d5171 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Thu, 27 Aug 2026 14:02:30 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=84[Docs]=20Record=20metadata=20or?= =?UTF-8?q?ientation=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++- README.md | 12 ++++++------ docs/image-metadata-privacy.md | 12 ++++++------ docs/ux-consistency-audit.md | 8 ++++++++ docs/v2-release-qa.md | 23 +++++++++++++++++------ 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c82e2..f9cae2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Added Image Resize with pixel and percentage modes, aspect-ratio preservation, optional enlargement, Original/JPEG/PNG/WebP output, and local batch ZIP saving. - Added Image Compressor with Original/JPEG/PNG/WebP output, truthful format-specific quality behavior, unchanged dimensions, and local batch ZIP saving. - Added the single-file Image Metadata Inspector & Cleaner for JPEG, PNG, and WebP with honest partial/opaque reporting, authoritative Privacy Clean, ICC preservation, and fail-closed verification before save. -- Pinned the immutable `secure-metadata v0.1.0` browser Release artifact as a same-origin dependency with exact provenance and SHA-256 release-gate coverage. +- Pinned the immutable `secure-metadata v0.1.1` browser Release artifact as a same-origin dependency with exact provenance and SHA-256 release-gate coverage. - Added per-file and aggregate compression metrics that distinguish byte savings from larger generated results. - Added per-image output dimension/pixel checks and a 200-megapixel aggregate resize-output workload limit. - Added JPEG, PNG, and WebP input/output, lossy quality controls for JPEG/WebP, deterministic white JPEG transparency, metadata-stripping canvas re-encoding, collision-safe Unicode names, and ZIP batch output. @@ -17,6 +17,8 @@ ### Changed - Hardened v2 promotion gates for all ten production tools, vendored-resource integrity, local-only network invariants, save failure paths, and resource boundaries. +- Upgraded secure-metadata to v0.1.1 so JPEG Privacy Clean preserves one valid rendering Orientation while removing other targeted EXIF/GPS data without decoding or re-encoding pixels. +- Aligned Image and PDF Metadata action panels and bounded their primary decoded summaries while retaining complete details. - Corrected canonical repository links and extended integration CI to `v2` pushes. - Narrowed homepage and category privacy copy to production file-content processing and localized shared navigation and summary accessible names across all six interface languages. - Added a live v2 promotion QA matrix that keeps blocked browser evidence separate from passing automated checks. diff --git a/README.md b/README.md index 2fcb273..77c143d 100644 --- a/README.md +++ b/README.md @@ -192,10 +192,10 @@ Image Compressor is available at `/tools/image/compress/` on the v2 integration Image Metadata Inspector & Cleaner is available at `/tools/image/metadata/` on the v2 integration branch. - Accepts exactly one signature-validated JPEG, PNG, or WebP file and enforces the application’s 50 MiB limit before full inspection. -- Uses the manually pinned, same-origin `secure-metadata v0.1.0` browser artifact. No npm package, CDN, runtime GitHub request, or automatic version check is used. -- Shows the local source thumbnail, detected format, size, and a keyboard-accessible remove/reset path. Decoded values are primary; opaque containers, coverage, and diagnostics remain available in a native details disclosure. +- Uses the manually pinned, same-origin `secure-metadata v0.1.1` browser artifact. No npm package, CDN, runtime GitHub request, or automatic version check is used. +- Shows the local source thumbnail, detected format, size, and a keyboard-accessible remove/reset path. Up to six high-value decoded fields are shown first; all decoded values, opaque containers, coverage, and diagnostics remain available in a native details disclosure. - Presents `metadata-partial` as successful but non-exhaustive. “No supported metadata detected” is not a claim that the file contains no metadata. -- Privacy Clean calls the library’s authoritative default policy: supported EXIF, XMP, IPTC, comments, PNG text metadata, and timestamps are removed while ICC color profiles are preserved. +- Privacy Clean calls the library’s authoritative default policy: supported privacy-related EXIF, XMP, IPTC, comments, PNG text metadata, and timestamps are removed while valid JPEG rendering orientation and ICC color profiles are preserved. - Customize exposes only supported class-level removal controls for the detected format. Unselected supported classes and unknown structures are preserved; individual metadata-value editing is not offered. - Keeps source bytes unchanged and never decodes pixels, creates Canvas, resizes, converts, changes quality, or re-encodes the image. - Calls `verifyMetadata` on cleaned bytes and requires a valid result with every policy check passing before saving. Invalid, incomplete, truncated, or mismatched results fail closed with no output write. @@ -331,10 +331,10 @@ All processing libraries are pinned and served as same-origin static files. Prod ### secure-metadata -- Version/tag: `v0.1.0` -- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` +- Version/tag: `v0.1.1` +- Release commit: `cdcd138e48d30618b6d76f7c6538cd43ad660b53` - Purpose: local JPEG, PNG, and WebP metadata inspection, Privacy Clean, and fail-closed verification -- Browser artifact SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- Browser artifact SHA-256: `4bfcc9e0e484db12192e46f076c19cf69cd36c496c7cfbb5a71c1057cbcccba1` - License: MIT - Runtime dependencies: 0 - Integration: manually pinned same-origin GitHub Release artifact; not an npm runtime dependency diff --git a/docs/image-metadata-privacy.md b/docs/image-metadata-privacy.md index 6f7623c..af500e2 100644 --- a/docs/image-metadata-privacy.md +++ b/docs/image-metadata-privacy.md @@ -2,9 +2,9 @@ The Image Metadata Inspector & Cleaner at `/tools/image/metadata/` processes one signature-validated JPEG, PNG, or WebP file in browser memory. The application enforces its existing 50 MiB per-image limit before reading the full file. It does not upload the image, decode pixels, use Canvas, resize, convert, or re-encode it. -Inspection reports only structures supported by `secure-metadata v0.1.0`. Decoded values and opaque detected containers are presented differently. A `metadata-partial` result is a successful but non-exhaustive inspection; it is not evidence that every possible metadata structure was decoded. “No supported metadata detected” does not mean that the image contains no metadata or hidden information. +Inspection reports only structures supported by `secure-metadata v0.1.1`. Decoded values and opaque detected containers are presented differently. A `metadata-partial` result is a successful but non-exhaustive inspection; it is not evidence that every possible metadata structure was decoded. “No supported metadata detected” does not mean that the image contains no metadata or hidden information. -Privacy Clean uses the library’s exported `DEFAULT_CLEANING_POLICY` directly. It removes supported EXIF, XMP, IPTC, comments, ordinary PNG text metadata, and standalone timestamps while preserving ICC color profiles. Unknown structures are not guessed away. The original source bytes remain unchanged. +Privacy Clean uses the library’s exported `DEFAULT_CLEANING_POLICY` directly. It removes supported privacy-related EXIF, XMP, IPTC, comments, ordinary PNG text metadata, and standalone timestamps while preserving ICC color profiles. For JPEG, one unambiguous valid EXIF Orientation value from 1–8 is retained as rendering information; ambiguous, duplicate, malformed, conflicting, or out-of-range Orientation is removed instead of guessed. Unknown structures are not guessed away. The original source bytes remain unchanged, and pixels are never decoded or re-encoded. Customize exposes only the same supported metadata classes that apply to the detected JPEG, PNG, or WebP format. The user may choose which classes to remove and whether to preserve ICC, but cannot edit individual values or target unknown structures. Verification expectations are derived from that explicit policy, so only requested removals are required to be absent and intentionally preserved supported classes may remain. @@ -13,10 +13,10 @@ The produced bytes are passed to `verifyMetadata` before any write or download. ## Pinned dependency - Library: `secure-metadata` -- Version/tag: `v0.1.0` -- Release commit: `352258ec413a838dfe8b9146370505f125b5ae10` -- Browser artifact: `secure-metadata-0.1.0.browser.js` -- SHA-256: `8d0b8a1addf904760aa1f52378fb05eed6540520cb05fe2320d77011cba69c28` +- Version/tag: `v0.1.1` +- Release commit: `cdcd138e48d30618b6d76f7c6538cd43ad660b53` +- Browser artifact: `secure-metadata-0.1.1.browser.js` +- SHA-256: `4bfcc9e0e484db12192e46f076c19cf69cd36c496c7cfbb5a71c1057cbcccba1` - License: MIT - Runtime dependencies: 0 diff --git a/docs/ux-consistency-audit.md b/docs/ux-consistency-audit.md index 69f814a..9695406 100644 --- a/docs/ux-consistency-audit.md +++ b/docs/ux-consistency-audit.md @@ -96,3 +96,11 @@ Sprint 18 aligned the Image Metadata and PDF Metadata source cards, made decoded Automated tests cover source reset and object-URL cleanup, decoded/opaque grouping, format-specific Image policies and expectations, PDF default/custom selection, retained-field fail-closed behavior, six-locale parity, responsive CSS contracts, security invariants, and the release gate. `git diff --check` and `node tests/run-all.mjs` passed on 2026-08-26. Interactive browser QA was attempted against `127.0.0.1:4173`, but the browser-control runtime terminated before navigation with `windows sandbox failed: helper_unknown_error: apply deny-read ACLs`. Visual rendering, keyboard interaction, responsive behavior, native save/download, and Network-panel observations remain **BLOCKED**, not passed. The live manual matrix is maintained in [v2 promotion QA](./v2-release-qa.md). + +## Metadata Orientation hotfix follow-up + +The hotfix upgrades the same-origin Image Metadata runtime to verified secure-metadata v0.1.1. Default JPEG Privacy Clean now preserves one unambiguous valid Orientation value as rendering information while removing other targeted EXIF/GPS data; it still does not decode, rotate, normalize, or re-encode pixels. + +Image and PDF Metadata now place source information and primary cleaning controls in a desktop action layout before decoded content in DOM order, then stack them at 54rem and below. Image shows at most six prioritized decoded values and PDF at most four; all supported decoded values and the existing opaque, partial, coverage, diagnostic, and container distinctions remain in native details disclosures. + +Real Chrome QA was attempted twice on 2026-08-27 against a temporary localhost server with synthetic Orientation=6 JPEG, PNG, WebP, and PDF fixtures. The Chrome-control runtime failed before connection or navigation with `failed to write kernel assets: The system cannot find the path specified. (os error 3)`. Visual orientation equivalence, interactive layout, keyboard, save, and Network-panel checks remain **BLOCKED**, not passed; temporary fixtures and the server were removed. diff --git a/docs/v2-release-qa.md b/docs/v2-release-qa.md index 303538f..bf6481c 100644 --- a/docs/v2-release-qa.md +++ b/docs/v2-release-qa.md @@ -4,12 +4,12 @@ This is the live promotion gate for the Secure Tools `v2` integration branch. It ## Decision -**READY EXCEPT MANUAL QA** as of 2026-08-26, conditional on the Sprint 17 pull-request CI gate passing before merge. +**READY EXCEPT MANUAL QA** as of 2026-08-27, conditional on the Metadata Orientation hotfix pull-request CI gate passing before merge. - Automated local gate: **PASS** — `git diff --check` and `node tests/run-all.mjs` completed successfully on Windows with Node.js 24. - Static privacy, network, CSP, resource, dependency, route, localization, save-path, responsive-contract, and accessibility checks: **PASS**. -- Interactive browser gate: **BLOCKED** before navigation by the Codex Windows sandbox ACL failure documented below. -- Promotion to `main`, release tagging, release publication, and deployment: **NOT RUN** and outside Sprint 17 scope. +- Interactive browser gate: **BLOCKED** before navigation by the local Chrome-control runtime failure documented below. +- Promotion to `main`, release tagging, release publication, and deployment: **NOT RUN** and outside this hotfix scope. The v2 branch must not be promoted until a human completes the open browser matrix and records evidence. Any failed automated or CI check, privacy regression, unexplained runtime request, corrupt output, inaccessible primary path, or failed save is a release blocker. @@ -59,7 +59,7 @@ Automated contracts cover supported and rejected inputs, deterministic naming, d ## Dependency and repository audit -Audited on 2026-08-26 against repository records and official npm registry metadata. +Audited on 2026-08-27 against repository records, immutable GitHub Release assets, and official npm registry metadata. | Dependency | Vendored version | Official npm latest | License/inventory/hash gate | | --- | ---: | ---: | --- | @@ -67,12 +67,12 @@ Audited on 2026-08-26 against repository records and official npm registry metad | pdf-lib | 1.17.1 | 1.17.1 | PASS | | JSZip | 3.10.1 | 3.10.1 | PASS | | PDF.js (`pdfjs-dist`) | 6.2.108 | 6.2.108 | PASS | -| secure-metadata | 0.1.0 immutable GitHub release artifact | Not an npm runtime dependency | PASS | +| secure-metadata | 0.1.1 immutable GitHub release artifact | Not an npm runtime dependency | PASS | - The v1.0.0 GitHub release is published; v1.0.0-rc.1 remains marked as a prerelease. - `main` and `v2` returned “Branch not protected” from the GitHub branch-protection API. This is a repository-governance risk, not a change authorized by Sprint 17. - GitHub returned `403 Dependabot alerts are disabled for this repository`; vulnerability-alert visibility is therefore **NOT AVAILABLE**, not `PASS`. -- No dependency, vendor byte, license, framework, package manager, build system, or runtime resource was added or upgraded. +- secure-metadata alone was upgraded from 0.1.0 to the verified 0.1.1 browser Release artifact. No framework, package manager, build system, runtime dependency, CDN, or remote processing resource was added. ## Automated command evidence @@ -87,6 +87,7 @@ Audited on 2026-08-26 against repository records and official npm registry metad | Per-tool file, queue, dimension, pixel, render, and aggregate-work boundaries | PASS | | Pull-request CI | REQUIRED BEFORE MERGE | | Sprint 18 Metadata source cards, decoded-first disclosure, safe customization, and fail-closed verification contracts | PASS | +| secure-metadata v0.1.1 provenance, Orientation 3/6/8 preservation, privacy-EXIF/GPS removal, unchanged JPEG scan bytes, bounded summaries, and action-panel contracts | PASS | The Node runner reports `MODULE_TYPELESS_PACKAGE_JSON` warnings because a parent user-level package file does not declare a module type. The static repository intentionally has no package manager or build configuration; all tests execute successfully. @@ -100,6 +101,16 @@ windows sandbox failed: helper_unknown_error: apply deny-read ACLs Result: **BLOCKED**. Browser family/version, viewport rendering, keyboard operation, file picker behavior, output downloads, and Network-panel observations are **NOT RUN**. The temporary local server was stopped after the failed attempt. No standalone browser automation result or visual pass is claimed. +## Metadata Orientation hotfix Chrome attempt + +Attempted twice on 2026-08-27 against a temporary server at `127.0.0.1:4173` using the requested real Chrome connection and synthetic Orientation=6 JPEG, PNG, WebP, and PDF fixtures. Chrome control failed before connection or navigation with: + +```text +failed to write kernel assets: The system cannot find the path specified. (os error 3) +``` + +Result: **BLOCKED**. Original-versus-cleaned visual orientation, interactive toolbar layout, responsive rendering, keyboard-only operation, browser-native save/cancel/failure paths, and Network-panel observations are **NOT RUN**. Automated Orientation tests and static UX contracts remain PASS but are not presented as manual Chrome evidence. The temporary server and all synthetic fixtures were removed. + ## Manual browser matrix Use current stable Chromium, Firefox, and Safari/WebKit where available. Serve the repository over HTTP, use synthetic non-sensitive fixtures, disable the Network-panel cache, and clear stored language/theme preferences before detection tests.