diff --git a/src/components/Gallery.jsx b/src/components/Gallery.jsx
index 263e0d0e..ac0d3dc5 100644
--- a/src/components/Gallery.jsx
+++ b/src/components/Gallery.jsx
@@ -86,7 +86,7 @@ export default function Gallery({ images, currentPage, lastPage, total, perPage
/>
{current.name.replaceAll("_", " ")}
-
{formatDate(current.date)}
+ {current.date ?
{formatDate(current.date)}
: null}
diff --git a/src/lib/gallery.ts b/src/lib/gallery.ts
index ecac79aa..768e4d1f 100644
--- a/src/lib/gallery.ts
+++ b/src/lib/gallery.ts
@@ -2,12 +2,13 @@ import { statSync } from "node:fs";
import { join } from "node:path";
import { getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
+import sharp from "sharp";
export const PER_PAGE = 9;
export interface GalleryImage {
name: string;
- date: string;
+ date?: string;
thumb: string;
full: string;
}
@@ -16,6 +17,74 @@ const files = import.meta.glob<{ default: ImageMetadata }>("../assets/gallery/*.
eager: true,
});
+function readExifDate(exif: Buffer): string | undefined {
+ const tiffStart = exif.toString("ascii", 0, 6) === "Exif\0\0" ? 6 : 0;
+ const byteOrder = exif.toString("ascii", tiffStart, tiffStart + 2);
+ if (byteOrder !== "II" && byteOrder !== "MM") return undefined;
+
+ const littleEndian = byteOrder === "II";
+ const read16 = (offset: number) =>
+ littleEndian ? exif.readUInt16LE(offset) : exif.readUInt16BE(offset);
+ const read32 = (offset: number) =>
+ littleEndian ? exif.readUInt32LE(offset) : exif.readUInt32BE(offset);
+ if (read16(tiffStart + 2) !== 42) return undefined;
+
+ const readDirectory = (offset: number) => {
+ const start = tiffStart + offset;
+ if (start + 2 > exif.length) return [];
+ const count = read16(start);
+ return Array.from({ length: count }, (_, index) => start + 2 + index * 12).filter(
+ (entry) => entry + 12 <= exif.length,
+ );
+ };
+ const firstDirectory = readDirectory(read32(tiffStart + 4));
+ let exifDirectoryOffset: number | undefined;
+ let dateTime: string | undefined;
+
+ for (const entry of firstDirectory) {
+ const tag = read16(entry);
+ if (tag === 0x8769) exifDirectoryOffset = read32(entry + 8);
+ if (tag === 0x0132) dateTime = readAscii(entry);
+ }
+
+ if (exifDirectoryOffset !== undefined) {
+ for (const entry of readDirectory(exifDirectoryOffset)) {
+ const tag = read16(entry);
+ if (tag === 0x9003) return toIsoDate(readAscii(entry));
+ if (tag === 0x9004) dateTime = readAscii(entry);
+ }
+ }
+
+ return toIsoDate(dateTime);
+
+ function readAscii(entry: number): string | undefined {
+ if (read16(entry + 2) !== 2) return undefined;
+ const count = read32(entry + 4);
+ const valueOffset = count <= 4 ? entry + 8 : tiffStart + read32(entry + 8);
+ if (valueOffset + count > exif.length) return undefined;
+ return exif.toString("ascii", valueOffset, valueOffset + count).replace(/\0+$/, "");
+ }
+
+ function toIsoDate(value: string | undefined): string | undefined {
+ const match = value?.match(/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);
+ if (!match) return undefined;
+ return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`;
+ }
+}
+
+async function getExifDate(path: string): Promise {
+ const metadata = await sharp(path).metadata();
+ let exif = metadata.exif;
+
+ if (!exif) {
+ const rawProfile = metadata.comments?.find((comment) => comment.keyword === "Raw profile type exif")?.text;
+ const hex = rawProfile?.match(/^\s*exif\s+\d+\s+([\s\S]+)$/i)?.[1]?.replace(/\s/g, "");
+ if (hex) exif = Buffer.from(hex, "hex");
+ }
+
+ return exif ? readExifDate(exif) : undefined;
+}
+
/** All gallery images, newest file first (like the Gatsby site's modifiedTime sort). */
export async function getGalleryImages(): Promise {
const entries = Object.entries(files).map(([path, mod]) => {
@@ -24,14 +93,15 @@ export async function getGalleryImages(): Promise {
.pop()!
.replace(/\.jpg$/, "");
// import.meta.url points into the build output, so resolve from the project root
- const mtime = statSync(join(process.cwd(), "src/assets/gallery", `${name}.jpg`)).mtime;
- return { name, mtime, image: mod.default };
+ const sourcePath = join(process.cwd(), "src/assets/gallery", `${name}.jpg`);
+ const mtime = statSync(sourcePath).mtime;
+ return { name, mtime, sourcePath, image: mod.default };
});
entries.sort((a, b) => b.mtime.getTime() - a.mtime.getTime() || a.name.localeCompare(b.name));
return Promise.all(
- entries.map(async ({ name, mtime, image }) => ({
+ entries.map(async ({ name, sourcePath, image }) => ({
name,
- date: mtime.toISOString(),
+ date: await getExifDate(sourcePath),
thumb: (await getImage({ src: image, width: 600, format: "webp" })).src,
full: (await getImage({ src: image, width: 1200, format: "webp" })).src,
})),