Skip to content

Commit a867c3c

Browse files
Add pdf-standard option with verapdf validation
Adds `pdf-standard` option for LaTeX and Typst PDF output supporting: - PDF versions: 1.4, 1.5, 1.6, 1.7, 2.0 - PDF/A: a-1b, a-2a, a-2b, a-2u, a-3a, a-3b, a-3u, a-4, a-4e, a-4f - PDF/UA: ua-1, ua-2 (LaTeX only) - PDF/X: x-4, x-4p, x-5g, x-5n, x-5pg, x-6, x-6n, x-6p (LaTeX only) Features: - Automatic PDF version inference from standard requirements - LaTeX image alt text propagation for PDF/UA compliance - Tagging enabled only for standards that require it - `quarto install verapdf` for PDF/A and PDF/UA validation - Automatic validation when verapdf is available Closes #4426, #13782, #13248 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 48b0c24 commit a867c3c

42 files changed

Lines changed: 1584 additions & 12 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-smokes.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ jobs:
141141
142142
- name: Install rsvg-convert for SVG conversion tests
143143
# Only do it on linux runner, and only if we are running the relevant tests
144-
if: runner.os == 'Linux' && contains(inputs.buckets, 'render-pdf-svg-conversion')
144+
if: runner.os == 'Linux' && (contains(inputs.buckets, 'render-pdf-svg-conversion') || contains(inputs.buckets, 'pdf-standard'))
145145
run: |
146146
sudo apt-get update -y
147147
sudo apt-get install -y librsvg2-bin
@@ -201,6 +201,13 @@ jobs:
201201
run: |
202202
quarto install tinytex
203203
204+
- name: Install veraPDF for PDF standard validation
205+
if: runner.os == 'Linux' && contains(inputs.buckets, 'pdf-standard')
206+
env:
207+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
208+
run: |
209+
quarto install verapdf
210+
204211
- name: Cache Typst packages
205212
id: cache-typst
206213
uses: ./.github/actions/cache-typst

news/changelog-1.9.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ All changes included in 1.9:
5555

5656
### `pdf`
5757

58+
- ([#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.
5859
- ([#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.
60+
- ([#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.
5961
- ([#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.
6062
- ([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.
6163
- ([#13667](https://github.com/quarto-dev/quarto-cli/issues/13667)): Fix LaTeX compilation error with Python error output containing caret characters.
@@ -113,6 +115,10 @@ All changes included in 1.9:
113115

114116
- (): 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.
115117

118+
### `install verapdf`
119+
120+
- ([#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`.
121+
116122
## Extensions
117123

118124
- Metadata and brand extensions now work without a `_quarto.yml` project. (Engine extensions do too.) A temporary default project is created in memory.

src/command/render/latexmk/parse-error.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,53 @@ const resolvingMatchers = [
107107
},
108108
];
109109

110+
// Finds PDF/UA accessibility warnings from tagpdf and DocumentMetadata
111+
export interface PdfAccessibilityWarnings {
112+
missingAltText: string[]; // filenames of images missing alt text
113+
missingLanguage: boolean; // document language not set
114+
otherWarnings: string[]; // other tagpdf warnings
115+
}
116+
117+
export function findPdfAccessibilityWarnings(
118+
logText: string,
119+
): PdfAccessibilityWarnings {
120+
const result: PdfAccessibilityWarnings = {
121+
missingAltText: [],
122+
missingLanguage: false,
123+
otherWarnings: [],
124+
};
125+
126+
// Match: Package tagpdf Warning: Alternative text for graphic is missing.
127+
// (tagpdf) Using 'filename' instead.
128+
const altTextRegex =
129+
/Package tagpdf Warning: Alternative text for graphic is missing\.\s*\n\(tagpdf\)\s*Using ['`]([^'`]+)['`] instead\./g;
130+
let match;
131+
while ((match = altTextRegex.exec(logText)) !== null) {
132+
result.missingAltText.push(match[1]);
133+
}
134+
135+
// Match: LaTeX DocumentMetadata Warning: The language has not been set in
136+
if (
137+
/LaTeX DocumentMetadata Warning: The language has not been set in/.test(
138+
logText,
139+
)
140+
) {
141+
result.missingLanguage = true;
142+
}
143+
144+
// Capture any other tagpdf warnings we haven't specifically handled
145+
const otherTagpdfRegex = /Package tagpdf Warning: ([^\n]+)/g;
146+
while ((match = otherTagpdfRegex.exec(logText)) !== null) {
147+
const warning = match[1];
148+
// Skip the alt text warning we already handle specifically
149+
if (!warning.startsWith("Alternative text for graphic is missing")) {
150+
result.otherWarnings.push(warning);
151+
}
152+
}
153+
154+
return result;
155+
}
156+
110157
// Finds missing hyphenation files (these appear as warnings in the log file)
111158
export function findMissingHyphenationFiles(logText: string) {
112159
//ngerman gets special cased
@@ -273,6 +320,19 @@ const packageMatchers = [
273320
return "colorprofiles.sty";
274321
},
275322
},
323+
{
324+
regex: /.*No support files for \\DocumentMetadata found.*/g,
325+
filter: (_match: string, _text: string) => {
326+
return "latex-lab";
327+
},
328+
},
329+
{
330+
// PDF/A requires embedded color profiles - pdfmanagement-testphase needs colorprofiles
331+
regex: /.*\(pdf backend\): cannot open file for embedding.*/g,
332+
filter: (_match: string, _text: string) => {
333+
return "colorprofiles";
334+
},
335+
},
276336
{
277337
regex: /.*No file ([^`'. ]+[.]fd)[.].*/g,
278338
filter: (match: string, _text: string) => {
@@ -297,7 +357,7 @@ const packageMatchers = [
297357
];
298358

299359
function fontSearchTerm(font: string): string {
300-
const fontPattern = font.replace(/\s+/g, '\\s*');
360+
const fontPattern = font.replace(/\s+/g, "\\s*");
301361
return `${fontPattern}(-(Bold|Italic|Regular).*)?[.](tfm|afm|mf|otf|ttf)`;
302362
}
303363

src/command/render/latexmk/pdf.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
findLatexError,
2222
findMissingFontsAndPackages,
2323
findMissingHyphenationFiles,
24+
findPdfAccessibilityWarnings,
2425
kMissingFontLog,
2526
needsRecompilation,
2627
} from "./parse-error.ts";
@@ -197,6 +198,25 @@ async function initialCompileLatex(
197198
`Possibly missing hyphenation file: '${missingHyphenationFile}'. See more in logfile (by setting 'latex-clean: false').\n`,
198199
);
199200
}
201+
202+
// Check for accessibility warnings (e.g., missing alt text, language with PDF/UA)
203+
const accessibilityWarnings = findPdfAccessibilityWarnings(logText);
204+
if (accessibilityWarnings.missingAltText.length > 0) {
205+
const fileList = accessibilityWarnings.missingAltText.join(", ");
206+
warning(
207+
`PDF accessibility: Missing alt text for image(s): ${fileList}. Add alt text using ![alt text](image.png) syntax for PDF/UA compliance.\n`,
208+
);
209+
}
210+
if (accessibilityWarnings.missingLanguage) {
211+
warning(
212+
`PDF accessibility: Document language not set. Add 'lang: en' (or appropriate language) to document metadata for PDF/UA compliance.\n`,
213+
);
214+
}
215+
if (accessibilityWarnings.otherWarnings.length > 0) {
216+
for (const warn of accessibilityWarnings.otherWarnings) {
217+
warning(`PDF accessibility: ${warn}\n`);
218+
}
219+
}
200220
} else if (pkgMgr.autoInstall) {
201221
// try autoinstalling
202222
// First be sure all packages are up to date

src/command/render/latexmk/texlive.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,11 @@ export async function findPackages(
8686
`finding package for ${searchTerm}`,
8787
);
8888
}
89-
// Special case for a known package
89+
// Special cases for known packages where tlmgr file search doesn't work
9090
// https://github.com/rstudio/tinytex/blob/33cbe601ff671fae47c594250de1d22bbf293b27/R/latex.R#L470
91-
if (searchTerm === "fandol") {
92-
results.push("fandol");
91+
const knownPackages = ["fandol", "latex-lab", "colorprofiles"];
92+
if (knownPackages.includes(searchTerm)) {
93+
results.push(searchTerm);
9394
} else {
9495
const result = await tlmgrCommand(
9596
"search",

src/command/render/output-tex.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ import {
1515
kKeepTex,
1616
kOutputExt,
1717
kOutputFile,
18+
kPdfStandard,
1819
kTargetFormat,
1920
} from "../../config/constants.ts";
2021
import { Format } from "../../config/types.ts";
22+
import { asArray } from "../../core/array.ts";
23+
import { validatePdfStandards } from "../../core/verapdf.ts";
2124

2225
import { PandocOptions, RenderFlags, RenderOptions } from "./types.ts";
2326
import { kStdOut, replacePandocOutputArg } from "./flags.ts";
@@ -80,6 +83,16 @@ export function texToPdfOutputRecipe(
8083
const input = join(inputDir, output);
8184
const pdfOutput = await pdfGenerator.generate(input, format, pandocOptions);
8285

86+
// Validate PDF against specified standards using verapdf (if available)
87+
const pdfStandards = asArray(
88+
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],
89+
) as string[];
90+
if (pdfStandards.length > 0) {
91+
await validatePdfStandards(pdfOutput, pdfStandards, {
92+
quiet: pandocOptions.flags?.quiet,
93+
});
94+
}
95+
8396
// keep tex if requested
8497
const compileTex = join(inputDir, output);
8598
if (!format.render[kKeepTex]) {

src/command/render/output-typst.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import {
2323
kKeepTyp,
2424
kOutputExt,
2525
kOutputFile,
26+
kPdfStandard,
2627
kVariant,
2728
} from "../../config/constants.ts";
29+
import { error, warning } from "../../deno_ral/log.ts";
2830
import { Format } from "../../config/types.ts";
2931
import { writeFileToStdout } from "../../core/console.ts";
3032
import { dirAndStem, expandPath } from "../../core/path.ts";
@@ -38,6 +40,7 @@ import {
3840
} from "../../core/typst.ts";
3941
import { asArray } from "../../core/array.ts";
4042
import { ProjectContext } from "../../project/types.ts";
43+
import { validatePdfStandards } from "../../core/verapdf.ts";
4144

4245
// Stage typst packages to .quarto/typst-packages/
4346
// First stages built-in packages, then extension packages (which can override)
@@ -137,6 +140,11 @@ export function typstPdfOutputRecipe(
137140
const typstOptions: TypstCompileOptions = {
138141
quiet: options.flags?.quiet,
139142
fontPaths: asArray(format.metadata?.[kFontPaths]) as string[],
143+
pdfStandard: normalizePdfStandardForTypst(
144+
asArray(
145+
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],
146+
),
147+
),
140148
};
141149
if (project?.dir) {
142150
typstOptions.rootDir = project.dir;
@@ -153,7 +161,21 @@ export function typstPdfOutputRecipe(
153161
typstOptions,
154162
);
155163
if (!result.success) {
156-
throw new Error();
164+
// Log the error so test framework can detect it via shouldError
165+
if (result.stderr) {
166+
error(result.stderr);
167+
}
168+
throw new Error("Typst compilation failed");
169+
}
170+
171+
// Validate PDF against specified standards using verapdf (if available)
172+
const pdfStandards = asArray(
173+
format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],
174+
) as string[];
175+
if (pdfStandards.length > 0) {
176+
await validatePdfStandards(pdfOutput, pdfStandards, {
177+
quiet: options.flags?.quiet,
178+
});
157179
}
158180

159181
// keep typ if requested
@@ -217,3 +239,50 @@ export function typstPdfOutputRecipe(
217239

218240
return recipe;
219241
}
242+
243+
// Typst-supported PDF standards
244+
const kTypstSupportedStandards = new Set([
245+
"1.4",
246+
"1.5",
247+
"1.6",
248+
"1.7",
249+
"2.0",
250+
"a-1b",
251+
"a-1a",
252+
"a-2b",
253+
"a-2u",
254+
"a-2a",
255+
"a-3b",
256+
"a-3u",
257+
"a-3a",
258+
"a-4",
259+
"a-4f",
260+
"a-4e",
261+
"ua-1",
262+
]);
263+
264+
function normalizePdfStandardForTypst(standards: unknown[]): string[] {
265+
const result: string[] = [];
266+
for (const s of standards) {
267+
// Convert to string - YAML may parse versions like 2.0 as integer 2
268+
let str: string;
269+
if (typeof s === "number") {
270+
// Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4"
271+
str = Number.isInteger(s) ? `${s}.0` : String(s);
272+
} else if (typeof s === "string") {
273+
str = s;
274+
} else {
275+
continue;
276+
}
277+
// Normalize: lowercase, remove any "pdf" prefix
278+
const normalized = str.toLowerCase().replace(/^pdf[/-]?/, "");
279+
if (kTypstSupportedStandards.has(normalized)) {
280+
result.push(normalized);
281+
} else {
282+
warning(
283+
`PDF standard '${s}' is not supported by Typst and will be ignored`,
284+
);
285+
}
286+
}
287+
return result;
288+
}

src/config/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const kShortcodes = "shortcodes";
8686
export const kKeepMd = "keep-md";
8787
export const kKeepTex = "keep-tex";
8888
export const kKeepTyp = "keep-typ";
89+
export const kPdfStandard = "pdf-standard";
8990
export const kKeepIpynb = "keep-ipynb";
9091
export const kKeepSource = "keep-source";
9192
export const kVariant = "variant";
@@ -219,6 +220,7 @@ export const kRenderDefaultsKeys = [
219220
kLatexTlmgrOpts,
220221
kLatexOutputDir,
221222
kLatexTinyTex,
223+
kPdfStandard,
222224
kLinkExternalIcon,
223225
kLinkExternalNewwindow,
224226
kLinkExternalFilter,
@@ -686,6 +688,7 @@ export const kPandocDefaultsKeys = [
686688
kPdfEngine,
687689
kPdfEngineOpts,
688690
kPdfEngineOpt,
691+
kPdfStandard,
689692
kWrap,
690693
kColumns,
691694
"dpi",

src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ import {
176176
kPdfEngine,
177177
kPdfEngineOpt,
178178
kPdfEngineOpts,
179+
kPdfStandard,
179180
kPlotlyConnected,
180181
kPreferHtml,
181182
kPreserveYaml,
@@ -492,6 +493,7 @@ export interface FormatRender {
492493
[kLatexMinRuns]?: number;
493494
[kLatexMaxRuns]?: number;
494495
[kLatexClean]?: boolean;
496+
[kPdfStandard]?: string | string[];
495497
[kLatexInputPaths]?: string[];
496498
[kLatexMakeIndex]?: string;
497499
[kLatexMakeIndexOpts]?: string[];

src/core/typst.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type TypstCompileOptions = {
4141
fontPaths?: string[];
4242
rootDir?: string;
4343
packagePath?: string;
44+
pdfStandard?: string[];
4445
};
4546

4647
export async function typstCompile(
@@ -72,6 +73,9 @@ export async function typstCompile(
7273
cmd.push("--package-cache-path", options.packagePath);
7374
}
7475
}
76+
if (options.pdfStandard && options.pdfStandard.length > 0) {
77+
cmd.push("--pdf-standard", options.pdfStandard.join(","));
78+
}
7579
cmd.push(
7680
input,
7781
...fontPathsArgs(fontPaths),

0 commit comments

Comments
 (0)