Skip to content

Commit beba5a5

Browse files
authored
Merge pull request Gnathonic#248 from Gnathonic/feat/extended-image-format-support
feat: Add extended image format support and fix URL-encoded filenames
2 parents ab16990 + d7c671f commit beba5a5

6 files changed

Lines changed: 225 additions & 28 deletions

File tree

src/lib/reader/image-cache.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,14 @@
77
*/
88

99
import type { Page } from '$lib/types';
10-
import { normalizeFilename } from '$lib/util/misc';
10+
import { getBasename, normalizeFilename, removeExtension } from '$lib/util/misc';
1111

1212
export interface CachedImage {
1313
image: HTMLImageElement; // Image element holds decoded bitmap and blob URL (in img.src)
1414
decoded: boolean;
1515
loading: Promise<void> | null;
1616
}
1717

18-
/**
19-
* Extract just the filename from a path (handles both / and \ separators)
20-
*/
21-
function getBasename(path: string): string {
22-
return path.split(/[/\\]/).pop() || path;
23-
}
24-
2518
/**
2619
* Natural sort comparator for filenames
2720
*/
@@ -36,7 +29,9 @@ function naturalSort(a: string, b: string): number {
3629
* Strategy order:
3730
* 1. Exact path match - all page.img_path values match file keys exactly
3831
* 2. Basename match - match just the filename portion without directories
39-
* 3. Page order fallback - sort files naturally and align by index
32+
* 3. Path without extension - full path match ignoring extension (e.g., dir/page.png -> dir/page.webp)
33+
* 4. Basename without extension - handles format conversions (e.g., png->webp, jpg->avif)
34+
* 5. Page order fallback - sort files naturally and align by index
4035
*/
4136
function matchFilesToPages(files: Record<string, File>, pages: Page[]): File[] {
4237
const fileKeys = Object.keys(files);
@@ -106,7 +101,79 @@ function matchFilesToPages(files: Record<string, File>, pages: Page[]): File[] {
106101
return result;
107102
}
108103

109-
// Strategy 3: Fall back to page order (sort files naturally)
104+
// Strategy 3: Try exact path without extension (handles format conversions with same path)
105+
const pathNoExtToFile = new Map<string, File>();
106+
const pathNoExtConflicts = new Set<string>();
107+
108+
for (const key of fileKeys) {
109+
const pathNoExt = normalizeFilename(removeExtension(key));
110+
if (pathNoExtToFile.has(pathNoExt)) {
111+
pathNoExtConflicts.add(pathNoExt);
112+
} else {
113+
pathNoExtToFile.set(pathNoExt, files[key]);
114+
}
115+
}
116+
117+
let allPathNoExtMatches = true;
118+
for (let i = 0; i < pages.length; i++) {
119+
const imgPath = pages[i].img_path;
120+
const pathNoExt = normalizeFilename(removeExtension(imgPath));
121+
122+
if (pathNoExtConflicts.has(pathNoExt)) {
123+
allPathNoExtMatches = false;
124+
break;
125+
}
126+
127+
const file = pathNoExtToFile.get(pathNoExt);
128+
if (file) {
129+
result[i] = file;
130+
} else {
131+
allPathNoExtMatches = false;
132+
break;
133+
}
134+
}
135+
136+
if (allPathNoExtMatches) {
137+
return result;
138+
}
139+
140+
// Strategy 4: Try basename without extension (handles format conversions like png->webp)
141+
const basenameNoExtToFile = new Map<string, File>();
142+
const basenameNoExtConflicts = new Set<string>();
143+
144+
for (const key of fileKeys) {
145+
const basenameNoExt = normalizeFilename(removeExtension(getBasename(key)));
146+
if (basenameNoExtToFile.has(basenameNoExt)) {
147+
basenameNoExtConflicts.add(basenameNoExt);
148+
} else {
149+
basenameNoExtToFile.set(basenameNoExt, files[key]);
150+
}
151+
}
152+
153+
let allBasenameNoExtMatches = true;
154+
for (let i = 0; i < pages.length; i++) {
155+
const imgPath = pages[i].img_path;
156+
const basenameNoExt = normalizeFilename(removeExtension(getBasename(imgPath)));
157+
158+
if (basenameNoExtConflicts.has(basenameNoExt)) {
159+
allBasenameNoExtMatches = false;
160+
break;
161+
}
162+
163+
const file = basenameNoExtToFile.get(basenameNoExt);
164+
if (file) {
165+
result[i] = file;
166+
} else {
167+
allBasenameNoExtMatches = false;
168+
break;
169+
}
170+
}
171+
172+
if (allBasenameNoExtMatches) {
173+
return result;
174+
}
175+
176+
// Strategy 5: Fall back to page order (sort files naturally)
110177
const sortedKeys = fileKeys.sort(naturalSort);
111178
for (let i = 0; i < pages.length && i < sortedKeys.length; i++) {
112179
result[i] = files[sortedKeys[i]];

src/lib/upload/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { VolumeData, VolumeMetadata } from '$lib/types';
33
import { showSnackbar } from '$lib/util/snackbar';
44
import { promptImageOnlyImport, type SeriesImportInfo } from '$lib/util/modals';
55
import { requestPersistentStorage } from '$lib/util/upload';
6-
import { normalizeFilename } from '$lib/util/misc';
6+
import { normalizeFilename, remapPagePaths } from '$lib/util/misc';
77
import { getMimeType, ZipReaderStream } from '@zip.js/zip.js';
88
import { generateThumbnail } from '$lib/catalog/thumbnails';
99
import { calculateCumulativeCharCounts } from '$lib/catalog/migration';
@@ -80,7 +80,7 @@ function groupOrphanedImagesBySeries(
8080
export * from './web-import';
8181

8282
const zipTypes = ['zip', 'cbz'];
83-
const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
83+
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'avif', 'tif', 'tiff', 'gif', 'bmp'];
8484

8585
function getDetails(file: File) {
8686
const { webkitRelativePath, name } = file;
@@ -137,7 +137,9 @@ function isMokuro(fileName: string) {
137137
}
138138

139139
function isImage(fileName: string) {
140-
return getMimeType(fileName).startsWith('image/') || imageTypes.includes(getExtension(fileName));
140+
return (
141+
getMimeType(fileName).startsWith('image/') || imageExtensions.includes(getExtension(fileName))
142+
);
141143
}
142144

143145
function isZip(fileName: string) {
@@ -206,6 +208,11 @@ async function uploadVolumeData(
206208
);
207209
}
208210

211+
// Remap page img_path values if image formats have changed (e.g., png->webp)
212+
if (uploadData.pages && uploadData.files) {
213+
uploadData.pages = remapPagePaths(uploadData.pages, uploadData.files);
214+
}
215+
209216
// Generate thumbnail from first file
210217
let thumbnailResult: { file: File; width: number; height: number } | undefined;
211218
const firstFileKey = uploadData.files ? Object.keys(uploadData.files)[0] : undefined;

src/lib/util/download-queue.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
incrementPoolUsers,
2525
decrementPoolUsers
2626
} from './file-processing-pool';
27-
import { normalizeFilename } from './misc';
27+
import { normalizeFilename, remapPagePaths } from './misc';
2828

2929
export interface QueueItem {
3030
volumeUuid: string;
@@ -354,7 +354,10 @@ async function processVolumeData(
354354
png: 'image/png',
355355
gif: 'image/gif',
356356
webp: 'image/webp',
357-
bmp: 'image/bmp'
357+
bmp: 'image/bmp',
358+
avif: 'image/avif',
359+
tif: 'image/tiff',
360+
tiff: 'image/tiff'
358361
};
359362
const mimeType = mimeTypes[extension] || 'application/octet-stream';
360363

@@ -366,6 +369,9 @@ async function processVolumeData(
366369
}
367370
}
368371

372+
// Remap page img_path values if image formats have changed (e.g., png->webp)
373+
const remappedPages = remapPagePaths(mokuroData.pages, files);
374+
369375
// Generate thumbnail from first image
370376
const fileNames = Object.keys(files).sort((a, b) =>
371377
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
@@ -390,7 +396,7 @@ async function processVolumeData(
390396
await db.volumes.add(metadataWithThumbnail);
391397
await db.volume_ocr.add({
392398
volume_uuid: mokuroData.volume_uuid,
393-
pages: mokuroData.pages
399+
pages: remappedPages
394400
});
395401
await db.volume_files.add({
396402
volume_uuid: mokuroData.volume_uuid,

src/lib/util/misc.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,112 @@ export function normalizeFilename(filename: string): string {
9191
return filename;
9292
}
9393
}
94+
95+
/**
96+
* Extract basename from a path (handles both / and \ separators)
97+
*/
98+
export function getBasename(path: string): string {
99+
return path.split(/[/\\]/).pop() || path;
100+
}
101+
102+
/**
103+
* Remove extension from a filename
104+
*/
105+
export function removeExtension(filename: string): string {
106+
const lastDot = filename.lastIndexOf('.');
107+
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
108+
}
109+
110+
/**
111+
* Remap page img_path values to match actual file names.
112+
* Handles cases where image formats have been converted (e.g., png->webp, jpg->avif).
113+
*
114+
* Returns the pages array with updated img_path values, or the original if no remapping needed.
115+
*/
116+
export function remapPagePaths<T extends { img_path: string }>(
117+
pages: T[],
118+
files: Record<string, unknown>
119+
): T[] {
120+
const fileNames = Object.keys(files);
121+
122+
// Build lookup maps for matching strategies
123+
const normalizedToActual = new Map<string, string>();
124+
const pathNoExtToActual = new Map<string, string>();
125+
const basenameToActual = new Map<string, string>();
126+
const basenameNoExtToActual = new Map<string, string>();
127+
128+
for (const fileName of fileNames) {
129+
const normalized = normalizeFilename(fileName);
130+
normalizedToActual.set(normalized, fileName);
131+
132+
const pathNoExt = normalizeFilename(removeExtension(fileName));
133+
pathNoExtToActual.set(pathNoExt, fileName);
134+
135+
const basename = normalizeFilename(getBasename(fileName));
136+
basenameToActual.set(basename, fileName);
137+
138+
const basenameNoExt = normalizeFilename(removeExtension(getBasename(fileName)));
139+
basenameNoExtToActual.set(basenameNoExt, fileName);
140+
}
141+
142+
// Check if any remapping is needed
143+
let needsRemapping = false;
144+
for (const page of pages) {
145+
const imgPath = page.img_path;
146+
// If exact match exists, no remapping needed for this page
147+
if (files[imgPath] || normalizedToActual.has(normalizeFilename(imgPath))) {
148+
continue;
149+
}
150+
// Check if we can find a match by path without ext, basename, or basename without extension
151+
const pathNoExt = normalizeFilename(removeExtension(imgPath));
152+
const basename = normalizeFilename(getBasename(imgPath));
153+
const basenameNoExt = normalizeFilename(removeExtension(getBasename(imgPath)));
154+
if (
155+
pathNoExtToActual.has(pathNoExt) ||
156+
basenameToActual.has(basename) ||
157+
basenameNoExtToActual.has(basenameNoExt)
158+
) {
159+
needsRemapping = true;
160+
break;
161+
}
162+
}
163+
164+
if (!needsRemapping) {
165+
return pages;
166+
}
167+
168+
// Remap pages
169+
return pages.map((page) => {
170+
const imgPath = page.img_path;
171+
172+
// Strategy 1: Exact match (with normalization)
173+
if (files[imgPath]) {
174+
return page;
175+
}
176+
const normalized = normalizeFilename(imgPath);
177+
if (normalizedToActual.has(normalized)) {
178+
return { ...page, img_path: normalizedToActual.get(normalized)! };
179+
}
180+
181+
// Strategy 2: Basename match
182+
const basename = normalizeFilename(getBasename(imgPath));
183+
if (basenameToActual.has(basename)) {
184+
return { ...page, img_path: basenameToActual.get(basename)! };
185+
}
186+
187+
// Strategy 3: Path without extension (handles format conversions with same path)
188+
const pathNoExt = normalizeFilename(removeExtension(imgPath));
189+
if (pathNoExtToActual.has(pathNoExt)) {
190+
return { ...page, img_path: pathNoExtToActual.get(pathNoExt)! };
191+
}
192+
193+
// Strategy 4: Basename without extension (handles format conversions)
194+
const basenameNoExt = normalizeFilename(removeExtension(getBasename(imgPath)));
195+
if (basenameNoExtToActual.has(basenameNoExt)) {
196+
return { ...page, img_path: basenameNoExtToActual.get(basenameNoExt)! };
197+
}
198+
199+
// No match found, keep original
200+
return page;
201+
});
202+
}

src/lib/views/UploadView.svelte

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import Loader from '$lib/components/Loader.svelte';
33
import { getItems, processFiles } from '$lib/upload';
4-
import { promptConfirmation, showSnackbar } from '$lib/util';
4+
import { normalizeFilename, promptConfirmation, showSnackbar } from '$lib/util';
55
import { nav } from '$lib/util/hash-router';
66
import { P, Progressbar } from 'flowbite-svelte';
77
import { onMount } from 'svelte';
@@ -23,12 +23,15 @@
2323
let progress = $derived(Math.floor((completed / max) * 100).toString());
2424
2525
async function onImport() {
26+
const normalizedVolume = normalizeFilename(volume || '');
2627
const mokuroRes = await fetch(url + '.mokuro', { cache: 'no-store' });
2728
const mokuroBlob = await mokuroRes.blob();
28-
const mokuroFile = new File([mokuroBlob], volume + '.mokuro', { type: mokuroBlob.type });
29+
const mokuroFile = new File([mokuroBlob], normalizedVolume + '.mokuro', {
30+
type: mokuroBlob.type
31+
});
2932
3033
Object.defineProperty(mokuroFile, 'webkitRelativePath', {
31-
value: '/' + volume + '.mokuro'
34+
value: '/' + normalizedVolume + '.mokuro'
3235
});
3336
3437
const res = await fetch(url + '/');
@@ -37,7 +40,7 @@
3740
const items = getItems(html);
3841
message = 'Downloading images...';
3942
40-
const imageTypes = ['.jpg', '.jpeg', '.png', '.webp'];
43+
const imageTypes = ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.tif', '.tiff', '.gif', '.bmp'];
4144
4245
max = items.length;
4346
@@ -46,9 +49,10 @@
4649
if (imageTypes.includes(itemFileExtension || '')) {
4750
const image = await fetch(url + item.pathname);
4851
const blob = await image.blob();
49-
const file = new File([blob], item.pathname.substring(1));
52+
const normalizedPath = normalizeFilename(item.pathname);
53+
const file = new File([blob], normalizedPath.substring(1));
5054
Object.defineProperty(file, 'webkitRelativePath', {
51-
value: '/' + volume + item.pathname
55+
value: '/' + normalizedVolume + normalizedPath
5256
});
5357
5458
files.push(file);

0 commit comments

Comments
 (0)