diff --git a/editors/vscode/src/cmd.export.ts b/editors/vscode/src/cmd.export.ts index b70dbf602..6c353e9cf 100644 --- a/editors/vscode/src/cmd.export.ts +++ b/editors/vscode/src/cmd.export.ts @@ -1,7 +1,8 @@ export interface ExportPdfOpts { pages?: string[]; creationTimestamp?: string | null; - // todo: pdf_standard + pdfStandard?: string[]; + noPdfTags?: boolean; } export interface PageMergeOpts { diff --git a/editors/vscode/src/features/tasks.export.ts b/editors/vscode/src/features/tasks.export.ts index 44963f133..f3bdf011f 100644 --- a/editors/vscode/src/features/tasks.export.ts +++ b/editors/vscode/src/features/tasks.export.ts @@ -38,6 +38,10 @@ export interface ExportArgs { "svg.merged.gap"?: string; "pdf.creationTimestamp"?: string | null; + "pdf.pdfVersion"?: string; + "pdf.pdfValidator"?: string; + "pdf.pdfStandard"?: string[]; + "pdf.pdfTags"?: boolean; "png.ppi"?: number; @@ -140,9 +144,21 @@ export const exportOps = (exportArgs: ExportArgs) => ({ export const provideFormats = (exportArgs: ExportArgs, ops = exportOps(exportArgs)) => ({ pdf: { opts(): ExportPdfOpts { + const rawCreationTimestamp = exportArgs["pdf.creationTimestamp"]; + const creationTimestamp = rawCreationTimestamp?.includes("T") + ? Math.floor(new Date(rawCreationTimestamp).getTime() / 1000).toString() // datetime-local to unix timestamp + : rawCreationTimestamp; // already unix timestamp or null/undefined + + const pdfStandard = exportArgs["pdf.pdfStandard"] ?? [ + ...(exportArgs["pdf.pdfVersion"] ? [exportArgs["pdf.pdfVersion"]] : []), + ...(exportArgs["pdf.pdfValidator"] ? [exportArgs["pdf.pdfValidator"]] : []), + ]; // combine version and validator into array + return { pages: ops.resolvePagesOpts("pdf"), - creationTimestamp: exportArgs["pdf.creationTimestamp"], + creationTimestamp, + pdfStandard, + noPdfTags: !exportArgs["pdf.pdfTags"], // invert to noPdfTags }; }, export: tinymist.exportPdf, diff --git a/tools/editor-tools/src/features/exporter/components/options-panel.ts b/tools/editor-tools/src/features/exporter/components/options-panel.ts index 410bfd257..af580c650 100644 --- a/tools/editor-tools/src/features/exporter/components/options-panel.ts +++ b/tools/editor-tools/src/features/exporter/components/options-panel.ts @@ -5,7 +5,8 @@ const { div, h3, label, input, select, option, span, p } = van.tags; interface OptionsPanelProps { format: ExportFormat; - optionStates: Record>; + // optionStates values can be scalar, an array (for multi-select), or undefined + optionStates: Record>; } export const OptionsPanel = ({ format, optionStates }: OptionsPanelProps) => { @@ -50,13 +51,17 @@ export const OptionsPanel = ({ format, optionStates }: OptionsPanelProps) => { ); }; -const OptionField = (schema: OptionSchema, valueState: State) => { - const { key, label: optionLabel, description } = schema; +const OptionField = (schema: OptionSchema, valueState: State) => { + const { key, label: optionLabel, description, type: optionType } = schema; const validationError = van.state(); + const labelElem = + optionType !== "boolean" + ? [label({ class: "text-sm font-medium", for: key }, optionLabel)] + : []; return div( { class: "flex flex-col gap-xs" }, - label({ class: "text-sm font-medium", for: key }, optionLabel), + ...labelElem, renderInput(schema, valueState, validationError), () => validationError.val @@ -67,10 +72,10 @@ const OptionField = (schema: OptionSchema, valueState: State) => { const renderInput = ( schema: OptionSchema, - valueState: State, + valueState: State, validationError: State, ) => { - const { type, key, options: selectOptions, min, max } = schema; + const { type, key, options: selectOptions, label: optionLabel } = schema; switch (type) { case "string": @@ -96,8 +101,8 @@ const renderInput = ( const current = valueState.val; return current === undefined || current === null ? "" : String(current); }, - min: min?.toString() ?? null, - max: max?.toString() ?? null, + min: schema.min?.toString() ?? null, + max: schema.max?.toString() ?? null, oninput: (e: Event) => { const target = e.target as HTMLInputElement; // Call custom validation function if provided @@ -119,7 +124,7 @@ const renderInput = ( valueState.val = target.checked; }, }), - span({ class: "text-sm" }, "Enable"), + label({ class: "text-sm font-medium", for: key }, optionLabel), ); case "color": @@ -137,8 +142,57 @@ const renderInput = ( }, }); + case "datetime": + return input({ + class: "input", + type: "datetime-local", + id: key, + value: () => { + const current = valueState.val; + return typeof current === "string" ? current : ""; + }, + onchange: (e: Event) => { + const target = e.target as HTMLInputElement; + valueState.val = target.value; + }, + }); + case "select": if (!selectOptions) return span("No options available"); + // multi-select + if (schema.multiple) { + return select( + { + class: "select", + id: key, + multiple: true, + onchange: (e: Event) => { + const target = e.target as HTMLSelectElement; + const values = Array.from(target.selectedOptions).map((o) => o.value); + const resolved = values + .map((v) => selectOptions.find((opt) => opt.value.toString() === v)) + .filter((o): o is { value: Scalar; label: string } => Boolean(o)) + .map((opt) => opt.value); + valueState.val = resolved; + }, + }, + ...selectOptions.map((opt) => + option( + { + value: opt.value.toString(), + selected: () => + Array.isArray(valueState.val) && + (valueState.val as Scalar[]) + .map((v) => v?.toString()) + .includes(opt.value.toString()), + }, + opt.label, + ), + ), + ); + } + + // single-select return select( { class: "select", @@ -156,6 +210,7 @@ const renderInput = ( valueState.val = newValue; }, }, + schema.default ? null : option({ value: "" }, "None"), // if no default, add a "None" option ...selectOptions.map((opt) => option( { diff --git a/tools/editor-tools/src/features/exporter/exporter.ts b/tools/editor-tools/src/features/exporter/exporter.ts index 7c7d38a53..1a55162e1 100644 --- a/tools/editor-tools/src/features/exporter/exporter.ts +++ b/tools/editor-tools/src/features/exporter/exporter.ts @@ -9,7 +9,8 @@ export function useExporter() { const inputPath = van.state(""); const outputPath = van.state(""); const format = van.state(EXPORT_FORMATS[0]); - const optionStates: Record> = {}; + // option value may be scalar or an array (multi-select) + const optionStates: Record> = {}; let previewVersion = 0; const previewGenerating = van.state(false); @@ -20,7 +21,10 @@ export function useExporter() { const extraOpts = Object.fromEntries( format.val.options.map((option) => { const val = optionStates[option.key]?.val; - return [option.key, val === "" ? undefined : val]; + // treat empty strings or empty arrays as undefined so the server can pick defaults + if (val === "") return [option.key, undefined]; + if (Array.isArray(val) && val.length === 0) return [option.key, undefined]; + return [option.key, val]; }), ); return { diff --git a/tools/editor-tools/src/features/exporter/formats.ts b/tools/editor-tools/src/features/exporter/formats.ts index 547471277..48c0d23b7 100644 --- a/tools/editor-tools/src/features/exporter/formats.ts +++ b/tools/editor-tools/src/features/exporter/formats.ts @@ -9,6 +9,18 @@ const PAGES_OPT: OptionSchema = { validate: validatePageRanges, }; +const IMAGE_PAGES_OPTS: OptionSchema[] = [ + PAGES_OPT, + { + key: "pageNumberTemplate", + type: "string", + label: "Page Number Template", + description: + 'Template used to render page numbers when exporting multiple pages (e.g., "Page {n}")', + default: "", + }, +]; + const MERGE_OPTS: OptionSchema[] = [ { key: "merged", @@ -36,10 +48,9 @@ export const EXPORT_FORMATS: ExportFormat[] = [ PAGES_OPT, { key: "pdf.creationTimestamp", - type: "string", + type: "datetime", label: "Creation Timestamp", - description: - "The document's creation date formatted as a UNIX timestamp. (leave empty for current time)", + description: "The document's creation date (leave empty for current time)", default: "", validate: (value: string) => { if (value.trim() === "") { @@ -54,6 +65,46 @@ export const EXPORT_FORMATS: ExportFormat[] = [ } }, }, + { + key: "pdf.pdfVersion", + type: "select", + label: "PDF Version", + description: "Optional version of the PDF document", + options: [ + { value: "1.4", label: "PDF 1.4" }, + { value: "1.5", label: "PDF 1.5" }, + { value: "1.6", label: "PDF 1.6" }, + { value: "1.7", label: "PDF 1.7" }, + { value: "2.0", label: "PDF 2.0" }, + ], + }, + { + key: "pdf.pdfValidator", + type: "select", + label: "PDF Validator", + description: "Optional validator for exporting PDF documents to a specific subset of PDF", + options: [ + { value: "a-1b", label: "PDF/A-1b" }, + { value: "a-1a", label: "PDF/A-1a" }, + { value: "a-2b", label: "PDF/A-2b" }, + { value: "a-2u", label: "PDF/A-2u" }, + { value: "a-2a", label: "PDF/A-2a" }, + { value: "a-3b", label: "PDF/A-3b" }, + { value: "a-3u", label: "PDF/A-3u" }, + { value: "a-3a", label: "PDF/A-3a" }, + { value: "a-4", label: "PDF/A-4" }, + { value: "a-4f", label: "PDF/A-4f" }, + { value: "a-4e", label: "PDF/A-4e" }, + { value: "ua-1", label: "PDF/UA-1" }, + ], + }, + { + key: "pdf.pdfTags", + type: "boolean", + label: "PDF Tags", + description: "Include tagged structure in the PDF for better accessibility.", + default: true, + }, ], }, { @@ -61,7 +112,7 @@ export const EXPORT_FORMATS: ExportFormat[] = [ label: "PNG", fileExtension: "png", options: [ - PAGES_OPT, + ...IMAGE_PAGES_OPTS, ...MERGE_OPTS, { key: "png.ppi", @@ -90,7 +141,7 @@ export const EXPORT_FORMATS: ExportFormat[] = [ id: "svg", label: "SVG", fileExtension: "svg", - options: [PAGES_OPT, ...MERGE_OPTS], + options: [...IMAGE_PAGES_OPTS, ...MERGE_OPTS], }, { id: "html", diff --git a/tools/editor-tools/src/features/exporter/types.ts b/tools/editor-tools/src/features/exporter/types.ts index c60f703d5..846748929 100644 --- a/tools/editor-tools/src/features/exporter/types.ts +++ b/tools/editor-tools/src/features/exporter/types.ts @@ -4,16 +4,20 @@ export type ExportFormatId = "pdf" | "png" | "svg" | "html" | "markdown" | "tex" export interface OptionSchema { key: string; - type: "string" | "number" | "boolean" | "color" | "select"; + type: "string" | "number" | "boolean" | "color" | "datetime" | "select"; label: string; description?: string; - default: Scalar; + // default can be a single scalar or an array of scalars for multi-select + default?: Scalar | Scalar[]; options?: Array<{ value: Scalar; label: string }>; + // when true and type === 'select', UI should render a multi-select + multiple?: boolean; min?: number; max?: number; dependsOn?: string; // Key of another option that this option depends on - // Custom validation function for string fields - validate?: (value: string) => string | undefined; // Returns error message or undefined if valid + // Custom validation function for string fields. + // Returns a string error message when invalid, or undefined when valid. + validate?: (value: string) => string | undefined; } export interface ExportFormat {