Repository: sambecker/exif-photo-blog

Issue:
Add EXIF Camera Name Mapping for Friendlier Display
**Is your feature request related to a problem? Please describe.**
Many EXIF metadata entries use technical or non-user-friendly camera names (e.g., "SONY ILCE-7M4" instead of "Sony A7 IV"). This can make it harder for users to quickly recognize camera names on the client side.

**Describe the solution you'd like**
A mapping between EXIF camera names and their more user-friendly equivalents should be provided. This would allow the frontend to easily replace complex EXIF names with commonly recognized camera names when displaying metadata while allowing the EXIF data on the backend to stay the same.

Example:
```
export const CAMERA_NAME_MAP: Record<string, string> = {
...
  // Sony
  "SONY ILCE-7RM5": "Sony A7R V",
  "SONY ILCE-7RM4": "Sony A7R IV",
  "SONY ILCE-7RM3": "Sony A7R III",
  "SONY ILCE-7M4": "Sony A7 IV",
  "SONY ILCE-7M3": "Sony A7 III",
  "SONY ILCE-7C": "Sony A7C",
  "SONY ILCE-7CR": "Sony A7CR",
  "SONY ILCE-6400": "Sony A6400",
  "SONY ILCE-6600": "Sony A6600",
  "SONY ILCE-9": "Sony A9",
  "SONY ILCE-9M2": "Sony A9 II",
  "SONY ILCE-1": "Sony A1",
  "SONY ZV-E10": "Sony ZV-E10",
...
};
```


Hints from issue comments:
@DevinTyler26: **From:**
![Image](https://github.com/user-attachments/assets/fe380b23-dba0-4ea3-b7ef-394395c5c6b8)
**To:**
![Image](https://github.com/user-attachments/assets/22c48eb6-3f18-4d6b-b14f-9a51c6e1914b)
@sambecker: Love it!

Is your code sample exhaustive for Sony A-line cameras?

I don't mind improving the experience for popular brands/cameras but I'm trying to be as thorough as possible. Just want to make sure I understand the scope of this upgrade.

Let me know, and I'll incorporate.@sambecker: Also, seems like https://github.com/sambecker/exif-photo-blog/issues/202#issuecomment-2727577046 relates to the same issue?

Is having a silent "M" (for Model?) a common EXIF camera convention?@DevinTyler26: Awesome! Glad to hear you like the idea.
The list is not exhaustive for Sony, but I could help out by getting a list together.

I think "M" might stand for "Mark" like Sony Alpha 7R Mark 5@sambecker: > I think "M" might stand for "Mark" like Sony Alpha 7R Mark 5

Of course! I do wonder if there are some regex rules we could generalize for more ranges. Good food for thought.

For next steps, I think having at least the A series would be enough to incorporate. If you provide that, I'll incorporate.@DevinTyler26: Here is what I could come up with for Sony:
```
{
  "SONY ILCE-1M2": "Sony A1 II",
  "SONY ILCE-1": "Sony A1",
  "SONY ILCE-9M3": "Sony A9 III",
  "SONY ILCE-9M2": "Sony A9 II",
  "SONY ILCE-9": "Sony A9",
  "SONY ILCE-7RM5": "Sony A7R V",
  "SONY ILCE-7RM4": "Sony A7R IV",
  "SONY ILCE-7RM3": "Sony A7R III",
  "SONY ILCE-7RM2": "Sony A7R II",
  "SONY ILCE-7R": "Sony A7R",
  "SONY ILCE-7SM3": "Sony A7S III",
  "SONY ILCE-7SM2": "Sony A7S II",
  "SONY ILCE-7S": "Sony A7S",
  "SONY ILCE-7M4": "Sony A7 IV",
  "SONY ILCE-7M3": "Sony A7 III",
  "SONY ILCE-7M2": "Sony A7 II",
  "SONY ILCE-7": "Sony A7",
  "SONY ILCE-7CR": "Sony A7CR",
  "SONY ILCE-7CM2": "Sony 7C II",
  "SONY ILCE-7C": "Sony A7C",
  "SONY ILCE-6700": "Sony A6700",
  "SONY ILCE-6600": "Sony A6600",
  "SONY ILCE-6500": "Sony A6500",
  "SONY ILCE-6400": "Sony A6400",
  "SONY ILCE-6300": "Sony A6300",
  "SONY ILCE-6100": "Sony A6100",
  "SONY ILCE-6000": "Sony A6000",
  "SONY ILCE-5100": "Sony A5100",
  "SONY ILCE-5000": "Sony A5000",
  "SONY ILCE-3500": "Sony A3500",
  "SONY ILCE-3000": "Sony A3000",
  "SONY ILME-FX3": "Sony FX3",
  "SONY ILME-FX6V": "Sony FX6",
  "SONY ILME-FX6VK": "Sony FX6",
  "SONY ILCE-QX1": "Sony A QX1"
}
```
I cross checked it with this wiki article: https://en.wikipedia.org/wiki/List_of_Sony_%CE%B1_cameras@sambecker: This looks great. Patterns make total sense. Is your instinct `SONY ILME-FX6V` is really meant to be labeled the same as `SONY ILME-FX6VK`? Want to make sure we don't lose nuance when applying formatting.@DevinTyler26: Yep the `SONY ILME-FX6V` and `SONY ILME-FX6VK` are both the model number for `Sony FX6`. Although after looking up the Sony FX6, it is a cine camera and I don't think it takes photos so we could leave those off the list.

I asked ChatGPT to help out with function that could do this mapping dynamically for us and this seems to work: https://gist.github.com/DevinTyler26/8cf3c1acdf945c9f65f1f46d4ba3db06@sambecker: Just put up a PR for this!

Put together a pretty concise regex that seems to do the job:
```ts
/^SONY (ILCE|ILME)-([0-9]*)([a-ln-z]*)M*([0-9]*)/
```

Only models I needed to tweak—in order to fit the pattern—were:
- `SONY ILCE-7CM2` : `Sony 7C II` -> `Sony A7C II` (added an `A`)
- `SONY ILCE-QX1` : `Sony A QX1` -> `Sony AQX1` (removed a space)

[Camera data here](https://github.com/sambecker/exif-photo-blog/blob/7ab259a09e4aeea9ff05e73f0f14f5a1c5ae0e9b/__tests__/camera.test.ts)@sambecker: Any final thoughts @DevinTyler26?@DevinTyler26: The PR looks great! Thanks for getting this implemented @Rylaris: > Here is what I could come up with for Sony:
> 
> ```
> {
>   "SONY ILCE-1M2": "Sony A1 II",
>   "SONY ILCE-1": "Sony A1",
>   "SONY ILCE-9M3": "Sony A9 III",
>   "SONY ILCE-9M2": "Sony A9 II",
>   "SONY ILCE-9": "Sony A9",
>   "SONY ILCE-7RM5": "Sony A7R V",
>   "SONY ILCE-7RM4": "Sony A7R IV",
>   "SONY ILCE-7RM3": "Sony A7R III",
>   "SONY ILCE-7RM2": "Sony A7R II",
>   "SONY ILCE-7R": "Sony A7R",
>   "SONY ILCE-7SM3": "Sony A7S III",
>   "SONY ILCE-7SM2": "Sony A7S II",
>   "SONY ILCE-7S": "Sony A7S",
>   "SONY ILCE-7M4": "Sony A7 IV",
>   "SONY ILCE-7M3": "Sony A7 III",
>   "SONY ILCE-7M2": "Sony A7 II",
>   "SONY ILCE-7": "Sony A7",
>   "SONY ILCE-7CR": "Sony A7CR",
>   "SONY ILCE-7CM2": "Sony 7C II",
>   "SONY ILCE-7C": "Sony A7C",
>   "SONY ILCE-6700": "Sony A6700",
>   "SONY ILCE-6600": "Sony A6600",
>   "SONY ILCE-6500": "Sony A6500",
>   "SONY ILCE-6400": "Sony A6400",
>   "SONY ILCE-6300": "Sony A6300",
>   "SONY ILCE-6100": "Sony A6100",
>   "SONY ILCE-6000": "Sony A6000",
>   "SONY ILCE-5100": "Sony A5100",
>   "SONY ILCE-5000": "Sony A5000",
>   "SONY ILCE-3500": "Sony A3500",
>   "SONY ILCE-3000": "Sony A3000",
>   "SONY ILME-FX3": "Sony FX3",
>   "SONY ILME-FX6V": "Sony FX6",
>   "SONY ILME-FX6VK": "Sony FX6",
>   "SONY ILCE-QX1": "Sony A QX1"
> }
> ```
> 
> I cross checked it with this wiki article: https://en.wikipedia.org/wiki/List_of_Sony_%CE%B1_cameras

My camera model is ILCE-7RM3A. As far as I know, there is ILCE-7RM3A and ILCE-7RM4A use 'A' suffixe indicate screen update model. This list have to update in order to include screen update model
@sambecker @sambecker: Just added that modifier @Rylaris in https://github.com/sambecker/exif-photo-blog/commit/bc0216f83205ff2c1815e38ccfff4ded0ed1cdf4

This updated regex should grab an optional 1-character modifier after the version number
```ts
/^SONY (ILCE|ILME)-([0-9]*)([a-ln-z]*)M*([0-9]*)([a-z]*)/gi
```

Wondering if there are any other significant exceptions @DevinTyler26?@sambecker: OK—merging this for now!

We can always refine as outliers are discovered.

Worst case, we can provide something like `DO_NOT_FORMAT_CAMERA_TEXT` to opt out of this behavior.@Rylaris: > OK—merging this for now!
> 
> We can always refine as outliers are discovered.
> 
> Worst case, we can provide something like `DO_NOT_FORMAT_CAMERA_TEXT` to opt out of this behavior.

Maybe the better solution is to provide custom mapping options to users, letting them customize how to map EXIF camera names to display names. 
Because not only Sony, but other camera makers may also use un-user-friendly names. For example, DJI FC8284 means DJI Air 3. Developers can't cover 100% of camera models. So I believe letting users customize the mapping dictionary would be a better solution.

Letting users customize mapping has another benefit. I can provide the same value to multiple keys to achieve behaviors like displaying by camera format (M43/APSC/Full Frame etc.) or by camera maker (Sony/Fuji/Canon etc.) or by camera type (DSLR/Mirrorless/Film/Compact/Smartphone/Drone etc.), In conclusion, I believe this approach is the most flexible and has the greatest potential for creative possibilities..@DevinTyler26: Oh interesting, I didn't know Sony appended "A" for minor refreshes, that's good to know! 
I took a look this morning and couldn't find any more exceptions than the "A" appended at the end

Solution patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 4840a4de..5ef0cd4d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -27,7 +27,10 @@
     "headlessui",
     "hgetall",
     "hset",
+    "IIIA",
+    "ILCE",
     "ILIKE",
+    "ILME",
     "jpgs",
     "Lightbox",
     "Makernote",
diff --git a/src/camera/index.ts b/src/camera/index.ts
index 0bc31f41..403f6615 100644
--- a/src/camera/index.ts
+++ b/src/camera/index.ts
@@ -1,5 +1,6 @@
 import type { Photo } from '@/photo';
 import { isCameraMakeApple } from '@/platforms/apple';
+import { formatSonyModel, isMakeSony } from '@/platforms/sony';
 import { parameterize } from '@/utility/string';
 
 const CAMERA_PLACEHOLDER: Camera = { make: 'Camera', model: 'Model' };
@@ -58,7 +59,7 @@ export const cameraFromPhoto = (
     : fallback ?? CAMERA_PLACEHOLDER;
 
 export const formatCameraText = (
-  { make, model: modelRaw }: Camera,
+  { make, model: _model }: Camera,
   length:
     'long' |    // Unmodified make and model
     'medium' |  // Make and model, with modifiers removed
@@ -67,11 +68,11 @@ export const formatCameraText = (
 ) => {
   // Capture simple make without modifiers like 'Corporation' or 'Company'
   const makeSimple = make.match(/^(\S+)/)?.[1];
+  let model = isMakeSony(make) ? formatSonyModel(_model) : _model;
   const doesModelStartWithMake = (
     makeSimple &&
-    modelRaw.toLocaleLowerCase().startsWith(makeSimple.toLocaleLowerCase())
+    model.toLocaleLowerCase().startsWith(makeSimple.toLocaleLowerCase())
   );
-  let model = modelRaw;
   switch (length) {
   case 'long':
     return `${make} ${model}`;
diff --git a/src/platforms/sony.ts b/src/platforms/sony.ts
new file mode 100644
index 00000000..251bc5b8
--- /dev/null
+++ b/src/platforms/sony.ts
@@ -0,0 +1,28 @@
+import { convertNumberToRomanNumeral } from '@/utility/number';
+
+export const MAKE_SONY = 'SONY';
+
+export const isMakeSony = (make: string) =>
+  make === MAKE_SONY;
+
+export const formatSonyModel = (model: string) => {
+  const [
+    _,
+    type,
+    series,
+    letter,
+    version,
+    modifier,
+  // eslint-disable-next-line max-len
+  ] = /^SONY (ILCE|ILME)-([0-9]*)([a-ln-z]*)M*([0-9]*)([a-z]*)/gi.exec(model) ?? [];
+  const versionNumber = parseInt(version || '0');
+  const versionRomanNumeral = versionNumber > 1 && versionNumber < 10
+    ? ` ${convertNumberToRomanNumeral(versionNumber)}`
+    : '';
+  if (type === 'ILCE' || type === 'ILME') {
+    return type === 'ILCE'
+      ? `A${series}${letter}${versionRomanNumeral || version}${modifier}`
+      : `FX${series}${version}`;
+  }
+  return model;
+};
diff --git a/src/utility/number.ts b/src/utility/number.ts
index 2bb5deba..f097c6f5 100644
--- a/src/utility/number.ts
+++ b/src/utility/number.ts
@@ -91,3 +91,30 @@ export const formatBytesToMB = (
   precision = 1,
 ) =>
   `${(bytes / byteSize / byteSize).toFixed(precision)}MB`;
+
+export const convertNumberToRomanNumeral = (number: number) => {
+  const romanNumerals = [
+    { value: 1000, numeral: 'M' },
+    { value: 900, numeral: 'CM' },
+    { value: 500, numeral: 'D' },
+    { value: 400, numeral: 'CD' },
+    { value: 100, numeral: 'C' },
+    { value: 90, numeral: 'XC' },
+    { value: 50, numeral: 'L' },
+    { value: 40, numeral: 'XL' },
+    { value: 10, numeral: 'X' },
+    { value: 9, numeral: 'IX' },
+    { value: 5, numeral: 'V' },
+    { value: 4, numeral: 'IV' },
+    { value: 1, numeral: 'I' },
+  ];
+
+  let result = '';
+  for (const romanNumeral of romanNumerals) {
+    while (number >= romanNumeral.value) {
+      result += romanNumeral.numeral;
+      number -= romanNumeral.value;
+    }
+  }
+  return result;
+};



Test patch:
diff --git a/__tests__/camera.test.ts b/__tests__/camera.test.ts
index 03b8181b..6bdacae6 100644
--- a/__tests__/camera.test.ts
+++ b/__tests__/camera.test.ts
@@ -1,4 +1,5 @@
 import { Camera, formatCameraText } from '@/camera';
+import { MAKE_SONY } from '@/platforms/sony';
 
 const APPLE     : Camera = { make: 'Apple', model: 'iPhone 11 Pro' };
 const APPLE_01  : Camera = { make: 'Apple', model: 'iPhone 11' };
@@ -11,6 +12,46 @@ const RICOH     : Camera = {
   model: 'RICOH GR III',
 };
 
+export const SONY_CAMERAS = {
+  'SONY ILCE-1M2': 'Sony A1 II',
+  'SONY ILCE-1': 'Sony A1',
+  'SONY ILCE-9M3': 'Sony A9 III',
+  'SONY ILCE-9M2': 'Sony A9 II',
+  'SONY ILCE-9': 'Sony A9',
+  'SONY ILCE-7RM5': 'Sony A7R V',
+  'SONY ILCE-7RM4': 'Sony A7R IV',
+  'SONY ILCE-7RM4A': 'Sony A7R IVA',
+  'SONY ILCE-7RM3': 'Sony A7R III',
+  'SONY ILCE-7RM3A': 'Sony A7R IIIA',
+  'SONY ILCE-7RM2': 'Sony A7R II',
+  'SONY ILCE-7R': 'Sony A7R',
+  'SONY ILCE-7SM3': 'Sony A7S III',
+  'SONY ILCE-7SM2': 'Sony A7S II',
+  'SONY ILCE-7S': 'Sony A7S',
+  'SONY ILCE-7M4': 'Sony A7 IV',
+  'SONY ILCE-7M3': 'Sony A7 III',
+  'SONY ILCE-7M2': 'Sony A7 II',
+  'SONY ILCE-7': 'Sony A7',
+  'SONY ILCE-7CR': 'Sony A7CR',
+  'SONY ILCE-7CM2': 'Sony A7C II',
+  'SONY ILCE-7C': 'Sony A7C',
+  'SONY ILCE-6700': 'Sony A6700',
+  'SONY ILCE-6600': 'Sony A6600',
+  'SONY ILCE-6500': 'Sony A6500',
+  'SONY ILCE-6400': 'Sony A6400',
+  'SONY ILCE-6300': 'Sony A6300',
+  'SONY ILCE-6100': 'Sony A6100',
+  'SONY ILCE-6000': 'Sony A6000',
+  'SONY ILCE-5100': 'Sony A5100',
+  'SONY ILCE-5000': 'Sony A5000',
+  'SONY ILCE-3500': 'Sony A3500',
+  'SONY ILCE-3000': 'Sony A3000',
+  'SONY ILME-FX3': 'Sony FX3',
+  'SONY ILME-FX6V': 'Sony FX6',
+  'SONY ILME-FX6VK': 'Sony FX6',
+  'SONY ILCE-QX1': 'Sony AQX1',
+};
+
 describe('Camera', () => {
   it('labels full text correctly', () => {
     expect(formatCameraText(APPLE)).toBe('iPhone 11 Pro');
@@ -33,5 +74,11 @@ describe('Camera', () => {
     expect(formatCameraText(RICOH, 'short')).toBe('GR III');
     expect(formatCameraText(NIKON, 'short')).toBe('D7000');
   });
+  it('formats Sony cameras', () => {
+    Object.entries(SONY_CAMERAS).forEach(([model, expected]) => {
+      const camera = { make: MAKE_SONY, model };
+      expect(formatCameraText(camera, 'medium'))
+        .toBe(expected.toLocaleUpperCase());
+    });
+  });
 });
-
diff --git a/__tests__/number.test.ts b/__tests__/number.test.ts
index 1d19a108..fa3f2e4d 100644
--- a/__tests__/number.test.ts
+++ b/__tests__/number.test.ts
@@ -1,6 +1,22 @@
-import { roundToString, roundToNumber } from '@/utility/number';
+import {
+  convertNumberToRomanNumeral,
+  roundToString,
+  roundToNumber,
+} from '@/utility/number';
 
 describe('number', () => {
+  it('converts to roman numerals', () => {
+    expect(convertNumberToRomanNumeral(1)).toBe('I');
+    expect(convertNumberToRomanNumeral(2)).toBe('II');
+    expect(convertNumberToRomanNumeral(3)).toBe('III');
+    expect(convertNumberToRomanNumeral(4)).toBe('IV');
+    expect(convertNumberToRomanNumeral(5)).toBe('V');
+    expect(convertNumberToRomanNumeral(6)).toBe('VI');
+    expect(convertNumberToRomanNumeral(7)).toBe('VII');
+    expect(convertNumberToRomanNumeral(8)).toBe('VIII');
+    expect(convertNumberToRomanNumeral(9)).toBe('IX');
+    expect(convertNumberToRomanNumeral(10)).toBe('X');
+  });
   describe('rounds to a', () => {
     it('string', () => {
       expect(roundToString(1.2345, 1)).toBe('1.2');