diff --git a/.gitignore b/.gitignore index 93b3bcee..da071b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ bun.lock package-lock.json node_modules +.cache +public/material-file-icons start.sh dist release diff --git a/base.css b/base.css new file mode 100644 index 00000000..06dd5b9f --- /dev/null +++ b/base.css @@ -0,0 +1,55 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); + +html, +body { + padding: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::after, +*::before { + margin: 0; + box-sizing: border-box; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +input, +button, +textarea, +select { + font: inherit; +} + +img { + font-size: 12px; +} + +button { + cursor: pointer; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 1cb9fef7..91a06128 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ chromium \ + git \ && rm -rf /var/lib/apt/lists/* ENV PUPPETEER_SKIP_DOWNLOAD=true @@ -11,6 +12,7 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium COPY .npmrc ./ COPY package.json ./ +COPY scripts ./scripts RUN bun install --frozen-lockfile COPY . . diff --git a/docs/Conversion flowchart.drawio b/docs/Conversion flowchart.drawio new file mode 100644 index 00000000..887512b6 --- /dev/null +++ b/docs/Conversion flowchart.drawio @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/Conversion flowchart.png b/docs/Conversion flowchart.png new file mode 100644 index 00000000..34716e60 Binary files /dev/null and b/docs/Conversion flowchart.png differ diff --git a/docs/wireframe.png b/docs/wireframe.png new file mode 100644 index 00000000..fef65515 Binary files /dev/null and b/docs/wireframe.png differ diff --git a/index.html b/index.html index f85e0f68..09e39712 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,12 @@ + + + Convert to it! @@ -17,48 +20,11 @@ - - - - - - -
-

Click to add your file

-

or drag and drop it here

-
-
- -
- -
- -
-

Convert from:

- -
- -
-
-
-

Convert to:

- -
- -
-
- -
- - - - - - - + + + + diff --git a/package.json b/package.json index 03411e81..d37ab392 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "type": "module", "main": "src/electron.cjs", "scripts": { + "postinstall": "bun run scripts/extract-material-icons.ts", "dev": "vite", "build": "tsc && vite build", "cache:build": "bun run buildCache.js dist/cache.json --minify", @@ -43,6 +44,7 @@ } }, "devDependencies": { + "@preact/preset-vite": "^2.10.3", "@types/jszip": "^3.4.0", "@types/less": "^3.0.8", "@types/opentype.js": "^1.3.9", @@ -55,6 +57,7 @@ }, "dependencies": { "7z-wasm": "^1.2.0", + "@aiden0z/pptx-renderer": "^1.0.2", "@bjorn3/browser_wasi_shim": "^0.4.2", "@bokuweb/zstd-wasm": "^0.0.27", "@ffmpeg/core": "^0.12.10", @@ -62,11 +65,13 @@ "@ffmpeg/util": "^0.12.2", "@flo-audio/reflo": "^0.1.2", "@imagemagick/magick-wasm": "^0.0.37", + "@preact/signals": "^2.8.1", "@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2", "@myriaddreamin/typst.ts": "^0.7.0-rc2", "@sqlite.org/sqlite-wasm": "^3.51.2-build6", "@stringsync/vexml": "^0.1.8", + "@tippyjs/react": "^4.2.6", "@toon-format/toon": "^2.1.0", "@types/bun": "^1.3.9", "@types/meyda": "^5.3.0", @@ -77,11 +82,14 @@ "celaria-formats": "^1.0.2", "chess.js": "^1.4.0", "confbox": "^0.2.4", + "dom-to-svg": "^0.12.2", + "epubjs": "^0.3.93", "imagetracer": "^0.2.2", "js-synthesizer": "^1.11.0", "json5": "^2.2.3", "jszip": "^3.10.1", "less": "^4.6.4", + "lucide-preact": "^1.7.0", "meyda": "^5.6.3", "mime": "^4.1.0", "nanotar": "^0.3.0", @@ -92,12 +100,15 @@ "pdf-parse": "^2.4.5", "pdftoimg-js": "^0.2.5", "pe-library": "^2.0.1", + "preact": "^10.28.3", "sass": "^1.98.0", "svg-pathdata": "^8.0.0", "three": "^0.182.0", "three-bvh-csg": "^0.0.17", "three-mesh-bvh": "^0.9.8", + "tippy.js": "^6.3.7", "ts-flp": "^1.0.3", + "use-debounce": "^10.1.0", "turbowarp-packager-browser": "3.11.1", "verovio": "^6.0.1", "vexflow": "^5.0.0", diff --git a/scripts/extra-language-extensions.ts b/scripts/extra-language-extensions.ts new file mode 100644 index 00000000..fc8fe0dd --- /dev/null +++ b/scripts/extra-language-extensions.ts @@ -0,0 +1,104 @@ +export const extraExtensionToIcon: Record = { + ts: "typescript", + cts: "typescript", + mts: "typescript", + js: "javascript", + cjs: "javascript", + mjs: "javascript", + jsx: "react", + tsx: "react_ts", + py: "python", + pyw: "python", + pyi: "python", + yml: "yaml", + yaml: "yaml", + md: "markdown", + markdown: "markdown", + html: "html", + htm: "html", + css: "css", + scss: "sass", + sass: "sass", + less: "less", + sh: "console", + bash: "console", + zsh: "console", + ksh: "console", + fish: "console", + ps1: "powershell", + psm1: "powershell", + psd1: "powershell", + cs: "csharp", + fs: "fsharp", + fsx: "fsharp", + fsi: "fsharp", + vb: "visualstudio", + swift: "swift", + kt: "kotlin", + kts: "kotlin", + java: "java", + go: "go", + rs: "rust", + rb: "ruby", + php: "php", + pl: "perl", + pm: "perl", + r: "r", + lua: "lua", + vim: "vim", + sql: "database", + kql: "kusto", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hrl: "erlang", + hs: "haskell", + clj: "clojure", + cljs: "clojure", + edn: "clojure", + groovy: "groovy", + scala: "scala", + ml: "ocaml", + mli: "ocaml", + diff: "diff", + patch: "git", + dockerfile: "docker", + vue: "vue", + svelte: "svelte", + astro: "astro", + solidity: "solidity", + graphql: "graphql", + gql: "graphql", + prisma: "prisma", + toml: "toml", + lock: "lock", + log: "log", + txt: "document", + rtf: "document", + tex: "tex", + bib: "bibliography", + bst: "bibtex-style", + dart: "dart", + coffee: "coffee", + iced: "coffee", + cls: "latex-class", + sty: "tex", + dtx: "tex", + ins: "doctex-installer", + ctx: "context", + v: "vlang", + gradle: "gradle", + hcl: "hcl", + tf: "terraform", + tfvars: "terraform", + nix: "nix", + dhall: "dhall", + cabal: "cabal", + purs: "purescript", + nim: "nim", + nimble: "nim", + cr: "crystal", + elm: "elm", + jl: "julia", + ipynb: "jupyter", +}; diff --git a/scripts/extract-material-icons.ts b/scripts/extract-material-icons.ts new file mode 100644 index 00000000..8890ebd4 --- /dev/null +++ b/scripts/extract-material-icons.ts @@ -0,0 +1,184 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { extraExtensionToIcon } from "./extra-language-extensions"; + +const REPO_URL = "https://github.com/material-extensions/vscode-material-icon-theme.git"; +const CACHE_DIR = join(process.cwd(), ".cache/material-icon-theme"); +const ICONS_SRC = join(CACHE_DIR, "icons"); +const FILE_ICONS_TS = join(CACHE_DIR, "src/core/icons/fileIcons.ts"); +const OUT_PUBLIC = join(process.cwd(), "public/material-file-icons"); +const OUT_ICONS = join(OUT_PUBLIC, "icons"); +const OUT_MAP = join(OUT_PUBLIC, "extension-map.json"); + +const FILE_SVG_PATH = + "m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z"; +const DEFAULT_FILE_COLOR = "#90a4ae"; + +interface FileIconEntry { + name: string; + fileExtensions: string[]; + cloneBase?: string; +} + +function ensureRepo(): void { + if (existsSync(join(CACHE_DIR, ".git"))) { + const r = spawnSync("git", ["-C", CACHE_DIR, "pull", "--ff-only"], { + stdio: "inherit", + encoding: "utf-8", + }); + if (r.status !== 0) { + console.warn("[material-icons] git pull failed; using cached tree"); + } + return; + } + mkdirSync(join(process.cwd(), ".cache"), { recursive: true }); + const r = spawnSync( + "git", + ["clone", "--depth", "1", REPO_URL, CACHE_DIR], + { stdio: "inherit", encoding: "utf-8" }, + ); + if (r.status !== 0) { + throw new Error("[material-icons] git clone failed"); + } +} + +function extractFileIconEntries(sourcePath: string): FileIconEntry[] { + const source = readFileSync(sourcePath, "utf-8"); + const sf = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const out: FileIconEntry[] = []; + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node)) { + const expr = node.expression; + if (ts.isIdentifier(expr) && expr.text === "parseByPattern" && node.arguments.length > 0) { + const arg = node.arguments[0]; + if (ts.isArrayLiteralExpression(arg)) { + for (const el of arg.elements) { + if (!ts.isObjectLiteralExpression(el)) continue; + let name: string | undefined; + const fileExtensions: string[] = []; + let cloneBase: string | undefined; + for (const prop of el.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const pn = prop.name; + const key = ts.isIdentifier(pn) + ? pn.text + : ts.isStringLiteral(pn) + ? pn.text + : ""; + const init = prop.initializer; + if (key === "name" && ts.isStringLiteral(init)) { + name = init.text; + } + if (key === "fileExtensions" && ts.isArrayLiteralExpression(init)) { + for (const e of init.elements) { + if (ts.isStringLiteral(e)) fileExtensions.push(e.text); + } + } + if (key === "clone" && ts.isObjectLiteralExpression(init)) { + for (const cp of init.properties) { + if (!ts.isPropertyAssignment(cp)) continue; + const cn = ts.isIdentifier(cp.name) ? cp.name.text : ""; + if (cn === "base" && ts.isStringLiteral(cp.initializer)) { + cloneBase = cp.initializer.text; + } + } + } + } + if (name && fileExtensions.length > 0) { + out.push({ name, fileExtensions, cloneBase }); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sf); + return out; +} + +function resolveSourceIconPath( + iconName: string, + cloneBase: string | undefined, +): string | null { + const candidates = [ + `${iconName}.svg`, + `${iconName}.clone.svg`, + ...(cloneBase ? [`${cloneBase}.svg`, `${cloneBase}.clone.svg`] : []), + "document.svg", + ]; + for (const c of candidates) { + const p = join(ICONS_SRC, c); + if (existsSync(p)) return p; + } + return null; +} + +function writeDefaultFileSvg(): void { + const svg = ``; + writeFileSync(join(OUT_ICONS, "file.svg"), svg, "utf-8"); +} + +function main(): void { + ensureRepo(); + if (!existsSync(FILE_ICONS_TS)) { + throw new Error("[material-icons] missing fileIcons.ts after clone"); + } + + const entries = extractFileIconEntries(FILE_ICONS_TS); + const extToLogical = new Map(); + + for (const e of entries) { + for (const ext of e.fileExtensions) { + extToLogical.set(ext.toLowerCase(), e.name); + } + } + + for (const [ext, logical] of Object.entries(extraExtensionToIcon)) { + if (!extToLogical.has(ext)) { + extToLogical.set(ext, logical); + } + } + + const logicalNames = new Set(extToLogical.values()); + logicalNames.add("file"); + + mkdirSync(OUT_ICONS, { recursive: true }); + + const documentFallback = join(ICONS_SRC, "document.svg"); + + function materializeIcon(logical: string): void { + if (logical === "file") { + writeDefaultFileSvg(); + return; + } + const entry = entries.find((x) => x.name === logical); + const src = resolveSourceIconPath(logical, entry?.cloneBase); + const dest = join(OUT_ICONS, `${logical}.svg`); + if (src) { + copyFileSync(src, dest); + } else if (existsSync(documentFallback)) { + copyFileSync(documentFallback, dest); + } + } + + for (const logical of logicalNames) { + materializeIcon(logical); + } + + const extensionMap: Record = {}; + for (const [ext, logical] of extToLogical.entries()) { + extensionMap[ext] = logical; + } + + writeFileSync(OUT_MAP, JSON.stringify(extensionMap), "utf-8"); + + console.log( + `[material-icons] wrote ${Object.keys(extensionMap).length} extension mappings and ${logicalNames.size} icon files under public/material-file-icons`, + ); +} + +main(); diff --git a/src/CommonFormats.ts b/src/CommonFormats.ts index b6917399..aff9743d 100644 --- a/src/CommonFormats.ts +++ b/src/CommonFormats.ts @@ -13,7 +13,7 @@ export const Category = { PRESENTATION: "presentation", FONT: "font", CODE: "code" -} +} as const /** * Common format definitions which can be used to reduce boilerplate definitions @@ -287,6 +287,13 @@ const CommonFormats = { "application/vnd.microsoft.portable-executable", Category.CODE ), + EPUB: new FormatDefinition( + "EPUB Document", + "epub", + "epub", + "application/epub+zip", + [Category.DOCUMENT] + ), TYPST: new FormatDefinition( "Typst Document", "typst", diff --git a/src/FormatHandler.ts b/src/FormatHandler.ts index b8161785..58dc424c 100644 --- a/src/FormatHandler.ts +++ b/src/FormatHandler.ts @@ -29,6 +29,189 @@ export interface FileFormat extends IFormatDefinition { lossless?: boolean; } +export type HandlerOptionValue = boolean | number | string | string[]; + +export interface HandlerOptionChoice { + label: string; + value: string; + description?: string; +} + +export type HandlerOptionVisibilityContext = Readonly>; + +interface HandlerOptionBase { + kind: TKind; + id: string; + name: string; + description?: string; + section?: string; + defaultValue?: TValue; + showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + getValue: () => TValue; + setValue: (value: TValue) => void; +} + +export interface ToggleOptionDefinition extends HandlerOptionBase<"toggle", boolean> {} + +export interface NumberOptionDefinition extends HandlerOptionBase<"number", number> { + min?: number; + max?: number; + step?: number; + control?: "input" | "slider"; + unit?: string; +} + +export interface TextOptionDefinition extends HandlerOptionBase<"text", string> { + inputType?: "text" | "email" | "url" | "password"; + placeholder?: string; + minLength?: number; + maxLength?: number; + multiline?: boolean; +} + +export interface SelectOptionDefinition extends HandlerOptionBase<"select", string> { + choices: HandlerOptionChoice[]; +} + +export interface MultiSelectOptionDefinition extends HandlerOptionBase<"multiselect", string[]> { + choices: HandlerOptionChoice[]; +} + +export type HandlerOptionDefinition = + | ToggleOptionDefinition + | NumberOptionDefinition + | TextOptionDefinition + | SelectOptionDefinition + | MultiSelectOptionDefinition; + +export class ToggleOption implements ToggleOptionDefinition { + public readonly kind = "toggle" as const; + constructor( + public id: string, + public name: string, + public getValue: () => boolean, + public setValue: (value: boolean) => void, + public defaultValue?: boolean, + public description?: string, + public section?: string, + public showWhen?: (values: HandlerOptionVisibilityContext) => boolean + ) {} +} + +export class NumberOption implements NumberOptionDefinition { + public readonly kind = "number" as const; + public min?: number; + public max?: number; + public step?: number; + public control?: "input" | "slider"; + public unit?: string; + public defaultValue?: number; + public description?: string; + public section?: string; + public showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + + constructor( + public id: string, + public name: string, + public getValue: () => number, + public setValue: (value: number) => void, + config: { + min?: number; + max?: number; + step?: number; + control?: "input" | "slider"; + unit?: string; + defaultValue?: number; + description?: string; + section?: string; + showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + } = {} + ) { + Object.assign(this, config); + } +} + +export class TextOption implements TextOptionDefinition { + public readonly kind = "text" as const; + public inputType?: "text" | "email" | "url" | "password"; + public placeholder?: string; + public minLength?: number; + public maxLength?: number; + public multiline?: boolean; + public defaultValue?: string; + public description?: string; + public section?: string; + public showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + + constructor( + public id: string, + public name: string, + public getValue: () => string, + public setValue: (value: string) => void, + config: { + inputType?: "text" | "email" | "url" | "password"; + placeholder?: string; + minLength?: number; + maxLength?: number; + multiline?: boolean; + defaultValue?: string; + description?: string; + section?: string; + showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + } = {} + ) { + Object.assign(this, config); + } +} + +export class SelectOption implements SelectOptionDefinition { + public readonly kind = "select" as const; + public defaultValue?: string; + public description?: string; + public section?: string; + public showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + + constructor( + public id: string, + public name: string, + public choices: HandlerOptionChoice[], + public getValue: () => string, + public setValue: (value: string) => void, + config: { + defaultValue?: string; + description?: string; + section?: string; + showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + } = {} + ) { + Object.assign(this, config); + } +} + +export class MultiSelectOption implements MultiSelectOptionDefinition { + public readonly kind = "multiselect" as const; + public defaultValue?: string[]; + public description?: string; + public section?: string; + public showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + + constructor( + public id: string, + public name: string, + public choices: HandlerOptionChoice[], + public getValue: () => string[], + public setValue: (value: string[]) => void, + config: { + defaultValue?: string[]; + description?: string; + section?: string; + showWhen?: (values: HandlerOptionVisibilityContext) => boolean; + } = {} + ) { + Object.assign(this, config); + } +} + /** * Class containing format definition and method used to produce FileFormat * that can be supported by handlers. @@ -167,6 +350,8 @@ export interface FormatHandler { * Conversion using this handler will be performed only if no other direct conversion is found. */ supportAnyInput?: boolean; + /** Optional list of user-configurable options. */ + getOptions?: () => HandlerOptionDefinition[]; /** * Whether the handler is ready for use. Should be set in {@link init}. @@ -185,13 +370,15 @@ export interface FormatHandler { * @param outputFormat Output {@link FileFormat}, the same for all outputs. * @param args Optional arguments as a string array. * Can be used to perform recursion with different settings. + * @param ctx Optional {@link ConvertContext} for progress reporting. * @returns Array of {@link FileData} entries, one per generated output file. */ doConvert: ( inputFiles: FileData[], inputFormat: FileFormat, outputFormat: FileFormat, - args?: string[] + args?: string[], + ctx?: import("./ui/ProgressStore.js").ConvertContext ) => Promise; } diff --git a/src/HandlerOptions.ts b/src/HandlerOptions.ts new file mode 100644 index 00000000..c63d6a2a --- /dev/null +++ b/src/HandlerOptions.ts @@ -0,0 +1,188 @@ +import type { FormatHandler, HandlerOptionDefinition, HandlerOptionValue } from "./FormatHandler.ts"; + +const STORAGE_KEY = "convert.handler-options.v1"; + +type StoredHandlerOptions = Record>; + +const defaultsByHandler = new Map>(); + +function isValidOptionValue(value: unknown): value is HandlerOptionValue { + if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return true; + } + if (!Array.isArray(value)) return false; + return value.every(item => typeof item === "string"); +} + +function safeGetStorage(): Storage | undefined { + try { + if (typeof window === "undefined") return undefined; + return window.localStorage; + } catch { + return undefined; + } +} + +function loadSnapshot(): StoredHandlerOptions { + const storage = safeGetStorage(); + if (!storage) return {}; + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + const out: StoredHandlerOptions = {}; + for (const [handlerName, values] of Object.entries(parsed as Record)) { + if (!values || typeof values !== "object") continue; + const perHandler: Record = {}; + for (const [id, value] of Object.entries(values as Record)) { + if (isValidOptionValue(value)) perHandler[id] = value; + } + out[handlerName] = perHandler; + } + return out; + } catch { + return {}; + } +} + +function saveSnapshot(snapshot: StoredHandlerOptions): void { + const storage = safeGetStorage(); + if (!storage) return; + storage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); +} + +function getCurrentValues(options: HandlerOptionDefinition[]): Record { + const out: Record = {}; + for (const option of options) { + out[option.id] = option.getValue(); + } + return out; +} + +function normalizeValue(option: HandlerOptionDefinition, value: unknown): HandlerOptionValue { + switch (option.kind) { + case "toggle": { + if (typeof value === "boolean") return value; + if (typeof value === "string") return value === "true"; + return !!value; + } + case "number": { + const parsed = typeof value === "number" ? value : Number(value); + const fallback = option.defaultValue ?? option.getValue(); + let next = Number.isFinite(parsed) ? parsed : fallback; + if (typeof option.min === "number") next = Math.max(option.min, next); + if (typeof option.max === "number") next = Math.min(option.max, next); + return next; + } + case "text": { + let next = typeof value === "string" ? value : String(value ?? ""); + if (typeof option.maxLength === "number") next = next.slice(0, option.maxLength); + return next; + } + case "select": { + const choices = new Set(option.choices.map(c => c.value)); + const fallback = option.defaultValue ?? option.getValue() ?? option.choices[0]?.value ?? ""; + if (typeof value !== "string") return fallback; + return choices.has(value) ? value : fallback; + } + case "multiselect": { + const choices = new Set(option.choices.map(c => c.value)); + const arr = Array.isArray(value) ? value.filter((c): c is string => typeof c === "string") : []; + const filtered = arr.filter(v => choices.has(v)); + if (filtered.length > 0) return filtered; + return option.defaultValue ?? option.getValue().filter(v => choices.has(v)); + } + } +} + +function setOptionValue(option: HandlerOptionDefinition, value: HandlerOptionValue): void { + switch (option.kind) { + case "toggle": + option.setValue(Boolean(value)); + return; + case "number": + option.setValue(Number(value)); + return; + case "text": + case "select": + option.setValue(String(value)); + return; + case "multiselect": + option.setValue(Array.isArray(value) ? value : []); + return; + } +} + +function persistHandler(handler: FormatHandler): void { + const options = handler.getOptions?.() ?? []; + const snapshot = loadSnapshot(); + if (options.length === 0) { + delete snapshot[handler.name]; + saveSnapshot(snapshot); + return; + } + snapshot[handler.name] = getCurrentValues(options); + saveSnapshot(snapshot); +} + +export function initializeHandlerOptions(handlers: FormatHandler[]): void { + const snapshot = loadSnapshot(); + for (const handler of handlers) { + const options = handler.getOptions?.() ?? []; + if (options.length === 0) continue; + const defaults = getCurrentValues(options); + defaultsByHandler.set(handler.name, defaults); + const savedForHandler = snapshot[handler.name] ?? {}; + for (const option of options) { + const next = normalizeValue(option, savedForHandler[option.id] ?? defaults[option.id]); + setOptionValue(option, next); + } + } +} + +export function applyOptionValue( + handler: FormatHandler, + option: HandlerOptionDefinition, + rawValue: unknown +): HandlerOptionValue { + const next = normalizeValue(option, rawValue); + setOptionValue(option, next); + persistHandler(handler); + return next; +} + +export function getOptionValues(handler: FormatHandler): Record { + const options = handler.getOptions?.() ?? []; + return getCurrentValues(options); +} + +export function shouldShowOption( + option: HandlerOptionDefinition, + values: Readonly> +): boolean { + if (!option.showWhen) return true; + try { + return option.showWhen(values); + } catch { + return true; + } +} + +export function resetHandlerOptions(handler: FormatHandler): void { + const options = handler.getOptions?.() ?? []; + if (options.length === 0) return; + const defaults = defaultsByHandler.get(handler.name); + for (const option of options) { + const baseline = defaults?.[option.id] ?? option.defaultValue ?? option.getValue(); + const next = normalizeValue(option, baseline); + setOptionValue(option, next); + } + persistHandler(handler); +} + +export function resetAllHandlerOptions(handlers: FormatHandler[]): void { + for (const handler of handlers) { + resetHandlerOptions(handler); + } +} diff --git a/src/TraversionGraph.ts b/src/TraversionGraph.ts index e4bd3172..922cbaff 100644 --- a/src/TraversionGraph.ts +++ b/src/TraversionGraph.ts @@ -5,7 +5,6 @@ interface QueueNode { index: number; cost: number; path: ConvertPathNode[]; - visitedBorder: number; }; interface CategoryChangeCost { from: string; @@ -331,20 +330,21 @@ export class TraversionGraph { this.listeners.forEach(l => l(state, path)); } - public async* searchPath(from: ConvertPathNode, to: ConvertPathNode, simpleMode: boolean) : AsyncGenerator { + public async* searchPath(from: ConvertPathNode, to: ConvertPathNode, simpleMode: boolean, onProgress?: (iterations: number, title?: string) => void) : AsyncGenerator { + console.log("searchPath called with:", from, "to:", to, "simpleMode:", simpleMode); // Dijkstra's algorithm // Priority queue of {index, cost, path} let queue: PriorityQueue = new PriorityQueue( 1000, (a: QueueNode, b: QueueNode) => a.cost - b.cost ); - let visited = new Array(); + let visited = new Set(); const fromIdentifier = from.format.mime + `(${from.format.format})`; const toIdentifier = to.format.mime + `(${to.format.format})`; let fromIndex = this.nodes.findIndex(node => node.identifier === fromIdentifier); let toIndex = this.nodes.findIndex(node => node.identifier === toIdentifier); if (fromIndex === -1 || toIndex === -1) return []; // If either format is not in the graph, return empty array - queue.add({index: fromIndex, cost: 0, path: [from], visitedBorder: visited.length }); + queue.add({index: fromIndex, cost: 0, path: [from] }); console.log(`Starting path search from ${from.format.mime}(${from.handler?.name}) to ${to.format.mime}(${to.handler?.name}) (simple mode: ${simpleMode})`); let iterations = 0; let pathsFound = 0; @@ -352,17 +352,21 @@ export class TraversionGraph { iterations++; // Get the node with the lowest cost let current = queue.poll()!; - const indexInVisited = visited.indexOf(current.index); - if (indexInVisited >= 0 && indexInVisited < current.visitedBorder) { + if (onProgress && iterations % 100 === 0) onProgress(iterations, `Trying ${current.path.map(p => p.format.format).join(" → ")}...`); + // In Dijkstra's, once a node is popped from the priority queue, + // its optimal cost is finalized. Skip if already processed. + if (visited.has(current.index)) { this.dispatchEvent("skipped", current.path); continue; } + visited.add(current.index); if (current.index === toIndex) { // Return the path of handlers and formats to get from the input format to the output format const logString = `${iterations} with cost ${current.cost.toFixed(3)}: ${current.path.map(p => p.handler.name + "(" + p.format.mime + ")").join(" → ")}`; const foundPathLast = current.path.at(-1); if (simpleMode || !to.handler || to.handler.name === foundPathLast?.handler.name) { console.log(`Found path at iteration ${logString}`); + if (onProgress) onProgress(iterations, `Trying ${current.path.map(p => p.format.format).join(" → ")}...`); this.dispatchEvent("found", current.path); yield current.path; pathsFound++; @@ -373,12 +377,10 @@ export class TraversionGraph { } continue; } - visited.push(current.index); this.dispatchEvent("searching", current.path); this.nodes[current.index].edges.forEach(edgeIndex => { let edge = this.edges[edgeIndex]; - const indexInVisited = visited.indexOf(edge.to.index); - if (indexInVisited >= 0 && indexInVisited < current.visitedBorder) return; + if (visited.has(edge.to.index)) return; const handler = this.handlers.find(h => h.name === edge.handler); if (!handler) return; // If the handler for this edge is not found, skip it @@ -387,7 +389,6 @@ export class TraversionGraph { index: edge.to.index, cost: current.cost + edge.cost + this.calculateAdaptiveCost(path), path: path, - visitedBorder: visited.length }); }); if (iterations % LOG_FREQUENCY === 0) { diff --git a/src/global.d.ts b/src/global.d.ts index e550594d..eb292c52 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -8,10 +8,35 @@ declare global { printSupportedFormatCache: () => string; showPopup: (html: string) => void; hidePopup: () => void; - tryConvertByTraversing: (files: FileData[], from: ConvertPathNode, to: ConvertPathNode) => Promise<{ + tryConvertByTraversing: ( + files: FileData[], + from: ConvertPathNode, + to: ConvertPathNode, + signal?: AbortSignal, + constraints?: { + forceInputHandler?: boolean; + forceOutputHandler?: boolean; + inputHandlerName?: string; + outputHandlerName?: string; + } + ) => Promise<{ files: FileData[]; path: ConvertPathNode[]; } | null>; + previewConvertPath: ( + from: ConvertPathNode, + to: ConvertPathNode, + simpleMode: boolean, + constraints?: { + forceInputHandler?: boolean; + forceOutputHandler?: boolean; + inputHandlerName?: string; + outputHandlerName?: string; + } + ) => Promise; + openPopup: () => boolean; + closePopup: () => boolean; + togglePopup: () => boolean; } } diff --git a/src/handlers/FFmpeg.ts b/src/handlers/FFmpeg.ts index 33a62c2a..30d1b872 100644 --- a/src/handlers/FFmpeg.ts +++ b/src/handlers/FFmpeg.ts @@ -1,4 +1,5 @@ -import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import { MultiSelectOption, NumberOption, SelectOption, TextOption, type FileData, type FileFormat, type FormatHandler } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; import { FFmpeg } from "@ffmpeg/ffmpeg"; import type { LogEvent } from "@ffmpeg/ffmpeg"; @@ -26,38 +27,78 @@ class FFmpegHandler implements FormatHandler { public name: string = "FFmpeg"; public supportedFormats: FileFormat[] = []; public ready: boolean = false; + private readonly options: { + imageTimingMode: "auto" | "fps" | "duration"; + imageSequenceFps: number; + singleImageDurationSeconds: number; + autoFixStrategies: string[]; + outputPreset: "source" | "small" | "balanced" | "quality"; + resizeMode: "none" | "720p" | "1080p" | "1440p" | "4k" | "custom"; + customWidth: number; + customHeight: number; + outputFrameRateMode: "keep" | "set"; + outputFrameRate: number; + customArgs: string; + } = { + imageTimingMode: "auto", + imageSequenceFps: 30, + singleImageDurationSeconds: 3, + autoFixStrategies: ["divisible-pad", "multiple-size", "valid-size", "sample-rate"], + outputPreset: "source", + resizeMode: "none", + customWidth: 1280, + customHeight: 720, + outputFrameRateMode: "keep", + outputFrameRate: 30, + customArgs: "" + }; #ffmpeg?: FFmpeg; + #ffmpegLoaded: boolean = false; + #initPromise?: Promise; #stdout: string = ""; - handleStdout (log: LogEvent) { + #boundStdoutHandler = (log: LogEvent) => { this.#stdout += log.message + "\n"; - } + }; clearStdout () { this.#stdout = ""; } async getStdout (callback: () => void | Promise) { if (!this.#ffmpeg) return ""; this.clearStdout(); - this.#ffmpeg.on("log", this.handleStdout.bind(this)); + this.#ffmpeg.on("log", this.#boundStdoutHandler); await callback(); - this.#ffmpeg.off("log", this.handleStdout.bind(this)); + this.#ffmpeg.off("log", this.#boundStdoutHandler); return this.#stdout; } async loadFFmpeg () { if (!this.#ffmpeg) return; - return await this.#ffmpeg.load({ - coreURL: "/convert/wasm/ffmpeg-core.js" + await this.#ffmpeg.load({ + coreURL: "/convert/wasm/ffmpeg-core.js", + wasmURL: "/convert/wasm/ffmpeg-core.wasm" }); + this.#ffmpegLoaded = true; } terminateFFmpeg () { - if (!this.#ffmpeg) return; - this.#ffmpeg.terminate(); + if (!this.#ffmpeg || !this.#ffmpegLoaded) return; + try { + this.#ffmpeg.terminate(); + } catch (e) { + if (!(e instanceof Error) || !e.message.includes("called FFmpeg.terminate()")) { + console.warn("FFmpeg termination warning:", e); + } + } finally { + this.#ffmpegLoaded = false; + } } async reloadFFmpeg () { - if (!this.#ffmpeg) return; + if (!this.#ffmpeg) { + this.#ffmpeg = new FFmpeg(); + } this.terminateFFmpeg(); + this.#ffmpeg = new FFmpeg(); await this.loadFFmpeg(); } /** @@ -96,9 +137,13 @@ class FFmpegHandler implements FormatHandler { } async init () { + if (this.ready) return; + if (this.#initPromise) return this.#initPromise; - this.#ffmpeg = new FFmpeg(); - await this.loadFFmpeg(); + this.#initPromise = (async () => { + this.#ffmpeg = new FFmpeg(); + this.#ffmpegLoaded = false; + await this.loadFFmpeg(); const getMuxerDetails = async (muxer: string) => { @@ -175,11 +220,15 @@ class FFmpegHandler implements FormatHandler { else category = "video"; } - const name = FFmpegHandler.formatNames.get(format) || (description + (formats.length > 1 ? (" / " + format) : "")); + // Canonicalize MIDI so graph nodes match dedicated MIDI handlers (which use "mid"). + // Without this, FFmpeg can expose "midi" and split routing into a separate node. + const canonicalFormat = mimeType === "audio/midi" ? "mid" : format; + + const name = FFmpegHandler.formatNames.get(canonicalFormat) || (description + (formats.length > 1 ? (" / " + format) : "")); this.supportedFormats.push({ name: name, - format, + format: canonicalFormat, extension, mime: mimeType, from: flags.includes("D"), @@ -259,32 +308,259 @@ class FFmpegHandler implements FormatHandler { // APNG as the same thing. this.supportedFormats.push(CommonFormats.PNG.builder("png").allowFrom()); - this.#ffmpeg.terminate(); + this.terminateFFmpeg(); this.ready = true; + })(); + + try { + await this.#initPromise; + } finally { + this.#initPromise = undefined; + } + } + + getOptions() { + return [ + new SelectOption( + "output-preset", + "Output preset", + [ + { label: "Source compatible", value: "source", description: "Keep FFmpeg defaults and avoid forcing bitrate changes." }, + { label: "Small file", value: "small", description: "Lower bitrate for smaller output files." }, + { label: "Balanced", value: "balanced", description: "Balanced quality and file size." }, + { label: "High quality", value: "quality", description: "Higher bitrate for better visual quality." } + ], + () => this.options.outputPreset, + (value) => { this.options.outputPreset = value as typeof this.options.outputPreset; }, + { + defaultValue: "source", + description: "Applies when converting into video outputs." + } + ), + new SelectOption( + "resize-mode", + "Resize", + [ + { label: "Keep original", value: "none", description: "Do not force output size." }, + { label: "1280 x 720 (720p)", value: "720p" }, + { label: "1920 x 1080 (1080p)", value: "1080p" }, + { label: "2560 x 1440 (1440p)", value: "1440p" }, + { label: "3840 x 2160 (4K)", value: "4k" }, + { label: "Custom", value: "custom" } + ], + () => this.options.resizeMode, + (value) => { this.options.resizeMode = value as typeof this.options.resizeMode; }, + { + defaultValue: "none", + description: "Resize and pad to common output dimensions for better playback compatibility." + } + ), + new NumberOption( + "resize-custom-width", + "Custom width", + () => this.options.customWidth, + (value) => { this.options.customWidth = value; }, + { + min: 16, + max: 7680, + step: 2, + unit: "px", + defaultValue: 1280, + showWhen: (values) => values["resize-mode"] === "custom" + } + ), + new NumberOption( + "resize-custom-height", + "Custom height", + () => this.options.customHeight, + (value) => { this.options.customHeight = value; }, + { + min: 16, + max: 4320, + step: 2, + unit: "px", + defaultValue: 720, + showWhen: (values) => values["resize-mode"] === "custom" + } + ), + new SelectOption( + "output-frame-rate-mode", + "Frame rate", + [ + { label: "Keep source", value: "keep", description: "Preserve source frame rate when possible." }, + { label: "Set custom FPS", value: "set", description: "Force output frame rate." } + ], + () => this.options.outputFrameRateMode, + (value) => { this.options.outputFrameRateMode = value as typeof this.options.outputFrameRateMode; }, + { + defaultValue: "keep" + } + ), + new NumberOption( + "output-frame-rate", + "Target FPS", + () => this.options.outputFrameRate, + (value) => { this.options.outputFrameRate = value; }, + { + min: 1, + max: 120, + step: 1, + unit: "fps", + control: "slider", + defaultValue: 30, + showWhen: (values) => values["output-frame-rate-mode"] === "set" + } + ), + new SelectOption( + "image-timing-mode", + "Image sequence timing", + [ + { label: "Auto", value: "auto", description: "Use 1 FPS for short image lists, otherwise 30 FPS." }, + { label: "Custom FPS", value: "fps", description: "Force a constant FPS when converting images to video." }, + { label: "Single image duration", value: "duration", description: "For one image input, hold the frame for N seconds." } + ], + () => this.options.imageTimingMode, + (value) => { this.options.imageTimingMode = value as typeof this.options.imageTimingMode; } + ), + new NumberOption( + "image-sequence-fps", + "Custom image FPS", + () => this.options.imageSequenceFps, + (value) => { this.options.imageSequenceFps = value; }, + { + min: 1, + max: 120, + step: 1, + unit: "fps", + control: "slider", + defaultValue: 30, + showWhen: (values) => values["image-timing-mode"] === "fps" + } + ), + new NumberOption( + "single-image-duration", + "Single image length", + () => this.options.singleImageDurationSeconds, + (value) => { this.options.singleImageDurationSeconds = value; }, + { + min: 1, + max: 60, + step: 1, + unit: "seconds", + control: "slider", + defaultValue: 3, + showWhen: (values) => values["image-timing-mode"] === "duration" + } + ), + new MultiSelectOption( + "auto-fix-strategies", + "Automatic FFmpeg fixes", + [ + { label: "Pad dimensions", value: "divisible-pad", description: "Fix divisibility errors by padding frames." }, + { label: "Force multiple size", value: "multiple-size", description: "Pad width/height to required multiples." }, + { label: "Adjust output size", value: "valid-size", description: "Use one of FFmpeg's valid target sizes." }, + { label: "Adjust sample rate", value: "sample-rate", description: "Use a supported sample rate when needed." } + ], + () => this.options.autoFixStrategies, + (values) => { + this.options.autoFixStrategies = values; + }, + { + defaultValue: ["divisible-pad", "multiple-size", "valid-size", "sample-rate"], + description: "Select which recovery strategies FFmpeg may apply when conversion fails." + } + ), + new TextOption( + "custom-args", + "Extra CLI arguments", + () => this.options.customArgs, + (value) => { this.options.customArgs = value; }, + { + defaultValue: "", + description: "Custom FFmpeg arguments (e.g. -c:v libx264 -crf 23)" + } + ) + ]; } async doConvert ( inputFiles: FileData[], inputFormat: FileFormat, outputFormat: FileFormat, - args?: string[] + args?: string[], + ctx?: ConvertContext ): Promise { if (!this.#ffmpeg) { throw "Handler not initialized."; } + ctx?.throwIfAborted(); + ctx?.log("Reloading FFmpeg..."); await this.reloadFFmpeg(); + let totalDurationUs = 0; + + if (ctx) { + const abortHandler = () => { + ctx.log("Abort signal received — terminating FFmpeg.", "error"); + this.terminateFFmpeg(); + }; + ctx.signal.addEventListener("abort", abortHandler, { once: true }); + + this.#ffmpeg.on("log", ({ message, type }) => { + let level: "log" | "error" | "warn" = "log"; + if (type === "stderr") level = "warn"; + ctx.log(message, level); + + if (!totalDurationUs) { + const durationMatch = message.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2})/); + if (durationMatch) { + const [, hours, minutes, seconds, centis] = durationMatch; + totalDurationUs = ( + Number(hours) * 3600 + + Number(minutes) * 60 + + Number(seconds) + + Number(centis) / 100 + ) * 1_000_000; + } + } + }); + + this.#ffmpeg.on("progress", ({ progress, time }) => { + const timeUs = Math.max(0, time); + + if (totalDurationUs > 0 && timeUs > 0) { + const timeBasedProgress = Math.min(0.99, timeUs / totalDurationUs); + const seconds = (timeUs / 1_000_000).toFixed(1); + const total = (totalDurationUs / 1_000_000).toFixed(1); + ctx.progress(`Transcoding... (${seconds}s / ${total}s)`, timeBasedProgress); + } else if (Number.isFinite(progress) && progress > 0 && progress <= 1) { + ctx.progress(`Transcoding...`, Math.max(0, Math.min(0.99, progress))); + } else if (timeUs > 0) { + const seconds = (timeUs / 1_000_000).toFixed(1); + ctx.progress(`Transcoding... (${seconds}s processed)`, p => Math.min(0.95, p + 0.005)); + } + }); + } + let forceFPS = 0; if (inputFormat.mime === "image/png" || inputFormat.mime === "image/jpeg") { - forceFPS = inputFiles.length < 30 ? 1 : 30; + if (this.options.imageTimingMode === "fps") { + forceFPS = this.options.imageSequenceFps; + } else if (this.options.imageTimingMode === "duration" && inputFiles.length === 1) { + forceFPS = Math.max(1, Math.round(1 / this.options.singleImageDurationSeconds)); + } else { + forceFPS = inputFiles.length < 30 ? 1 : 30; + } } let fileIndex = 0; let listString = ""; + ctx?.log(`Preparing ${inputFiles.length} input files...`); for (const file of inputFiles) { + ctx?.throwIfAborted(); const entryName = `file_${fileIndex++}.${inputFormat.extension}`; await this.#ffmpeg.writeFile(entryName, new Uint8Array(file.bytes)); listString += `file '${entryName}'\n`; @@ -293,6 +569,56 @@ class FFmpegHandler implements FormatHandler { await this.#ffmpeg.writeFile("list.txt", new TextEncoder().encode(listString)); const command = ["-hide_banner", "-f", "concat", "-safe", "0", "-i", "list.txt", "-f", outputFormat.internal]; + const isVideoOutput = outputFormat.mime.startsWith("video/"); + const isAudioOutput = outputFormat.mime.startsWith("audio/"); + + const presetBitrateMap: Record, { video: string; audio: string }> = { + small: { video: "2M", audio: "128k" }, + balanced: { video: "5M", audio: "160k" }, + quality: { video: "12M", audio: "192k" } + }; + + const resizeTargets: Record, { width: number; height: number }> = { + "720p": { width: 1280, height: 720 }, + "1080p": { width: 1920, height: 1080 }, + "1440p": { width: 2560, height: 1440 }, + "4k": { width: 3840, height: 2160 } + }; + + const appendVideoFilter = (filter: string) => { + const filterIndex = command.lastIndexOf("-vf"); + if (filterIndex !== -1 && filterIndex + 1 < command.length) { + command[filterIndex + 1] = `${command[filterIndex + 1]},${filter}`; + } else { + command.push("-vf", filter); + } + }; + + if (isVideoOutput) { + if (this.options.outputPreset !== "source") { + const bitrate = presetBitrateMap[this.options.outputPreset]; + command.push("-b:v", bitrate.video, "-b:a", bitrate.audio); + } + + if (this.options.outputFrameRateMode === "set") { + command.push("-r", String(this.options.outputFrameRate)); + } + + if (this.options.resizeMode !== "none") { + const target = this.options.resizeMode === "custom" + ? { width: this.options.customWidth, height: this.options.customHeight } + : resizeTargets[this.options.resizeMode]; + const width = Math.max(16, Math.round(target.width / 2) * 2); + const height = Math.max(16, Math.round(target.height / 2) * 2); + appendVideoFilter(`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`); + } + } + + if (isAudioOutput && this.options.outputPreset !== "source") { + const bitrate = presetBitrateMap[this.options.outputPreset]; + command.push("-b:a", bitrate.audio); + } + if (outputFormat.mime === "video/mp4") { command.push("-pix_fmt", "yuv420p"); } else if (outputFormat.internal === "dvd") { @@ -302,6 +628,12 @@ class FFmpegHandler implements FormatHandler { } else if (outputFormat.internal === "asf") { command.push("-b:v", "15M", "-b:a", "192k"); } + + if (this.options.customArgs) { + const parsedArgs = this.options.customArgs.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + command.push(...parsedArgs.map(arg => arg.replaceAll(/^"|"$/g, ""))); + } + if (args) command.push(...args); command.push("output"); @@ -309,6 +641,8 @@ class FFmpegHandler implements FormatHandler { await this.#ffmpeg!.exec(command); }); + ctx?.throwIfAborted(); + ctx?.log("Cleaning up input files..."); for (let i = 0; i < fileIndex; i ++) { const entryName = `file_${i}.${inputFormat.extension}`; await this.#ffmpeg.deleteFile(entryName); @@ -316,23 +650,24 @@ class FFmpegHandler implements FormatHandler { if (stdout.includes("Conversion failed!\n")) { - const oldArgs = args ? args : [] - if (stdout.includes(" not divisible by") && !oldArgs.includes("-vf")) { + ctx?.log("Conversion failed, attempting auto-fix...", "error"); + const oldArgs = args ?? []; + if (stdout.includes(" not divisible by") && !oldArgs.includes("-vf") && this.options.autoFixStrategies.includes("divisible-pad")) { const division = stdout.split(" not divisible by ")[1].split(" ")[0]; - return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-vf", `pad=ceil(iw/${division})*${division}:ceil(ih/${division})*${division}`]); + return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-vf", `pad=ceil(iw/${division})*${division}:ceil(ih/${division})*${division}`], ctx); } - if (stdout.includes("width and height must be a multiple of") && !oldArgs.includes("-vf")) { + if (stdout.includes("width and height must be a multiple of") && !oldArgs.includes("-vf") && this.options.autoFixStrategies.includes("multiple-size")) { const division = stdout.split("width and height must be a multiple of ")[1].split(" ")[0].split("")[0]; - return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-vf", `pad=ceil(iw/${division})*${division}:ceil(ih/${division})*${division}`]); + return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-vf", `pad=ceil(iw/${division})*${division}:ceil(ih/${division})*${division}`], ctx); } - if (stdout.includes("Valid sizes are") && !oldArgs.includes("-s")) { + if (stdout.includes("Valid sizes are") && !oldArgs.includes("-s") && this.options.autoFixStrategies.includes("valid-size")) { const newSize = stdout.split("Valid sizes are ")[1].split(".")[0].split(" ").pop(); if (typeof newSize !== "string") throw stdout; - return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-s", newSize]); + return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-s", newSize], ctx); } - if (stdout.includes("does not support that sample rate, choose from (") && !oldArgs.includes("-ar")) { + if (stdout.includes("does not support that sample rate, choose from (") && !oldArgs.includes("-ar") && this.options.autoFixStrategies.includes("sample-rate")) { const acceptedBitrate = stdout.split("does not support that sample rate, choose from (")[1].split(", ")[0]; - return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-ar", acceptedBitrate]); + return this.doConvert(inputFiles, inputFormat, outputFormat, [...oldArgs, "-ar", acceptedBitrate], ctx); } throw stdout; @@ -340,15 +675,17 @@ class FFmpegHandler implements FormatHandler { let bytes: Uint8Array; - // Validate that output file exists before attempting to read + ctx?.log("Reading output file..."); let fileData; try { fileData = await this.#ffmpeg.readFile("output"); } catch (e) { + ctx?.log(`Output file not created: ${e}`, "error"); throw `Output file not created: ${e}`; } if (!fileData || (fileData instanceof Uint8Array && fileData.length === 0)) { + ctx?.log("FFmpeg failed to produce output file", "error"); throw "FFmpeg failed to produce output file"; } if (!(fileData instanceof Uint8Array)) { @@ -364,6 +701,9 @@ class FFmpegHandler implements FormatHandler { const baseName = inputFiles[0].name.split(".").slice(0, -1).join("."); const name = baseName + "." + outputFormat.extension; + ctx?.progress("Conversion complete!", 1); + ctx?.log(`Successfully converted to ${name} (${bytes.length} bytes)`); + return [{ bytes, name }]; } diff --git a/src/handlers/ImageMagick.ts b/src/handlers/ImageMagick.ts index 215f0c34..cdcd8240 100644 --- a/src/handlers/ImageMagick.ts +++ b/src/handlers/ImageMagick.ts @@ -11,6 +11,8 @@ import mime from "mime"; import normalizeMimeType from "../normalizeMimeType.ts"; import CommonFormats from "src/CommonFormats.ts"; import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import { NumberOption, SelectOption, ToggleOption } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; class ImageMagickHandler implements FormatHandler { @@ -20,6 +22,96 @@ class ImageMagickHandler implements FormatHandler { public ready: boolean = false; + private readonly options: { + resizeMode: "none" | "720p" | "1080p" | "1440p" | "4k" | "custom"; + customWidth: number; + customHeight: number; + ignoreAspectRatio: boolean; + quality: number; + } = { + resizeMode: "none", + customWidth: 1280, + customHeight: 720, + ignoreAspectRatio: false, + quality: 90 + }; + + getOptions() { + return [ + new NumberOption( + "quality", + "Quality", + () => this.options.quality, + (value) => { this.options.quality = value; }, + { + min: 1, + max: 100, + step: 1, + unit: "%", + control: "slider", + defaultValue: 90, + description: "Compression quality. Lower values produce smaller files but worse quality." + } + ), + new SelectOption( + "resize-mode", + "Resize", + [ + { label: "Keep original", value: "none", description: "Do not force output size." }, + { label: "1280 x 720 (720p)", value: "720p" }, + { label: "1920 x 1080 (1080p)", value: "1080p" }, + { label: "2560 x 1440 (1440p)", value: "1440p" }, + { label: "3840 x 2160 (4K)", value: "4k" }, + { label: "Custom", value: "custom" } + ], + () => this.options.resizeMode, + (value) => { this.options.resizeMode = value as typeof this.options.resizeMode; }, + { + defaultValue: "none", + description: "Resize dimensions for better compatibility or smaller file size." + } + ), + new NumberOption( + "resize-custom-width", + "Custom width", + () => this.options.customWidth, + (value) => { this.options.customWidth = value; }, + { + min: 1, + max: 7680, + step: 1, + unit: "px", + defaultValue: 1280, + showWhen: (values) => values["resize-mode"] === "custom" + } + ), + new NumberOption( + "resize-custom-height", + "Custom height", + () => this.options.customHeight, + (value) => { this.options.customHeight = value; }, + { + min: 1, + max: 4320, + step: 1, + unit: "px", + defaultValue: 720, + showWhen: (values) => values["resize-mode"] === "custom" + } + ), + new ToggleOption( + "ignore-aspect-ratio", + "Ignore Aspect Ratio", + () => this.options.ignoreAspectRatio, + (value) => { this.options.ignoreAspectRatio = value; }, + false, + "Force the output image to have the target dimensions by stretching instead of scaling proportionally.", + undefined, + (values) => values["resize-mode"] !== "none" && values["resize-mode"] !== undefined + ) + ]; + } + async init () { const wasmLocation = "/convert/wasm/magick.wasm"; @@ -83,7 +175,9 @@ class ImageMagickHandler implements FormatHandler { async doConvert ( inputFiles: FileData[], inputFormat: FileFormat, - outputFormat: FileFormat + outputFormat: FileFormat, + args?: string[], + ctx?: ConvertContext ): Promise { const inputMagickFormat = inputFormat.internal as MagickFormat; @@ -93,29 +187,58 @@ class ImageMagickHandler implements FormatHandler { inputSettings.format = inputMagickFormat; + ctx?.log(`Initialising ImageMagick for ${inputFiles.length} files...`); + const bytes: Uint8Array = await new Promise(resolve => { MagickImageCollection.use(outputCollection => { + let processedCount = 0; for (const inputFile of inputFiles) { - if (inputFormat.format === "rgb") { + ctx?.throwIfAborted(); + const progressMsg = `Reading ${inputFile.name}...`; + ctx?.progress(progressMsg, processedCount / inputFiles.length); + ctx?.log(progressMsg); + + if (inputFormat.format === "rgb") { // Guess how big the Image should be inputSettings.width = Math.sqrt(inputFile.bytes.length / 3); inputSettings.height = inputSettings.width; - } + ctx?.log(`Detected RAW RGB format. Guessed dimensions: ${inputSettings.width}x${inputSettings.height}`, "debug"); + } MagickImageCollection.use(fileCollection => { fileCollection.read(inputFile.bytes, inputSettings); + ctx?.log(`Successfully read ${inputFile.name}. Found ${fileCollection.length} sub-images/frames.`, "debug"); + + let frameIndex = 0; while (fileCollection.length > 0) { const image = fileCollection.shift(); if (!image) break; if(outputFormat.format === "ico" && (image.width > 256 || image.height > 256)) { + ctx?.log(`Image ${inputFile.name} frame ${frameIndex} too large for ICO (${image.width}x${image.height}). Resizing to 256x256...`, "warn"); const geometry = new MagickGeometry(256, 256); image.resize(geometry); + } else if (this.options.resizeMode !== "none") { + const target = this.options.resizeMode === "custom" + ? { width: this.options.customWidth, height: this.options.customHeight } + : { "720p": { width: 1280, height: 720 }, "1080p": { width: 1920, height: 1080 }, "1440p": { width: 2560, height: 1440 }, "4k": { width: 3840, height: 2160 } }[this.options.resizeMode]; + const geometry = new MagickGeometry(target.width, target.height); + geometry.ignoreAspectRatio = this.options.ignoreAspectRatio; + image.resize(geometry); } + image.quality = Math.max(1, Math.min(100, Math.round(this.options.quality))); + outputCollection.push(image); + frameIndex++; } }); + processedCount++; } + + const writingMsg = `Encoding output as ${outputFormat.extension}...`; + ctx?.progress(writingMsg, 0.9); + ctx?.log(writingMsg); + outputCollection.write(outputMagickFormat, (bytes) => { resolve(new Uint8Array(bytes)); }); @@ -124,6 +247,10 @@ class ImageMagickHandler implements FormatHandler { const baseName = inputFiles[0].name.split(".").slice(0, -1).join("."); const name = baseName + "." + outputFormat.extension; + + ctx?.progress("Conversion complete!", 1); + ctx?.log(`Successfully converted ${inputFiles.length} files to ${name} (${bytes.length} bytes)`); + return [{ bytes, name }]; } diff --git a/src/handlers/celariaMap.ts b/src/handlers/celariaMap.ts index 672c5a7c..9560642f 100644 --- a/src/handlers/celariaMap.ts +++ b/src/handlers/celariaMap.ts @@ -7,7 +7,7 @@ import { PlayerSpawnPoint } from "celaria-formats/class/maps/objects/PlayerSpawn import { Sphere } from "celaria-formats/class/maps/objects/Sphere.mjs" import { TutorialHologram } from "celaria-formats/class/maps/objects/TutorialHologram.mjs" import type { FlatVector3, Vector3 } from "celaria-formats/types/data.mts" -import CommonFormats from "src/CommonFormats.ts" +import CommonFormats, { Category } from "src/CommonFormats.ts" import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts" class celariaMapHandler implements FormatHandler { @@ -25,7 +25,7 @@ class celariaMapHandler implements FormatHandler { from: false, to: true, internal: "obj", - category: "model", + category: Category.DATA, lossless: false, }, CommonFormats.JSON.builder("json").allowFrom(true).allowTo(true).markLossless(false), diff --git a/src/handlers/epub.ts b/src/handlers/epub.ts new file mode 100644 index 00000000..cf5df296 --- /dev/null +++ b/src/handlers/epub.ts @@ -0,0 +1,316 @@ +import CommonFormats from "../CommonFormats.ts"; +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; +import ePub from "epubjs"; + +function blobUrlRegex() { + return /url\(\s*(['"]?)(blob:[^'")\s]+)\1\s*\)/gu; +} + +async function blobToDataUrl(blob: Blob): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function blobUrlToDataUrl(url: string, cache: Map>): Promise { + const existing = cache.get(url); + if (existing) return await existing; + + const task = (async () => { + const response = await fetch(url); + const blob = await response.blob(); + return await blobToDataUrl(blob); + })(); + cache.set(url, task); + return await task; +} + +async function replaceBlobUrlsInCss( + cssText: string, + cache: Map>, +): Promise { + const urls = Array.from(cssText.matchAll(blobUrlRegex())) + .map((match) => match[2]); + const uniqueUrls = Array.from(new Set(urls)); + + if (uniqueUrls.length === 0) return cssText; + + const dataUrlMap = new Map(); + await Promise.all( + uniqueUrls.map(async (url) => { + dataUrlMap.set(url, await blobUrlToDataUrl(url, cache)); + }), + ); + + return cssText.replace(blobUrlRegex(), (match, quote, blobUrl) => { + const dataUrl = dataUrlMap.get(blobUrl); + return dataUrl ? `url(${quote}${dataUrl}${quote})` : match; + }); +} + +async function inlineBlobBackedAttributes( + doc: Document, + cache: Map>, +) { + const blobElements = Array.from( + doc.querySelectorAll('[src^="blob:"], [href^="blob:"], [poster^="blob:"]'), + ); + + await Promise.all( + blobElements.map(async (element) => { + for (const attr of ["src", "href", "poster"]) { + const value = element.getAttribute(attr); + if (value?.startsWith("blob:")) { + element.setAttribute(attr, await blobUrlToDataUrl(value, cache)); + } + } + }), + ); +} + +class EpubHandler implements FormatHandler { + public name: string = "epub"; + public ready: boolean = false; + + public supportedFormats: FileFormat[] = [ + CommonFormats.EPUB.supported("epub", true, false), + CommonFormats.HTML.supported("html", false, true), + ]; + + async init() { + this.ready = true; + } + + async doConvert( + inputFiles: FileData[], + _inputFormat: FileFormat, + outputFormat: FileFormat, + _args?: string[], + ctx?: ConvertContext, + ): Promise { + if (!this.ready) throw new Error("Handler not initialized."); + + const outputFiles: FileData[] = []; + const blobUrlCache = new Map>(); + + for (const file of inputFiles) { + const baseName = file.name.replace(/\.[^.]+$/u, ""); + + if (outputFormat.internal === "html") { + ctx?.log("Creating rendering iframe..."); + const printIframe = document.createElement("iframe"); + printIframe.style.width = "100%"; + printIframe.style.height = "600px"; + printIframe.style.display = "none"; + document.body.appendChild(printIframe); + + const printDoc = printIframe.contentDocument || printIframe.contentWindow?.document; + if (!printDoc) throw new Error("Could not create print iframe"); + + const epubContainer = document.createElement("div"); + epubContainer.style.position = "absolute"; + epubContainer.style.top = "-9999px"; + epubContainer.style.visibility = "hidden"; + document.body.appendChild(epubContainer); + + const arrayBuffer = file.bytes.buffer.slice( + file.bytes.byteOffset, + file.bytes.byteOffset + file.bytes.byteLength + ); + + ctx?.log(`Parsing EPUB buffer (${file.bytes.byteLength} bytes)...`); + ctx?.progress("Parsing EPUB...", 0); + const currentBook = ePub(arrayBuffer as ArrayBuffer); + await currentBook.ready; + ctx?.log("EPUB ready."); + + printDoc.open(); + printDoc.write(` + + + + + ${(currentBook as any).package?.metadata?.title || baseName} + + + + + + + `); + printDoc.close(); + + const printContent = printDoc.getElementById('print-content')!; + const head = printDoc.head; + const injectedStyles = new Set(); + + const spineItems = (currentBook.spine as any).spineItems || []; + const totalSpineItems = spineItems.length; + + if (totalSpineItems === 0) { + throw new Error("No spine items found in the EPUB."); + } + + ctx?.log(`Found ${totalSpineItems} spine chapters. Rendering...`); + + const CONCURRENCY = 8; + const results: Array<{ headStyles: string[], bodyHTML: string } | null> = new Array(totalSpineItems).fill(null); + let currentIndex = 0; + let completedChapters = 0; + + const processWorker = async () => { + const container = document.createElement("div"); + container.style.position = "absolute"; + container.style.visibility = "hidden"; + container.style.width = "800px"; + container.style.height = "600px"; + epubContainer.appendChild(container); + + const rendition = currentBook.renderTo(container, { + width: 800, + height: 600, + manager: "continuous", + flow: "scrolled", + }); + + while (true) { + ctx?.throwIfAborted(); + const index = currentIndex++; + if (index >= totalSpineItems) break; + + const chapterProgress = completedChapters / totalSpineItems; + ctx?.progress(`Rendering chapter ${index + 1}/${totalSpineItems}...`, chapterProgress * 0.7); + ctx?.log(`Rendering chapter ${index + 1}/${totalSpineItems}...`); + + const item = (currentBook.spine as any).get ? (currentBook.spine as any).get(index) : spineItems[index]; + + try { + await rendition.display(item.href); + const contentsList = rendition.getContents() as any; + + if (contentsList && contentsList.length > 0) { + const sectionDoc = contentsList[0].document; + const headStyles = Array.from(sectionDoc.querySelectorAll('style, link[rel="stylesheet"]')) + .map((node: any) => node.outerHTML); + const bodyHTML = sectionDoc.body.innerHTML; + + results[index] = { headStyles, bodyHTML }; + } + } catch (e) { + ctx?.log(`Failed to render chapter ${index + 1}: ${e}`, "warn"); + } + + completedChapters++; + } + + rendition.destroy(); + container.remove(); + }; + + const workers = Array.from({ length: Math.min(CONCURRENCY, totalSpineItems) }, () => processWorker()); + await Promise.all(workers); + + ctx?.progress("Assembling chapters...", 0.7); + ctx?.log("Assembling chapters..."); + + for (let i = 0; i < totalSpineItems; i++) { + const res = results[i]; + if (!res) continue; + + const tempDiv = printDoc.createElement('div'); + tempDiv.innerHTML = res.headStyles.join('\n'); + + Array.from(tempDiv.childNodes).forEach((node: any) => { + if (node.nodeName.toLowerCase() === 'link') { + if (!injectedStyles.has(node.href)) { + injectedStyles.add(node.href); + head.appendChild(node.cloneNode(true)); + } + } else if (node.nodeName.toLowerCase() === 'style') { + head.appendChild(node.cloneNode(true)); + } + }); + + const sectionWrapper = printDoc.createElement('div'); + sectionWrapper.className = 'epub-section'; + sectionWrapper.innerHTML = res.bodyHTML; + printContent.appendChild(sectionWrapper); + } + + const cssLinks = Array.from(printDoc.querySelectorAll('link[rel="stylesheet"]')); + ctx?.progress("Resolving stylesheets...", 0.8); + ctx?.log(`Resolving ${cssLinks.length} dynamic stylesheets...`); + + const cssFetchPromises = cssLinks.map(async (link) => { + const href = (link as HTMLLinkElement).href; + if (href.startsWith('blob:')) { + try { + const response = await fetch(href); + const text = await replaceBlobUrlsInCss(await response.text(), blobUrlCache); + const style = printDoc.createElement('style'); + style.textContent = text; + link.replaceWith(style); + } catch (e) { + ctx?.log(`Failed to fetch blob CSS: ${e}`, "warn"); + } + } + }); + await Promise.all(cssFetchPromises); + + ctx?.progress("Inlining assets...", 0.9); + ctx?.log("Inlining blob-backed asset references..."); + await inlineBlobBackedAttributes(printDoc, blobUrlCache); + + ctx?.progress("Assembling final HTML...", 0.95); + ctx?.log("Assembling final HTML..."); + const finalHtml = "\n" + printDoc.documentElement.outerHTML; + + printIframe.remove(); + epubContainer.remove(); + + outputFiles.push({ + name: `${baseName}.html`, + bytes: new TextEncoder().encode(finalHtml) + }); + + ctx?.progress("Conversion complete!", 1); + ctx?.log(`Successfully converted ${baseName}.epub to HTML`); + } + } + + return outputFiles; + } +} + +export default EpubHandler; diff --git a/src/handlers/htmlToSvg.ts b/src/handlers/htmlToSvg.ts new file mode 100644 index 00000000..d03bff64 --- /dev/null +++ b/src/handlers/htmlToSvg.ts @@ -0,0 +1,167 @@ +import { elementToSVG, inlineResources } from "dom-to-svg"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; + +function nextPaint(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +async function waitForRenderableAssets(root: ParentNode): Promise { + const pendingImages = Array.from(root.querySelectorAll("img")) + .filter(image => !image.complete) + .map(image => new Promise(resolve => { + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + })); + + const pendingVideos = Array.from(root.querySelectorAll("video")) + .filter(video => video.readyState < 2) + .map(video => new Promise(resolve => { + video.addEventListener("loadeddata", () => resolve(), { once: true }); + video.addEventListener("error", () => resolve(), { once: true }); + })); + + await Promise.all([...pendingImages, ...pendingVideos]); + await nextPaint(); +} + +type HtmlToSvgOptions = { + width?: number; + height?: number; + backgroundColor?: string; +}; + +function measureRenderedElement( + element: Element, + options: HtmlToSvgOptions, +): { width: number; height: number } { + const rect = element.getBoundingClientRect(); + const widthCandidate = element instanceof HTMLElement || element instanceof SVGElement + ? Math.max(rect.width, element.scrollWidth || 0, element.clientWidth || 0) + : rect.width; + const heightCandidate = element instanceof HTMLElement || element instanceof SVGElement + ? Math.max(rect.height, element.scrollHeight || 0, element.clientHeight || 0) + : rect.height; + + return { + width: Math.max(1, Math.ceil(options.width ?? widthCandidate)), + height: Math.max(1, Math.ceil(options.height ?? heightCandidate)), + }; +} + +async function renderRootToSvgString( + root: HTMLElement, + options: HtmlToSvgOptions, +): Promise { + await waitForRenderableAssets(root); + + const { width, height } = measureRenderedElement(root, options); + const existingStyle = root.getAttribute("style") || ""; + const bg = options.backgroundColor ? `background-color:${options.backgroundColor};` : ""; + root.setAttribute( + "style", + `${existingStyle}${bg}width:${width}px;height:${height}px;box-sizing:border-box;`, + ); + + await nextPaint(); + + const bounds = root.getBoundingClientRect(); + const svgDocument = elementToSVG(root, { captureArea: bounds }); + await inlineResources(svgDocument.documentElement); + return new XMLSerializer().serializeToString(svgDocument); +} + +export async function htmlContentToSvgString( + htmlContent: string, + options: HtmlToSvgOptions = {}, +): Promise { + const parsed = new DOMParser().parseFromString(htmlContent, "text/html"); + const host = document.createElement("div"); + host.style.all = "initial"; + host.style.position = "fixed"; + host.style.left = "-20000px"; + host.style.top = "0"; + host.style.pointerEvents = "none"; + host.style.background = "transparent"; + document.body.appendChild(host); + + try { + const shadow = host.attachShadow({ mode: "closed" }); + + for (const styleElement of Array.from(parsed.querySelectorAll("style"))) { + shadow.appendChild(styleElement.cloneNode(true)); + } + + const root = document.createElement("div"); + const bodyStyle = parsed.body.getAttribute("style"); + if (bodyStyle) root.setAttribute("style", bodyStyle); + + const sourceNodes = parsed.body.childNodes.length > 0 + ? Array.from(parsed.body.childNodes) + : Array.from(parsed.documentElement.childNodes); + for (const childNode of sourceNodes) { + root.appendChild(childNode.cloneNode(true)); + } + + shadow.appendChild(root); + + return await renderRootToSvgString(root, options); + } finally { + host.remove(); + } +} + +class HtmlToSvgHandler implements FormatHandler { + + public name: string = "dom-to-svg"; + + public supportedFormats: FileFormat[] = [ + CommonFormats.HTML.supported("html", true, false), + CommonFormats.SVG.supported("svg", false, true, false, { + category: [Category.IMAGE, Category.VECTOR], + }), + ]; + + public ready: boolean = true; + + async init() { + this.ready = true; + } + + async doConvert( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat, + _args?: string[], + ctx?: ConvertContext, + ): Promise { + if (inputFormat.internal !== "html") throw new Error("Invalid input format."); + if (outputFormat.internal !== "svg") throw new Error("Invalid output format."); + + const outputFiles: FileData[] = []; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + for (let i = 0; i < inputFiles.length; i++) { + ctx?.throwIfAborted(); + ctx?.progress(`Rendering ${inputFiles[i].name} to SVG...`, i / inputFiles.length); + ctx?.log(`Rendering to SVG (${i + 1}/${inputFiles.length})...`); + + const { name, bytes } = inputFiles[i]; + const htmlStr = decoder.decode(bytes); + const svgStr = await htmlContentToSvgString(htmlStr); + const newName = (name.endsWith(".html") ? name.slice(0, -5) : name) + ".svg"; + outputFiles.push({ name: newName, bytes: encoder.encode(svgStr) }); + } + + ctx?.progress("Conversion complete!", 1); + return outputFiles; + } +} + +export default HtmlToSvgHandler; diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 0c6fae4b..11b1b879 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -72,8 +72,16 @@ import xcursorHandler from "./xcursor.ts"; import shToElfHandler from "./shToElf.ts"; import cssHandler from "./css.ts"; import TypstHandler from "./typst.ts"; +import EpubHandler from "./epub.ts"; +import PptxRendererHandler from "./pptxRenderer.ts"; +import HtmlToSvgHandler from "./htmlToSvg.ts"; const handlers: FormatHandler[] = []; +try { handlers.push(new PptxRendererHandler()) } catch (_) { }; +try { handlers.push(new EpubHandler()) } catch (_) { }; +try { handlers.push(new pandocHandler()) } catch (_) { }; +try { handlers.push(new TypstHandler()) } catch (_) { }; +try { handlers.push(new HtmlToSvgHandler()) } catch (_) { }; try { handlers.push(new svgTraceHandler()) } catch (_) { }; try { handlers.push(new canvasToBlobHandler()) } catch (_) { }; try { handlers.push(new meydaHandler()) } catch (_) { }; @@ -116,7 +124,6 @@ try { handlers.push(new midiCodecHandler()) } catch (_) { }; try { handlers.push(new midiSynthHandler()) } catch (_) { }; try { handlers.push(new lzhHandler()) } catch (_) { }; try { handlers.push(new wadHandler()) } catch (_) { }; -try { handlers.push(new pandocHandler()) } catch (_) { }; try { handlers.push(new txtToInfiniteCraftHandler()) } catch (_) { }; try { handlers.push(new espeakngHandler()) } catch (_) { }; try { handlers.push(new exeToBatHandler()) } catch (_) { }; @@ -149,6 +156,5 @@ try { handlers.push(new piskelHandler()) } catch (_) { }; try { handlers.push(new xcursorHandler()) } catch (_) { }; try { handlers.push(new shToElfHandler()) } catch (_) { }; try { handlers.push(new cssHandler()) } catch (_) { }; -try { handlers.push(new TypstHandler()) } catch (_) { }; export default handlers; diff --git a/src/handlers/json.ts b/src/handlers/json.ts index 515fb001..184fca02 100644 --- a/src/handlers/json.ts +++ b/src/handlers/json.ts @@ -1,5 +1,5 @@ import CommonFormats from "src/CommonFormats.ts"; -import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import { NumberOption, type FileData, type FileFormat, type FormatHandler } from "../FormatHandler.ts"; import parseXML from "./envelope/parseXML.js"; import * as yaml from "yaml"; @@ -7,6 +7,11 @@ import * as yaml from "yaml"; export class toJsonHandler implements FormatHandler { public name: string = "tojson"; public ready: boolean = true; + private readonly options: { + indent: number; + } = { + indent: 2 + }; public supportedFormats: FileFormat[] = [ CommonFormats.CSV.builder("csv").allowFrom(), @@ -19,6 +24,26 @@ export class toJsonHandler implements FormatHandler { this.ready = true; } + getOptions() { + return [ + new NumberOption( + "indent", + "Output JSON indentation", + () => this.options.indent, + (value) => { this.options.indent = value; }, + { + min: 0, + max: 8, + step: 1, + unit: "spaces", + control: "slider", + defaultValue: 2, + description: "Use 0 for compact JSON output." + } + ) + ]; + } + async doConvert ( inputFiles: FileData[], inputFormat: FileFormat, @@ -64,7 +89,7 @@ export class toJsonHandler implements FormatHandler { } return { name: name, - bytes: new TextEncoder().encode(JSON.stringify(object)) + bytes: new TextEncoder().encode(JSON.stringify(object, null, this.options.indent)) }; }); } diff --git a/src/handlers/pandoc.ts b/src/handlers/pandoc.ts index 734144f5..05c2a143 100644 --- a/src/handlers/pandoc.ts +++ b/src/handlers/pandoc.ts @@ -1,8 +1,294 @@ import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; import CommonFormats from "src/CommonFormats.ts"; import mime from "mime"; import normalizeMimeType from "../normalizeMimeType.ts"; +export const TYPST_PAGEBREAK_MARKER = "CONVERTTYPSTPAGEBREAKTOKEN"; +export const TYPST_ASSET_MANIFEST_START = "// convert-assets-start"; +export const TYPST_ASSET_MANIFEST_END = "// convert-assets-end"; + +export function normalizeTypstAssetPaths( + typstContent: string, + shadowFiles: Record, +): string { + const availablePaths = new Map(); + + for (const path of Object.keys(shadowFiles)) { + const normalized = path + .replace(/\\/gu, "/") + .replace(/^\/+/u, "") + .replace(/^\.\/+/u, "") + .replace(/^(?:\.\.\/)+/u, ""); + availablePaths.set(normalized, path); + } + + return typstContent.replace(/(['"])([^"'\\\n]+)\1/gu, (match, quote, candidatePath) => { + const normalized = candidatePath + .replace(/\\/gu, "/") + .replace(/^\/+/u, "") + .replace(/^\.\/+/u, "") + .replace(/^(?:\.\.\/)+/u, ""); + const canonicalPath = availablePaths.get(normalized); + if (!canonicalPath) return match; + return `${quote}${canonicalPath}${quote}`; + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function parseStyleAttribute(style: string): Map { + const entries = new Map(); + + for (const declaration of style.split(";")) { + const separatorIndex = declaration.indexOf(":"); + if (separatorIndex === -1) continue; + + const property = declaration.slice(0, separatorIndex).trim().toLowerCase(); + const value = declaration.slice(separatorIndex + 1).trim(); + if (!property || !value) continue; + + entries.set(property, value); + } + + return entries; +} + +function hasPageBreakValue(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "page" || normalized === "always"; +} + +function elementHasMeaningfulContent(element: Element): boolean { + if (element.children.length > 0) return true; + if ((element.textContent || "").trim().length > 0) return true; + + return [ + "img", + "svg", + "table", + "hr", + "video", + "audio", + "canvas", + "iframe", + ].includes(element.tagName.toLowerCase()); +} + +function shouldInsertPageBreakBefore(element: Element): boolean { + const classList = element.classList; + if (classList.contains("__page") || classList.contains("epub-section")) return true; + + const styles = parseStyleAttribute(element.getAttribute("style") || ""); + return hasPageBreakValue(styles.get("break-before")) + || hasPageBreakValue(styles.get("page-break-before")); +} + +function shouldInsertPageBreakAfter(element: Element): boolean { + const styles = parseStyleAttribute(element.getAttribute("style") || ""); + return hasPageBreakValue(styles.get("break-after")) + || hasPageBreakValue(styles.get("page-break-after")); +} + +function createPageBreakMarker(document: Document): HTMLParagraphElement { + const marker = document.createElement("p"); + marker.setAttribute("data-typst-pagebreak-marker", "true"); + marker.textContent = TYPST_PAGEBREAK_MARKER; + return marker; +} + +function appendTypstAttribute( + element: Element, + name: string, + value: string | undefined, +) { + if (!value || element.hasAttribute(name)) return; + element.setAttribute(name, value); +} + +function promoteImageDimensions(element: Element, styles: Map) { + if (element.tagName.toLowerCase() !== "img") return; + + const width = styles.get("width") || styles.get("max-width"); + const height = styles.get("height") || styles.get("max-height"); + + if (width && !element.getAttribute("width")) { + element.setAttribute("width", width); + } + if (height && !element.getAttribute("height")) { + element.setAttribute("height", height); + } +} + +function applyTypstStyleHints(element: Element) { + const style = element.getAttribute("style"); + if (!style) return; + + const styles = parseStyleAttribute(style); + if (styles.size === 0) return; + + const tagName = element.tagName.toLowerCase(); + const inlineTextContainer = [ + "span", + "a", + "code", + "kbd", + "mark", + "small", + "sub", + "sup", + ].includes(tagName); + const blockContainer = [ + "div", + "p", + "section", + "article", + "blockquote", + "pre", + "figure", + "table", + "td", + "th", + ].includes(tagName); + + if (inlineTextContainer) { + appendTypstAttribute(element, "typst:text:fill", styles.get("color")); + appendTypstAttribute(element, "typst:text:size", styles.get("font-size")); + appendTypstAttribute(element, "typst:text:font", styles.get("font-family")); + } + + if (blockContainer) { + appendTypstAttribute( + element, + "typst:fill", + styles.get("background") || styles.get("background-color"), + ); + appendTypstAttribute( + element, + "typst:inset", + styles.get("padding"), + ); + appendTypstAttribute( + element, + "typst:stroke", + styles.get("border"), + ); + + if ( + styles.get("break-inside")?.toLowerCase() === "avoid" + || styles.get("page-break-inside")?.toLowerCase() === "avoid" + ) { + appendTypstAttribute(element, "typst:breakable", "false"); + } + } + + promoteImageDimensions(element, styles); +} + +export function preprocessHtmlForTypst(htmlContent: string): string { + if (typeof DOMParser === "undefined") return htmlContent; + + const document = new DOMParser().parseFromString(htmlContent, "text/html"); + const elements = Array.from(document.body.querySelectorAll("*")); + let sawMeaningfulContent = false; + + for (const element of elements) { + if ( + shouldInsertPageBreakBefore(element) + && sawMeaningfulContent + && element.previousElementSibling?.getAttribute("data-typst-pagebreak-marker") !== "true" + ) { + element.before(createPageBreakMarker(document)); + } + + applyTypstStyleHints(element); + + if (elementHasMeaningfulContent(element)) { + sawMeaningfulContent = true; + } + + if ( + shouldInsertPageBreakAfter(element) + && element.nextElementSibling?.getAttribute("data-typst-pagebreak-marker") !== "true" + ) { + element.after(createPageBreakMarker(document)); + } + } + + return "\n" + document.documentElement.outerHTML; +} + +export function postprocessTypstFromPandoc(typstContent: string): string { + const escaped = escapeRegExp(TYPST_PAGEBREAK_MARKER); + + return typstContent.replace( + new RegExp(`^.*${escaped}.*$`, "gmu"), + "#colbreak(weak: true)", + ); +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + + return btoa(binary); +} + +async function collectTypstAssetFiles( + files: Record, + excludedPaths: string[] = [], +): Promise> { + const bundledAssets: Record = {}; + const excluded = new Set([ + "stdin", + "stdout", + "stderr", + "warnings", + "output", + ...excludedPaths, + ]); + + for (const [path, file] of Object.entries(files)) { + if (excluded.has(path)) continue; + if (!(file instanceof Blob)) continue; + + const arrayBuffer = await file.arrayBuffer(); + bundledAssets[path] = new Uint8Array(arrayBuffer); + } + + return bundledAssets; +} + +export async function bundleTypstAssets( + typstContent: string, + files: Record, + excludedPaths: string[] = [], +): Promise { + const shadowFiles = await collectTypstAssetFiles(files, excludedPaths); + const assetPaths = Object.keys(shadowFiles); + + if (assetPaths.length === 0) return typstContent; + const bundledAssets = Object.fromEntries( + Object.entries(shadowFiles).map(([path, bytes]) => [path, bytesToBase64(bytes)]), + ); + + return [ + TYPST_ASSET_MANIFEST_START, + `// ${JSON.stringify(bundledAssets)}`, + TYPST_ASSET_MANIFEST_END, + "", + typstContent, + ].join("\n"); +} + class pandocHandler implements FormatHandler { static formatNames: Map = new Map([ @@ -60,7 +346,7 @@ class pandocHandler implements FormatHandler { ["opendocument", "OpenDocument XML"], ["opml", "OPML"], ["org", "Emacs Org mode"], - ["pdf", "PDF via Typst"], + ["pdf", CommonFormats.PDF.name], ["text", CommonFormats.TEXT.name], ["pod", "Perl POD"], ["pptx", CommonFormats.PPTX.name], @@ -168,7 +454,6 @@ class pandocHandler implements FormatHandler { const inputFormats: string[] = await query({ query: "input-formats" }); const outputFormats: string[] = await query({ query: "output-formats" }); - // Pandoc supports MathML natively but doesn't expose as a format outputFormats.push("mathml"); const allFormats = new Set(inputFormats); @@ -177,11 +462,8 @@ class pandocHandler implements FormatHandler { this.supportedFormats = []; for (const internal of allFormats) { let format = internal; - // PDF doesn't seem to work, at least with this configuration - if (format === "pdf") continue; - // RevealJS seems to hang forever? if (format === "revealjs") continue; - // Adjust plaintext format name to match other handlers + if (format === "pdf") continue; if (format === "plain") format = "text"; const name = pandocHandler.formatNames.get(format) || format; const extension = pandocHandler.formatExtensions.get(format) || format; @@ -203,23 +485,31 @@ class pandocHandler implements FormatHandler { || format === "odt" || format === "ods" || format === "odp"; + const isTypst = format === "typst"; + const isEpubInput = format === "epub" + || format === "epub2" + || format === "epub3"; this.supportedFormats.push({ name, format, extension, mime: mimeType, - from: inputFormats.includes(internal), + from: inputFormats.includes(internal) && !isEpubInput, to: outputFormats.includes(internal), internal, category: categories.length === 1 ? categories[0] : categories, - lossless: !isOfficeDocument + lossless: !isOfficeDocument && !isTypst }); } - // Move HTML up, it's the only format that can embed resources const htmlIndex = this.supportedFormats.findIndex(c => c.internal === "html"); const htmlFormat = this.supportedFormats[htmlIndex]; this.supportedFormats.splice(htmlIndex, 1); this.supportedFormats.unshift(htmlFormat); - // pandoc internal formats is almost always never what the user wants + const typstIndex = this.supportedFormats.findIndex(c => c.internal === "typst"); + if (typstIndex !== -1) { + const typstFormat = this.supportedFormats[typstIndex]; + this.supportedFormats.splice(typstIndex, 1); + this.supportedFormats.splice(1, 0, typstFormat); + } const jsonXmlFormats = this.supportedFormats.filter(c => c.mime === "application/json" || c.mime === "application/xml" @@ -236,7 +526,9 @@ class pandocHandler implements FormatHandler { async doConvert ( inputFiles: FileData[], inputFormat: FileFormat, - outputFormat: FileFormat + outputFormat: FileFormat, + args?: string[], + ctx?: ConvertContext ): Promise { if ( !this.ready @@ -246,42 +538,87 @@ class pandocHandler implements FormatHandler { const outputFiles: FileData[] = []; + ctx?.log(`Initialising Pandoc for ${inputFormat.name} -> ${outputFormat.name}...`); + + let i = 0; for (const inputFile of inputFiles) { + const progressMsg = `Converting ${inputFile.name}...`; + ctx?.progress(progressMsg, i / inputFiles.length); + ctx?.log(progressMsg); - const files = { - [inputFile.name]: new Blob([inputFile.bytes as BlobPart]) + const vfsInputName = inputFile.name.replace(/^.*[/\\]/, "") || "input.bin"; + const shouldNormalizeHtmlForTypst = inputFormat.internal === "html" + && (outputFormat.internal === "pdf" || outputFormat.internal === "typst"); + const sourceBytes = shouldNormalizeHtmlForTypst + ? new TextEncoder().encode( + preprocessHtmlForTypst(new TextDecoder().decode(inputFile.bytes)), + ) + : inputFile.bytes; + const files: Record = { + [vfsInputName]: new Blob([sourceBytes as BlobPart]) }; - let options = { + const options: Record = { from: inputFormat.internal, to: outputFormat.internal, - "input-files": [inputFile.name], + "input-files": [vfsInputName], "output-file": "output", "embed-resources": true, "html-math-method": "mathjax", - } + }; - // Set flag for outputting mathml if (outputFormat.internal === "mathml") { options.to = "html"; options["html-math-method"] = "mathml"; } - const { stderr } = await this.convert(options, null, files); + if (outputFormat.internal === "typst") { + options.standalone = true; + options["extract-media"] = "media"; + } + + const { stderr, warnings } = await this.convert(options, null, files); + + if (stderr) { + ctx?.log(`Pandoc Error: ${stderr}`, "error"); + throw stderr; + } - if (stderr) throw stderr; + if (warnings && warnings.length > 0) { + for (const warning of warnings) { + ctx?.log(`Pandoc Warning: ${JSON.stringify(warning)}`, "warn"); + } + } const outputBlob = files.output; - if (!(outputBlob instanceof Blob)) continue; + if (!(outputBlob instanceof Blob)) { + ctx?.log(`Pandoc failed to produce output for ${inputFile.name}`, "error"); + continue; + } const arrayBuffer = await outputBlob.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); + let bytes = new Uint8Array(arrayBuffer); + if (outputFormat.internal === "typst") { + const normalizedTypst = normalizeTypstAssetPaths( + postprocessTypstFromPandoc(new TextDecoder().decode(bytes)), + await collectTypstAssetFiles(files, [vfsInputName]), + ); + const bundledTypst = await bundleTypstAssets( + normalizedTypst, + files, + [vfsInputName], + ); + bytes = new TextEncoder().encode(bundledTypst); + } const name = inputFile.name.split(".").slice(0, -1).join(".") + "." + outputFormat.extension; outputFiles.push({ bytes, name }); - + i++; } + ctx?.progress("Conversion complete!", 1); + ctx?.log(`Successfully converted ${outputFiles.length} files.`); + return outputFiles; } diff --git a/src/handlers/pandoc/pandoc.js b/src/handlers/pandoc/pandoc.js index 0a8a2caf..f70730e4 100644 --- a/src/handlers/pandoc/pandoc.js +++ b/src/handlers/pandoc/pandoc.js @@ -32,6 +32,7 @@ import { WASI, OpenFile, File, + Directory, ConsoleStdout, PreopenDirectory, } from "@bjorn3/browser_wasi_shim"; @@ -127,9 +128,9 @@ export async function convert(options, stdin, files) { if (options["output-file"]) { await addFile(options["output-file"], new Blob(), false); } - // add media file for extracted media + // Pre-create the extract-media directory so pandoc can write into it. if (options["extract-media"]) { - await addFile(options["extract-media"], new Blob(), false); + ensureDirectory(options["extract-media"]); } if (stdin) { in_file.data = new TextEncoder().encode(stdin); @@ -141,10 +142,11 @@ export async function convert(options, stdin, files) { new Blob([fileSystem.get(options["output-file"]).data]); } if (options["extract-media"]) { - const mediaFile = fileSystem.get(options["extract-media"]); - if (mediaFile && mediaFile.data && mediaFile.data.length > 0) { - files[options["extract-media"]] = - new Blob([mediaFile.data], { type: 'application/zip' }); + const mediaEntry = getEntry(options["extract-media"]); + if (mediaEntry instanceof Directory) { + for (const [path, blob] of collectFiles(mediaEntry, options["extract-media"])) { + files[path] = blob; + } } } const rawWarnings = new TextDecoder("utf-8", { fatal: true }) @@ -163,5 +165,76 @@ export async function convert(options, stdin, files) { async function addFile(filename, blob, readonly) { const buffer = await blob.arrayBuffer(); const file = new File(new Uint8Array(buffer), { readonly: readonly }); - fileSystem.set(filename, file); + setEntry(filename, file); +} + +function ensureDirectory(path) { + if (!path) return; + const normalized = splitPath(path); + let current = fileSystem; + + for (const segment of normalized) { + let entry = current.get(segment); + if (entry === undefined) { + entry = new Directory(new Map()); + current.set(segment, entry); + } + if (!(entry instanceof Directory)) { + throw new Error(`Path segment "${segment}" is not a directory`); + } + current = entry.contents; + } +} + +function setEntry(path, entry) { + const parts = splitPath(path); + const name = parts.pop(); + if (!name) return; + if (parts.length > 0) { + ensureDirectory(parts.join("/")); + } + let current = fileSystem; + for (const segment of parts) { + const next = current.get(segment); + if (!(next instanceof Directory)) { + throw new Error(`Path segment "${segment}" is not a directory`); + } + current = next.contents; + } + current.set(name, entry); +} + +function getEntry(path) { + const parts = splitPath(path); + let current = fileSystem; + let entry = null; + + for (const segment of parts) { + entry = current.get(segment); + if (!entry) return null; + if (entry instanceof Directory) { + current = entry.contents; + } + } + + return entry; +} + +function collectFiles(directory, prefix = "") { + const results = new Map(); + for (const [name, entry] of directory.contents.entries()) { + const path = prefix ? `${prefix}/${name}` : name; + if (entry instanceof File) { + results.set(path, new Blob([entry.data])); + } else if (entry instanceof Directory) { + for (const [nestedPath, blob] of collectFiles(entry, path)) { + results.set(nestedPath, blob); + } + } + } + return results; +} + +function splitPath(path) { + return path.split("/").filter(Boolean); } diff --git a/src/handlers/pptxRenderer.ts b/src/handlers/pptxRenderer.ts new file mode 100644 index 00000000..2f7d1092 --- /dev/null +++ b/src/handlers/pptxRenderer.ts @@ -0,0 +1,150 @@ +import { + buildPresentation, + parseZip, + renderSlide, +} from "@aiden0z/pptx-renderer"; +import CommonFormats from "src/CommonFormats.ts"; +import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; +import type { ConvertContext } from "../ui/ProgressStore.js"; +import { htmlContentToSvgString } from "./htmlToSvg.ts"; + +async function blobUrlToDataUrl(blobUrl: string): Promise { + const response = await fetch(blobUrl); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function convertBlobUrlsToDataUrls(element: HTMLElement): Promise { + const images = Array.from(element.querySelectorAll("img")); + for (const img of images) { + if (img.src.startsWith("blob:")) { + try { + img.src = await blobUrlToDataUrl(img.src); + } catch (_) { /* ignore errors */ } + } + } +} + +async function waitForSlideToSettle(element: HTMLElement): Promise { + const imagePromises = Array.from(element.querySelectorAll("img")) + .filter(image => !image.complete) + .map(image => new Promise(resolve => { + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + })); + + await Promise.all(imagePromises); + await new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + await new Promise(resolve => setTimeout(resolve, 100)); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return sliced as ArrayBuffer; +} + +export default class PptxRendererHandler implements FormatHandler { + public name: string = "pptx-renderer"; + + public ready: boolean = true; + + public supportedFormats: FileFormat[] = [ + CommonFormats.PPTX.supported("pptx", true, false), + CommonFormats.SVG.supported("svg", false, true), + ]; + + async init() { + this.ready = true; + } + + async doConvert( + inputFiles: FileData[], + inputFormat: FileFormat, + outputFormat: FileFormat, + _args?: string[], + ctx?: ConvertContext, + ): Promise { + if (!this.ready) throw new Error("Handler not initialized."); + if (inputFormat.internal !== "pptx") throw new Error("Invalid input format."); + if (outputFormat.internal !== "svg") throw new Error("Invalid output format."); + + const outputFiles: FileData[] = []; + const stagingRoot = document.createElement("div"); + stagingRoot.style.position = "fixed"; + stagingRoot.style.left = "-20000px"; + stagingRoot.style.top = "0"; + stagingRoot.style.pointerEvents = "none"; + stagingRoot.style.background = "#ffffff"; + stagingRoot.style.zIndex = "-1"; + document.body.appendChild(stagingRoot); + + try { + for (const inputFile of inputFiles) { + ctx?.log(`Parsing ${inputFile.name}...`); + ctx?.progress("Parsing PPTX...", 0); + const files = await parseZip(toArrayBuffer(inputFile.bytes)); + const presentation = buildPresentation(files); + + if (presentation.slides.length === 0) { + throw new Error(`${inputFile.name} does not contain any slides.`); + } + + const totalSlides = presentation.slides.length; + ctx?.log(`Found ${totalSlides} slides (${presentation.width}×${presentation.height}px)`); + const mediaUrlCache = new Map(); + + for (const [slideIndex, slide] of presentation.slides.entries()) { + ctx?.throwIfAborted(); + ctx?.progress(`Rendering slide ${slideIndex + 1}/${totalSlides}...`, slideIndex / totalSlides); + ctx?.log(`Rendering slide ${slideIndex + 1}/${totalSlides}...`); + + const handle = renderSlide(presentation, slide, { mediaUrlCache }); + try { + stagingRoot.replaceChildren(); + stagingRoot.style.width = `${presentation.width}px`; + stagingRoot.style.height = `${presentation.height}px`; + stagingRoot.appendChild(handle.element); + + await waitForSlideToSettle(handle.element); + await convertBlobUrlsToDataUrls(handle.element); + + const slideHtml = `${handle.element.outerHTML}`; + + const svgString = await htmlContentToSvgString(slideHtml, { + width: presentation.width, + height: presentation.height, + }); + + const baseName = inputFile.name.replace(/\.[^.]+$/u, ""); + const svgName = totalSlides === 1 + ? `${baseName}.svg` + : `${baseName}_slide${slideIndex + 1}.svg`; + + outputFiles.push({ + name: svgName, + bytes: new TextEncoder().encode(svgString), + }); + } finally { + stagingRoot.replaceChildren(); + handle.dispose(); + } + } + } + } finally { + stagingRoot.remove(); + } + + ctx?.progress("Slides rendered to SVG", 1); + ctx?.log(`Generated ${outputFiles.length} SVG file(s)`); + return outputFiles; + } +} diff --git a/src/handlers/sevenZip.ts b/src/handlers/sevenZip.ts index 359b73f8..909eedcd 100644 --- a/src/handlers/sevenZip.ts +++ b/src/handlers/sevenZip.ts @@ -5,6 +5,7 @@ import CommonFormats, { Category } from "src/CommonFormats.ts"; import SevenZip from "7z-wasm"; import mime from "mime"; import normalizeMimeType from "src/normalizeMimeType.ts"; +import type { ConvertContext } from "src/ui/ProgressStore.js"; const defaultSevenZipOptions = { locateFile: () => "/convert/wasm/7zz.wasm" @@ -91,7 +92,9 @@ class sevenZipHandler implements FormatHandler { async doConvert ( inputFiles: FileData[], inputFormat: FileFormat, - outputFormat: FileFormat + outputFormat: FileFormat, + args?: string[], + ctx?: ConvertContext ): Promise { const outputFiles: FileData[] = []; @@ -99,13 +102,34 @@ class sevenZipHandler implements FormatHandler { throw new Error(`sevenZipHandler cannot convert to ${outputFormat.mime}`); } + let logBuffer = ""; + const createSevenZip = async () => { + return await SevenZip({ + ...defaultSevenZipOptions, + stdout: (c) => { + const char = String.fromCharCode(c); + if (char === "\n") { + ctx?.log(logBuffer, "debug"); + logBuffer = ""; + } else { + logBuffer += char; + } + }, + }); + }; + + ctx?.log(`Initialising SevenZip for ${inputFormat.name} -> ${outputFormat.name}...`); + // handle compressed tars if (this.#tarCompressedFormats.includes(inputFormat.internal) || this.#tarCompressedFormats.includes(outputFormat.internal)) { if (outputFormat.internal === "tar") { + let i = 0; for (const inputFile of inputFiles) { - const sevenZip = await SevenZip(defaultSevenZipOptions); + ctx?.progress(`Extracting ${inputFile.name}...`, i / inputFiles.length); + ctx?.log(`Extracting ${inputFile.name}...`); + const sevenZip = await createSevenZip(); sevenZip.FS.writeFile(inputFile.name, inputFile.bytes); sevenZip.callMain(["x", inputFile.name]); @@ -113,10 +137,14 @@ class sevenZipHandler implements FormatHandler { const name = inputFile.name.replace(/\.[^.]+$/, ""); const bytes = sevenZip.FS.readFile(name); outputFiles.push({ bytes, name }); + i++; } } else if (inputFormat.internal === "tar") { + let i = 0; for (const inputFile of inputFiles) { - const sevenZip = await SevenZip(defaultSevenZipOptions); + ctx?.progress(`Compressing ${inputFile.name}...`, i / inputFiles.length); + ctx?.log(`Compressing ${inputFile.name}...`); + const sevenZip = await createSevenZip(); sevenZip.FS.writeFile(inputFile.name, inputFile.bytes); const name = inputFile.name + `.${outputFormat.extension}`; @@ -124,41 +152,54 @@ class sevenZipHandler implements FormatHandler { const bytes = sevenZip.FS.readFile(name); outputFiles.push({ bytes, name }); + i++; } } else { throw new Error(`sevenZipHandler cannot convert from ${inputFormat.mime} to ${outputFormat.mime}`); } + ctx?.progress("Complete!", 1); return outputFiles; } if (this.supportedFormats.some(format => format.internal === inputFormat.internal)) { + let i = 0; for (const inputFile of inputFiles) { - const sevenZip = await SevenZip(defaultSevenZipOptions); + ctx?.progress(`Processing archive ${inputFile.name}...`, i / inputFiles.length); + ctx?.log(`Processing archive ${inputFile.name}...`); + const sevenZip = await createSevenZip(); sevenZip.FS.writeFile(inputFile.name, inputFile.bytes); + ctx?.log(`Extracting ${inputFile.name} to temporary directory...`, "debug"); sevenZip.callMain(["x", inputFile.name, `-odata`]); const name = inputFile.name.replace(/\.[^.]+$/, "") + `.${outputFormat.extension}`; sevenZip.FS.chdir("data"); // we need to preserve the structure of the input archive + ctx?.log(`Re-archiving contents as ${outputFormat.internal}...`, "debug"); sevenZip.callMain(["a", "../" + name]); sevenZip.FS.chdir(".."); const bytes = sevenZip.FS.readFile(name); outputFiles.push({ bytes, name }); + i++; } } else { - const sevenZip = await SevenZip(defaultSevenZipOptions); + ctx?.progress(`Creating ${outputFormat.name}...`, 0.5); + ctx?.log(`Creating ${outputFormat.name} from ${inputFiles.length} files...`); + const sevenZip = await createSevenZip(); sevenZip.FS.mkdir("data"); sevenZip.FS.chdir("data"); for (const inputFile of inputFiles) { + ctx?.log(`Adding ${inputFile.name} to archive...`, "debug"); sevenZip.FS.writeFile(inputFile.name, inputFile.bytes); } const name = inputFiles.length === 1 ? inputFiles[0].name + `.${outputFormat.extension}` : `archive.${outputFormat.extension}`; + + ctx?.log(`Compiling archive ${name}...`); sevenZip.callMain(["a", "../" + name]); sevenZip.FS.chdir(".."); @@ -166,6 +207,8 @@ class sevenZipHandler implements FormatHandler { outputFiles.push({ bytes, name }); } + ctx?.progress("Complete!", 1); + ctx?.log(`SevenZip successfully processed ${inputFiles.length} files.`); return outputFiles; } diff --git a/src/handlers/sqlite.ts b/src/handlers/sqlite.ts index 61b4eaf3..8d0612e5 100644 --- a/src/handlers/sqlite.ts +++ b/src/handlers/sqlite.ts @@ -1,4 +1,4 @@ -import CommonFormats from "src/CommonFormats.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; import sqlite3InitModule from "@sqlite.org/sqlite-wasm"; import {parse} from "papaparse"; @@ -19,7 +19,7 @@ class sqlite3Handler implements FormatHandler { from: true, to: true, internal: "sqlite3", - category: "database", + category: Category.DATA, lossless: false }, { @@ -30,7 +30,7 @@ class sqlite3Handler implements FormatHandler { from: true, to: false, internal: "sqlite3", - category: "database", + category: Category.DATA, lossless: false }, // Lossy because extracts only tables diff --git a/src/handlers/svgForeignObject.ts b/src/handlers/svgForeignObject.ts index 1ac21a08..87192f23 100644 --- a/src/handlers/svgForeignObject.ts +++ b/src/handlers/svgForeignObject.ts @@ -1,4 +1,4 @@ -import CommonFormats from "src/CommonFormats.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; class svgForeignObjectHandler implements FormatHandler { @@ -7,8 +7,9 @@ class svgForeignObjectHandler implements FormatHandler { public supportedFormats: FileFormat[] = [ CommonFormats.HTML.supported("html", true, false), - // Identical to the input HTML, just wrapped in an SVG foreignObject, so it's lossless - CommonFormats.SVG.supported("svg", false, true, true) + CommonFormats.SVG.supported("svg", false, true, false, { + category: [Category.IMAGE, Category.VECTOR], + }) ]; public ready: boolean = true; diff --git a/src/handlers/threejs.ts b/src/handlers/threejs.ts index 5e460cf3..e31a1022 100644 --- a/src/handlers/threejs.ts +++ b/src/handlers/threejs.ts @@ -1,4 +1,4 @@ -import CommonFormats from "src/CommonFormats.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; import * as THREE from "three"; @@ -18,7 +18,7 @@ class threejsHandler implements FormatHandler { from: true, to: false, internal: "glb", - category: "model", + category: Category.DATA, lossless: false }, { @@ -29,7 +29,7 @@ class threejsHandler implements FormatHandler { from: true, to: false, internal: "glb", - category: "model", + category: Category.DATA, lossless: false }, { @@ -40,7 +40,7 @@ class threejsHandler implements FormatHandler { from: true, to: false, internal: "obj", - category: "model", + category: Category.DATA, lossless: false, }, CommonFormats.PNG.supported("png", false, true), diff --git a/src/handlers/typst.ts b/src/handlers/typst.ts index df610bfc..41375389 100644 --- a/src/handlers/typst.ts +++ b/src/handlers/typst.ts @@ -1,6 +1,78 @@ -import CommonFormats from "src/CommonFormats.ts"; +import CommonFormats, { Category } from "src/CommonFormats.ts"; import type { FileData, FileFormat, FormatHandler } from "../FormatHandler.ts"; import type { TypstSnippet } from "@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs"; +import { MemoryAccessModel } from "@myriaddreamin/typst.ts/fs/memory"; +import type { ConvertContext } from "../ui/ProgressStore.js"; +import { + TYPST_ASSET_MANIFEST_END, + TYPST_ASSET_MANIFEST_START, +} from "./pandoc.ts"; + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * @param mainContent Typst source that may start with an asset manifest block. + * @returns The cleaned Typst source and a map of shadow file paths to their bytes. + */ +export function unpackTypstAssets( + mainContent: string, +): { mainContent: string; shadowFiles: Record } { + if (!mainContent.startsWith(TYPST_ASSET_MANIFEST_START)) { + return { mainContent, shadowFiles: {} }; + } + + const newline = "\n"; + const manifestEndOffset = mainContent.indexOf(TYPST_ASSET_MANIFEST_END); + if (manifestEndOffset === -1) { + return { mainContent, shadowFiles: {} }; + } + + const manifestLineStart = TYPST_ASSET_MANIFEST_START.length + newline.length; + const manifestRaw = mainContent + .slice(manifestLineStart, manifestEndOffset) + .trim() + .replace(/^\/\/\s?/u, ""); + const remainderStart = manifestEndOffset + TYPST_ASSET_MANIFEST_END.length; + const strippedContent = mainContent.slice(remainderStart).replace(/^\s+/u, ""); + + if (!manifestRaw) { + return { mainContent: strippedContent, shadowFiles: {} }; + } + + const parsedManifest = JSON.parse(manifestRaw) as Record; + const shadowFiles = Object.fromEntries( + Object.entries(parsedManifest).map(([path, base64]) => [path, base64ToBytes(base64)]), + ); + + return { mainContent: strippedContent, shadowFiles }; +} + +function parseSvgPageDimensions(svgBytes: Uint8Array): { widthPt: number; heightPt: number } { + const head = new TextDecoder().decode(svgBytes.slice(0, 16384)); + const wAttr = /\bwidth="([\d.]+)\s*(?:px|pt)?"/i.exec(head); + const hAttr = /\bheight="([\d.]+)\s*(?:px|pt)?"/i.exec(head); + const vb = /viewBox="\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*"/i.exec(head); + let w = 960; + let h = 540; + if (wAttr && hAttr) { + w = Number.parseFloat(wAttr[1]); + h = Number.parseFloat(hAttr[1]); + } else if (vb) { + w = Number.parseFloat(vb[1]); + h = Number.parseFloat(vb[2]); + } + return { + widthPt: Math.max(1, Number.isFinite(w) ? w : 960), + heightPt: Math.max(1, Number.isFinite(h) ? h : 540), + }; +} class TypstHandler implements FormatHandler { public name: string = "typst"; @@ -9,51 +81,148 @@ class TypstHandler implements FormatHandler { public supportedFormats: FileFormat[] = [ CommonFormats.TYPST.supported("typst", true, false, true), CommonFormats.PDF.supported("pdf", false, true), - CommonFormats.SVG.supported("svg", false, true), + CommonFormats.SVG.supported("svg", true, true, false, { + category: [Category.IMAGE, Category.VECTOR, Category.DOCUMENT], + }), ]; private $typst?: TypstSnippet; async init() { - const { $typst } = await import( + const { TypstSnippet } = await import( "@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs" ); + const typst = new TypstSnippet(); - $typst.setCompilerInitOptions({ + typst.setCompilerInitOptions({ getModule: () => `${import.meta.env.BASE_URL}wasm/typst_ts_web_compiler_bg.wasm`, }); - $typst.setRendererInitOptions({ + typst.setRendererInitOptions({ getModule: () => `${import.meta.env.BASE_URL}wasm/typst_ts_renderer_bg.wasm`, }); - this.$typst = $typst; + const accessModel = new MemoryAccessModel(); + typst.use(TypstSnippet.withAccessModel(accessModel)); + + this.$typst = typst; this.ready = true; } + /** + * Converts N SVG files into a single multi-page PDF. + * Each SVG becomes one page, sized to match the first SVG's dimensions. + */ + private async svgFilesToSinglePdf( + inputFiles: FileData[], + ctx?: ConvertContext, + ): Promise { + const $typst = this.$typst!; + const { widthPt, heightPt } = parseSvgPageDimensions(inputFiles[0].bytes); + + const id = `s${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 11)}`; + const shadowPaths: string[] = []; + const imageBasenames: string[] = []; + + ctx?.log(`Mapping ${inputFiles.length} SVG slide(s) for Typst (${widthPt}×${heightPt}pt)...`); + + for (let i = 0; i < inputFiles.length; i++) { + ctx?.throwIfAborted(); + const basename = `${id}_${i}.svg`; + const absPath = `/tmp/${basename}`; + await $typst.mapShadow(absPath, inputFiles[i].bytes); + shadowPaths.push(absPath); + imageBasenames.push(basename); + } + + const body = imageBasenames + .map((basename, i) => { + const page = `#box(width: 100%, height: 100%)[#image("${basename}", width: 100%, height: 100%)]`; + return i < imageBasenames.length - 1 ? `${page}\n#pagebreak()\n` : page; + }) + .join("\n"); + + const mainContent = `#set page(margin: 0pt, width: ${widthPt}pt, height: ${heightPt}pt)\n${body}\n`; + + ctx?.progress("Compiling SVG slides to PDF...", 0.85); + ctx?.log("Compiling Typst document to PDF..."); + + try { + const pdfData = await $typst.pdf({ mainContent }); + if (!pdfData) throw new Error("Typst compilation to PDF failed."); + const baseName = inputFiles[0].name.replace(/\.[^.]+$/u, ""); + ctx?.progress("Conversion complete!", 1); + return [{ + name: `${baseName}.pdf`, + bytes: new Uint8Array(pdfData), + }]; + } finally { + for (const p of shadowPaths) { + await $typst.unmapShadow(p); + } + } + } + async doConvert( inputFiles: FileData[], - _inputFormat: FileFormat, + inputFormat: FileFormat, outputFormat: FileFormat, + _args?: string[], + ctx?: ConvertContext, ): Promise { if (!this.ready || !this.$typst) throw new Error("Handler not initialized."); + if (inputFormat.internal === "svg" && outputFormat.internal === "svg") { + return inputFiles.map(f => ({ name: f.name, bytes: f.bytes.slice() })); + } + + if (inputFormat.internal === "svg" && outputFormat.internal === "pdf") { + ctx?.progress("Starting SVG → PDF conversion...", 0); + return this.svgFilesToSinglePdf(inputFiles, ctx); + } + const outputFiles: FileData[] = []; - for (const file of inputFiles) { - const mainContent = new TextDecoder().decode(file.bytes); + for (let i = 0; i < inputFiles.length; i++) { + const file = inputFiles[i]; + const fileProgress = i / inputFiles.length; + ctx?.progress(`Processing ${file.name}...`, fileProgress); + + const { mainContent, shadowFiles } = unpackTypstAssets(new TextDecoder().decode(file.bytes)); const baseName = file.name.replace(/\.[^.]+$/u, ""); + await this.$typst.resetShadow(); + + const shadowEntries = Object.entries(shadowFiles); + if (shadowEntries.length > 0) { + ctx?.log(`Mapping ${shadowEntries.length} shadow files...`); + } + for (const [path, bytes] of shadowEntries) { + const cleanPath = path.replaceAll("\\", "/").replace(/^\/+/u, ""); + await this.$typst.mapShadow(`/${cleanPath}`, bytes); + } + + await this.$typst.mapShadow("/main.typ", new TextEncoder().encode(mainContent)); if (outputFormat.internal === "pdf") { - const pdfData = await this.$typst.pdf({ mainContent }); + ctx?.progress(`Compiling to PDF...`, fileProgress + 0.5 / inputFiles.length); + ctx?.log("Compiling Typst to PDF..."); + const pdfData = await this.$typst.pdf({ + mainFilePath: "/main.typ", + root: "/", + }); if (!pdfData) throw new Error("Typst compilation to PDF failed."); outputFiles.push({ name: `${baseName}.pdf`, bytes: new Uint8Array(pdfData), }); } else if (outputFormat.internal === "svg") { - const svgString = await this.$typst.svg({ mainContent }); + ctx?.progress(`Compiling to SVG...`, fileProgress + 0.5 / inputFiles.length); + ctx?.log("Compiling Typst to SVG..."); + const svgString = await this.$typst.svg({ + mainFilePath: "/main.typ", + root: "/", + }); outputFiles.push({ name: `${baseName}.svg`, bytes: new TextEncoder().encode(svgString), @@ -61,9 +230,9 @@ class TypstHandler implements FormatHandler { } } + ctx?.progress("Conversion complete!", 1); return outputFiles; } } export default TypstHandler; - diff --git a/src/main.new.ts b/src/main.new.ts new file mode 100644 index 00000000..e034a5b8 --- /dev/null +++ b/src/main.new.ts @@ -0,0 +1,258 @@ +import type { FileFormat, FileData, FormatHandler, ConvertPathNode } from "./FormatHandler.js"; +import handlers from "./handlers"; +import { TraversionGraph } from "./TraversionGraph.js"; +import { getOptionValues, initializeHandlerOptions } from "./HandlerOptions.js"; +import { CurrentPage, LoadingToolsText, Pages, PopupData } from "./ui/AppState.js"; +import { signal } from "@preact/signals"; +import { Mode, ModeEnum } from "./ui/ModeStore.js"; +import { ProgressStore } from "./ui/ProgressStore.js"; + +type FileRecord = Record<`${string}-${string}`, File>; + +export type ConversionOptionsMap = Map; +export type ConversionOption = ConversionOptionsMap extends Map ? [K, V] : never; + +export const ConversionOptions: ConversionOptionsMap = new Map(); + +export const SelectedFiles = signal({}); + +export function goToUploadHome(): void { + CurrentPage.value = Pages.Upload; + SelectedFiles.value = {}; +} + +export const ConversionsFromAnyInput: ConvertPathNode[] = + handlers + .filter(h => h.supportAnyInput && h.supportedFormats) + .flatMap(h => h.supportedFormats! + .filter(f => f.to) + .map(f => ({ handler: h, format: f }))); + +window.supportedFormatCache = new Map(); +window.traversionGraph = new TraversionGraph(); + +window.printSupportedFormatCache = () => { + const entries = []; + for (const entry of window.supportedFormatCache) + entries.push(entry); + return JSON.stringify(entries, null, 2); +}; + +async function buildOptionList() { + ConversionOptions.clear(); + + const totalHandlers = handlers.length; + let loadedCount = 0; + + for (const handler of handlers) { + LoadingToolsText.value = `Loading ${handler.name} (${loadedCount}/${totalHandlers}, ${ConversionOptions.size} formats)…`; + + if (!window.supportedFormatCache.has(handler.name)) { + console.warn(`Cache miss for formats of handler "${handler.name}"`); + + try { + await handler.init(); + } catch (_) { continue; } + + if (handler.supportedFormats) { + window.supportedFormatCache.set(handler.name, handler.supportedFormats); + console.info(`Updated supported format cache for "${handler.name}"`); + } + } + + const supportedFormats = window.supportedFormatCache.get(handler.name); + + if (!supportedFormats) { + console.warn(`Handler "${handler.name}" doesn't support any formats`); + continue; + } + + for (const format of supportedFormats) { + if (!format.mime) continue; + ConversionOptions.set(format, handler); + } + + loadedCount++; + } + + window.traversionGraph.init(window.supportedFormatCache, handlers); + LoadingToolsText.value = undefined; +} + +let deadEndAttempts: ConvertPathNode[][]; + +interface RouteConstraints { + forceInputHandler?: boolean; + forceOutputHandler?: boolean; + inputHandlerName?: string; + outputHandlerName?: string; +} + +async function attemptConvertPath(files: FileData[], path: ConvertPathNode[], signal?: AbortSignal) { + const pathString = path.map(c => c.format.format).join(" → "); + + for (const deadEnd of deadEndAttempts) { + let isDeadEnd = true; + for (let i = 0; i < deadEnd.length; i++) { + if (path[i] === deadEnd[i]) continue; + isDeadEnd = false; + break; + } + if (isDeadEnd) { + const deadEndString = deadEnd.slice(-2).map(c => c.format.format).join(" → "); + console.warn(`Skipping ${pathString} due to dead end near ${deadEndString}.`); + return null; + } + } + + ProgressStore.progress(`Trying ${pathString}...`, 0); + + const totalSteps = path.length - 1; + for (let i = 0; i < path.length - 1; i++) { + if (signal?.aborted) return null; + + const handler = path[i + 1].handler; + const ctx = ProgressStore.createContext(handler.name, signal); + + try { + let supportedFormats = window.supportedFormatCache.get(handler.name); + + if (!handler.ready) { + ctx.log(`Initializing ${handler.name}...`); + await handler.init(); + if (!handler.ready) throw `Handler "${handler.name}" not ready after init.`; + if (handler.supportedFormats) { + window.supportedFormatCache.set(handler.name, handler.supportedFormats); + supportedFormats = handler.supportedFormats; + } + } + + if (!supportedFormats) throw `Handler "${handler.name}" doesn't support any formats.`; + + const inputFormat = supportedFormats.find(c => + c.from + && c.mime === path[i].format.mime + && c.format === path[i].format.format + ) || (handler.supportAnyInput ? path[i].format : undefined); + + if (!inputFormat) throw `Handler "${handler.name}" doesn't support the "${path[i].format.format}" format.`; + + ctx.log(`Plugin call: ${handler.name} | from=${path[i].format.format} (${path[i].format.mime}) | to=${path[i + 1].format.format} (${path[i + 1].format.mime})`); + ctx.log(`Plugin options: ${JSON.stringify(getOptionValues(handler))}`, "debug"); + ctx.log(`Converting ${path[i].format.format} → ${path[i + 1].format.format}`); + ProgressStore.progress(`${handler.name}: ${path[i].format.format} → ${path[i + 1].format.format}`, i / totalSteps); + + files = (await Promise.all([ + handler.doConvert(files, inputFormat, path[i + 1].format, undefined, ctx), + new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ]))[0]; + + ctx.log(`Plugin done: ${handler.name} | from=${path[i].format.format} (${path[i].format.mime}) | to=${path[i + 1].format.format} (${path[i + 1].format.mime})`); + ctx.log(`Step ${i + 1}/${totalSteps} complete`); + if (files.some(c => !c.bytes.length)) throw "Output is empty."; + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") { + throw e; + } + + console.log(path.map(c => c.format.format)); + console.error(handler.name, `${path[i].format.format} → ${path[i + 1].format.format}`, e); + + const deadEndPath = path.slice(0, i + 2); + deadEndAttempts.push(deadEndPath); + window.traversionGraph.addDeadEndPath(path.slice(0, i + 2)); + + ctx.log(`Dead end: ${path[i].format.format} → ${path[i + 1].format.format}`); + ProgressStore.progress("Looking for a valid path...", 0); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + return null; + } + } + + ProgressStore.logs.value = [ + ...ProgressStore.logs.value, + { + timestamp: Date.now(), + plugin: "Router", + message: `Route done: ${path.map(c => `${c.handler.name}:${c.format.format}`).join(" -> ")}`, + level: "log" + } + ]; + return { files, path }; +} + +window.tryConvertByTraversing = async function ( + files: FileData[], + from: ConvertPathNode, + to: ConvertPathNode, + signal?: AbortSignal, + constraints?: RouteConstraints +) { + deadEndAttempts = []; + window.traversionGraph.clearDeadEndPaths(); + const simpleMode = Mode.value === ModeEnum.Simple; + let searchedPaths = 0; + for await (const path of window.traversionGraph.searchPath(from, to, simpleMode, (iterations, title) => { + ProgressStore.progress(title ?? `Finding route... (Checked ${iterations} paths)`, 0); + })) { + searchedPaths++; + if (searchedPaths % 8 === 0) { + ProgressStore.progress(`Finding route... (Checked ${searchedPaths} paths)`, 0); + } + if (signal?.aborted) return null; + if (path.at(-1)?.handler.name === to.handler.name) { + path[path.length - 1] = to; + } + const attempt = await attemptConvertPath(files, path, signal); + if (attempt) return attempt; + } + return null; +}; + +window.previewConvertPath = async function ( + from: ConvertPathNode, + to: ConvertPathNode, + simpleMode: boolean, + constraints?: RouteConstraints +) { + for await (const path of window.traversionGraph.searchPath(from, to, simpleMode, () => {})) { + if (path.at(-1)?.handler.name === to.handler.name) { + path[path.length - 1] = to; + } + return path; + } + return null; +}; + +export function downloadFile(bytes: Uint8Array, name: string, mime: string) { + const blob = new Blob([bytes as BlobPart], { type: mime }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = name; + link.click(); +} + +async function initSupportedFormats() { + try { + initializeHandlerOptions(handlers); + try { + const cacheJSON = await fetch("cache.json").then(r => r.json()); + window.supportedFormatCache = new Map(cacheJSON); + } catch { + console.warn( + "Missing supported format precache.\n\n" + + "Consider saving the output of printSupportedFormatCache() to cache.json." + ); + } + await buildOptionList(); + console.log("Built initial format list."); + } catch (e) { + console.error(e); + LoadingToolsText.value = "Could not load formats."; + } +} + +void initSupportedFormats(); + +console.debug(ConversionOptions); diff --git a/src/main.ts b/src/main.ts index 0298e2eb..d87017ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import handlers from "./handlers"; import { TraversionGraph } from "./TraversionGraph.js"; /** Files currently selected for conversion */ -let selectedFiles: File[] = []; +export let selectedFiles: File[] = []; /** * Whether to use "simple" mode. * - In **simple** mode, the input/output lists are grouped by file format. @@ -309,10 +309,10 @@ ui.modeToggleButton.addEventListener("click", () => { simpleMode = !simpleMode; if (simpleMode) { ui.modeToggleButton.textContent = "Advanced mode"; - document.body.style.setProperty("--highlight-color", "#1C77FF"); + document.body.style.setProperty("--primary", "#1C77FF"); } else { ui.modeToggleButton.textContent = "Simple mode"; - document.body.style.setProperty("--highlight-color", "#FF6F1C"); + document.body.style.setProperty("--primary", "#FF6F1C"); } buildOptionList(); }); diff --git a/src/normalizeMimeType.ts b/src/normalizeMimeType.ts index ace45f4f..09bf7a94 100644 --- a/src/normalizeMimeType.ts +++ b/src/normalizeMimeType.ts @@ -1,5 +1,9 @@ function normalizeMimeType (mime: string) { switch (mime) { + case "audio/mid": return "audio/midi"; + case "audio/x-mid": return "audio/midi"; + case "audio/x-midi": return "audio/midi"; + case "audio/sp-midi": return "audio/midi"; case "audio/x-wav": return "audio/wav"; case "audio/vnd.wave": return "audio/wav"; case "application/ogg": return "audio/ogg"; diff --git a/src/ui/AppState.ts b/src/ui/AppState.ts new file mode 100644 index 00000000..0b9d87b1 --- /dev/null +++ b/src/ui/AppState.ts @@ -0,0 +1,20 @@ +import { signal } from "@preact/signals"; + +import type { PopupDataContainer } from "./PopupStore"; + +export const enum Pages { + Upload = "uploadPage", + Conversion = "conversionPage" +} + +export const CurrentPage = signal(Pages.Upload); + +export const PopupData = signal({ + title: "Loading tools...", + text: "Please wait while the app loads conversion tools.", + dismissible: false, + buttonText: "Ignore" +}); + +export const LoadingToolsText = signal("Loading formats…"); +export const ConversionInProgress = signal(false); diff --git a/src/ui/FormatCategories.ts b/src/ui/FormatCategories.ts new file mode 100644 index 00000000..eae5e605 --- /dev/null +++ b/src/ui/FormatCategories.ts @@ -0,0 +1,32 @@ +import { signal } from "@preact/signals" +import { Category } from "src/CommonFormats" +import type { ComponentType } from "preact" + +export type CategoryEnum = typeof Category[keyof typeof Category] | 'all' + +export type FormatCategory = { + id: CategoryEnum + categoryText: string +} + +export const SelectedCategories = signal>(new Set()); + +export function toggleCategory(id: CategoryEnum) { + const current = new Set(SelectedCategories.value); + if (id === "all") { + current.clear(); + } else if (current.has(id)) { + current.delete(id); + } else { + current.add(id); + } + SelectedCategories.value = current; +} + +export function clearCategories() { + SelectedCategories.value = new Set(); +} + +export function hasActiveFilters(): boolean { + return SelectedCategories.value.size > 0; +} diff --git a/src/ui/ModeStore.ts b/src/ui/ModeStore.ts new file mode 100644 index 00000000..9b38c18a --- /dev/null +++ b/src/ui/ModeStore.ts @@ -0,0 +1,43 @@ +import { signal } from "@preact/signals"; + +const STORAGE_KEY = "mode"; + +export enum ModeEnum { + Simple, + Advanced +} + +export const enum ModeText { + Simple = "Simple mode", + Advanced = "Advanced mode" +} + +function getInitialMode(): ModeEnum { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return ModeEnum.Simple; + const parsed = parseInt(stored, 10); + if (parsed === ModeEnum.Simple || parsed === ModeEnum.Advanced) return parsed; + return ModeEnum.Simple; +} + +export const Mode = signal(getInitialMode()); + +function applyMode(value: ModeEnum) { + if (value === ModeEnum.Simple) document.documentElement.style.setProperty("--primary", "#1C77FF"); + if (value === ModeEnum.Advanced) document.documentElement.style.setProperty("--primary", "#FF6F1C"); +} + +Mode.subscribe((value) => { + localStorage.setItem(STORAGE_KEY, value.toString()); + applyMode(value); +}) + +export function toggleMode() { + Mode.value = Mode.value === ModeEnum.Advanced + ? ModeEnum.Simple + : ModeEnum.Advanced; +} + +export function initMode() { + applyMode(Mode.value); +} diff --git a/src/ui/PopupStore.ts b/src/ui/PopupStore.ts new file mode 100644 index 00000000..92b0a4eb --- /dev/null +++ b/src/ui/PopupStore.ts @@ -0,0 +1,37 @@ +import { signal } from '@preact/signals' + +export interface PopupDataContainer { + /** Title of the popup */ + title?: string + /** The description text of the popup */ + text?: string + /** + * Is the popup soft-dismissible? + * + * If this is false, the modal must be closed programmatically, or else it'll be stuck blocking input + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#popover_api_html_attributes + */ + dismissible?: boolean + /** Text for the button. If this is undefined, the popup will hide the button */ + buttonText?: string + /** The event handler for the button. If this is just `true`, clicking the button will close the modal */ + buttonOnClick?: preact.MouseEventHandler | true + /** + * Raw contents of the popup. Can be any arbitrary JSX data. + * + * If this is declared, properties `title` and `text` are ignored + */ + contents?: preact.JSX.Element +} + +export const popupOpen = signal(false); + +export const openPopup = () => (popupOpen.value = true); +export const closePopup = () => (popupOpen.value = false); +export const togglePopup = () => (popupOpen.value = !popupOpen.value); + +// Window exports +window.openPopup = openPopup; +window.closePopup = closePopup; +window.togglePopup = togglePopup; diff --git a/src/ui/ProgressStore.ts b/src/ui/ProgressStore.ts new file mode 100644 index 00000000..4fa021a1 --- /dev/null +++ b/src/ui/ProgressStore.ts @@ -0,0 +1,61 @@ +import { signal } from "@preact/signals"; + +export type LogLevel = "log" | "error" | "debug" | "warn"; + +export interface LogEntry { + timestamp: number; + plugin: string; + message: string; + level: LogLevel; +} + +export interface ConvertContext { + progress: (message: string, value: number | ((prev: number) => number)) => void; + log: (message: string, level?: LogLevel) => void; + signal: AbortSignal; + throwIfAborted: () => void; +} + +export const ProgressStore = { + percent: signal(0), + message: signal(""), + logs: signal([]), + controller: new AbortController(), + + reset() { + this.percent.value = 0; + this.message.value = ""; + this.logs.value = []; + this.controller = new AbortController(); + }, + + abort() { + this.controller.abort(); + }, + + progress(message: string, percent: number) { + this.message.value = message; + this.percent.value = Math.max(0, Math.min(1, percent)); + }, + + createContext(pluginName: string, userSignal?: AbortSignal): ConvertContext { + const parentSignal = userSignal ?? this.controller.signal; + return { + progress: (msg, val) => { + this.message.value = msg; + const nextVal = typeof val === "function" ? val(this.percent.value) : val; + this.percent.value = Math.max(0, Math.min(1, nextVal)); + }, + log: (msg, level = "log") => { + this.logs.value = [ + ...this.logs.value, + { timestamp: Date.now(), plugin: pluginName, message: msg, level } + ]; + }, + signal: parentSignal, + throwIfAborted() { + if (parentSignal.aborted) throw new DOMException("Conversion cancelled", "AbortError"); + } + }; + } +}; diff --git a/src/ui/ThemeStore.ts b/src/ui/ThemeStore.ts new file mode 100644 index 00000000..981310f7 --- /dev/null +++ b/src/ui/ThemeStore.ts @@ -0,0 +1,27 @@ +import { signal } from "@preact/signals"; + +export type Theme = "light" | "dark"; + +function getSystemTheme(): Theme { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +export const theme = signal(getSystemTheme()); + +function applyTheme(value: Theme) { + document.documentElement.dataset.theme = value; +} + +theme.subscribe((value) => { + applyTheme(value); +}); + +export function initTheme() { + applyTheme(theme.value); + + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { + theme.value = e.matches ? "dark" : "light"; + }); +} \ No newline at end of file diff --git a/src/ui/components/AdvancedModeToggle/index.tsx b/src/ui/components/AdvancedModeToggle/index.tsx new file mode 100644 index 00000000..a24be10a --- /dev/null +++ b/src/ui/components/AdvancedModeToggle/index.tsx @@ -0,0 +1,48 @@ +import { Mode, ModeEnum, toggleMode } from "src/ui/ModeStore"; +import { Wrench } from "lucide-preact"; +import tippy from "tippy.js"; +import "tippy.js/dist/tippy.css"; +import { useEffect, useRef } from "preact/hooks"; +import StyledButton, { ButtonVariant, ButtonSize } from "src/ui/components/StyledButton"; + +interface AdvancedModeToggleProps { + compact?: boolean; +} + +export default function AdvancedModeToggle({ compact = true }: Readonly) { + const btnRef = useRef(null); + + useEffect(() => { + if (!btnRef.current) return; + const instance = tippy(btnRef.current, { + content: `Switch to ${Mode.value === ModeEnum.Advanced ? "Simple" : "Advanced"} mode`, + placement: "bottom", + delay: [300, 0], + }); + return () => instance.destroy(); + }, [Mode.value]); + + const handleClick = () => { + toggleMode(); + }; + + const isAdvanced = Mode.value === ModeEnum.Advanced; + + return ( + + {compact ? ( + + ) : ( + <> + + {isAdvanced ? " Simple mode" : " Advanced mode"} + + )} + + ); +} diff --git a/src/ui/components/Chip/index.css b/src/ui/components/Chip/index.css new file mode 100644 index 00000000..ff50c94d --- /dev/null +++ b/src/ui/components/Chip/index.css @@ -0,0 +1,51 @@ +.chip { + appearance: none; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 6px; + border: 1px solid var(--border-transparent); + background: var(--bg-section); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 150ms ease; + user-select: none; + white-space: nowrap; + + .chip-icon { + display: flex; + align-items: center; + color: var(--text-muted); + transition: color 150ms; + } + + .chip-label { + line-height: 1; + } + + &:hover { + background: var(--bg-light); + color: var(--text-primary); + + .chip-icon { + color: var(--text-secondary); + } + } + + &.chip-active { + background: var(--primary); + border-color: var(--primary); + color: var(--text-on-primary); + + .chip-icon { + color: var(--text-on-primary); + } + + &:hover { + opacity: 0.9; + } + } +} diff --git a/src/ui/components/Chip/index.tsx b/src/ui/components/Chip/index.tsx new file mode 100644 index 00000000..bf95e732 --- /dev/null +++ b/src/ui/components/Chip/index.tsx @@ -0,0 +1,21 @@ +import "./index.css"; + +interface ChipProps { + label: string; + icon?: preact.ComponentChildren; + selected?: boolean; + onClick?: () => void; +} + +export default function Chip({ label, icon, selected = false, onClick }: ChipProps) { + return ( + + ); +} diff --git a/src/ui/components/Conversion/ConversionHeader/index.css b/src/ui/components/Conversion/ConversionHeader/index.css new file mode 100644 index 00000000..1a3f8de6 --- /dev/null +++ b/src/ui/components/Conversion/ConversionHeader/index.css @@ -0,0 +1,39 @@ +.conversion-header { + height: 56px; + background: var(--bg-card); + border-block-end: 1px solid var(--border-primary); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.25rem; + z-index: 20; + flex-shrink: 0; + position: sticky; + top: 0; + gap: 1rem; + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; + + .header-step-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, transparent); + padding: 0.2rem 0.55rem; + border-radius: 4px; + } + } + + .header-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + } +} diff --git a/src/ui/components/Conversion/ConversionHeader/index.tsx b/src/ui/components/Conversion/ConversionHeader/index.tsx new file mode 100644 index 00000000..92748827 --- /dev/null +++ b/src/ui/components/Conversion/ConversionHeader/index.tsx @@ -0,0 +1,25 @@ +import Logo from "src/ui/components/Logo"; +import AdvancedModeToggle from "src/ui/components/AdvancedModeToggle"; +import { goToUploadHome } from "src/main.new"; + +import "./index.css"; + +interface ConversionHeaderProps { + stepLabel?: string; + logoDisabled?: boolean; +} + +export default function ConversionHeader({ stepLabel, logoDisabled = false }: ConversionHeaderProps) { + return ( +
+
+ + {stepLabel && {stepLabel}} +
+ +
+ +
+
+ ); +} diff --git a/src/ui/components/Conversion/FormatCard/index.css b/src/ui/components/Conversion/FormatCard/index.css new file mode 100644 index 00000000..c08cecc4 --- /dev/null +++ b/src/ui/components/Conversion/FormatCard/index.css @@ -0,0 +1,105 @@ +.format-card { + appearance: none; + background: var(--bg-card); + border: 1.5px solid var(--border-primary); + border-radius: 10px; + padding: 0.65rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + text-align: left; + font: inherit; + cursor: pointer; + transition: border-color 120ms, box-shadow 120ms; + + &:hover { + border-color: var(--primary); + } + + &.active { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 20%, transparent); + } + + .format-card-row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .format-card-text { + display: flex; + flex-direction: column; + min-width: 0; + } + + .format-card-check { + margin-inline-start: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + color: var(--primary); + } + + .format-card-check-inner { + display: inline-flex; + align-items: center; + justify-content: center; + transform: scale(0.85); + opacity: 0; + transition: transform 120ms ease, opacity 120ms ease; + will-change: transform, opacity; + } + + .format-card-check-inner.is-on { + transform: scale(1); + opacity: 1; + } + + .format-card-ext { + font-size: 0.85rem; + font-weight: 800; + color: var(--text-primary); + line-height: 1.2; + letter-spacing: 0.02em; + } + + .format-card-name { + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; + } + + .format-card-meta { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + padding-top: 0.15rem; + } + + .format-card-mime, + .format-card-plugin { + font-size: 0.6rem; + padding: 1px 5px; + border-radius: 3px; + line-height: 1.4; + } + + .format-card-mime { + background: var(--bg-section); + color: var(--text-tertiary); + } + + .format-card-plugin { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-weight: 600; + } +} diff --git a/src/ui/components/Conversion/FormatCard/index.tsx b/src/ui/components/Conversion/FormatCard/index.tsx new file mode 100644 index 00000000..1e6756eb --- /dev/null +++ b/src/ui/components/Conversion/FormatCard/index.tsx @@ -0,0 +1,56 @@ +import type { ConversionOption } from "src/main.new"; +import FileIcon from "src/ui/components/FileIcon"; +import { Check } from "lucide-preact"; +import "./index.css"; + +interface FormatCardProps { + conversionOption: ConversionOption; + id: string; + selected: boolean; + onSelect: (id: string) => void; + advanced?: boolean; +} + +export default function FormatCard({ conversionOption, id, selected, onSelect, advanced = false }: FormatCardProps) { + const [format, handler] = conversionOption; + + const cleanName = advanced + ? format.name + : format.name + .split("(").join(")").split(")") + .filter((_, i) => i % 2 === 0) + .filter(c => c !== "") + .join(" ") + .trim(); + + return ( + + ); +} diff --git a/src/ui/components/Conversion/FormatExplorer/index.css b/src/ui/components/Conversion/FormatExplorer/index.css new file mode 100644 index 00000000..f07d48d0 --- /dev/null +++ b/src/ui/components/Conversion/FormatExplorer/index.css @@ -0,0 +1,142 @@ +.format-explorer { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +.format-browser { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-page); +} + +.search-container { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + + .search-input-wrapper { + position: relative; + width: 100%; + + .search-icon { + position: absolute; + left: 0.85rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; + } + + input { + width: 100%; + padding: 0.6rem 2.2rem 0.6rem 2.4rem; + border-radius: 6px; + border: 1px solid var(--border-transparent); + color: var(--text-primary); + background-color: var(--bg-section); + outline: none; + font-size: 0.875rem; + transition: all 150ms; + + &::placeholder { + color: var(--text-muted); + } + + &:focus { + background-color: var(--bg-card); + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .search-clear { + position: absolute; + right: 0.6rem; + top: 50%; + transform: translateY(-50%); + appearance: none; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + color: var(--text-primary); + background: var(--bg-section); + } + } + } +} + +.chip-filters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.format-list-container { + flex: 1; + padding: 0 1.25rem 1.25rem; + overflow-y: auto; + + .list-header { + display: flex; + justify-content: space-between; + margin-bottom: 0.65rem; + + span { + font-size: 0.7rem; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + } +} + +.format-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.5rem; +} + +.no-results { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 3rem 1rem; + text-align: center; + + p { + font-size: 0.9rem; + color: var(--text-muted); + font-weight: 500; + } + + .clear-filters-btn { + appearance: none; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-card); + color: var(--primary); + padding: 0.45rem 1rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: all 150ms; + + &:hover { + background: var(--primary); + color: var(--text-on-primary); + border-color: var(--primary); + } + } +} diff --git a/src/ui/components/Conversion/FormatExplorer/index.tsx b/src/ui/components/Conversion/FormatExplorer/index.tsx new file mode 100644 index 00000000..d0dec2bb --- /dev/null +++ b/src/ui/components/Conversion/FormatExplorer/index.tsx @@ -0,0 +1,275 @@ +import FormatCard from "src/ui/components/Conversion/FormatCard"; +import Chip from "src/ui/components/Chip"; +import { Search, X } from "lucide-preact"; +import { + Image, Video, Music, Archive, FileText, Code, + Type, BarChart3, Presentation, Database +} from "lucide-preact"; +import { useDebouncedCallback } from "use-debounce"; + +import "./index.css"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import type { FileFormat } from "src/FormatHandler"; +import type { ConversionOption, ConversionOptionsMap } from "src/main.new"; +import { Mode, ModeEnum } from "src/ui/ModeStore"; +import FromTo from "src/ui/components/Conversion/FromTo"; +import { + SelectedCategories, + toggleCategory, + clearCategories, + hasActiveFilters, + type CategoryEnum +} from "src/ui/FormatCategories"; +import { Category } from "src/CommonFormats"; + +interface FormatExplorerProps { + conversionOptions: ConversionOptionsMap; + onSelect?: (format: ConversionOption | null) => void; + debounceWaitMs?: number; + filterDirection?: "from" | "to"; + fromOption?: ConversionOption | null; + toOption?: ConversionOption | null; + fromCount?: number; + toCount?: number; + onClickFrom?: () => void; + onClickTo?: () => void; +} + +type SearchIndex = Map; + +function formatExplorerRowKey(file: FileFormat, handlerName: string): string { + return [ + handlerName, + file.internal, + file.mime, + file.format, + file.extension, + String(file.from), + String(file.to), + file.name, + ].join("\0"); +} + +function matchesFormatSearch(option: ConversionOption, termLower: string): boolean { + if (termLower === "") return true; + const [file, handler] = option; + return ( + file.name.toLowerCase().includes(termLower) + || file.format.toLowerCase().includes(termLower) + || file.extension.toLowerCase().includes(termLower) + || file.mime.toLowerCase().includes(termLower) + || file.internal.toLowerCase().includes(termLower) + || handler.name.toLowerCase().includes(termLower) + ); +} + +const CATEGORY_CHIPS: Array<{ id: CategoryEnum; label: string; icon: preact.ComponentChildren }> = [ + { id: Category.IMAGE, label: "Image", icon: }, + { id: Category.VIDEO, label: "Video", icon: