feat: add new pdf opts and image page number template to exporter (#2281)

Enhanced the exporter tool:
- For PDF format, added `pdf.pdfVersion`, `pdf.pdfValidator` and
`pdf.pdfTags`. `pdf.pdfVersion`, and `pdf.pdfValidator` will be combined
into `pdfStandard`, and `pdf.pdfTags` will be inverted into `noPdfTags`.
- Use `datetime-local` input for `pdf.pdfCreationTimestamp`.
- For image formats, added `pageNumberTemplate` option, since we cannot
customize that with output path for now.

<img width="1028" height="391" alt="image"
src="https://github.com/user-attachments/assets/1c0c44c9-0ea3-4f7c-a316-5a2746cb8a96"
/>

---------

Co-authored-by: Myriad-Dreamin <camiyoru@gmail.com>
This commit is contained in:
QuadnucYard 2025-12-07 20:00:26 +08:00 committed by GitHub
parent ff76fb17d6
commit c26dc8de85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 153 additions and 22 deletions

View file

@ -1,7 +1,8 @@
export interface ExportPdfOpts {
pages?: string[];
creationTimestamp?: string | null;
// todo: pdf_standard
pdfStandard?: string[];
noPdfTags?: boolean;
}
export interface PageMergeOpts {

View file

@ -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,

View file

@ -5,7 +5,8 @@ const { div, h3, label, input, select, option, span, p } = van.tags;
interface OptionsPanelProps {
format: ExportFormat;
optionStates: Record<string, State<Scalar>>;
// optionStates values can be scalar, an array (for multi-select), or undefined
optionStates: Record<string, State<Scalar | Scalar[] | undefined>>;
}
export const OptionsPanel = ({ format, optionStates }: OptionsPanelProps) => {
@ -50,13 +51,17 @@ export const OptionsPanel = ({ format, optionStates }: OptionsPanelProps) => {
);
};
const OptionField = (schema: OptionSchema, valueState: State<Scalar>) => {
const { key, label: optionLabel, description } = schema;
const OptionField = (schema: OptionSchema, valueState: State<Scalar | Scalar[] | undefined>) => {
const { key, label: optionLabel, description, type: optionType } = schema;
const validationError = van.state<string | undefined>();
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<Scalar>) => {
const renderInput = (
schema: OptionSchema,
valueState: State<Scalar | undefined>,
valueState: State<Scalar | Scalar[] | undefined>,
validationError: State<string | undefined>,
) => {
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(
{

View file

@ -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<string, State<Scalar>> = {};
// option value may be scalar or an array (multi-select)
const optionStates: Record<string, State<Scalar | Scalar[] | undefined>> = {};
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 {

View file

@ -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",

View file

@ -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 {