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.
This commit is contained in:
2026-09-07 16:31:24 +07:00
parent 96f412e269
commit aa6a74f229
3 changed files with 178 additions and 3 deletions
+2 -1
View File
@@ -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) {
+15 -2
View File
@@ -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<ExportResult | null> {
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,
+161
View File
@@ -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;
}