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
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
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 (
+
+ );
+}
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: },
+ { id: Category.AUDIO, label: "Audio", icon: },
+ { id: Category.DOCUMENT, label: "Document", icon: },
+ { id: Category.ARCHIVE, label: "Archive", icon: },
+ { id: Category.TEXT, label: "Text", icon: },
+ { id: Category.CODE, label: "Code", icon: },
+ { id: Category.DATA, label: "Data", icon: },
+ { id: Category.VECTOR, label: "Vector", icon: },
+ { id: Category.SPREADSHEET, label: "Spreadsheet", icon: },
+ { id: Category.FONT, label: "Font", icon: },
+];
+
+function generateSearchIndex(optionsMap: ConversionOptionsMap, advancedMode: boolean, direction: "from" | "to"): SearchIndex {
+ const index: SearchIndex = new Map();
+ const seen = new Set();
+
+ for (const [file, handler] of optionsMap) {
+ if (direction === "from" && !file.from) continue;
+ if (direction === "to" && !file.to) continue;
+
+ const dedupeKey = `${file.mime}|${file.format}`;
+ const id = formatExplorerRowKey(file, handler.name);
+ if (advancedMode) {
+ index.set(id, [file, handler]);
+ } else {
+ if (!seen.has(dedupeKey)) {
+ seen.add(dedupeKey);
+ index.set(id, [file, handler]);
+ }
+ }
+ }
+
+ return index;
+}
+
+function filterByCategories(options: SearchIndex, categories: Set): SearchIndex {
+ if (categories.size === 0) return options;
+
+ const filtered: SearchIndex = new Map();
+ for (const [key, pair] of options) {
+ const cat = pair[0].category;
+ if (typeof cat === "string" && categories.has(cat as CategoryEnum)) {
+ filtered.set(key, pair);
+ } else if (Array.isArray(cat)) {
+ for (const c of cat) {
+ if (categories.has(c as CategoryEnum)) {
+ filtered.set(key, pair);
+ break;
+ }
+ }
+ }
+ }
+ return filtered;
+}
+
+function filterByTerm(options: SearchIndex, term: string): SearchIndex {
+ if (term === "") return options;
+ const filtered: SearchIndex = new Map();
+ const t = term.toLowerCase();
+ for (const [key, pair] of options) {
+ if (matchesFormatSearch(pair, t)) filtered.set(key, pair);
+ }
+ return filtered;
+}
+
+export default function FormatExplorer({
+ conversionOptions,
+ onSelect,
+ debounceWaitMs = 200,
+ filterDirection = "to",
+ fromOption,
+ toOption,
+ fromCount,
+ toCount,
+ onClickFrom,
+ onClickTo,
+}: FormatExplorerProps) {
+ const isAdvanced = Mode.value === ModeEnum.Advanced;
+
+ const originalIndex = useMemo(
+ () => generateSearchIndex(conversionOptions, isAdvanced, filterDirection),
+ [conversionOptions, isAdvanced, filterDirection]
+ );
+
+ const [searchTerm, setSearchTerm] = useState("");
+ const [searchInputValue, setSearchInputValue] = useState("");
+ const searchInputRef = useRef(null);
+
+ const activeCategories = SelectedCategories.value;
+
+ const searchResultsIndex = useMemo(
+ () => filterByTerm(filterByCategories(originalIndex, activeCategories), searchTerm.toLowerCase()),
+ [originalIndex, searchTerm, activeCategories]
+ );
+
+ const [selectedOptionId, setSelectedOptionId] = useState(null);
+
+ const handleDebounceSearch = useDebouncedCallback((term: string) => {
+ setSearchTerm(term);
+ }, debounceWaitMs);
+
+ const handleOptionSelection = (id: string, option: ConversionOption) => {
+ if (id === selectedOptionId) {
+ setSelectedOptionId(null);
+ onSelect?.(null);
+ return;
+ }
+ setSelectedOptionId(id);
+ onSelect?.(option);
+ };
+
+ const handleClearFilters = () => {
+ clearCategories();
+ setSearchTerm("");
+ setSearchInputValue("");
+ };
+
+ const noResults = searchResultsIndex.size === 0;
+ const filtersActive = hasActiveFilters() || searchTerm !== "";
+
+ useEffect(() => {
+ setSelectedOptionId(null);
+ }, [filterDirection]);
+
+ return (
+
+
+
+
onClickFrom?.()}
+ onClickTo={() => {
+ onClickTo?.();
+ requestAnimationFrame(() => searchInputRef.current?.focus());
+ }}
+ />
+
+
+ {
+ const val = ev.currentTarget.value;
+ setSearchInputValue(val);
+ handleDebounceSearch(val);
+ }}
+ />
+ {searchInputValue && (
+
+ )}
+
+
+
+ {CATEGORY_CHIPS.map(chip => (
+ toggleCategory(chip.id)}
+ />
+ ))}
+
+
+
+
+
+ {searchResultsIndex.size} format{searchResultsIndex.size !== 1 ? "s" : ""}
+
+
+ {noResults ? (
+
+
No formats found
+ {filtersActive && (
+
+ )}
+
+ ) : (
+
+ {Array.from(searchResultsIndex).map(([key, option]) => (
+ handleOptionSelection(key, option)}
+ conversionOption={option}
+ id={key}
+ key={key}
+ advanced={isAdvanced}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/components/Conversion/FromTo/index.css b/src/ui/components/Conversion/FromTo/index.css
new file mode 100644
index 00000000..6baa036a
--- /dev/null
+++ b/src/ui/components/Conversion/FromTo/index.css
@@ -0,0 +1,67 @@
+.fromto {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.85rem;
+ padding: 0.15rem 0;
+ /* Properly balance out */
+ margin-bottom: .5rem;
+}
+
+.fromto-arrow {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+}
+
+.fromto-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ height: 46px;
+ padding: 0 0.95rem;
+ border-radius: 10px;
+ border: 1px solid var(--border-transparent);
+ background: var(--bg-section);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 150ms;
+ min-width: 160px;
+ justify-content: center;
+}
+
+.fromto-pill:hover {
+ background: var(--bg-card);
+ border-color: var(--border-primary);
+}
+
+.fromto-pill:active {
+ transform: translateY(0.5px);
+}
+
+.fromto-pill:focus-visible {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent);
+}
+
+.fromto-ext {
+ font-size: 1.05rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.fromto-pill.is-placeholder {
+ background: color-mix(in srgb, var(--bg-section) 60%, transparent);
+ border-style: dashed;
+ border-color: color-mix(in srgb, var(--text-muted) 40%, transparent);
+}
+
+.fromto-count {
+ font-size: 0.95rem;
+ font-weight: 650;
+ letter-spacing: 0.01em;
+ color: var(--text-muted);
+}
+
diff --git a/src/ui/components/Conversion/FromTo/index.tsx b/src/ui/components/Conversion/FromTo/index.tsx
new file mode 100644
index 00000000..33f46c6d
--- /dev/null
+++ b/src/ui/components/Conversion/FromTo/index.tsx
@@ -0,0 +1,67 @@
+import { ArrowRight } from "lucide-preact";
+import type { ConversionOption } from "src/main.new";
+import FileIcon from "src/ui/components/FileIcon";
+import "./index.css";
+
+interface FromToProps {
+ fromOption: ConversionOption | null;
+ toOption: ConversionOption | null;
+ fromCount: number;
+ toCount: number;
+ onClickFrom: () => void;
+ onClickTo: () => void;
+}
+
+function ExtPill({
+ option,
+ placeholder,
+ count,
+ kind,
+ onClick,
+}: {
+ option: ConversionOption | null;
+ placeholder: boolean;
+ count: number;
+ kind: "input" | "output";
+ onClick: () => void;
+}) {
+ const ext = option?.[0].extension?.toUpperCase();
+ const mime = option?.[0].mime;
+ const label = `${count} ${kind} format${count === 1 ? "" : "s"}`;
+
+ return (
+
+ );
+}
+
+export default function FromTo({ fromOption, toOption, fromCount, toCount, onClickFrom, onClickTo }: FromToProps) {
+ return (
+
+ );
+}
+
diff --git a/src/ui/components/Conversion/HandlerOptionsModal/index.css b/src/ui/components/Conversion/HandlerOptionsModal/index.css
new file mode 100644
index 00000000..8a92d431
--- /dev/null
+++ b/src/ui/components/Conversion/HandlerOptionsModal/index.css
@@ -0,0 +1,450 @@
+/* Overlay */
+.handler-options-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 90;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ background: color-mix(in srgb, var(--bg-overlay) 40%, transparent);
+ backdrop-filter: blur(8px);
+}
+
+/* Modal Container */
+.handler-options-modal {
+ width: min(600px, 100%);
+ max-height: min(90vh, 720px);
+ display: flex;
+ flex-direction: column;
+ border-radius: 8px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-primary);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--border-primary) 30%, transparent),
+ 0 8px 24px color-mix(in srgb, var(--bg-overlay) 10%, transparent);
+ overflow: hidden;
+}
+
+/* Header - Sticky */
+.handler-options-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--border-primary);
+ background: var(--bg-card);
+ flex-shrink: 0;
+}
+
+.handler-options-modal-title-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.handler-options-modal-title-wrap h3 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.4;
+ margin: 0;
+}
+
+.handler-options-modal-title-wrap p {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ line-height: 1.5;
+ margin: 0;
+}
+
+.handler-options-modal-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ flex-shrink: 0;
+}
+
+.handler-modal-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--text-muted);
+ cursor: pointer;
+ outline: none;
+ transition: none;
+}
+
+.handler-modal-icon-btn:hover {
+ background: var(--bg-section);
+ color: var(--text-secondary);
+}
+
+.handler-modal-icon-btn:active {
+ background: var(--bg-card-alt-hover);
+}
+
+.handler-modal-icon-btn:focus-visible {
+ box-shadow: 0 0 0 2px var(--primary-transparent);
+}
+
+/* Settings Sections - Collapsible */
+.conversion-settings-section {
+ display: flex;
+ flex-direction: column;
+ border-bottom: 1px solid var(--border-primary);
+ overflow: hidden;
+}
+
+.conversion-settings-section:last-child {
+ border-bottom: none;
+}
+
+.conversion-settings-section.expanded {
+ flex: 1;
+ min-height: 0;
+}
+
+.conversion-settings-section.collapsed {
+ flex: 0 0 auto;
+}
+
+.conversion-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.5rem;
+ background: var(--bg-section);
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+}
+
+.conversion-section-header:hover {
+ background: var(--bg-card-alt-hover);
+}
+
+.conversion-settings-section.collapsed .conversion-section-header:hover {
+ background: color-mix(in srgb, var(--primary-transparent) 10%, var(--bg-section));
+}
+
+.conversion-section-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.handler-role-label {
+ font-weight: 500;
+ color: var(--text-muted);
+ font-size: 0.7rem;
+ opacity: 0.8;
+}
+
+.conversion-settings-section.expanded .conversion-section-title {
+ color: var(--primary);
+}
+
+.conversion-section-header .reset-btn {
+ opacity: 0.6;
+}
+
+.conversion-section-header .reset-btn:hover {
+ opacity: 1;
+ background: transparent;
+}
+
+/* Scrollable Content Area */
+.conversion-option-list {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+ background: var(--bg-card);
+}
+
+.conversion-settings-section.collapsed .conversion-option-list {
+ display: none;
+}
+
+/* Empty State */
+.conversion-settings-empty {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ padding: 3rem 1.5rem;
+ text-align: center;
+ line-height: 1.6;
+}
+
+/* Option Item */
+.conversion-option-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 1rem 1.5rem;
+ border-bottom: 1px solid var(--border-primary);
+ position: relative;
+}
+
+.conversion-option-item:last-child {
+ border-bottom: none;
+}
+
+.conversion-option-item[data-changed="true"] {
+ /* Highlight changed opts */
+ background: linear-gradient(-125deg, var(--primary-transparent) 0%, transparent 10%);
+}
+
+/* Option Header */
+.conversion-option-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.conversion-option-name {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.5;
+}
+
+.conversion-option-description {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ line-height: 1.6;
+}
+
+/* Base Input Styles */
+.conversion-option-item input:not([type="checkbox"]):not([type="range"]),
+.conversion-option-item textarea,
+.conversion-option-item select {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-input);
+ color: var(--text-primary);
+ outline: none;
+ font-size: 0.8125rem;
+ font-weight: 400;
+ line-height: 1.5;
+ border: 1px solid var(--border-primary);
+ border-radius: 5px;
+}
+
+.conversion-option-item input:not([type="checkbox"]):not([type="range"]):hover,
+.conversion-option-item textarea:hover,
+.conversion-option-item select:hover {
+ border-color: var(--border-secondary);
+}
+
+.conversion-option-item input:not([type="checkbox"]):not([type="range"]):focus,
+.conversion-option-item textarea:focus,
+.conversion-option-item select:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-transparent);
+}
+
+.conversion-option-item input::placeholder,
+.conversion-option-item textarea::placeholder {
+ color: var(--text-muted);
+}
+
+/* Select Dropdown */
+.conversion-option-item select {
+ cursor: pointer;
+}
+
+/* Textarea */
+.handler-option-textarea {
+ resize: vertical;
+ min-height: 100px;
+ font-family: inherit;
+}
+
+/* Toggle Control */
+.handler-option-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ user-select: none;
+}
+
+.handler-option-toggle input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+ border: 1.5px solid var(--border-secondary);
+ border-radius: 4px;
+ cursor: pointer;
+ margin: 0;
+ flex-shrink: 0;
+}
+
+.handler-option-toggle input[type="checkbox"]:hover {
+ border-color: var(--primary);
+}
+
+.handler-option-toggle input[type="checkbox"]:checked {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+.handler-option-toggle input[type="checkbox"]:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--primary-transparent);
+}
+
+/* Number Control */
+.handler-option-number {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.handler-option-number input[type="range"] {
+ width: 100%;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--bg-section);
+ outline: none;
+ cursor: pointer;
+}
+
+.handler-option-number input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary);
+ cursor: pointer;
+ border: 2px solid var(--bg-card);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.handler-option-number input[type="range"]::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary);
+ cursor: pointer;
+ border: 2px solid var(--bg-card);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.handler-option-number input[type="range"]:focus {
+ outline: none;
+}
+
+.handler-option-number input[type="range"]:focus::-webkit-slider-thumb {
+ box-shadow: 0 0 0 3px var(--primary-transparent);
+}
+
+.handler-option-number input[type="range"]:focus::-moz-range-thumb {
+ box-shadow: 0 0 0 3px var(--primary-transparent);
+}
+
+.handler-option-inline-field {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.handler-option-inline-field input {
+ flex: 1;
+ min-width: 0;
+}
+
+.handler-option-inline-field span {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* Multi-select */
+.handler-option-multi-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.handler-option-multi-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.4rem 0.65rem;
+ border-radius: 5px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ background: var(--bg-section);
+ border: 1px solid var(--border-primary);
+ color: var(--text-secondary);
+ cursor: pointer;
+ user-select: none;
+}
+
+.handler-option-multi-item:hover {
+ background: var(--bg-card-alt-hover);
+ border-color: var(--border-secondary);
+}
+
+.handler-option-multi-item input[type="checkbox"] {
+ width: 0.875rem;
+ height: 0.875rem;
+ border: 1.5px solid var(--border-secondary);
+ border-radius: 3px;
+ margin: 0;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.handler-option-multi-item input[type="checkbox"]:checked {
+ background: var(--primary);
+ border-color: var(--primary);
+}
+
+/* Mobile Responsive */
+@media (max-width: 720px) {
+ .handler-options-modal-overlay {
+ padding: 1rem;
+ }
+
+ .handler-options-modal {
+ max-height: 95vh;
+ border-radius: 6px;
+ }
+
+ .handler-options-modal-header {
+ padding: 1rem 1.25rem;
+ }
+
+ .handler-options-modal-title-wrap h3 {
+ font-size: 0.875rem;
+ }
+
+ .handler-options-modal-title-wrap p {
+ font-size: 0.75rem;
+ }
+
+ .conversion-option-item {
+ padding: 0.875rem 1.25rem;
+ }
+}
diff --git a/src/ui/components/Conversion/HandlerOptionsModal/index.tsx b/src/ui/components/Conversion/HandlerOptionsModal/index.tsx
new file mode 100644
index 00000000..8fef4ae7
--- /dev/null
+++ b/src/ui/components/Conversion/HandlerOptionsModal/index.tsx
@@ -0,0 +1,311 @@
+import { RotateCcw, X } from "lucide-preact";
+import { useEffect, useState } from "preact/hooks";
+import type { FormatHandler, HandlerOptionDefinition } from "src/FormatHandler";
+
+import "./index.css";
+
+interface HandlerOptionsModalProps {
+ open: boolean;
+ inputHandler: FormatHandler | null;
+ outputHandler: FormatHandler | null;
+ inputVisibleOptions: HandlerOptionDefinition[];
+ outputVisibleOptions: HandlerOptionDefinition[];
+ onApplyOption: (handler: FormatHandler, option: HandlerOptionDefinition, value: unknown) => void;
+ onResetHandler: (handler: FormatHandler) => void;
+ onClose: () => void;
+}
+
+export default function HandlerOptionsModal({
+ open,
+ inputHandler,
+ outputHandler,
+ inputVisibleOptions,
+ outputVisibleOptions,
+ onApplyOption,
+ onResetHandler,
+ onClose,
+}: HandlerOptionsModalProps) {
+ const [numberInputValues, setNumberInputValues] = useState>({});
+ const [expandedSection, setExpandedSection] = useState<"input" | "output" | null>(null);
+
+ useEffect(() => {
+ if (expandedSection === null) {
+ if (inputHandler && !outputHandler) {
+ setExpandedSection("input");
+ } else if (outputHandler) {
+ setExpandedSection("output");
+ } else if (inputHandler) {
+ setExpandedSection("input");
+ }
+ }
+ }, [inputHandler, outputHandler, expandedSection]);
+
+ useEffect(() => {
+ const handleEscape = (ev: KeyboardEvent) => {
+ if (ev.key === "Escape" && open) {
+ onClose();
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ const handleReset = (handler: FormatHandler) => {
+ if (!handler) return;
+ const confirmed = confirm(`Are you sure you want to reset all settings for ${handler.name}?`);
+ if (confirmed) {
+ onResetHandler(handler);
+ }
+ };
+
+ const isOptionChanged = (option: HandlerOptionDefinition): boolean => {
+ if (option.defaultValue === undefined) return false;
+ const currentValue = option.getValue();
+ const defaultValue = option.defaultValue;
+
+ if (Array.isArray(currentValue) && Array.isArray(defaultValue)) {
+ if (currentValue.length !== defaultValue.length) return true;
+ return currentValue.some((v, i) => v !== defaultValue[i]);
+ }
+
+ return currentValue !== defaultValue;
+ };
+
+ const renderOptionControlForHandler = (handler: FormatHandler, option: HandlerOptionDefinition) => {
+ const optionStateKey = `${handler.name}:${option.id}`;
+ switch (option.kind) {
+ case "toggle":
+ return (
+
+ );
+ case "number": {
+ const value = option.getValue();
+ const inputValue = numberInputValues[optionStateKey] ?? String(value);
+
+ return (
+
+ {option.control === "slider" && (
+ onApplyOption(handler, option, Number(ev.currentTarget.value))}
+ />
+ )}
+
+
+ );
+ }
+ case "text":
+ if (option.multiline) {
+ return (
+