Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"nodemailer": "6.9.4",
"reflect-metadata": "0.1.13",
"sharp": "0.31.3",
"ts-node-iptc": "1.0.11",
"typeconfig": "2.2.11",
"typeorm": "0.3.12",
"xml2js": "0.6.2"
Expand Down
89 changes: 27 additions & 62 deletions src/backend/model/fileaccess/MetadataLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { FfprobeData } from 'fluent-ffmpeg';
import { FileHandle } from 'fs/promises';
import * as util from 'node:util';
import * as path from 'path';
import { IptcParser } from 'ts-node-iptc';
import { Utils } from '../../../common/Utils';
import { FFmpegFactory } from '../FFmpegFactory';
import { ExtensionDecorator } from '../extension/ExtensionDecorator';
Expand Down Expand Up @@ -181,7 +180,7 @@ export class MetadataLoader {
icc: false,
jfif: false, //not needed and not supported for png
ihdr: true,
iptc: false, //exifr reads UTF8-encoded data wrongly, using IptcParser instead
iptc: true,
exif: true,
gps: true,
reviveValues: false, //don't convert timestamps
Expand Down Expand Up @@ -221,46 +220,6 @@ export class MetadataLoader {
await fileHandle.close();
}
try {


try { //Parse iptc data using the IptcParser, which works correctly for both UTF-8 and ASCII
const iptcData = IptcParser.parse(data);
if (iptcData.country_or_primary_location_name) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.country =
iptcData.country_or_primary_location_name
.replace(/\0/g, '')
.trim();
}
if (iptcData.province_or_state) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.state = iptcData.province_or_state
.replace(/\0/g, '')
.trim();
}
if (iptcData.city) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.city = iptcData.city
.replace(/\0/g, '')
.trim();
}
if (iptcData.object_name) {
metadata.title = iptcData.object_name.replace(/\0/g, '').trim();
}
if (iptcData.caption) {
metadata.caption = iptcData.caption.replace(/\0/g, '').trim();
}
if (Array.isArray(iptcData.keywords)) {
metadata.keywords = iptcData.keywords;
}

if (iptcData.date_time) {
metadata.creationDate = iptcData.date_time.getTime();
}
} catch (err) {
// Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err);
}

try {
const exif = await exifr.parse(data, exifrOptions);
MetadataLoader.mapMetadata(metadata, exif);
Expand Down Expand Up @@ -370,20 +329,35 @@ export class MetadataLoader {
}
}
}
if (exif.iptc &&
exif.iptc.Keywords &&
exif.iptc.Keywords.length > 0) {
const subj = Array.isArray(exif.iptc.Keywords) ? exif.iptc.Keywords : [exif.iptc.Keywords];
if (metadata.keywords === undefined) {
metadata.keywords = [];
}
for (let kw of subj) {
kw = Utils.asciiToUTF8(kw);
if (metadata.keywords.indexOf(kw) === -1) {
metadata.keywords.push(kw);
}
}
}
}

private static mapTitle(metadata: PhotoMetadata, exif: any) {
metadata.title = exif.dc?.title?.value || metadata.title || exif.photoshop?.Headline || exif.acdsee?.caption; //acdsee caption holds the title when data is saved by digikam. Used as last resort if iptc and dc do not contain the data
metadata.title = exif.dc?.title?.value || Utils.asciiToUTF8(exif.iptc?.ObjectName) || metadata.title || exif.photoshop?.Headline || exif.acdsee?.caption; //acdsee caption holds the title when data is saved by digikam. Used as last resort if iptc and dc do not contain the data
}

private static mapCaption(metadata: PhotoMetadata, exif: any) {
metadata.caption = exif.dc?.description?.value || metadata.caption || exif.ifd0?.ImageDescription || exif.exif?.UserComment?.value || exif.Iptc4xmpCore?.ExtDescrAccessibility?.value ||exif.acdsee?.notes;
metadata.caption = exif.dc?.description?.value || Utils.asciiToUTF8(exif.iptc?.Caption) || metadata.caption || exif.ifd0?.ImageDescription || exif.exif?.UserComment?.value || exif.Iptc4xmpCore?.ExtDescrAccessibility?.value ||exif.acdsee?.notes;
}

private static mapTimestampAndOffset(metadata: PhotoMetadata, exif: any) {
metadata.creationDate = Utils.timestampToMS(exif?.photoshop?.DateCreated, null) ||
Utils.timestampToMS(exif?.xmp?.CreateDate, null) ||
Utils.timestampToMS(exif?.xmp?.ModifyDate, null) ||
Utils.timestampToMS(Utils.toIsoTimestampString(exif?.iptc?.DateCreated, exif?.iptc?.TimeCreated), null) ||
metadata.creationDate;

metadata.creationDateOffset = Utils.timestampToOffsetString(exif?.photoshop?.DateCreated) ||
Expand Down Expand Up @@ -490,24 +464,15 @@ export class MetadataLoader {

private static mapToponyms(metadata: PhotoMetadata, exif: any) {
//Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section)
const unescape = (tag: string) => {
return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) {
return String.fromCharCode(parseInt(numStr, 10));
});
}
//photoshop section sometimes has City, Country and State
if (exif.photoshop) {
if (!metadata.positionData?.country && exif.photoshop.Country) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.country = unescape(exif.photoshop.Country);
}
if (!metadata.positionData?.state && exif.photoshop.State) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.state = unescape(exif.photoshop.State);
}
if (!metadata.positionData?.city && exif.photoshop.City) {
metadata.positionData = metadata.positionData || {};
metadata.positionData.city = unescape(exif.photoshop.City);

metadata.positionData = metadata.positionData || {};
metadata.positionData.country = Utils.asciiToUTF8(exif.iptc?.Country) || Utils.decodeHTMLChars(exif.photoshop?.Country);
metadata.positionData.state = Utils.asciiToUTF8(exif.iptc?.State) || Utils.decodeHTMLChars(exif.photoshop?.State);
metadata.positionData.city = Utils.asciiToUTF8(exif.iptc?.City) || Utils.decodeHTMLChars(exif.photoshop?.City);
if (metadata.positionData) {
Utils.removeNullOrEmptyObj(metadata.positionData);
if (Object.keys(metadata.positionData).length === 0) {
delete metadata.positionData;
}
}
}
Expand Down
118 changes: 118 additions & 0 deletions src/common/HTMLCharCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
interface HTMLCharDictionary {
[key: string]: string;
}

export const HTMLChar: HTMLCharDictionary = {
""": "\"",
"&": "&",
"&lt;": "<",
"&gt;": ">",
"&nbsp;": " ",
"&iexcl;": "¡",
"&cent;": "¢",
"&pound;": "£",
"&curren;": "¤",
"&yen;": "¥",
"&brvbar;": "¦",
"&sect;": "§",
"&uml;": "¨",
"&copy;": "©",
"&reg;": "®",
"&trade;": "™",
"&ordf;": "ª",
"&laquo;": "«",
"&not;": "¬",
"&shy;": "­",
"&macr;": "¯",
"&deg;": "°",
"&plusmn;": "±",
"&sup2;": "²",
"&sup3;": "³",
"&acute;": "´",
"&micro;": "µ",
"&para;": "¶",
"&middot;": "·",
"&cedil;": "¸",
"&sup1;": "¹",
"&ordm;": "º",
"&raquo;": "»",
"&frac14;": "¼",
"&frac12;": "½",
"&frac34;": "¾",
"&iquest;": "¿",
"&times;": "×",
"&divide;": "÷",
"&ETH;": "Ð",
"&eth;": "ð",
"&THORN;": "Þ",
"&thorn;": "þ",
"&AElig;": "Æ",
"&aelig;": "æ",
"&OElig;": "Œ",
"&oelig;": "œ",
"&Aring;": "Å",
"&Oslash;": "Ø",
"&Ccedil;": "Ç",
"&ccedil;": "ç",
"&szlig;": "ß",
"&Ntilde;": "Ñ",
"&ntilde;": "ñ",
"&Aacute;": "Á",
"&Agrave;": "À",
"&Acirc;": "Â",
"&Auml;": "Ä",
"&Atilde;": "Ã",
"&aacute;": "á",
"&agrave;": "à",
"&acirc;": "â",
"&auml;": "ä",
"&atilde;": "ã",
"&aring;": "å",
"&Eacute;": "É",
"&Egrave;": "È",
"&Ecirc;": "Ê",
"&Euml;": "Ë",
"&Etilde;": "Ẽ",
"&eacute;": "é",
"&egrave;": "è",
"&ecirc;": "ê",
"&euml;": "ë",
"&Iacute;": "Í",
"&Igrave;": "Ì",
"&Icirc;": "Î",
"&Iuml;": "Ï",
"&Itilde;": "Ĩ",
"&iacute;": "í",
"&igrave;": "ì",
"&icirc;": "î",
"&iuml;": "ï",
"&itilde;": "ĩ",
"&Oacute;": "Ó",
"&Ograve;": "Ò",
"&Ocirc;": "Ô",
"&Ouml;": "Ö",
"&Otilde;": "Õ",
"&oacute;": "ó",
"&ograve;": "ò",
"&ocirc;": "ô",
"&ouml;": "ö",
"&otilde;": "õ",
"&Uacute;": "Ú",
"&Ugrave;": "Ù",
"&Ucirc;": "Û",
"&Uuml;": "Ü",
"&Utilde;": "Ũ",
"&Uring;": "Ů",
"&uacute;": "ú",
"&ugrave;": "ù",
"&ucirc;": "û",
"&uuml;": "ü",
"&utilde;": "ũ",
"&uring;": "ů",
"&Yacute;": "Ý",
"&Ycirc;": "Ŷ",
"&Yuml;": "Ÿ",
"&yacute;": "ý",
"&ycirc;": "ŷ",
"&yuml;": "ÿ"
};
48 changes: 47 additions & 1 deletion src/common/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { HTMLChar } from './HTMLCharCodes';

export class Utils {
static GUID(): string {
const s4 = (): string =>
Expand Down Expand Up @@ -97,6 +99,25 @@ export class Utils {
return d.getUTCFullYear() + '-' + d.getUTCMonth() + '-' + d.getUTCDate();
}

static toIsoTimestampString(YYYYMMDD: string, hhmmss: string): string {
if (YYYYMMDD && hhmmss) {
// Regular expression to match YYYYMMDD format
const dateRegex = /^(\d{4})(\d{2})(\d{2})$/;
// Regular expression to match hhmmss+/-ohom format
const timeRegex = /^(\d{2})(\d{2})(\d{2})([+-]\d{2})?(\d{2})?$/;
const [, year, month, day] = YYYYMMDD.match(dateRegex);
const [, hour, minute, second, offsetHour, offsetMinute] = hhmmss.match(timeRegex);
const isoTimestamp = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
if (offsetHour && offsetMinute) {
return isoTimestamp + `${offsetHour}:${offsetMinute}`;
} else {
return isoTimestamp;
}
} else {
return undefined;
}
}


static makeUTCMidnight(d: number | Date) {
if (!(d instanceof Date)) {
Expand Down Expand Up @@ -125,7 +146,7 @@ export class Utils {
}

//function to convert timestamp into milliseconds taking offset into account
static timestampToMS(timestamp: string, offset: string) {
static timestampToMS(timestamp: string, offset: string): number {
if (!timestamp) {
return undefined;
}
Expand Down Expand Up @@ -371,6 +392,31 @@ export class Utils {
return curr;
}

public static asciiToUTF8(text: string): string {
if (text) {
return Buffer.from(text, 'ascii').toString('utf-8');
} else {
return text;
}
}



public static decodeHTMLChars(text: string): string {
if (text) {
const newtext = text.replace(/&#([0-9]{1,3});/gi, function (match, numStr) {
return String.fromCharCode(parseInt(numStr, 10));
});
return newtext.replace(/&[^;]+;/g, function (match) {
const char = HTMLChar[match];
return char ? char : match;
});
} else {
return text;
}
}


public static isUInt32(value: number, max = 4294967295): boolean {
value = parseInt('' + value, 10);
return !isNaN(value) && value >= 0 && value <= max;
Expand Down
4 changes: 2 additions & 2 deletions test/backend/assets/sidecar/testimagedesc1.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
"latitude": 37.871093,
"longitude": -122.25678
},
"city": "test city őúéáűóöí-.,)(=",
"city": "test city őúéáűóöí-.,)(=/%!+\"'",
"country": "test country őúéáűóöí-.,)(=/%!+\"'",
"state": "test state őúéáűóöí-.,)("
"state": "test state őúéáűóöí-.,)(=/%!+\"'"
},
"rating": 3,
"size": {
Expand Down
Binary file removed test/tmp/sqlite.db-journal
Binary file not shown.