From aa6a74f229320128549badb4423e1f86bf13d527 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 7 Sep 2026 16:31:24 +0700 Subject: [PATCH] feat: stamp 300dpi metadata on camera-captured exports Skia JPEG re-encode drops original header (72dpi camera default). New src/utils/jpegDpi.ts rewrites JFIF APP0 density + EXIF X/YResolution (endian-aware) or inserts minimal JFIF when neither exists; applied via processAndExportPhoto options.dpi in the camera-capture branch only. Library-mode exports untouched. Pixels never change - metadata only. --- App.tsx | 3 +- src/utils/exportEngine.ts | 17 +++- src/utils/jpegDpi.ts | 161 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 src/utils/jpegDpi.ts diff --git a/App.tsx b/App.tsx index a210d96..0cd4244 100644 --- a/App.tsx +++ b/App.tsx @@ -293,7 +293,8 @@ export default function App() { { ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag }, selectedFrame, useGeotag, - captureGps + captureGps, + { dpi: 300 } // stamp 300dpi into JPEG header (print-ready metadata) ); if (result) { diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 16e35d0..527de5b 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -6,12 +6,18 @@ import { Recipe, GPSInfo, FrameId } from '../types'; import { getSkiaColorMatrix } from './colorUtils'; import { drawFrameOnCanvas } from './frameUtils'; import { formatCoordinate } from './locationUtils'; +import { base64ToBytes, bytesToBase64, patchJpegDpi } from './jpegDpi'; export interface ExportResult { uri: string; savedToLibrary: boolean; } +export interface ExportOptions { + // DPI to stamp into the JPEG header (metadata only — pixel size never changes). + dpi?: number; +} + // Fixed filename: every export overwrites the previous one, so temp files // never accumulate in the cache directory. const EXPORT_FILE = `${FileSystem.cacheDirectory}camrecipe_pro_last_export.jpg`; @@ -21,7 +27,8 @@ export async function processAndExportPhoto( recipe: Recipe, frameId: FrameId, useGeotag: boolean, - gpsInfo: GPSInfo | null + gpsInfo: GPSInfo | null, + options?: ExportOptions ): Promise { try { // 1. Read source image file into Skia @@ -181,13 +188,19 @@ export async function processAndExportPhoto( // 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip) const resultImage = surface.makeImageSnapshot(); - const base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95); + let base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95); if (!base64) { console.error('Failed to encode image to JPEG'); return null; } + // 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested + if (options?.dpi && options.dpi > 0) { + const patched = patchJpegDpi(base64ToBytes(base64), options.dpi); + base64 = bytesToBase64(patched); + } + // 10. Write binary bytes to temporary local file (overwrites previous export) await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, { encoding: FileSystem.EncodingType.Base64, diff --git a/src/utils/jpegDpi.ts b/src/utils/jpegDpi.ts new file mode 100644 index 0000000..060fe26 --- /dev/null +++ b/src/utils/jpegDpi.ts @@ -0,0 +1,161 @@ +// Pure JPEG DPI metadata patcher. +// Re-encoding a JPEG through Skia drops the original EXIF/JFIF headers, so the +// DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only — pixel +// dimensions never change — but print pipelines read it, so this rewrites the +// JFIF density (and EXIF XResolution/YResolution when present) to the requested +// value. All writes are same-length, so no segment offsets move. +// +// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is +// inserted right after SOI. + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +export function base64ToBytes(b64: string): Uint8Array { + const clean = b64.replace(/\s+/g, ''); + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of clean) { + if (ch === '=') break; + const v = B64.indexOf(ch); + if (v < 0) continue; + buffer = (buffer << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out.push((buffer >> bits) & 0xff); + } + } + return Uint8Array.from(out); +} + +export function bytesToBase64(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]; + const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0; + const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0; + out += B64[b0 >> 2]; + out += B64[((b0 & 3) << 4) | (b1 >> 4)]; + out += i + 1 < bytes.length ? B64[((b1 & 15) << 2) | (b2 >> 6)] : '='; + out += i + 2 < bytes.length ? B64[b2 & 63] : '='; + } + return out; +} + +function u16(bytes: Uint8Array, p: number): number { + return (bytes[p] << 8) | bytes[p + 1]; +} +function setU16(bytes: Uint8Array, p: number, v: number): void { + bytes[p] = (v >> 8) & 0xff; + bytes[p + 1] = v & 0xff; +} + +// Patches `bytes` in place for JFIF; returns the (possibly rebuilt) array. +export function patchJpegDpi(input: Uint8Array, dpi: number): Uint8Array { + if (input.length < 2 || input[0] !== 0xff || input[1] !== 0xd8) return input; + const bytes = input.slice(); // work on a copy; insertion path rebuilds anyway + const dpiLo = dpi & 0xff; + const dpiHi = (dpi >> 8) & 0xff; + + let sawJfif = false; + let sawExif = false; + + let i = 2; + while (i + 1 < bytes.length && bytes[i] === 0xff) { + while (bytes[i] === 0xff) i++; + const marker = bytes[i]; + i++; + if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS — headers only + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (i + 2 > bytes.length) break; + const len = u16(bytes, i); + if (len < 2 || i + len > bytes.length) break; + const dataStart = i + 2; + const payloadLen = len - 2; + + // APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd][1 thW][1 thH]... + if (marker === 0xe0 && payloadLen >= 14 && bytes[dataStart] === 0x4a && + bytes[dataStart + 1] === 0x46 && bytes[dataStart + 2] === 0x49 && + bytes[dataStart + 3] === 0x46 && bytes[dataStart + 4] === 0x00) { + sawJfif = true; + bytes[dataStart + 7] = 1; // units = dots per inch + bytes[dataStart + 8] = dpiHi; + bytes[dataStart + 9] = dpiLo; + bytes[dataStart + 10] = dpiHi; + bytes[dataStart + 11] = dpiLo; + } + + // APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF + if (marker === 0xe1 && payloadLen >= 14 && + bytes[dataStart] === 0x45 && bytes[dataStart + 1] === 0x78 && + bytes[dataStart + 2] === 0x69 && bytes[dataStart + 3] === 0x66 && + bytes[dataStart + 4] === 0x00 && bytes[dataStart + 5] === 0x00) { + sawExif = true; + const tiff = dataStart + 6; + const le = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + const be = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d; + if ((le || be) && tiff + 8 <= bytes.length) { + const read32 = (p: number): number => + le + ? bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24) + : ((bytes[p] << 24) | (bytes[p + 1] << 16) | (bytes[p + 2] << 8) | bytes[p + 3]) >>> 0; + const write32 = (p: number, v: number): void => { + if (le) { + bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; + bytes[p + 2] = (v >> 16) & 0xff; bytes[p + 3] = (v >> 24) & 0xff; + } else { + bytes[p] = (v >> 24) & 0xff; bytes[p + 1] = (v >> 16) & 0xff; + bytes[p + 2] = (v >> 8) & 0xff; bytes[p + 3] = v & 0xff; + } + }; + // TIFF integer fields (tag/type/count/values) follow the file byte order + const u16r = (p: number): number => + le ? bytes[p] | (bytes[p + 1] << 8) : (bytes[p] << 8) | bytes[p + 1]; + const u16w = (p: number, v: number): void => { + if (le) { bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; } + else { bytes[p] = (v >> 8) & 0xff; bytes[p + 1] = v & 0xff; } + }; + const ifdOff = read32(tiff + 4); + if (ifdOff + 2 <= bytes.length - tiff) { + const ifd = tiff + ifdOff; + const count = u16r(ifd); + for (let e = 0; e < count; e++) { + const entry = ifd + 2 + e * 12; + if (entry + 12 > bytes.length) break; + const tag = u16r(entry); + const type = u16r(entry + 2); + const cnt = read32(entry + 4); + if ((tag === 0x011a || tag === 0x011b) && type === 5 && cnt === 1) { + // RATIONAL: entry+8 holds offset to (num, den), relative to TIFF start + const rat = tiff + read32(entry + 8); + if (rat + 8 <= bytes.length) write32(rat, dpi); // keep denominator + } else if (tag === 0x0128 && type === 3 && cnt === 1) { + // ResolutionUnit (2 = inch): normalize to inch so the new density reads correctly + u16w(entry + 8, 2); + } + } + } + } + } + + i += len; + } + + if (sawJfif || sawExif) return bytes; + + // Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch) after SOI. + const app0 = new Uint8Array([ + 0xff, 0xe0, 0x00, 0x10, + 0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // version 1.01 + 0x01, // units: inch + dpiHi, dpiLo, dpiHi, dpiLo, + 0x00, 0x00, // no thumbnail + ]); + const out = new Uint8Array(bytes.length + app0.length); + out[0] = 0xff; out[1] = 0xd8; + out.set(app0, 2); + out.set(bytes.subarray(2), 2 + app0.length); + return out; +}