Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,13 @@ 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.)

### `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.
Expand Down
62 changes: 61 additions & 1 deletion src/command/render/latexmk/parse-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)`;
}

Expand Down
20 changes: 20 additions & 0 deletions src/command/render/latexmk/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
findLatexError,
findMissingFontsAndPackages,
findMissingHyphenationFiles,
findPdfAccessibilityWarnings,
kMissingFontLog,
needsRecompilation,
} from "./parse-error.ts";
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/command/render/latexmk/texlive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions src/command/render/output-typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
kKeepTyp,
kOutputExt,
kOutputFile,
kPdfStandard,
kVariant,
} from "../../config/constants.ts";
import { 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";
Expand Down Expand Up @@ -66,6 +68,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;
Expand Down Expand Up @@ -140,3 +147,41 @@ 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) {
if (typeof s !== "string") continue;
// Normalize: lowercase, remove any "pdf" prefix
const normalized = s.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;
}
3 changes: 3 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -219,6 +220,7 @@ export const kRenderDefaultsKeys = [
kLatexTlmgrOpts,
kLatexOutputDir,
kLatexTinyTex,
kPdfStandard,
kLinkExternalIcon,
kLinkExternalNewwindow,
kLinkExternalFilter,
Expand Down Expand Up @@ -686,6 +688,7 @@ export const kPandocDefaultsKeys = [
kPdfEngine,
kPdfEngineOpts,
kPdfEngineOpt,
kPdfStandard,
kWrap,
kColumns,
"dpi",
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ import {
kPdfEngine,
kPdfEngineOpt,
kPdfEngineOpts,
kPdfStandard,
kPlotlyConnected,
kPreferHtml,
kPreserveYaml,
Expand Down Expand Up @@ -492,6 +493,7 @@ export interface FormatRender {
[kLatexMinRuns]?: number;
[kLatexMaxRuns]?: number;
[kLatexClean]?: boolean;
[kPdfStandard]?: string | string[];
[kLatexInputPaths]?: string[];
[kLatexMakeIndex]?: string;
[kLatexMakeIndexOpts]?: string[];
Expand Down
4 changes: 4 additions & 0 deletions src/core/typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type TypstCompileOptions = {
quiet?: boolean;
fontPaths?: string[];
rootDir?: string;
pdfStandard?: string[];
};

export async function typstCompile(
Expand All @@ -58,6 +59,9 @@ export async function typstCompile(
if (options.rootDir) {
cmd.push("--root", options.rootDir);
}
if (options.pdfStandard && options.pdfStandard.length > 0) {
cmd.push("--pdf-standard", options.pdfStandard.join(","));
}
cmd.push(
input,
...fontPathsArgs(fontPaths),
Expand Down
98 changes: 98 additions & 0 deletions src/format/pdf/format-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -254,6 +257,7 @@ function createPdfFormat(
const partialNamesPandoc: string[] = [
"after-header-includes",
"common",
"document-metadata",
"font-settings",
"fonts",
"hypersetup",
Expand Down Expand Up @@ -313,6 +317,28 @@ 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);
extras.pandoc.variables = extras.pandoc.variables || {};
extras.pandoc.variables["pdf-standard"] = true; // Enable the partial
if (version) {
extras.pandoc.variables["pdf-version"] = version;
}
if (standards.length > 0) {
// Pass as array - Pandoc template will iterate with $for()$
extras.pandoc.variables["pdf-standards"] = standards;
}
// Enable tagging only for standards that require it (ua-*, a-*a)
if (needsTagging) {
extras.pandoc.variables["pdf-tagging"] = true;
}
}

return extras;
},
},
Expand Down Expand Up @@ -1236,3 +1262,75 @@ 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)$/;

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) {
if (typeof s !== "string") continue;
// Normalize: lowercase, remove any "pdf" prefix
const normalized = s.toLowerCase().replace(/^pdf[/-]?/, "");

if (kVersionPattern.test(normalized)) {
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;
}
} else {
warning(
`PDF standard '${s}' is not supported by LaTeX and will be ignored`,
);
}
}

return { version, standards: result, needsTagging };
}
Loading