release: v0.15.0 (#585)
Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
135
lib/__tests__/alt-text.test.ts
Normal file
135
lib/__tests__/alt-text.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
albumPaths,
|
||||
buildAltTextReport,
|
||||
immichWebUrl,
|
||||
pickImmichWebUrl,
|
||||
type AltTextAssetInput,
|
||||
} from '../admin/alt-text';
|
||||
|
||||
const photo = (id: string, description: string | null = null): AltTextAssetInput => ({
|
||||
id,
|
||||
type: 'IMAGE',
|
||||
originalFileName: `${id}.jpg`,
|
||||
exifInfo: { description },
|
||||
});
|
||||
|
||||
describe('buildAltTextReport', () => {
|
||||
it('lists photos without a description, per album', () => {
|
||||
const report = buildAltTextReport(
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Island',
|
||||
path: '/travel',
|
||||
assets: [photo('p1', 'A fjord'), photo('p2')],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
expect(report.total).toBe(2);
|
||||
expect(report.described).toBe(1);
|
||||
expect(report.albums).toEqual([
|
||||
{
|
||||
albumId: 'a1',
|
||||
albumName: 'Island',
|
||||
path: '/travel',
|
||||
total: 2,
|
||||
missing: [{ assetId: 'p2', fileName: 'p2.jpg' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
/** A description of spaces renders as an empty alt all the same. */
|
||||
it('treats a blank description as missing', () => {
|
||||
const report = buildAltTextReport(
|
||||
[{ id: 'a1', name: 'A', path: '/', assets: [photo('p1', ' ')] }],
|
||||
true,
|
||||
);
|
||||
expect(report.described).toBe(0);
|
||||
expect(report.albums[0].missing).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** Videos have no alt attribute to fill. */
|
||||
it('does not count videos', () => {
|
||||
const report = buildAltTextReport(
|
||||
[
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'A',
|
||||
path: '/',
|
||||
assets: [{ id: 'v1', type: 'VIDEO', originalFileName: 'v1.mp4' }, photo('p1', 'x')],
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
expect(report.total).toBe(1);
|
||||
expect(report.albums).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a photo that sits in two albums once', () => {
|
||||
const shared = photo('p1');
|
||||
const report = buildAltTextReport(
|
||||
[
|
||||
{ id: 'a1', name: 'A', path: '/', assets: [shared] },
|
||||
{ id: 'a2', name: 'B', path: '/', assets: [shared] },
|
||||
],
|
||||
true,
|
||||
);
|
||||
expect(report.total).toBe(1);
|
||||
expect(report.albums).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('puts the album with the most gaps first and leaves out complete ones', () => {
|
||||
const report = buildAltTextReport(
|
||||
[
|
||||
{ id: 'few', name: 'Few', path: '/', assets: [photo('p1')] },
|
||||
{ id: 'done', name: 'Done', path: '/', assets: [photo('p2', 'x')] },
|
||||
{ id: 'many', name: 'Many', path: '/', assets: [photo('p3'), photo('p4')] },
|
||||
],
|
||||
true,
|
||||
);
|
||||
expect(report.albums.map((a) => a.albumId)).toEqual(['many', 'few']);
|
||||
});
|
||||
|
||||
it('passes the caption setting through', () => {
|
||||
expect(buildAltTextReport([], false).captionsEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumPaths', () => {
|
||||
it('uses the first subpage that lists an album', () => {
|
||||
const paths = albumPaths([
|
||||
{ slug: 'travel', albumIds: ['a1'] },
|
||||
{ slug: 'best-of', albumIds: ['a1', 'a2'] },
|
||||
]);
|
||||
expect(paths.get('a1')).toBe('/travel');
|
||||
expect(paths.get('a2')).toBe('/best-of');
|
||||
expect(paths.get('a3')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('immichWebUrl', () => {
|
||||
it('drops the /api suffix Folio appends', () => {
|
||||
expect(immichWebUrl('http://immich:2283/api')).toBe('http://immich:2283');
|
||||
expect(immichWebUrl('https://photos.example.com/api/')).toBe('https://photos.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickImmichWebUrl', () => {
|
||||
it("prefers Immich's external domain", () => {
|
||||
expect(pickImmichWebUrl('https://photos.example.com/', 'http://immich:2283/api')).toEqual({
|
||||
url: 'https://photos.example.com',
|
||||
source: 'external-domain',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the API address when the domain is unset or not a URL', () => {
|
||||
for (const domain of ['', ' ', null, undefined, 'photos.example.com']) {
|
||||
expect(pickImmichWebUrl(domain, 'http://immich:2283/api')).toEqual({
|
||||
url: 'http://immich:2283',
|
||||
source: 'api-url',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
|
||||
'gallery.yaml', // the live file, not a backup
|
||||
'gallery.yaml.2026-05-31T17-30-00-000Z.bak.txt',
|
||||
'evil-gallery.yaml.2026-05-31T17-30-00-000Z.bak',
|
||||
'about.yaml.2026-05-31T17-30-00-000Z.bak',
|
||||
'trip.md.2026-05-31T17-30-00-000Z.bak', // journal backups go through the journal service
|
||||
'',
|
||||
];
|
||||
|
||||
@@ -66,6 +68,13 @@ describe('restoreBackup accepts the names it writes', () => {
|
||||
expect(fs.copyFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores an about.md backup onto content/about.md', async () => {
|
||||
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
|
||||
|
||||
const calls = vi.mocked(fs.copyFile).mock.calls;
|
||||
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
|
||||
});
|
||||
|
||||
it('restores a pre-restore backup', async () => {
|
||||
await expect(
|
||||
restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.pre-restore.bak'),
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('checkAlbumIds', () => {
|
||||
const f = checkAlbumIds(['a', 'ghost'], known);
|
||||
expect(f.level).toBe('error');
|
||||
expect(f.detail).toContain('ghost');
|
||||
expect(f.albumIds).toEqual(['ghost']);
|
||||
});
|
||||
|
||||
it('flags an empty gallery', () => {
|
||||
@@ -132,6 +133,7 @@ describe('checkAlbumsShared', () => {
|
||||
const f = checkAlbumsShared(['a', 'b'], known);
|
||||
expect(f.level).toBe('warn');
|
||||
expect(f.detail).toContain('Private trip');
|
||||
expect(f.albumIds).toEqual(['b']);
|
||||
});
|
||||
|
||||
/** An older Immich may not report the flag; silence beats a false alarm. */
|
||||
|
||||
@@ -142,4 +142,45 @@ A closing paragraph with **bold** details.`;
|
||||
expect(markdown).toContain('');
|
||||
expect(markdown).toContain('');
|
||||
});
|
||||
|
||||
it('keeps a markdown link in the quote attribution for rendering (#559)', () => {
|
||||
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
|
||||
const parsed = parseEssayMarkdown(markdown);
|
||||
const quote = parsed.blocks[0];
|
||||
expect(quote.type).toBe('quote');
|
||||
if (quote.type !== 'quote') return;
|
||||
|
||||
// The attribution stays Markdown; EssayView renders it with
|
||||
// renderInlineMarkdown, which is what turns the link into an anchor.
|
||||
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
|
||||
expect(renderInlineMarkdown(quote.author!)).toContain(
|
||||
'<a href="https://example.com/archive" target="_blank" rel="noopener noreferrer">BBC Archive</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives a serialize/parse round-trip without losing the attribution link', () => {
|
||||
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
|
||||
|
||||
const once = serializeEssayMarkdown(parseEssayMarkdown(markdown));
|
||||
expect(once).toContain('> Silence was absolute. -- [BBC Archive](https://example.com/archive)');
|
||||
|
||||
const reparsed = parseEssayMarkdown(once);
|
||||
const quote = reparsed.blocks[0];
|
||||
expect(quote.type).toBe('quote');
|
||||
if (quote.type === 'quote') {
|
||||
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not turn an unsafe scheme in the attribution into a link', () => {
|
||||
const markdown = '> Careful. -- [x](javascript:alert(1))';
|
||||
const parsed = parseEssayMarkdown(markdown);
|
||||
const quote = parsed.blocks[0];
|
||||
expect(quote.type).toBe('quote');
|
||||
if (quote.type !== 'quote') return;
|
||||
|
||||
const html = renderInlineMarkdown(quote.author!);
|
||||
expect(html).not.toContain('<a');
|
||||
expect(html).not.toContain('href');
|
||||
});
|
||||
});
|
||||
|
||||
144
lib/__tests__/journal-backup.test.ts
Normal file
144
lib/__tests__/journal-backup.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Deleting a journal entry used to be a bare unlink, while saving rotated a
|
||||
* backup — the one irreversible action was the one without a way back, and the
|
||||
* Backup Manager could not restore journal backups at all.
|
||||
*
|
||||
* The service resolves content/ from process.cwd() at import time, so each test
|
||||
* points cwd at a fresh temp directory and re-imports it.
|
||||
*/
|
||||
let root: string;
|
||||
let journalDir: string;
|
||||
let backupDir: string;
|
||||
let service: typeof import('../admin/journal-service');
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
|
||||
journalDir = path.join(root, 'content', 'journal');
|
||||
backupDir = path.join(journalDir, '.backups');
|
||||
await mkdir(journalDir, { recursive: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(root);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.resetModules();
|
||||
service = await import('../admin/journal-service');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const entry = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
|
||||
|
||||
describe('deleteJournalEntry', () => {
|
||||
it('keeps a .deleted.bak copy before removing the file', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
|
||||
|
||||
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
|
||||
|
||||
await expect(readdir(journalDir)).resolves.not.toContain('trip.md');
|
||||
const backups = await readdir(backupDir);
|
||||
expect(backups).toHaveLength(1);
|
||||
expect(backups[0]).toMatch(/^trip\.md\.[\w-]+\.deleted\.bak$/);
|
||||
await expect(readFile(path.join(backupDir, backups[0]), 'utf8')).resolves.toBe(entry('Trip'));
|
||||
});
|
||||
|
||||
it('returns false for a missing entry without writing a backup', async () => {
|
||||
await expect(service.deleteJournalEntry('nope')).resolves.toBe(false);
|
||||
await expect(readdir(backupDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses to delete when the backup cannot be written', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
|
||||
// A file where the backup directory should be makes mkdir and copy fail.
|
||||
await writeFile(backupDir, 'not a directory');
|
||||
|
||||
await expect(service.deleteJournalEntry('trip')).rejects.toThrow();
|
||||
await expect(readdir(journalDir)).resolves.toContain('trip.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreJournalBackup', () => {
|
||||
it('brings a deleted entry back under its slug', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
|
||||
await service.deleteJournalEntry('trip');
|
||||
const [deleted] = await service.listJournalBackups();
|
||||
expect(deleted).toMatchObject({ slug: 'trip', kind: 'deleted' });
|
||||
|
||||
await expect(service.restoreJournalBackup(deleted.filename)).resolves.toBe('trip');
|
||||
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Trip'));
|
||||
});
|
||||
|
||||
it('snapshots the current version before overwriting it', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
|
||||
await service.writeJournalEntry('trip', entry('New'));
|
||||
const [saved] = await service.listJournalBackups();
|
||||
|
||||
await service.restoreJournalBackup(saved.filename);
|
||||
|
||||
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
|
||||
const kinds = (await service.listJournalBackups()).map((b) => b.kind).sort();
|
||||
expect(kinds).toEqual(['pre-restore', 'save']);
|
||||
});
|
||||
|
||||
const rejected = [
|
||||
'../../../../etc/passwd',
|
||||
'trip.md',
|
||||
'../trip.md.2026-05-31T17-30-00-000Z.bak',
|
||||
'a/trip.md.2026-05-31T17-30-00-000Z.bak',
|
||||
'trip.md.2026-05-31T17-30-00-000Z.bak.txt',
|
||||
'gallery.yaml.2026-05-31T17-30-00-000Z.bak',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const name of rejected) {
|
||||
it(`refuses ${JSON.stringify(name)}`, async () => {
|
||||
await expect(service.restoreJournalBackup(name)).rejects.toThrow(/unrecognised backup name/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('backup rotation', () => {
|
||||
// Backup names carry a millisecond timestamp; saves in a tight loop would
|
||||
// share one and overwrite each other, which a person clicking Save cannot.
|
||||
let clock = Date.parse('2026-09-01T00:00:00Z');
|
||||
beforeEach(() => vi.useFakeTimers({ toFake: ['Date'] }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const save = async (slug: string, body: string) => {
|
||||
vi.setSystemTime((clock += 1000));
|
||||
await service.writeJournalEntry(slug, body);
|
||||
};
|
||||
|
||||
it('prunes save backups but never the deleted snapshot', async () => {
|
||||
await writeFile(path.join(journalDir, 'trip.md'), entry('Gone'));
|
||||
await service.deleteJournalEntry('trip');
|
||||
|
||||
// Re-create the slug and save it well past the rotation limit.
|
||||
await save('trip', entry('v0'));
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await save('trip', entry(`v${i}`));
|
||||
}
|
||||
|
||||
const backups = await service.listJournalBackups();
|
||||
expect(backups.filter((b) => b.kind === 'save')).toHaveLength(10);
|
||||
expect(backups.filter((b) => b.kind === 'deleted')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not prune another entry whose slug shares a prefix', async () => {
|
||||
await save('trip-2', entry('Other v0'));
|
||||
await save('trip-2', entry('Other v1'));
|
||||
|
||||
await save('trip', entry('v0'));
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
await save('trip', entry(`v${i}`));
|
||||
}
|
||||
|
||||
const other = (await service.listJournalBackups()).filter((b) => b.slug === 'trip-2');
|
||||
expect(other).toEqual([expect.objectContaining({ kind: 'save' })]);
|
||||
});
|
||||
});
|
||||
177
lib/__tests__/metadataStrip.test.ts
Normal file
177
lib/__tests__/metadataStrip.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findJpegMetadataRanges,
|
||||
findIsobmffMetadataRanges,
|
||||
findMetadataRanges,
|
||||
stripMetadata,
|
||||
isStrippable,
|
||||
} from '../metadataStrip';
|
||||
|
||||
// ── kleine Bau-Helfer ───────────────────────────────────────────────────────
|
||||
|
||||
const cat = (...parts: Uint8Array[]) => {
|
||||
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
let o = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, o);
|
||||
o += p.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const bytes = (...n: number[]) => new Uint8Array(n);
|
||||
const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
|
||||
const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
|
||||
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
|
||||
|
||||
/** JPEG-Segment: FFxx <len inkl. Längenfeld> <payload> */
|
||||
const seg = (marker: number, payload: Uint8Array) =>
|
||||
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
|
||||
|
||||
/** ISOBMFF-Box mit korrekt berechneter Größe. */
|
||||
const box = (type: string, ...content: Uint8Array[]) => {
|
||||
const body = cat(...content);
|
||||
return cat(be32(body.length + 8), ascii(type), body);
|
||||
};
|
||||
|
||||
describe('isStrippable', () => {
|
||||
it('kennt JPEG und die ISOBMFF-Familie', () => {
|
||||
expect(isStrippable('image/jpeg')).toBe(true);
|
||||
expect(isStrippable('IMAGE/AVIF')).toBe(true);
|
||||
expect(isStrippable('image/jpeg; charset=binary')).toBe(true);
|
||||
expect(isStrippable('image/png')).toBe(false);
|
||||
expect(isStrippable('application/octet-stream')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JPEG', () => {
|
||||
const exifPayload = cat(ascii('Exif\0\0'), ascii('MM\0*GPS 54.156, 15.3717'));
|
||||
const iccPayload = cat(ascii('ICC_PROFILE\0'), bytes(1, 1, 2, 3, 4, 5));
|
||||
const jfifPayload = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
|
||||
const bildDaten = bytes(0x12, 0x34, 0x56, 0x78, 0x9a);
|
||||
|
||||
const bauJpeg = () =>
|
||||
cat(
|
||||
bytes(0xff, 0xd8), // SOI
|
||||
seg(0xe0, jfifPayload), // APP0 JFIF — bleibt
|
||||
seg(0xe1, exifPayload), // APP1 EXIF — muss weg
|
||||
seg(0xe2, iccPayload), // APP2 ICC — bleibt (Farbprofil!)
|
||||
seg(0xed, ascii('Photoshop 3.0\0IPTC')), // APP13 — muss weg
|
||||
seg(0xfe, ascii('ein Kommentar')), // COM — muss weg
|
||||
bytes(0xff, 0xda),
|
||||
be16(8),
|
||||
bytes(1, 0, 0, 63, 0), // SOS
|
||||
bildDaten,
|
||||
bytes(0xff, 0xd9), // EOI
|
||||
);
|
||||
|
||||
it('findet EXIF, IPTC und Kommentar, aber nicht JFIF/ICC', () => {
|
||||
const b = bauJpeg();
|
||||
const ranges = findJpegMetadataRanges(b);
|
||||
expect(ranges).toHaveLength(3);
|
||||
// Das EXIF-Segment muss vollständig erfasst sein
|
||||
const exifStart = b.indexOf(0x45); // 'E' von 'Exif'
|
||||
expect(ranges[0].start).toBe(exifStart);
|
||||
expect(ranges[0].end - ranges[0].start).toBe(exifPayload.length);
|
||||
});
|
||||
|
||||
it('nullt die GPS-Daten und lässt alles andere unangetastet', () => {
|
||||
const original = bauJpeg();
|
||||
const gestrippt = stripMetadata(bauJpeg(), 'image/jpeg')!;
|
||||
|
||||
expect(gestrippt).not.toBeNull();
|
||||
// Länge unverändert -> Content-Length bleibt gültig
|
||||
expect(gestrippt.length).toBe(original.length);
|
||||
|
||||
const alsText = new TextDecoder('latin1').decode(gestrippt);
|
||||
expect(alsText).not.toContain('GPS 54.156');
|
||||
expect(alsText).not.toContain('Exif');
|
||||
expect(alsText).not.toContain('IPTC');
|
||||
expect(alsText).not.toContain('ein Kommentar');
|
||||
|
||||
// Farbprofil und JFIF müssen erhalten bleiben, sonst kippen die Farben
|
||||
expect(alsText).toContain('ICC_PROFILE');
|
||||
expect(alsText).toContain('JFIF');
|
||||
|
||||
// Bilddaten hinter SOS Byte für Byte identisch
|
||||
const sos = original.length - bildDaten.length - 2;
|
||||
expect(Array.from(gestrippt.slice(sos))).toEqual(Array.from(original.slice(sos)));
|
||||
});
|
||||
|
||||
it('weist etwas zurück, das kein JPEG ist', () => {
|
||||
expect(() => findJpegMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03))).toThrow();
|
||||
expect(findMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03), 'image/jpeg')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AVIF / ISOBMFF', () => {
|
||||
const exifNutzlast = ascii('MM\0*GEHEIME-GPS-KOORDINATEN');
|
||||
const bildNutzlast = ascii('AV1-BILDDATEN-UNANTASTBAR');
|
||||
|
||||
/** infe v2: version+flags, item_ID(16), protection(16), item_type(4) */
|
||||
const infe = (id: number, typ: string) =>
|
||||
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(typ), bytes(0));
|
||||
|
||||
const bauAvif = () => {
|
||||
// Erst ohne mdat bauen, um die Offsets zu kennen.
|
||||
const ftyp = box('ftyp', ascii('avif'), be32(0), ascii('avifmif1'));
|
||||
const iinf = box('iinf', bytes(0, 0, 0, 0), be16(2), infe(1, 'Exif'), infe(2, 'av01'));
|
||||
|
||||
// iloc v0: offset_size=4, length_size=4, base_offset_size=0
|
||||
const ilocEintrag = (id: number, offset: number, len: number) =>
|
||||
cat(be16(id), be16(0), be16(1), be32(offset), be32(len));
|
||||
|
||||
// Platzhalter, um die Gesamtlänge des Headers zu bestimmen
|
||||
const ilocRoh = (o1: number, o2: number) =>
|
||||
box(
|
||||
'iloc',
|
||||
bytes(0, 0, 0, 0),
|
||||
bytes(0x44, 0x00),
|
||||
be16(2),
|
||||
ilocEintrag(1, o1, exifNutzlast.length),
|
||||
ilocEintrag(2, o2, bildNutzlast.length),
|
||||
);
|
||||
|
||||
const kopfLaenge = (iloc: Uint8Array) =>
|
||||
ftyp.length + box('meta', bytes(0, 0, 0, 0), iinf, iloc).length + 8; // +8 mdat-Header
|
||||
|
||||
let iloc = ilocRoh(0, 0);
|
||||
const basis = kopfLaenge(iloc);
|
||||
iloc = ilocRoh(basis, basis + exifNutzlast.length);
|
||||
|
||||
const meta = box('meta', bytes(0, 0, 0, 0), iinf, iloc);
|
||||
const mdat = box('mdat', exifNutzlast, bildNutzlast);
|
||||
return cat(ftyp, meta, mdat);
|
||||
};
|
||||
|
||||
it('findet das Exif-Item über iinf und iloc', () => {
|
||||
const b = bauAvif();
|
||||
const ranges = findIsobmffMetadataRanges(b);
|
||||
expect(ranges).toHaveLength(1);
|
||||
const gefunden = new TextDecoder('latin1').decode(b.slice(ranges[0].start, ranges[0].end));
|
||||
expect(gefunden).toBe('MM\0*GEHEIME-GPS-KOORDINATEN');
|
||||
});
|
||||
|
||||
it('nullt EXIF, lässt die Bilddaten und die Länge unberührt', () => {
|
||||
const original = bauAvif();
|
||||
const gestrippt = stripMetadata(bauAvif(), 'image/avif')!;
|
||||
|
||||
expect(gestrippt.length).toBe(original.length);
|
||||
const alsText = new TextDecoder('latin1').decode(gestrippt);
|
||||
expect(alsText).not.toContain('GEHEIME-GPS-KOORDINATEN');
|
||||
expect(alsText).toContain('AV1-BILDDATEN-UNANTASTBAR');
|
||||
// Die Container-Struktur muss intakt bleiben
|
||||
expect(alsText).toContain('ftyp');
|
||||
expect(alsText).toContain('iloc');
|
||||
});
|
||||
|
||||
it('gibt null zurück, wenn es kein ISOBMFF ist', () => {
|
||||
expect(findMetadataRanges(ascii('nicht mal ansatzweise'), 'image/avif')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unbekannte Formate', () => {
|
||||
it('liefern null — lieber nicht ausliefern als leaken', () => {
|
||||
expect(findMetadataRanges(bytes(0x89, 0x50, 0x4e, 0x47), 'image/png')).toBeNull();
|
||||
expect(stripMetadata(bytes(1, 2, 3), 'application/octet-stream')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,58 @@ describe('assetAspectRatio', () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* A portrait frame off a Sony A7 V: the sensor is read out in landscape and
|
||||
* the quarter turn lives in the orientation flag, so the stored dimensions
|
||||
* are the displayed ones swapped.
|
||||
*/
|
||||
const rotated = (orientation?: string | null) => ({
|
||||
exifInfo: {
|
||||
make: null,
|
||||
model: null,
|
||||
lensModel: null,
|
||||
focalLength: null,
|
||||
fNumber: null,
|
||||
exposureTime: null,
|
||||
iso: null,
|
||||
exifImageWidth: 7008,
|
||||
exifImageHeight: 4672,
|
||||
orientation,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
city: null,
|
||||
state: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
},
|
||||
});
|
||||
|
||||
it.each([
|
||||
['6', 'rotated 90 CW'],
|
||||
['8', 'rotated 90 CCW'],
|
||||
['5', 'mirrored and rotated 90 CCW'],
|
||||
['7', 'mirrored and rotated 90 CW'],
|
||||
])('swaps width and height for orientation %s (%s)', (orientation) => {
|
||||
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(4672 / 7008);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['1', 'upright'],
|
||||
['2', 'mirrored horizontally'],
|
||||
['3', 'rotated 180'],
|
||||
['4', 'mirrored vertically'],
|
||||
])('leaves the ratio alone for orientation %s (%s)', (orientation) => {
|
||||
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['absent', undefined],
|
||||
])('treats a %s orientation as upright', (_label, orientation) => {
|
||||
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetCaption', () => {
|
||||
|
||||
Reference in New Issue
Block a user