diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index 5cfc7acdb95..989e36bad75 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -141,7 +141,7 @@ jobs: - name: Install rsvg-convert for SVG conversion tests # Only do it on linux runner, and only if we are running the relevant tests - if: runner.os == 'Linux' && contains(inputs.buckets, 'render-pdf-svg-conversion') + if: runner.os == 'Linux' && (contains(inputs.buckets, 'render-pdf-svg-conversion') || contains(inputs.buckets, 'pdf-standard')) run: | sudo apt-get update -y sudo apt-get install -y librsvg2-bin @@ -201,6 +201,13 @@ jobs: run: | quarto install tinytex + - name: Install veraPDF for PDF standard validation + if: runner.os == 'Linux' && contains(inputs.buckets, 'pdf-standard') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + quarto install verapdf + - name: Cache Typst packages id: cache-typst uses: ./.github/actions/cache-typst diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 5996e470f2e..1a6e1222817 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -42,6 +42,7 @@ All changes included in 1.9: - ([#13589](https://github.com/quarto-dev/quarto-cli/issues/13589)): Fix callouts with invalid ID prefixes crashing with "attempt to index a nil value". Callouts with unknown reference types now render as non-crossreferenceable callouts with a warning, ignoring the invalid ID. - ([#13602](https://github.com/quarto-dev/quarto-cli/issues/13602)): Fix support for multiple files set in `bibliography` field in `biblio.typ` template partial. - ([#13775](https://github.com/quarto-dev/quarto-cli/issues/13775)): Fix brand fonts not being applied when using `citeproc: true` with Typst format. Format detection now properly handles Pandoc format variants like `typst-citations`. +- ([#13868](https://github.com/quarto-dev/quarto-cli/issues/13868)): Add image alt text support for PDF/UA accessibility. Alt text from markdown captions and explicit `alt` attributes is now passed to Typst's `image()` function. (Temporary workaround until [jgm/pandoc#11394](https://github.com/jgm/pandoc/pull/11394) is merged.) - ([#13249](https://github.com/quarto-dev/quarto-cli/pull/13249)): Update to Pandoc's Typst template following Pandoc 3.8.3 and Typst 0.14.2 support: - Code syntax highlighting now uses Skylighting by default. - New template variables `mathfont`, `codefont`, and `linestretch` for font and line spacing customization. @@ -55,7 +56,9 @@ All changes included in 1.9: ### `pdf` +- ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): Add `pdf-standard` option for PDF/A, PDF/UA, and PDF version control. Supports standards like `a-2b`, `ua-1`, and versions `1.7`, `2.0`. Works with both LaTeX and Typst formats. - ([#10291](https://github.com/quarto-dev/quarto-cli/issues/10291)): Fix detection of babel hyphenation warnings with straight-quote format instead of backtick-quote format. +- ([#13248](https://github.com/quarto-dev/quarto-cli/issues/13248)): Fix image alt text not being passed to LaTeX `\includegraphics[alt={...}]` for PDF accessibility. Markdown image captions and `fig-alt` attributes are now preserved for PDF/UA compliance. - ([#13661](https://github.com/quarto-dev/quarto-cli/issues/13661)): Fix LaTeX compilation errors when using `mermaid-format: svg` with PDF/LaTeX output. SVG diagrams are now written directly without HTML script tags. Note: `mermaid-format: png` is recommended for best compatibility. SVG format requires `rsvg-convert` (or Inkscape with `use-rsvg-convert: false`) in PATH for conversion to PDF, and may experience text clipping in diagrams with multi-line labels. - ([rstudio/tinytex-releases#49](https://github.com/rstudio/tinytex-releases/issues/49)): Fix detection of LuaTeX-ja missing file errors by matching both "File" and "file" in error messages. - ([#13667](https://github.com/quarto-dev/quarto-cli/issues/13667)): Fix LaTeX compilation error with Python error output containing caret characters. @@ -113,6 +116,10 @@ All changes included in 1.9: - (): New `quarto call build-ts-extension` command builds a TypeScript extension, such as an engine extension, and places the artifacts in the `_extensions` directory. See the [engine extension pre-release documentation](https://prerelease.quarto.org/docs/extensions/engine.html) for details. +### `install verapdf` + +- ([#4426](https://github.com/quarto-dev/quarto-cli/issues/4426)): New `quarto install verapdf` command installs [veraPDF](https://verapdf.org/) for PDF/A and PDF/UA validation. When verapdf is available, PDFs created with the `pdf-standard` option are automatically validated for compliance. Also supports `quarto uninstall verapdf`, `quarto update verapdf`, and `quarto tools`. + ## Extensions - Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory. diff --git a/src/command/render/latexmk/parse-error.ts b/src/command/render/latexmk/parse-error.ts index 22ff6d18570..94459309674 100644 --- a/src/command/render/latexmk/parse-error.ts +++ b/src/command/render/latexmk/parse-error.ts @@ -107,6 +107,53 @@ const resolvingMatchers = [ }, ]; +// Finds PDF/UA accessibility warnings from tagpdf and DocumentMetadata +export interface PdfAccessibilityWarnings { + missingAltText: string[]; // filenames of images missing alt text + missingLanguage: boolean; // document language not set + otherWarnings: string[]; // other tagpdf warnings +} + +export function findPdfAccessibilityWarnings( + logText: string, +): PdfAccessibilityWarnings { + const result: PdfAccessibilityWarnings = { + missingAltText: [], + missingLanguage: false, + otherWarnings: [], + }; + + // Match: Package tagpdf Warning: Alternative text for graphic is missing. + // (tagpdf) Using 'filename' instead. + const altTextRegex = + /Package tagpdf Warning: Alternative text for graphic is missing\.\s*\n\(tagpdf\)\s*Using ['`]([^'`]+)['`] instead\./g; + let match; + while ((match = altTextRegex.exec(logText)) !== null) { + result.missingAltText.push(match[1]); + } + + // Match: LaTeX DocumentMetadata Warning: The language has not been set in + if ( + /LaTeX DocumentMetadata Warning: The language has not been set in/.test( + logText, + ) + ) { + result.missingLanguage = true; + } + + // Capture any other tagpdf warnings we haven't specifically handled + const otherTagpdfRegex = /Package tagpdf Warning: ([^\n]+)/g; + while ((match = otherTagpdfRegex.exec(logText)) !== null) { + const warning = match[1]; + // Skip the alt text warning we already handle specifically + if (!warning.startsWith("Alternative text for graphic is missing")) { + result.otherWarnings.push(warning); + } + } + + return result; +} + // Finds missing hyphenation files (these appear as warnings in the log file) export function findMissingHyphenationFiles(logText: string) { //ngerman gets special cased @@ -273,6 +320,19 @@ const packageMatchers = [ return "colorprofiles.sty"; }, }, + { + regex: /.*No support files for \\DocumentMetadata found.*/g, + filter: (_match: string, _text: string) => { + return "latex-lab"; + }, + }, + { + // PDF/A requires embedded color profiles - pdfmanagement-testphase needs colorprofiles + regex: /.*\(pdf backend\): cannot open file for embedding.*/g, + filter: (_match: string, _text: string) => { + return "colorprofiles"; + }, + }, { regex: /.*No file ([^`'. ]+[.]fd)[.].*/g, filter: (match: string, _text: string) => { @@ -297,7 +357,7 @@ const packageMatchers = [ ]; function fontSearchTerm(font: string): string { - const fontPattern = font.replace(/\s+/g, '\\s*'); + const fontPattern = font.replace(/\s+/g, "\\s*"); return `${fontPattern}(-(Bold|Italic|Regular).*)?[.](tfm|afm|mf|otf|ttf)`; } diff --git a/src/command/render/latexmk/pdf.ts b/src/command/render/latexmk/pdf.ts index 7aa66aa942a..9af74e170f7 100644 --- a/src/command/render/latexmk/pdf.ts +++ b/src/command/render/latexmk/pdf.ts @@ -21,6 +21,7 @@ import { findLatexError, findMissingFontsAndPackages, findMissingHyphenationFiles, + findPdfAccessibilityWarnings, kMissingFontLog, needsRecompilation, } from "./parse-error.ts"; @@ -197,6 +198,25 @@ async function initialCompileLatex( `Possibly missing hyphenation file: '${missingHyphenationFile}'. See more in logfile (by setting 'latex-clean: false').\n`, ); } + + // Check for accessibility warnings (e.g., missing alt text, language with PDF/UA) + const accessibilityWarnings = findPdfAccessibilityWarnings(logText); + if (accessibilityWarnings.missingAltText.length > 0) { + const fileList = accessibilityWarnings.missingAltText.join(", "); + warning( + `PDF accessibility: Missing alt text for image(s): ${fileList}. Add alt text using ![alt text](image.png) syntax for PDF/UA compliance.\n`, + ); + } + if (accessibilityWarnings.missingLanguage) { + warning( + `PDF accessibility: Document language not set. Add 'lang: en' (or appropriate language) to document metadata for PDF/UA compliance.\n`, + ); + } + if (accessibilityWarnings.otherWarnings.length > 0) { + for (const warn of accessibilityWarnings.otherWarnings) { + warning(`PDF accessibility: ${warn}\n`); + } + } } else if (pkgMgr.autoInstall) { // try autoinstalling // First be sure all packages are up to date diff --git a/src/command/render/latexmk/texlive.ts b/src/command/render/latexmk/texlive.ts index 2a1884155a8..923e6421966 100644 --- a/src/command/render/latexmk/texlive.ts +++ b/src/command/render/latexmk/texlive.ts @@ -86,10 +86,11 @@ export async function findPackages( `finding package for ${searchTerm}`, ); } - // Special case for a known package + // Special cases for known packages where tlmgr file search doesn't work // https://github.com/rstudio/tinytex/blob/33cbe601ff671fae47c594250de1d22bbf293b27/R/latex.R#L470 - if (searchTerm === "fandol") { - results.push("fandol"); + const knownPackages = ["fandol", "latex-lab", "colorprofiles"]; + if (knownPackages.includes(searchTerm)) { + results.push(searchTerm); } else { const result = await tlmgrCommand( "search", diff --git a/src/command/render/output-tex.ts b/src/command/render/output-tex.ts index c93b22b9d50..bb54154caf3 100644 --- a/src/command/render/output-tex.ts +++ b/src/command/render/output-tex.ts @@ -15,9 +15,12 @@ import { kKeepTex, kOutputExt, kOutputFile, + kPdfStandard, kTargetFormat, } from "../../config/constants.ts"; import { Format } from "../../config/types.ts"; +import { asArray } from "../../core/array.ts"; +import { validatePdfStandards } from "../../core/verapdf.ts"; import { PandocOptions, RenderFlags, RenderOptions } from "./types.ts"; import { kStdOut, replacePandocOutputArg } from "./flags.ts"; @@ -80,6 +83,16 @@ export function texToPdfOutputRecipe( const input = join(inputDir, output); const pdfOutput = await pdfGenerator.generate(input, format, pandocOptions); + // Validate PDF against specified standards using verapdf (if available) + const pdfStandards = asArray( + format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard], + ) as string[]; + if (pdfStandards.length > 0) { + await validatePdfStandards(pdfOutput, pdfStandards, { + quiet: pandocOptions.flags?.quiet, + }); + } + // keep tex if requested const compileTex = join(inputDir, output); if (!format.render[kKeepTex]) { diff --git a/src/command/render/output-typst.ts b/src/command/render/output-typst.ts index 7f11880185b..695a144a8f9 100644 --- a/src/command/render/output-typst.ts +++ b/src/command/render/output-typst.ts @@ -23,8 +23,10 @@ import { kKeepTyp, kOutputExt, kOutputFile, + kPdfStandard, kVariant, } from "../../config/constants.ts"; +import { error, warning } from "../../deno_ral/log.ts"; import { Format } from "../../config/types.ts"; import { writeFileToStdout } from "../../core/console.ts"; import { dirAndStem, expandPath } from "../../core/path.ts"; @@ -38,6 +40,7 @@ import { } from "../../core/typst.ts"; import { asArray } from "../../core/array.ts"; import { ProjectContext } from "../../project/types.ts"; +import { validatePdfStandards } from "../../core/verapdf.ts"; // Stage typst packages to .quarto/typst-packages/ // First stages built-in packages, then extension packages (which can override) @@ -137,6 +140,11 @@ export function typstPdfOutputRecipe( const typstOptions: TypstCompileOptions = { quiet: options.flags?.quiet, fontPaths: asArray(format.metadata?.[kFontPaths]) as string[], + pdfStandard: normalizePdfStandardForTypst( + asArray( + format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard], + ), + ), }; if (project?.dir) { typstOptions.rootDir = project.dir; @@ -153,7 +161,21 @@ export function typstPdfOutputRecipe( typstOptions, ); if (!result.success) { - throw new Error(); + // Log the error so test framework can detect it via shouldError + if (result.stderr) { + error(result.stderr); + } + throw new Error("Typst compilation failed"); + } + + // Validate PDF against specified standards using verapdf (if available) + const pdfStandards = asArray( + format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard], + ) as string[]; + if (pdfStandards.length > 0) { + await validatePdfStandards(pdfOutput, pdfStandards, { + quiet: options.flags?.quiet, + }); } // keep typ if requested @@ -217,3 +239,50 @@ export function typstPdfOutputRecipe( return recipe; } + +// Typst-supported PDF standards +const kTypstSupportedStandards = new Set([ + "1.4", + "1.5", + "1.6", + "1.7", + "2.0", + "a-1b", + "a-1a", + "a-2b", + "a-2u", + "a-2a", + "a-3b", + "a-3u", + "a-3a", + "a-4", + "a-4f", + "a-4e", + "ua-1", +]); + +function normalizePdfStandardForTypst(standards: unknown[]): string[] { + const result: string[] = []; + for (const s of standards) { + // Convert to string - YAML may parse versions like 2.0 as integer 2 + let str: string; + if (typeof s === "number") { + // Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4" + str = Number.isInteger(s) ? `${s}.0` : String(s); + } else if (typeof s === "string") { + str = s; + } else { + continue; + } + // Normalize: lowercase, remove any "pdf" prefix + const normalized = str.toLowerCase().replace(/^pdf[/-]?/, ""); + if (kTypstSupportedStandards.has(normalized)) { + result.push(normalized); + } else { + warning( + `PDF standard '${s}' is not supported by Typst and will be ignored`, + ); + } + } + return result; +} diff --git a/src/config/constants.ts b/src/config/constants.ts index 56e75b1d823..2a64c4193f9 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -86,6 +86,7 @@ export const kShortcodes = "shortcodes"; export const kKeepMd = "keep-md"; export const kKeepTex = "keep-tex"; export const kKeepTyp = "keep-typ"; +export const kPdfStandard = "pdf-standard"; export const kKeepIpynb = "keep-ipynb"; export const kKeepSource = "keep-source"; export const kVariant = "variant"; @@ -219,6 +220,7 @@ export const kRenderDefaultsKeys = [ kLatexTlmgrOpts, kLatexOutputDir, kLatexTinyTex, + kPdfStandard, kLinkExternalIcon, kLinkExternalNewwindow, kLinkExternalFilter, @@ -686,6 +688,7 @@ export const kPandocDefaultsKeys = [ kPdfEngine, kPdfEngineOpts, kPdfEngineOpt, + kPdfStandard, kWrap, kColumns, "dpi", diff --git a/src/config/types.ts b/src/config/types.ts index 09daafe951d..1f7c3132a9d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -176,6 +176,7 @@ import { kPdfEngine, kPdfEngineOpt, kPdfEngineOpts, + kPdfStandard, kPlotlyConnected, kPreferHtml, kPreserveYaml, @@ -492,6 +493,7 @@ export interface FormatRender { [kLatexMinRuns]?: number; [kLatexMaxRuns]?: number; [kLatexClean]?: boolean; + [kPdfStandard]?: string | string[]; [kLatexInputPaths]?: string[]; [kLatexMakeIndex]?: string; [kLatexMakeIndexOpts]?: string[]; diff --git a/src/core/typst.ts b/src/core/typst.ts index 42d9ea97876..0653f207012 100644 --- a/src/core/typst.ts +++ b/src/core/typst.ts @@ -41,6 +41,7 @@ export type TypstCompileOptions = { fontPaths?: string[]; rootDir?: string; packagePath?: string; + pdfStandard?: string[]; }; export async function typstCompile( @@ -72,6 +73,9 @@ export async function typstCompile( cmd.push("--package-cache-path", options.packagePath); } } + if (options.pdfStandard && options.pdfStandard.length > 0) { + cmd.push("--pdf-standard", options.pdfStandard.join(",")); + } cmd.push( input, ...fontPathsArgs(fontPaths), diff --git a/src/core/verapdf.ts b/src/core/verapdf.ts new file mode 100644 index 00000000000..7de98d35b97 --- /dev/null +++ b/src/core/verapdf.ts @@ -0,0 +1,276 @@ +/* + * verapdf.ts + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ + +import { info, warning } from "../deno_ral/log.ts"; +import { basename, join } from "../deno_ral/path.ts"; +import * as colors from "fmt/colors"; + +import { execProcess } from "./process.ts"; +import { which } from "./path.ts"; +import { quartoDataDir } from "./appdirs.ts"; +import { existsSync } from "../deno_ral/fs.ts"; +import { isWindows } from "../deno_ral/platform.ts"; + +// verapdf flavours (profiles) supported for validation +// Maps quarto pdf-standard values to verapdf --flavour values +const kVerapdfFlavours: Record = { + // PDF/A standards + "a-1a": "1a", + "a-1b": "1b", + "a-2a": "2a", + "a-2b": "2b", + "a-2u": "2u", + "a-3a": "3a", + "a-3b": "3b", + "a-3u": "3u", + "a-4": "4", + "a-4e": "4e", + "a-4f": "4f", + // PDF/UA standards + "ua-1": "ua1", + "ua-2": "ua2", +}; + +// Standards that verapdf can validate (keys of the above map) +const kVerapdfSupportedStandards = new Set(Object.keys(kVerapdfFlavours)); + +/** + * Check if a pdf-standard value can be validated by verapdf + */ +export function isVerapdfSupportedStandard(standard: string): boolean { + const normalized = standard.toLowerCase().replace(/^pdf[/-]?/, ""); + return kVerapdfSupportedStandards.has(normalized); +} + +/** + * Get verapdf flavour for a pdf-standard value, or undefined if not supported + */ +export function getVerapdfFlavour(standard: string): string | undefined { + const normalized = standard.toLowerCase().replace(/^pdf[/-]?/, ""); + return kVerapdfFlavours[normalized]; +} + +/** + * Find verapdf binary path - checks PATH and quarto install location + */ +export async function findVerapdfPath(): Promise { + // First check PATH + const pathResult = await which("verapdf"); + if (pathResult) { + return pathResult; + } + + // Check quarto install location + const quartoVerapdfDir = quartoDataDir("verapdf"); + const quartoVerapdfBin = isWindows + ? join(quartoVerapdfDir, "verapdf.bat") + : join(quartoVerapdfDir, "verapdf"); + + if (existsSync(quartoVerapdfBin)) { + return quartoVerapdfBin; + } + + return undefined; +} + +export interface VerapdfValidationResult { + valid: boolean; + flavour: string; + errors: string[]; + output: string; +} + +/** + * Run verapdf validation on a PDF file + */ +export async function validatePdf( + pdfPath: string, + flavour: string, + quiet = false, +): Promise { + const verapdfPath = await findVerapdfPath(); + if (!verapdfPath) { + throw new Error("verapdf not found"); + } + + if (!quiet) { + info( + `[verapdf]: Validating ${ + basename(pdfPath) + } against PDF/${flavour.toUpperCase()}...`, + { newline: false }, + ); + } + + const result = await execProcess({ + cmd: verapdfPath, + args: ["-f", flavour, pdfPath], + stdout: "piped", + stderr: "piped", + }); + + const output = result.stdout || ""; + + // Parse verapdf XML output to determine compliance + // Look for isCompliant="true" in validationReport or compliant count in batchSummary + const isCompliantMatch = output.match(/isCompliant="(true|false)"/); + const batchSummaryMatch = output.match( + /validationReports[^>]*compliant="(\d+)"[^>]*nonCompliant="(\d+)"/, + ); + + let valid = false; + if (isCompliantMatch) { + valid = isCompliantMatch[1] === "true"; + } else if (batchSummaryMatch) { + const compliant = parseInt(batchSummaryMatch[1]); + const nonCompliant = parseInt(batchSummaryMatch[2]); + valid = nonCompliant === 0 && compliant > 0; + } else { + // Fallback: assume valid if exit code is 0 and no obvious failure indicators + valid = result.success; + } + + // Extract error messages from output when validation fails + const errors: string[] = []; + if (!valid) { + // Look for failed rules with descriptions + // Pattern: ... + const ruleDescMatches = output.matchAll( + /]*>[\s\S]*?([^<]+)<\/description>/g, + ); + for (const match of ruleDescMatches) { + const desc = match[1].trim(); + if (desc && !errors.includes(desc)) { + errors.push(desc); + } + } + + // Also look for clause violations in ruleId + if (errors.length === 0) { + const clauseMatches = output.matchAll( + /]*clause="([^"]*)"[^>]*specification="([^"]*)"/g, + ); + for (const match of clauseMatches) { + errors.push(`${match[2]} clause ${match[1]} violation`); + } + } + + // Extract failedChecks count for summary + const failedChecksMatch = output.match(/failedChecks="(\d+)"/); + if (failedChecksMatch && parseInt(failedChecksMatch[1]) > 0) { + const count = failedChecksMatch[1]; + if (errors.length === 0) { + errors.push(`${count} compliance check(s) failed`); + } + } + } + + if (!quiet) { + if (valid) { + info(colors.green("PASSED\n")); + } else { + info(colors.red("FAILED\n")); + } + } + + return { + valid, + flavour, + errors, + output, + }; +} + +export interface PdfValidationOptions { + quiet?: boolean; + warnOnMissingVerapdf?: boolean; +} + +/** + * Validate a PDF against specified standards using verapdf + * Returns true if all validations pass (or if no validatable standards specified) + */ +export async function validatePdfStandards( + pdfPath: string, + standards: string[], + options: PdfValidationOptions = {}, +): Promise { + const quiet = options.quiet ?? false; + const warnOnMissingVerapdf = options.warnOnMissingVerapdf ?? true; + + // Filter to standards that verapdf can validate + const validatableStandards: { standard: string; flavour: string }[] = []; + const nonValidatableStandards: string[] = []; + + for (const standard of standards) { + // Convert to string in case YAML parsed a version number (e.g., 2.0) as a number + const standardStr = String(standard); + const normalized = standardStr.toLowerCase().replace(/^pdf[/-]?/, ""); + // Skip version numbers (1.4, 1.5, etc.) + if (/^\d+\.\d+$/.test(normalized)) { + continue; + } + const flavour = getVerapdfFlavour(normalized); + if (flavour) { + validatableStandards.push({ standard: normalized, flavour }); + } else { + nonValidatableStandards.push(standardStr); + } + } + + // Nothing to validate + if (validatableStandards.length === 0) { + return true; + } + + // Check if verapdf is available + const verapdfPath = await findVerapdfPath(); + if (!verapdfPath) { + if (warnOnMissingVerapdf) { + warning( + `PDF standard validation requested but verapdf is not installed.\n` + + `Install with: quarto install verapdf\n` + + `Standards requested: ${ + validatableStandards.map((s) => s.standard).join(", ") + }`, + ); + } + return true; // Don't fail the build, just warn + } + + // Warn about standards we can't validate + if (nonValidatableStandards.length > 0) { + warning( + `The following PDF standards cannot be validated by verapdf and will be skipped: ` + + nonValidatableStandards.join(", "), + ); + } + + // Run validation for each standard + let allValid = true; + for (const { standard, flavour } of validatableStandards) { + try { + const result = await validatePdf(pdfPath, flavour, quiet); + if (!result.valid) { + allValid = false; + if (!quiet) { + warning( + `PDF validation failed for ${standard}:\n` + + (result.errors.length > 0 + ? result.errors.slice(0, 5).join("\n") + : "See verapdf output for details"), + ); + } + } + } catch (error) { + if (!quiet) { + warning(`Failed to run verapdf validation: ${error}`); + } + } + } + + return allValid; +} diff --git a/src/format/pdf/format-pdf.ts b/src/format/pdf/format-pdf.ts index 16142cab7cc..cb6fecc994d 100644 --- a/src/format/pdf/format-pdf.ts +++ b/src/format/pdf/format-pdf.ts @@ -31,12 +31,15 @@ import { kNumberSections, kPaperSize, kPdfEngine, + kPdfStandard, kReferenceLocation, kShiftHeadingLevelBy, kTblCapLoc, kTopLevelDivision, kWarning, } from "../../config/constants.ts"; +import { warning } from "../../deno_ral/log.ts"; +import { asArray } from "../../core/array.ts"; import { Format, FormatExtras, PandocFlags } from "../../config/types.ts"; import { createFormat } from "../formats-shared.ts"; @@ -254,6 +257,7 @@ function createPdfFormat( const partialNamesPandoc: string[] = [ "after-header-includes", "common", + "document-metadata", "font-settings", "fonts", "hypersetup", @@ -277,10 +281,16 @@ function createPdfFormat( ], }; }; + // Beamer doesn't use document-metadata partial (its template doesn't include it) + const beamerPartialNamesPandoc = partialNamesPandoc.filter( + (name) => name !== "document-metadata", + ); extras.templateContext = createTemplateContext( displayName === "Beamer" ? "beamer" : "pdf", partialNamesQuarto, - partialNamesPandoc, + displayName === "Beamer" + ? beamerPartialNamesPandoc + : partialNamesPandoc, ); // Don't shift the headings if we see any H1s (we can't shift up any longer) @@ -313,6 +323,30 @@ function createPdfFormat( extras.pandoc[kNumberSections] = true; } + // Handle pdf-standard option for PDF/A, PDF/UA, PDF/X conformance + const pdfStandard = asArray( + format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard], + ); + if (pdfStandard.length > 0) { + const { version, standards, needsTagging } = + normalizePdfStandardForLatex(pdfStandard); + // Set pdfstandard as a map if there are standards or a version + if (standards.length > 0 || version) { + extras.pandoc.variables = extras.pandoc.variables || {}; + const pdfstandardMap: Record = {}; + if (standards.length > 0) { + pdfstandardMap.standards = standards; + } + if (version) { + pdfstandardMap.version = version; + } + if (needsTagging) { + pdfstandardMap.tagging = true; + } + extras.pandoc.variables["pdfstandard"] = pdfstandardMap; + } + } + return extras; }, }, @@ -1239,3 +1273,108 @@ const kbeginLongTablesideCap = `{ \\makeatother`; const kEndLongTableSideCap = "}"; + +// LaTeX-supported PDF standards (from latex3/latex2e DocumentMetadata) +// See: https://github.com/latex3/latex2e - documentmetadata-support.dtx +const kLatexSupportedStandards = new Set([ + // PDF/A standards (note: a-1a is NOT supported, only a-1b) + "a-1b", + "a-2a", + "a-2b", + "a-2u", + "a-3a", + "a-3b", + "a-3u", + "a-4", + "a-4e", + "a-4f", + // PDF/X standards + "x-4", + "x-4p", + "x-5g", + "x-5n", + "x-5pg", + "x-6", + "x-6n", + "x-6p", + // PDF/UA standards + "ua-1", + "ua-2", +]); + +// Standards that require PDF tagging (document structure) +// - PDF/A level "a" variants require tagged structure per PDF/A spec +// - PDF/UA standards require tagging for universal accessibility +// (LaTeX does NOT automatically enable tagging for UA standards) +const kTaggingRequiredStandards = new Set([ + "a-2a", + "a-3a", + "ua-1", + "ua-2", +]); + +const kVersionPattern = /^(1\.[4-7]|2\.0)$/; + +// PDF version required by each standard (maximum version limits) +// LaTeX defaults to PDF 2.0 with \DocumentMetadata, but some standards +// have maximum version requirements that are incompatible with 2.0 +// Note: a-1a is intentionally omitted as LaTeX doesn't support it +const kStandardRequiredVersion: Record = { + // PDF/A-1 requires exactly PDF 1.4 (only a-1b supported by LaTeX) + "a-1b": "1.4", + // PDF/A-2 and PDF/A-3 have maximum version of 1.7 + "a-2a": "1.7", + "a-2b": "1.7", + "a-2u": "1.7", + "a-3a": "1.7", + "a-3b": "1.7", + "a-3u": "1.7", + // PDF/A-4, PDF/UA-1, PDF/UA-2 all work with PDF 2.0 (the default) +}; + +function normalizePdfStandardForLatex( + standards: unknown[], +): { version?: string; standards: string[]; needsTagging: boolean } { + let version: string | undefined; + const result: string[] = []; + let needsTagging = false; + + for (const s of standards) { + // Convert to string - YAML may parse versions like 2.0 as integer 2 + let str: string; + if (typeof s === "number") { + // Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4" + str = Number.isInteger(s) ? `${s}.0` : String(s); + } else if (typeof s === "string") { + str = s; + } else { + continue; + } + // Normalize: lowercase, remove any "pdf" prefix + const normalized = str.toLowerCase().replace(/^pdf[/-]?/, ""); + + if (kVersionPattern.test(normalized)) { + // Use first explicit version (ignore subsequent ones) + if (!version) { + version = normalized; + } + } else if (kLatexSupportedStandards.has(normalized)) { + // LaTeX is case-insensitive, pass through lowercase + result.push(normalized); + // Check if this standard requires tagging + if (kTaggingRequiredStandards.has(normalized)) { + needsTagging = true; + } + // Infer required PDF version from standard (if not explicitly set) + if (!version && kStandardRequiredVersion[normalized]) { + version = kStandardRequiredVersion[normalized]; + } + } else { + warning( + `PDF standard '${s}' is not supported by LaTeX and will be ignored`, + ); + } + } + + return { version, standards: result, needsTagging }; +} diff --git a/src/resources/filters/layout/latex.lua b/src/resources/filters/layout/latex.lua index e5bce66ba45..df5619e8c12 100644 --- a/src/resources/filters/layout/latex.lua +++ b/src/resources/filters/layout/latex.lua @@ -332,6 +332,10 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) -- see if it's a captioned figure if image and #image.caption > 0 then caption = image.caption:clone() + -- preserve caption as alt attribute for PDF accessibility before clearing + if not image.attributes["alt"] then + image.attributes["alt"] = pandoc.utils.stringify(image.caption) + end tclear(image.caption) elseif tbl then caption = pandoc.utils.blocks_to_inlines(tbl.caption.long) @@ -380,6 +384,10 @@ function latexCell(cell, vAlign, endOfRow, endOfTable) if image and #image.caption > 0 then local caption = image.caption:clone() markupLatexCaption(cell, caption) + -- preserve caption as alt attribute for PDF accessibility before clearing + if not image.attributes["alt"] then + image.attributes["alt"] = pandoc.utils.stringify(image.caption) + end tclear(image.caption) content:insert(pandoc.RawBlock("latex", "\\raisebox{-\\height}{")) content:insert(pandoc.Para(image)) @@ -658,9 +666,13 @@ end function latexImageFigure(image) return renderLatexFigure(image, function(figure) - + -- make a copy of the caption and clear it local caption = image.caption:clone() + -- preserve caption as alt attribute for PDF accessibility before clearing + if #image.caption > 0 and not image.attributes["alt"] then + image.attributes["alt"] = pandoc.utils.stringify(image.caption) + end tclear(image.caption) -- get align diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index 0f8cacf35c3..88ff875dec1 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -232,6 +232,48 @@ function render_typst_fixups() if image.attributes["width"] ~= nil and type(width_as_number) == "number" then image.attributes["width"] = tostring(image.attributes["width"] / PANDOC_WRITER_OPTIONS.dpi) .. "in" end + + -- Workaround for Pandoc not passing alt text to Typst image() calls + -- See: https://github.com/jgm/pandoc/issues/XXXX (TODO: file upstream) + local alt_text = image.attributes["alt"] + if (alt_text == nil or alt_text == "") and #image.caption > 0 then + alt_text = pandoc.utils.stringify(image.caption) + end + + if alt_text and #alt_text > 0 then + -- When returning RawInline instead of Image, Pandoc won't write mediabag + -- entries to disk, so we must do it explicitly + local src = image.src + local mediabagPath = _quarto.modules.mediabag.write_mediabag_entry(src) + if mediabagPath then + src = mediabagPath + end + + -- Build image() parameters + local params = {} + + -- Source path (escape backslashes for Windows paths) + src = src:gsub('\\', '\\\\') + table.insert(params, '"' .. src .. '"') + + -- Alt text second (escape backslashes and quotes) + local escaped_alt = alt_text:gsub('\\', '\\\\'):gsub('"', '\\"') + table.insert(params, 'alt: "' .. escaped_alt .. '"') + + -- Height if present + if image.attributes["height"] then + table.insert(params, 'height: ' .. image.attributes["height"]) + end + + -- Width if present + if image.attributes["width"] then + table.insert(params, 'width: ' .. image.attributes["width"]) + end + + -- Use #box() wrapper for inline compatibility + return pandoc.RawInline("typst", "#box(image(" .. table.concat(params, ", ") .. "))") + end + return image end, Div = function(div) diff --git a/src/resources/filters/quarto-pre/figures.lua b/src/resources/filters/quarto-pre/figures.lua index 20d61f116e6..9992644b2f9 100644 --- a/src/resources/filters/quarto-pre/figures.lua +++ b/src/resources/filters/quarto-pre/figures.lua @@ -38,6 +38,17 @@ end return float end elseif _quarto.format.isLatexOutput() then + -- propagate fig-alt to Image elements for LaTeX (enables \includegraphics[alt={...}]) + local altText = attribute(float, kFigAlt, nil) + if altText ~= nil then + float.content = _quarto.ast.walk(float.content, { + Image = function(image) + image.attributes["alt"] = altText + return image + end + }) + float.attributes[kFigAlt] = nil + end return forward_pos_and_env(float) end end, diff --git a/src/resources/formats/pdf/pandoc/document-metadata.latex b/src/resources/formats/pdf/pandoc/document-metadata.latex new file mode 100644 index 00000000000..33bab5f93b7 --- /dev/null +++ b/src/resources/formats/pdf/pandoc/document-metadata.latex @@ -0,0 +1,20 @@ +$-- +$-- PDF standard support (PDF/A, PDF/UA, PDF/X) +$-- Requires LuaLaTeX and recent LaTeX (2023+) +$-- +$if(pdfstandard)$ +\DocumentMetadata{ +$if(pdfstandard.version)$ + pdfversion=$pdfstandard.version$, +$endif$ +$if(pdfstandard.standards)$ + pdfstandard={$for(pdfstandard.standards)$$it$$sep$,$endfor$}, +$endif$ +$if(pdfstandard.tagging)$ + tagging=on, +$endif$ +$if(lang)$ + lang=$lang$, +$endif$ + xmp=true} +$endif$ diff --git a/src/resources/formats/pdf/pandoc/hypersetup.latex b/src/resources/formats/pdf/pandoc/hypersetup.latex index ff67655576e..25eff3205e9 100644 --- a/src/resources/formats/pdf/pandoc/hypersetup.latex +++ b/src/resources/formats/pdf/pandoc/hypersetup.latex @@ -6,8 +6,12 @@ $if(author-meta)$ pdfauthor={$author-meta$}, $endif$ $if(lang)$ +$if(pdfstandard)$ +$-- lang is set in DocumentMetadata instead +$else$ pdflang={$lang$}, $endif$ +$endif$ $if(subject)$ pdfsubject={$subject$}, $endif$ diff --git a/src/resources/formats/pdf/pandoc/template.tex b/src/resources/formats/pdf/pandoc/template.tex index a2d09433608..5ce3e586c35 100644 --- a/src/resources/formats/pdf/pandoc/template.tex +++ b/src/resources/formats/pdf/pandoc/template.tex @@ -1,3 +1,4 @@ +$document-metadata.latex()$ % Options for packages loaded elsewhere $passoptions.latex()$ % diff --git a/src/resources/formats/typst/pandoc/quarto/definitions.typ b/src/resources/formats/typst/pandoc/quarto/definitions.typ index 4434f3edf52..49531d5da9d 100644 --- a/src/resources/formats/typst/pandoc/quarto/definitions.typ +++ b/src/resources/formats/typst/pandoc/quarto/definitions.typ @@ -17,13 +17,15 @@ #stack(dir: ltr, spacing: 3pt, super[#num], contents) ] +// Use nested show rule to preserve list structure for PDF/UA-1 accessibility +// See: https://github.com/quarto-dev/quarto-cli/pull/13249#discussion_r2678934509 #show terms: it => { - it.children - .map(child => [ - #strong[#child.term] - #block(inset: (left: 1.5em, top: -0.4em))[#child.description] - ]) - .join() + show terms.item: item => { + set text(weight: "bold") + item.term + block(inset: (left: 1.5em, top: -0.4em))[#item.description] + } + it } // Some quarto-specific definitions. diff --git a/src/resources/schema/document-pdfa.yml b/src/resources/schema/document-pdfa.yml index 1faed1f95fb..93fac92ac52 100644 --- a/src/resources/schema/document-pdfa.yml +++ b/src/resources/schema/document-pdfa.yml @@ -48,3 +48,64 @@ the colors, for example `ISO coated v2 300\letterpercent\space (ECI)` If left unspecified, `sRGB IEC61966-2.1` is used as default. + +- name: pdf-standard + schema: + maybeArrayOf: + enum: + # PDF versions + - "1.4" + - "1.5" + - "1.6" + - "1.7" + - "2.0" + # PDF/A standards (supported by both Typst and LaTeX) + - a-1b + - a-2a + - a-2b + - a-2u + - a-3a + - a-3b + - a-3u + - a-4 + - a-4f + # PDF/A standards (Typst only) + - a-1a + - a-4e + # PDF/UA standards + - ua-1 + - ua-2 + # PDF/X standards (LaTeX only) + - x-4 + - x-4p + - x-5g + - x-5n + - x-5pg + - x-6 + - x-6n + - x-6p + tags: + formats: [$pdf-all, typst] + description: + short: PDF conformance standard (e.g., a-2b, ua-1, 1.7) + long: | + Specifies PDF conformance standards and/or version for the output. + + Accepts a single value or array of values: + + **PDF versions** (both Typst and LaTeX): + `1.4`, `1.5`, `1.6`, `1.7`, `2.0` + + **PDF/A standards** (both engines): + `a-1b`, `a-2a`, `a-2b`, `a-2u`, `a-3a`, `a-3b`, `a-3u`, `a-4`, `a-4f` + + **PDF/A standards** (Typst only): + `a-1a`, `a-4e` + + **PDF/UA standards**: + `ua-1` (both), `ua-2` (LaTeX only) + + **PDF/X standards** (LaTeX only): + `x-4`, `x-4p`, `x-5g`, `x-5n`, `x-5pg`, `x-6`, `x-6n`, `x-6p` + + Example: `pdf-standard: [a-2b, ua-1]` for accessible archival PDF. diff --git a/src/tools/impl/verapdf.ts b/src/tools/impl/verapdf.ts new file mode 100644 index 00000000000..9873440f1c3 --- /dev/null +++ b/src/tools/impl/verapdf.ts @@ -0,0 +1,283 @@ +/* + * verapdf.ts + * + * Copyright (C) 2020-2022 Posit Software, PBC + */ + +import { existsSync, safeRemoveSync } from "../../deno_ral/fs.ts"; +import { basename, join } from "../../deno_ral/path.ts"; + +import { unzip } from "../../core/zip.ts"; +import { execProcess } from "../../core/process.ts"; +import { quartoDataDir } from "../../core/appdirs.ts"; + +import { + InstallableTool, + InstallContext, + kUpdatePath, + PackageInfo, + RemotePackageInfo, +} from "../types.ts"; +import { createToolSymlink, removeToolSymlink } from "../tools.ts"; +import { isWindows } from "../../deno_ral/platform.ts"; + +// veraPDF version API +const kVersionApiUrl = "https://software.verapdf.org/get-latest-version.php"; + +// Supported Java versions for veraPDF +const kSupportedJavaVersions = [8, 11, 17, 21]; + +// The name of the file that we use to store the installed version +const kVersionFileName = "version"; + +export const verapdfInstallable: InstallableTool = { + name: "VeraPDF", + prereqs: [{ + check: async () => { + const javaVersion = await getJavaVersion(); + return javaVersion !== undefined && + kSupportedJavaVersions.includes(javaVersion); + }, + os: ["darwin", "linux", "windows"], + message: + `Java is not installed or version is not supported. veraPDF requires Java 8, 11, 17, or 21. +Please install a compatible Java version from https://adoptium.net/`, + }], + installed, + installDir, + binDir, + installedVersion, + latestRelease, + preparePackage, + install, + afterInstall, + uninstall, +}; + +async function getJavaVersion(): Promise { + try { + const result = await execProcess({ + cmd: "java", + args: ["-version"], + stderr: "piped", + }); + if (!result.success) { + return undefined; + } + // Java outputs version to stderr + // Parse: openjdk version "17.0.1" or java version "1.8.0_301" + const match = result.stderr?.match(/version "(\d+)(?:\.(\d+))?/); + if (match) { + const major = parseInt(match[1]); + // Java 8 reports as "1.8", newer versions report as "11", "17", etc. + return major === 1 ? parseInt(match[2]) : major; + } + } catch { + return undefined; + } + return undefined; +} + +function verapdfInstallDir(): string { + return quartoDataDir("verapdf"); +} + +async function installed(): Promise { + const dir = verapdfInstallDir(); + const verapdfBin = isWindows + ? join(dir, "verapdf.bat") + : join(dir, "verapdf"); + return existsSync(verapdfBin); +} + +async function installDir(): Promise { + if (await installed()) { + return verapdfInstallDir(); + } + return undefined; +} + +async function binDir(): Promise { + if (await installed()) { + return verapdfInstallDir(); + } + return undefined; +} + +async function installedVersion(): Promise { + const dir = verapdfInstallDir(); + const versionFile = join(dir, kVersionFileName); + if (existsSync(versionFile)) { + return await Deno.readTextFile(versionFile); + } + return undefined; +} + +function noteInstalledVersion(version: string): void { + const dir = verapdfInstallDir(); + const versionFile = join(dir, kVersionFileName); + Deno.writeTextFileSync(versionFile, version); +} + +async function latestRelease(): Promise { + const response = await fetch(kVersionApiUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch veraPDF version info: ${response.statusText}`, + ); + } + + const data = await response.json(); + if (!data.success) { + throw new Error("Failed to fetch veraPDF version info from API"); + } + + const greenfield = data.releases.greenfield; + const versionMatch = greenfield.filename.match(/(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : "unknown"; + const downloadUrl = `https://software.verapdf.org${greenfield.path}`; + + return { + url: downloadUrl, + version, + assets: [{ name: greenfield.filename, url: downloadUrl }], + }; +} + +async function preparePackage(context: InstallContext): Promise { + const pkgInfo = await latestRelease(); + const version = pkgInfo.version; + const asset = pkgInfo.assets[0]; + const filePath = join(context.workingDir, asset.name); + + await context.download(`VeraPDF ${version}`, asset.url, filePath); + return { filePath, version }; +} + +async function install( + pkgInfo: PackageInfo, + context: InstallContext, +): Promise { + const targetDir = verapdfInstallDir(); + + // Extract the downloaded ZIP + await context.withSpinner( + { message: `Extracting ${basename(pkgInfo.filePath)}` }, + async () => { + await unzip(pkgInfo.filePath); + }, + ); + + // Find the installer JAR in the extracted files + // The ZIP extracts to a subdirectory like "verapdf-greenfield-1.28.2/" + const extractedDir = context.workingDir; + let installerJar: string | undefined; + + for await (const entry of Deno.readDir(extractedDir)) { + if (entry.isDirectory && entry.name.startsWith("verapdf-")) { + // Look inside the extracted subdirectory for the JAR + const subDir = join(extractedDir, entry.name); + for await (const subEntry of Deno.readDir(subDir)) { + if (subEntry.isFile && subEntry.name.endsWith(".jar")) { + installerJar = join(subDir, subEntry.name); + break; + } + } + if (installerJar) break; + } else if (entry.isFile && entry.name.endsWith(".jar")) { + // JAR might be at the root level + installerJar = join(extractedDir, entry.name); + break; + } + } + + if (!installerJar) { + context.error( + "Could not find veraPDF installer JAR in the downloaded package", + ); + return Promise.reject(); + } + + // Create auto-install.xml for headless installation + // Panel IDs and pack names must match the IzPack installer configuration + const autoInstallXml = ` + + + + ${targetDir} + + + + + + + + + + + +`; + + const autoInstallPath = join(extractedDir, "auto-install.xml"); + await Deno.writeTextFile(autoInstallPath, autoInstallXml); + + // Run the installer in headless mode + // Pass the auto-install XML path directly to the installer + await context.withSpinner( + { message: "Installing veraPDF" }, + async () => { + const result = await execProcess({ + cmd: "java", + args: ["-jar", installerJar!, autoInstallPath], + stdout: "piped", + stderr: "piped", + }); + + if (!result.success) { + const errorMsg = result.stderr || "Unknown error"; + throw new Error(`veraPDF installation failed: ${errorMsg}`); + } + }, + ); + + // Note the installed version + noteInstalledVersion(pkgInfo.version); +} + +async function afterInstall(context: InstallContext): Promise { + if (context.flags[kUpdatePath]) { + const dir = verapdfInstallDir(); + const verapdfBin = isWindows + ? join(dir, "verapdf.bat") + : join(dir, "verapdf"); + + await context.withSpinner( + { message: "Updating PATH" }, + async () => { + await createToolSymlink(verapdfBin, "verapdf", context); + }, + ); + + // On Windows, a restart may be needed + return isWindows; + } + + return false; +} + +async function uninstall(context: InstallContext): Promise { + // Remove symlinks if they exist + if (context.flags[kUpdatePath]) { + await removeToolSymlink("verapdf"); + } + + await context.withSpinner( + { message: "Removing veraPDF" }, + async () => { + const dir = verapdfInstallDir(); + if (existsSync(dir)) { + safeRemoveSync(dir, { recursive: true }); + } + }, + ); +} diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 01e0422ace0..82a7a6ce9cb 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -18,16 +18,21 @@ import { } from "./types.ts"; import { tinyTexInstallable } from "./impl/tinytex.ts"; import { chromiumInstallable } from "./impl/chromium.ts"; +import { verapdfInstallable } from "./impl/verapdf.ts"; import { downloadWithProgress } from "../core/download.ts"; import { Confirm } from "cliffy/prompt/mod.ts"; import { isWSL } from "../core/platform.ts"; -import { safeRemoveSync } from "../deno_ral/fs.ts"; +import { ensureDirSync, existsSync, safeRemoveSync } from "../deno_ral/fs.ts"; +import { join } from "../deno_ral/path.ts"; +import { expandPath, suggestUserBinPaths } from "../core/path.ts"; +import { isWindows } from "../deno_ral/platform.ts"; // The tools that are available to install const kInstallableTools: { [key: string]: InstallableTool } = { tinytex: tinyTexInstallable, // temporarily disabled until deno 1.28.* gets puppeteer support chromium: chromiumInstallable, + verapdf: verapdfInstallable, }; export async function allTools(): Promise<{ @@ -315,3 +320,76 @@ const installContext = ( }, }; }; + +// Shared utility functions for --update-path functionality + +/** + * Creates a symlink for a tool binary in a user bin directory. + * Returns true if successful, false otherwise. + */ +export async function createToolSymlink( + binaryPath: string, + symlinkName: string, + context: InstallContext, +): Promise { + if (isWindows) { + context.info( + `Add the tool's directory to your PATH to use ${symlinkName} from anywhere.`, + ); + return false; + } + + const binPaths = suggestUserBinPaths(); + if (binPaths.length === 0) { + context.info( + `No suitable bin directory found in PATH. Add the tool's directory to your PATH manually.`, + ); + return false; + } + + for (const binPath of binPaths) { + const expandedBinPath = expandPath(binPath); + ensureDirSync(expandedBinPath); + const symlinkPath = join(expandedBinPath, symlinkName); + + try { + // Remove existing symlink if present + if (existsSync(symlinkPath)) { + await Deno.remove(symlinkPath); + } + // Create new symlink + await Deno.symlink(binaryPath, symlinkPath); + return true; + } catch { + // Try next path + continue; + } + } + + context.info( + `Could not create symlink. Add the tool's directory to your PATH manually.`, + ); + return false; +} + +/** + * Removes a tool's symlink from user bin directories. + */ +export async function removeToolSymlink(symlinkName: string): Promise { + if (isWindows) { + return; + } + + const binPaths = suggestUserBinPaths(); + for (const binPath of binPaths) { + const symlinkPath = join(expandPath(binPath), symlinkName); + try { + const stat = await Deno.lstat(symlinkPath); + if (stat.isSymlink) { + await Deno.remove(symlinkPath); + } + } catch { + // Symlink doesn't exist, continue + } + } +} diff --git a/tests/docs/smoke-all/crossrefs/float/typst/typst-float-4.qmd b/tests/docs/smoke-all/crossrefs/float/typst/typst-float-4.qmd index b92aa195c98..1e7c5e192e0 100644 --- a/tests/docs/smoke-all/crossrefs/float/typst/typst-float-4.qmd +++ b/tests/docs/smoke-all/crossrefs/float/typst/typst-float-4.qmd @@ -6,10 +6,11 @@ _quarto: tests: typst: ensureTypstFileRegexMatches: - - + - - "#ref\\(, supplement: \\[Table\\]\\)" - "#ref\\(, supplement: \\[Figure\\]\\)" - - "#link\\(\"https://www.example.com/\"\\)\\[#.*image\\(\"img/surus.jpg\"\\)\\)" + # Images with captions now include alt text for accessibility + - '#link\("https://www\.example\.com/"\)\[#box\(image\("img/surus\.jpg", alt:' --- This tests: diff --git a/tests/docs/smoke-all/pdf-standard/.gitignore b/tests/docs/smoke-all/pdf-standard/.gitignore new file mode 100644 index 00000000000..5e7556938fc --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/.gitignore @@ -0,0 +1,5 @@ +# Generated test outputs (from keep-tex, keep-typ options) +*.typ +*.tex +*.pdf +*-luamml-mathml.html diff --git a/tests/docs/smoke-all/pdf-standard/alt-test.qmd b/tests/docs/smoke-all/pdf-standard/alt-test.qmd new file mode 100644 index 00000000000..0a1b35f669c --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/alt-test.qmd @@ -0,0 +1,8 @@ +--- +title: "Alt text test" +pdf-standard: ua-1 +keep-tex: true +--- + +![This is the alt text](penrose.svg) + diff --git a/tests/docs/smoke-all/pdf-standard/latex-combined.qmd b/tests/docs/smoke-all/pdf-standard/latex-combined.qmd new file mode 100644 index 00000000000..9a7f139410e --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-combined.qmd @@ -0,0 +1,17 @@ +--- +title: "LaTeX version + standards combined" +format: pdf +pdf-standard: ["1.7", a-2b, ua-1] +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfversion=1\.7', 'pdfstandard=\{a-2b,ua-1\}', 'tagging=on'] + - [] +--- + +# Test Document + +This tests PDF 1.7 with PDF/A-2b + PDF/UA-1. diff --git a/tests/docs/smoke-all/pdf-standard/latex-multi-standard.qmd b/tests/docs/smoke-all/pdf-standard/latex-multi-standard.qmd new file mode 100644 index 00000000000..ad76943c42d --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-multi-standard.qmd @@ -0,0 +1,17 @@ +--- +title: "LaTeX multiple PDF standards" +format: pdf +pdf-standard: [a-2b, ua-1] +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{a-2b,ua-1\}', 'tagging=on'] + - [] +--- + +# Test Document + +This tests combined PDF/A-2b + PDF/UA-1 output. diff --git a/tests/docs/smoke-all/pdf-standard/latex-pdfa-accessible.qmd b/tests/docs/smoke-all/pdf-standard/latex-pdfa-accessible.qmd new file mode 100644 index 00000000000..cc891b98df3 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-pdfa-accessible.qmd @@ -0,0 +1,18 @@ +--- +title: "LaTeX PDF/A-2a standard (accessible)" +format: pdf +pdf-standard: a-2a +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + # a-2a is "accessible" level - REQUIRES tagging + - ['\\DocumentMetadata\{', 'pdfstandard=\{a-2a\}', 'tagging=on'] + - [] +--- + +# Test Document + +This tests PDF/A-2a output with LaTeX. The "a" (accessible) level requires tagging. diff --git a/tests/docs/smoke-all/pdf-standard/latex-pdfa1b.qmd b/tests/docs/smoke-all/pdf-standard/latex-pdfa1b.qmd new file mode 100644 index 00000000000..0390105bff4 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-pdfa1b.qmd @@ -0,0 +1,24 @@ +--- +title: "PDF/A-1b compliance test" +lang: en +pdf-standard: a-1b +keep-tex: true +_quarto: + tests: + pdf: + # PDF/A-1b requires PDF 1.4 - this is automatically set for LaTeX + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfversion=1\.4', 'pdfstandard=\{a-1b\}'] + - [] + typst: + # Typst automatically uses PDF 1.4 for PDF/A-1b + noErrors: default +--- + +# PDF/A-1b Test + +PDF/A-1b is based on ISO 19005-1 and requires PDF 1.4. + +Both LaTeX and Typst automatically use PDF 1.4 when `pdf-standard: a-1b` +is specified, since PDF/A-1 is not compatible with higher PDF versions. diff --git a/tests/docs/smoke-all/pdf-standard/latex-pdfa3b.qmd b/tests/docs/smoke-all/pdf-standard/latex-pdfa3b.qmd new file mode 100644 index 00000000000..98a93e3793f --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-pdfa3b.qmd @@ -0,0 +1,24 @@ +--- +title: "PDF/A-3b compliance test" +lang: en +pdf-standard: a-3b +keep-tex: true +keep-typ: true +_quarto: + tests: + pdf: + # PDF/A-3b requires PDF 1.7 - this is automatically set for LaTeX + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfversion=1\.7', 'pdfstandard=\{a-3b\}'] + - [] + typst: + noErrors: default +--- + +# PDF/A-3b Test + +PDF/A-3b is based on ISO 19005-3 which extends PDF/A-2 with embedded file support. + +Like PDF/A-2, PDF/A-3 has a maximum PDF version requirement of 1.7. +Both LaTeX and Typst automatically set PDF version 1.7 when `pdf-standard: a-3b` is specified. diff --git a/tests/docs/smoke-all/pdf-standard/latex-pdfversion.qmd b/tests/docs/smoke-all/pdf-standard/latex-pdfversion.qmd new file mode 100644 index 00000000000..4e0ba58d8fc --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-pdfversion.qmd @@ -0,0 +1,18 @@ +--- +title: "LaTeX PDF version" +format: pdf +pdf-standard: "1.7" +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + # Version-only should NOT enable tagging (tagging is independent of version) + - ['\\DocumentMetadata\{', 'pdfversion=1\.7'] + - ['tagging=on'] +--- + +# Test Document + +This tests explicit PDF version 1.7 without any standard requiring tagging. diff --git a/tests/docs/smoke-all/pdf-standard/latex-unsupported-warning.qmd b/tests/docs/smoke-all/pdf-standard/latex-unsupported-warning.qmd new file mode 100644 index 00000000000..3e24f2650dc --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/latex-unsupported-warning.qmd @@ -0,0 +1,17 @@ +--- +title: "LaTeX unsupported standard warning" +format: pdf +pdf-standard: a-1a +keep-tex: true +_quarto: + tests: + pdf: + noErrors: default + printsMessage: + level: WARN + regex: "PDF standard 'a-1a' is not supported by LaTeX" +--- + +# Test Document + +This tests that PDF/A-1a (Typst-only) produces a warning with LaTeX. diff --git a/tests/docs/smoke-all/pdf-standard/pdfa-2b.qmd b/tests/docs/smoke-all/pdf-standard/pdfa-2b.qmd new file mode 100644 index 00000000000..507403d89bf --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/pdfa-2b.qmd @@ -0,0 +1,28 @@ +--- +title: "PDF/A-2b standard" +lang: en +pdf-standard: a-2b +keep-tex: true +keep-typ: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + # a-2b is "basic" level - does NOT require tagging + # PDF/A-2 requires maximum PDF version 1.7 + - ['\\DocumentMetadata\{', 'pdfversion=1\.7', 'pdfstandard=\{a-2b\}'] + - ['tagging=on'] + typst: + noErrors: default +--- + +# Test Document + +This tests PDF/A-2b output with both LaTeX and Typst. + +PDF/A-2 is based on ISO 32000-1 (PDF 1.7) and has a maximum PDF version of 1.7. +Both LaTeX and Typst automatically use PDF 1.7 when `pdf-standard: a-2b` is specified. diff --git a/tests/docs/smoke-all/pdf-standard/pdfversion-numeric.qmd b/tests/docs/smoke-all/pdf-standard/pdfversion-numeric.qmd new file mode 100644 index 00000000000..8226dc51f46 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/pdfversion-numeric.qmd @@ -0,0 +1,24 @@ +--- +title: "PDF version (numeric YAML)" +lang: en +pdf-standard: 2.0 +keep-tex: true +keep-typ: true +_quarto: + tests: + pdf: + # Test that YAML-parsed numeric version (2.0 -> 2) is handled correctly + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfversion=2\.0'] + - [] + typst: + # Test that YAML-parsed numeric version (2.0 -> 2) is handled correctly + # pdf-version is passed to typst CLI as --pdf-standard, not in .typ file + noErrors: default +--- + +# Test Document + +This tests that numeric PDF versions in YAML (like `2.0` which YAML parses as integer `2`) +are correctly handled and converted to the proper version string for both LaTeX and Typst. diff --git a/tests/docs/smoke-all/pdf-standard/penrose.svg b/tests/docs/smoke-all/pdf-standard/penrose.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/penrose.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc1-figure.svg b/tests/docs/smoke-all/pdf-standard/tc1-figure.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc1-figure.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc2-inline.svg b/tests/docs/smoke-all/pdf-standard/tc2-inline.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc2-inline.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc3-explicit.svg b/tests/docs/smoke-all/pdf-standard/tc3-explicit.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc3-explicit.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc4-dimensions.svg b/tests/docs/smoke-all/pdf-standard/tc4-dimensions.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc4-dimensions.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc5-quotes.svg b/tests/docs/smoke-all/pdf-standard/tc5-quotes.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc5-quotes.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc6-backslash.svg b/tests/docs/smoke-all/pdf-standard/tc6-backslash.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc6-backslash.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/tc7-no-alt.svg b/tests/docs/smoke-all/pdf-standard/tc7-no-alt.svg new file mode 100644 index 00000000000..30308f170f9 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/tc7-no-alt.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd b/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd new file mode 100644 index 00000000000..c9070ce0e82 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/typst-image-alt-text.qmd @@ -0,0 +1,57 @@ +--- +title: "Typst image alt text support" +format: + typst: + keep-typ: true +_quarto: + tests: + typst: + noErrors: default + ensureTypstFileRegexMatches: + - # Patterns that MUST be found - alt text in image() calls + - 'image\("tc1-figure\.svg",\s*alt:\s*"TC1 figure caption as alt' + - 'image\("tc2-inline\.svg",\s*alt:\s*"TC2 inline image' + - 'image\("tc3-explicit\.svg",\s*alt:\s*"TC3 explicit alt attribute' + - 'image\("tc4-dimensions\.svg",\s*alt:\s*"TC4 with dimensions",\s*height:\s*1in,\s*width:\s*1in' + - 'image\("tc5-quotes\.svg",\s*alt:\s*"TC5 with \\"escaped\\" quotes' + - 'image\("tc6-backslash\.svg",\s*alt:\s*"TC6 backslash C:\\\\path' + # TC7 should have the image but without alt parameter + - 'image\("tc7-no-alt\.svg"\)' + - # Patterns that must NOT be found + # TC7 with no caption/alt should NOT have alt parameter + - 'tc7-no-alt\.svg.*alt:' +--- + +# Test Document: Typst Image Alt Text + +This tests that Quarto passes alt text to Typst's `image()` function for PDF/UA accessibility. + +## TC1: Figure with alt text from caption + +![TC1 figure caption as alt text](tc1-figure.svg) + +## TC2: Inline image with alt text + +Here is an icon ![TC2 inline image](tc2-inline.svg) in the text. + +## TC3: Explicit alt attribute (different from caption) + +![Visible caption](tc3-explicit.svg){alt="TC3 explicit alt attribute"} + +## TC4: Image with dimensions should preserve alt + +Here is ![TC4 with dimensions](tc4-dimensions.svg){width=1in height=1in} inline. + +## TC5: Alt text with special characters (quotes) + +![Caption](tc5-quotes.svg){alt="TC5 with \"escaped\" quotes"} + +## TC6: Alt text with backslashes + +![Caption](tc6-backslash.svg){alt="TC6 backslash C:\\path\\file"} + +## TC7: Image with no alt text (should omit alt parameter) + +This image has no caption and no alt attribute. + +![](tc7-no-alt.svg) diff --git a/tests/docs/smoke-all/pdf-standard/typst-unsupported-warning.qmd b/tests/docs/smoke-all/pdf-standard/typst-unsupported-warning.qmd new file mode 100644 index 00000000000..74f2e721323 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/typst-unsupported-warning.qmd @@ -0,0 +1,17 @@ +--- +title: "Typst unsupported standard warning" +format: typst +pdf-standard: x-4 +keep-typ: true +_quarto: + tests: + typst: + noErrors: default + printsMessage: + level: WARN + regex: "PDF standard 'x-4' is not supported by Typst" +--- + +# Test Document + +This tests that PDF/X-4 (LaTeX-only) produces a warning with Typst. diff --git a/tests/docs/smoke-all/pdf-standard/typst-version-and-standard.qmd b/tests/docs/smoke-all/pdf-standard/typst-version-and-standard.qmd new file mode 100644 index 00000000000..6a569e372de --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/typst-version-and-standard.qmd @@ -0,0 +1,14 @@ +--- +title: "Typst PDF version + standard" +format: typst +pdf-standard: ["1.7", a-2b] +keep-typ: true +_quarto: + tests: + typst: + noErrors: default +--- + +# Test Document + +This tests combined PDF 1.7 + PDF/A-2b output with Typst. diff --git a/tests/docs/smoke-all/pdf-standard/ua1-compliant.qmd b/tests/docs/smoke-all/pdf-standard/ua1-compliant.qmd new file mode 100644 index 00000000000..33519da149e --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-compliant.qmd @@ -0,0 +1,24 @@ +--- +title: "UA-1 with language set" +lang: en +pdf-standard: ua-1 +keep-tex: true +keep-typ: true +_quarto: + tests: + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-1\}', 'tagging=on'] + - [] + printsMessage: + level: WARN + regex: "Document language not set" + negate: true + typst: + noErrors: default +--- + +# Test Document + +This document has UA-1 with language properly set, so no language warning should appear. diff --git a/tests/docs/smoke-all/pdf-standard/ua1-definition-list.qmd b/tests/docs/smoke-all/pdf-standard/ua1-definition-list.qmd new file mode 100644 index 00000000000..039a8dca060 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-definition-list.qmd @@ -0,0 +1,26 @@ +--- +title: "UA-1 definition list accessibility" +lang: en +pdf-standard: ua-1 +keep-typ: true +_quarto: + tests: + typst: + # Definition lists should pass PDF/UA-1 validation + # Fix: https://github.com/quarto-dev/quarto-cli/pull/13249#discussion_r2678934509 + noErrors: default +--- + +# Definition List Test + +This tests that definition lists are accessible in PDF/UA-1. + +Term 1 +: Definition for term 1 + +Term 2 +: Definition for term 2 + +Term 3 +: Definition for term 3 with more detailed explanation + that spans multiple lines. diff --git a/tests/docs/smoke-all/pdf-standard/ua1-figure-missing-alt.qmd b/tests/docs/smoke-all/pdf-standard/ua1-figure-missing-alt.qmd new file mode 100644 index 00000000000..9121fe22fa5 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-figure-missing-alt.qmd @@ -0,0 +1,36 @@ +--- +title: "UA-1 figure missing alt text" +lang: en +pdf-standard: ua-1 +keep-tex: true +keep-typ: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + # LaTeX generates a proper \begin{figure} environment + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-1\}', 'tagging=on', '\\begin\{figure\}'] + - [] + # NOTE: verapdf passes because LaTeX's tagging=on doesn't create proper + # Figure structure elements in the PDF. This is a LaTeX limitation. + typst: + # Typst correctly errors at compile time for missing alt text + shouldError: default + printsMessage: + level: ERROR + regex: "missing alt text" +--- + +# Test Document + +This is a proper figure with a cross-reference label but no alt text. +See @fig-penrose for the image. + +- **Typst**: Correctly errors at compile time ("PDF/UA-1 error: missing alt text") +- **LaTeX**: Passes verapdf because `tagging=on` doesn't create proper Figure structure elements + +![](penrose.svg){#fig-penrose} diff --git a/tests/docs/smoke-all/pdf-standard/ua1-image-alt-text.qmd b/tests/docs/smoke-all/pdf-standard/ua1-image-alt-text.qmd new file mode 100644 index 00000000000..313d2560d7a --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-image-alt-text.qmd @@ -0,0 +1,28 @@ +--- +title: "UA-1 image with alt text" +lang: en +pdf-standard: ua-1 +keep-tex: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + # Alt text MUST be passed to \includegraphics[alt={...}] for PDF/UA + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-1\}', 'tagging=on', 'includegraphics\[.*alt='] + - [] + printsMessage: + # Should NOT warn about missing alt text since we provided it + level: WARN + regex: "PDF accessibility:.*Missing alt text" + negate: true +--- + +# Test Document + +This image has alt text which should be passed through to LaTeX's `\includegraphics[alt={...}]` for PDF/UA compliance. + +![Test image description](penrose.svg) diff --git a/tests/docs/smoke-all/pdf-standard/ua1-math-test.qmd b/tests/docs/smoke-all/pdf-standard/ua1-math-test.qmd new file mode 100644 index 00000000000..a5d2d744dd8 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-math-test.qmd @@ -0,0 +1,39 @@ +--- +title: "UA-1 math without alt text" +lang: en +pdf-standard: ua-1 +keep-tex: true +keep-typ: true +_quarto: + tests: + pdf: + # LaTeX with tagging=on + unicode-math auto-generates MathML for accessibility + noErrors: default + typst: + # Typst requires explicit alt text for math in PDF/UA-1 + shouldError: default + printsMessage: + level: ERROR + regex: "missing alt text" +--- + +# Math Without Alt Text in PDF/UA-1 + +PDF/UA-1 requires alternative text for Formula elements. + +- **LaTeX**: Auto-generates MathML via `tagging=on` + `unicode-math` (LuaLaTeX) +- **Typst**: Requires explicit alt text - errors at compile time + +Note: PR #13872 adds `alt` attribute support for equations in Quarto. + +## Inline Math + +The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$. + +## Display Math + +$$E = mc^2$$ + +## Integral + +$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$ diff --git a/tests/docs/smoke-all/pdf-standard/ua1-math-with-alt.qmd b/tests/docs/smoke-all/pdf-standard/ua1-math-with-alt.qmd new file mode 100644 index 00000000000..8619025ce2e --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-math-with-alt.qmd @@ -0,0 +1,45 @@ +--- +title: "UA-1 math with alt text" +lang: en +pdf-standard: ua-1 +keep-tex: true +keep-typ: true +_quarto: + tests: + pdf: + noErrors: default + typst: + # TODO: Remove shouldError once PR #13872 is merged + # PR #13872 adds alt attribute support for equations + # Once merged, this test should pass with noErrors: default + shouldError: default + printsMessage: + level: ERROR + regex: "missing alt text" +--- + +# Math With Alt Text in PDF/UA-1 + +This tests equation alt text for PDF/UA-1 accessibility. + +See @eq-einstein for Einstein's famous equation. + +$$ +E = mc^2 +$$ {#eq-einstein alt="E equals m c squared"} + +See @eq-quadratic for the quadratic formula. + +$$ +x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} +$$ {#eq-quadratic alt="x equals negative b plus or minus the square root of b squared minus 4 a c, all over 2 a"} + +See @eq-integral for an integral using div syntax. + +::: {#eq-integral alt="The integral from 0 to infinity of e to the negative x squared equals square root of pi over 2"} + +$$ +\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2} +$$ + +::: diff --git a/tests/docs/smoke-all/pdf-standard/ua1-missing-alt.qmd b/tests/docs/smoke-all/pdf-standard/ua1-missing-alt.qmd new file mode 100644 index 00000000000..cd470405f71 --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-missing-alt.qmd @@ -0,0 +1,37 @@ +--- +title: "UA-1 inline image missing alt text" +pdf-standard: ua-1 +keep-tex: true +keep-typ: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-1\}', 'tagging=on'] + - [] + printsMessage: + level: WARN + regex: "PDF accessibility:" + # NOTE: verapdf passes because LaTeX's tagging=on doesn't create proper + # structure elements for inline images. This is a LaTeX limitation. + typst: + # Typst correctly errors at compile time for missing alt text + shouldError: default + printsMessage: + level: ERROR + regex: "missing alt text" +--- + +# Test Document + +This inline image has no alt text. + +- **Typst**: Correctly errors at compile time ("PDF/UA-1 error: missing alt text") +- **LaTeX**: Warns about missing alt text but verapdf passes because `tagging=on` + doesn't create proper Figure structure elements for inline images + +![](penrose.svg) diff --git a/tests/docs/smoke-all/pdf-standard/ua1-missing-title.qmd b/tests/docs/smoke-all/pdf-standard/ua1-missing-title.qmd new file mode 100644 index 00000000000..4d99a807e1c --- /dev/null +++ b/tests/docs/smoke-all/pdf-standard/ua1-missing-title.qmd @@ -0,0 +1,34 @@ +--- +pdf-standard: ua-1 +lang: en +keep-tex: true +keep-typ: true +_quarto: + tests: + run: + # verapdf validation not available on Windows CI + not_os: windows + pdf: + noErrors: default + ensureLatexFileRegexMatches: + - ['\\DocumentMetadata\{', 'pdfstandard=\{ua-1\}', 'tagging=on'] + - [] + printsMessage: + # verapdf correctly catches missing document title + level: WARN + regex: "PDF validation failed for ua-1" + typst: + # Typst correctly errors at compile time for missing title + shouldError: default + printsMessage: + level: ERROR + regex: "missing document title" +--- + +# Content without document title + +PDF/UA-1 requires a document title in the metadata. This document has no `title:` +in the YAML front matter. + +- **LaTeX**: Renders but verapdf fails with "dc:title entry" error +- **Typst**: Correctly errors at compile time with "missing document title" diff --git a/tests/docs/smoke-all/typst/.gitignore b/tests/docs/smoke-all/typst/.gitignore new file mode 100644 index 00000000000..3e0982561e1 --- /dev/null +++ b/tests/docs/smoke-all/typst/.gitignore @@ -0,0 +1,3 @@ +# Generated test outputs (from keep-typ option) +*.typ +*.pdf diff --git a/tests/docs/smoke-all/typst/url-image-mediabag.qmd b/tests/docs/smoke-all/typst/url-image-mediabag.qmd new file mode 100644 index 00000000000..9b9228ff566 --- /dev/null +++ b/tests/docs/smoke-all/typst/url-image-mediabag.qmd @@ -0,0 +1,21 @@ +--- +title: URL image mediabag handling +format: typst +keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + # Image should be extracted to mediabag, not remain as URL + - ['image\("url-image-mediabag_files(/|\\\\)mediabag(/|\\\\)400'] + # Should NOT contain the raw URL - it must be extracted to mediabag + - ['https://placehold\.co/400'] +--- + +# URL Image Test + +This tests that URL images are correctly extracted to the mediabag +when rendering to Typst format. The image below should be fetched +from the URL and stored locally, not left as a raw URL. + +![Placeholder image from URL](https://placehold.co/400){alt="A 400x400 placeholder image"}